main
465
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
94ccea185c | Merge pull request 'fix(ui): the phone's nav sheet says when it scrolls' (#222) from fix/210-nav-sheet-scroll-affordance into main | ||
|
|
b5bdba2f38 |
fix(queue): name the queue header's two older actions
Clear queue and Add queue to playlist were named by a `title` attribute and nothing else, while the close button beside them has carried an `aria-label` since #24. They get one too. `title` is a name, so this is the weak-name case rather than the missing one: it is the *last* fallback in the accname order, so any content put inside the button later silently outranks it, and a phone has no hover to show it. The `title`s stay — on a desktop they are also the tooltip for an icon-only control, which is a different job. The assertion is the part worth reading. The obvious spec — `getByRole` by name, which is what `queue-overlay.spec.ts` already does for the close button — is **green on the broken build**: measured against the running pre-fix app, both buttons matched. So the second test states the property as what it is, that the name is not the tooltip: it removes the `title` attributes and asks again, which was 0 and 0 on main and is 1 and 1 now. Closes #170 |
||
|
|
20c337651f |
fix(ui): the phone's nav sheet says when it scrolls
Since #71 the phone's "More" is a bottom sheet, and at the reference viewport it does not fit: measured at 424x439 with the seed's eight destinations, the body is scrollHeight 412 against clientHeight 373, so 39px is below a fold nothing announces. Where the cut lands on a row boundary the sheet ends in a clean edge that reads as the end of the list, which is what #207 fixed one sheet over. The rule is that sheet's, not a second answer to the same question: #207's two background layers move into styles/sheet-scroll.css.ts and both sheets adopt them, with the colour left to each host as --yj-sheet-surface. The nav sheet paints the sidebar's --yj-bg-surface and the context sheet the menus' --yj-bg-elevated, so a shared rule that hard-coded either would draw that seam across the other one. The half that makes it visible is that nothing inside the sheet may repaint the surface. These are layers on the scroller, and app-sidebar's host carries the same grey -- in the shell its own background, in the sheet a second opaque copy of the sheet's, over the fade. With the fragment adopted and that rule missing, the running app measured a flat 52,58,64 to the bottom edge with 39px still below: the defect unchanged, with every assertion about background-attachment passing. menu-surface already meets it from the other side, where the sheet's panel is background-color: transparent. Closes #210 |
||
|
|
3479ae8d39 |
feat(ui): warm album art ahead of the scroll
Scrolling the albums grid pops art in: the cards already draw the smallest adequate tier and are already lazy, so what was left is *when* the request happens. The grids are virtualized, so the `<img>` — and therefore the fetch — does not exist until the virtualizer renders its card, which is about 1000px past the viewport, or two screens on the reference device. The issue asks for a larger overscan and that is not available: `_overhang` is a hard-coded `protected` field on `BaseLayout` with no configuration surface. So the request is issued ahead of the element instead. `utils/image-prefetch.ts` warms a bounded window either side of the rendered range, from `rangeChanged` rather than `visibilityChanged` — the two report different ranges, and a window measured from what is *visible* is spent on cards that already exist. Cover and artist URLs are served under `Cache-Control: immutable` (content-hashed filenames), so a prefetched image is a cache hit by the time its card is drawn. The bytes are the browser's; what this holds is the set of URLs asked for, capped and reported to `__yjCacheStats()`. Measured on the bulk seed (4 988 albums), ten 2 400px jumps, covers in the viewport with `naturalWidth === 0`: 254 of 258 blank one frame after the jump and 214 two frames after, against 117 and 77 with the prefetch. Closes #65 |
||
|
|
52d095e3c6 |
feat(android): the phone's "More" is a bottom sheet
The tab bar's fifth item opened `<app-sidebar>` in a `wa-drawer` sliding in from the side, which is a desktop shape put on a phone: a 200px column of a 424px screen, opening away from the thumb that asked for it, with the rest of its 400px band empty. It also had three nested scrollers in it -- the dialog, its body, and the sidebar's own `overflow-y: auto` host -- so which box a drag moved depended on where the finger landed, which is the "only part of the screen scrolls under my finger" in the report. It is the same element with `placement="bottom"` and `without-header`, so the surface is the sheet #60 already built rather than a second pattern: a `wa-drawer` is a native `<dialog>` opened with `showModal()`, which is exactly the top layer that finding rests on, so the focus trap, Escape, tap-outside and `wa-after-hide` come along unchanged and nothing new has to be proved about paint containment. The sidebar is still mounted rather than re-listed as data, because the shell's own copy is `display: none` below 600px rather than removed -- a second list drawing `nav-*` handles is the duplicate-testid failure this component already renders conditionally to avoid. What `expanded` means had to grow to say the host owns the *box*: `app-sidebar` writes an inline width and caps itself at 400px, which beats any rule the host could write, so the width, the scrolling and the mouse-only resize handle now follow that attribute. The rows are 48px below 600px, stated in the sidebar's own stylesheet since that is the only place it renders there. Measured in the running app at 424x439: the sheet is 424 wide, 373 tall (85vh, so there is an outside to tap), rows 48px, one scroller with `overscroll-behavior: contain`, and Settings' row reachable at the end of it. Desktop and Compact are untouched. Closes #71 |
||
|
|
3aa2a434b4 |
feat(android): the tap highlight goes, a press state replaces it
The phone drew a grey box over the bounding rect of whatever was tapped, which is the web view saying what it is. It is gone in one declaration: `-webkit-tap-highlight-color` is inherited and an inherited property crosses a shadow boundary, so `html` in index.css reaches every shadow root in the app. Measured three roots deep, rgba(0, 0, 0, 0.18) before and rgba(0, 0, 0, 0) after. Removing it removes the only touch feedback several surfaces had, so the press state is part of the same change rather than a later polish item — with the highlight gone a held row measured the *hover* tint, which on a phone is synthesised by the hold itself and outlives it. The four lists' rows, the tab bar, the sidebar's destinations and the shared context-menu item take --yj-press-overlay on :active; the cards already had scale(0.97). The press selector carries a state class because a row is .track-row.selected.active, so a bare :active shows nothing on the row a phone is most likely to press. And those surfaces' hover tints move behind (hover: hover) and (pointer: fine), which is #68's gate applied to a tint rather than a revealed control. user-select, the other half of the Findings, was already done: the first rule in index.css covers the shadow roots for the same reason. touch-action: manipulation is declined — the 300ms delay it is offered for is already absent on a width=device-width viewport, and what it would really change is the gesture stack tuned by measurement on a device this session cannot measure. Closes #54 |
||
|
|
8de412cf36 |
feat(android): a name is not a link on a phone, the menu carries it
Every track, album and artist name in the app navigates through `utils/explore-link.ts`, and every sentence of how it does that is a desktop compromise: the navigation is held for one double-click interval so double-clicking the row can still play it, and the target is a few characters of text inside a row. On touch that is a delay on an ambiguous target, and since #63 the row's own tap claims the click anyway -- so the link was unreachable as well as fiddly. So below the phone breakpoint a name renders as plain text and the row's context menu carries the destination instead: `go-to-menu.ts` draws "Go to Artist" / "Go to Album" under exactly the condition the link is not, using `explore-link`'s own exported routing so an untagged entity reaches the library page by the same lookup. Three things this leans on. Suppressing a link with no menu behind it is not a smaller affordance but a destination the phone cannot reach, so `keepOnPhone` is the exception for the three surfaces with no row menu. The items are drawn for a single selection only, which is the Play item's rule one step on. And there is no "Go to Genre", because no row renders a genre link to lose -- that would be new navigation rather than a replacement. Closes #67 |
||
|
|
aeb173c684 | Merge pull request 'fix(ui): the phone's context sheet says when it scrolls' (#209) from fix/207-sheet-scroll-affordance into main | ||
|
|
d365850321 |
test(ui): refresh two stale baselines and settle whether they gate
`make ui-visual` had been red on main since #27, and nothing runs it, so four references had drifted across three unrelated merges. Two were refreshed with #186; these are the other two. Each recorded two changes, not one. `app-sidebar` lost Jobs (#27, shipped) and moved its highlight from Home to Tracks; `now-playing` gained the source line (shipped) and was playing from "a dynamic mix". Both are singleton stores read by a case that sets nothing, so the shot photographs whatever the case above it left behind — blessing that would have pinned the file's own ordering into a PNG. Both cases state their world now, and only then are the references re-recorded. The second half of the issue asks whether this tier should gate, and the answer is measured rather than preferred: replayed in a bare ubuntu:24.04 container — CI's `check` image — three of the ten baselines fail on rendering alone (`track-info` and one `page-header` shot at ratio 0.03 against a 0.02 allowance, `seek-bar` one pixel shorter), and the two stale ones disagree about their new height between the machines. So CI cannot run this suite without a second, container-recorded baseline set that every local run would then fail against, and a pre-push hook is the same fault with the machines swapped. It stays local and opt-in; what replaces the gate is the rule that a change moving a component's geometry refreshes that component's baseline in the same commit, having read the image, and never one it did not cause. Written where a person meets it: the skill's tier doc has the table, SKILL.md has the obligation, CLAUDE.md has the constraint. Deleting the baselines was the third option and is declined: this tier has caught one thing no other could, the `<span>` that lost the UA stylesheet's `box-sizing` and grew a badge 36→38px. Closes #196 |
||
|
|
772c71c49f |
test(ui): clear localStorage between component tests
A test file does not get its own origin. `@vitest/browser-playwright` opens one BrowserContext per session and runs several files in it, one after another, so everything a component persists survives from file to file. `track-list` restores its sort in `connectedCallback` and `aria-tail.test.ts` activates the Title column header, so any file that mounts a track list later in that tab opens sorted by title — where `track-11` precedes `track-3`, which is #138's failure exactly. Nothing about it is specific to that pair: a probe that throws when a test starts with a non-empty `localStorage` failed 24 test-starts in one full run (11 with the two sort keys, 8 with `cover-grid-size`, 5 with `track-list-column-widths`) and cascaded into 248 failures. Which files share a tab, and in what order, changes run to run, which is the whole of why this reads as a 1-in-3 flake and passes in isolation. The clear belongs in `setup.ts` rather than in the specs that write, because the spec that reads is never the one that knows — and it is safe for the same reason the leak exists: files within a session are sequential, so it cannot wipe storage a concurrent file is using. The spec now also states the order it asserts rather than inheriting a default, and checks the row it is about to double-click carries the path it expects, so a stray sort fails by naming itself instead of as an off-by-eight file path. Closes #138 |
||
|
|
8c85db8968 |
docs(ui): quote the shipped fade's own measurement
The comment on `wa-dialog::part(body)` carried bottom-edge pixels from an intermediate probe (29,33,36) while `.planning/NOTES.md` recorded the final sample against the shipped rule (22,24,27) — the same gradient, read a few pixels higher up the box. A measurement written in two places has to agree, or neither can be trusted. |
||
|
|
02e2251bb2 |
fix(ui): the phone's context sheet says when it scrolls
The bottom sheet's body has scrolled since #60 and said nothing about it. Measured at 424x439, the track list's menu ended at y=470 with the fold at 439 — reachable, since the body is `overflow-y: auto`, but with no affordance saying so, and worst where the cut lands on a row boundary and the sheet ends in a clean edge that reads as the end of the list. The cap stays: `menu-surface`'s own comment says a surface covering the whole screen is a page, not a sheet. What changes is that the body draws a fade, from two background layers whose *attachments* are the feature — a shadow pinned to the box (`scroll`) under a cover of the sheet's own colour painted at the end of the content (`local`), which scrolls up over the shadow exactly when there is nothing more to see. So the fade is absent on a menu that fits, present the moment one does not, and gone again at the end of the list, with no scroll listener and nothing reaching into `wa-dialog`'s shadow root for the scroller. `background-attachment` is Chrome 4; the reference device is Chrome 113. The other two options in the report — a shortened last row, or a max height that makes the cut obvious — both need `height mod 48`, which CSS cannot express, and the observed case is exactly the one where the cut already lands on a row boundary. The curve is steep rather than linear because the rows under it stay live: a scrim over a menu item is that item's text surface, so the 4.5:1 rule reaches it, and the light ramp is what makes that real. A 48px linear scrim at 0.8 greyed the last label to 5.0:1; 32px already down to a quarter strength at 14px measures 9.9:1 there and spends its weight on the strip below it. The test asserts the pair of attachments rather than the pixels, on this file's existing grounds that no tier here renders like the device — it fails on the pre-fix stylesheet with `expected 'scroll' to be 'local, scroll'`. The rendered result was measured in the harness and is recorded in `.planning/NOTES.md`. Closes #207 |
||
|
|
29feb4b94b |
feat(android): the touch model reaches the other three lists
Plan 019 phases 3 and 4, which finish #63. The queue panel and both playlist detail views get tap-to-play and hold-to-select; the playlist views get swipe-to-queue as well. Phase 3 was not the pure wiring the plan expected, in two places. A tap on a queue row plays that position. Copying track-list's tap -- which sets the queue to the list the row is in -- would rebuild the queue from the queue, discarding its source, its shuffle order and anything inserted by hand. It reads as a no-op and is not one. And the queue panel has no swipe, deliberately. A right swipe means add to the queue everywhere else it exists, and a queue row is already in the queue; the only thing it could mean there is remove, which is the same gesture with the opposite effect one screen away. Removing a queue row is on the row, on its sheet since #60, and now on its selection bar. The assertion is that its rows do not opt in. The reveal became utils/swipe-to-queue.ts rather than being copied into three lists, keyed on a data-swipe attribute so one stylesheet carries the touch-action half of the device fix to rows that are called two different things. Phase 4 was already true and is now asserted: a claimed tap has its click swallowed, so an explore-link inside a row never sees one and tap-to-play wins with no rule of its own. Its test was vacuous when written -- the tap helper sent no click, so there was nothing to swallow -- which also weakened phase 1's. It sends one now. Escape leaves selection mode, from selection-bar rather than from each of the four hosts, since that element exists only while the mode does. The platform's back gesture deliberately does not reach it: the shell owns the history stack and four lists reaching for history is four stacks. That is #200. Verified on the reference phone: a queue row taps to its own index and refuses a swipe, a playlist row queues on a swipe and plays its playlist on a tap, and a hold raises the bar without the menu. Closes #63 |
||
|
|
4e667759c4 |
feat(android): swipe a track row right to queue it
Plan 019 phase 2. A finger on a track row now drags a reveal out from under it and queues the track on release, with the affordance saying what it will do before it does it. Two things the device said that the plan did not predict, and both change the implementation rather than decorate it. The gesture runs on touch events, not pointer events. Chrome 113's WebView cancels the pointer stream ~16px into any drag whatever touch-action says -- measured at auto, pan-y and none alike -- while touchmove keeps firing. So touch-action: pan-y is half the fix and a non-passive touchmove calling preventDefault is the other half, and neither works alone: with the preventDefault in place and touch-action back at auto the gesture died after one move. Both are correct in Chromium either way, which is why the module's header carries the measurement and the component tier asserts the stylesheet. And a phase 1 defect the device found on the way past: the native contextmenu arrives in either order and only one was handled. Our 500ms timer firing first, a component claiming it, and Chrome delivering its own menu 50-70ms later was suppressed by nothing -- so the context menu opened over the selection bar, two holds in four, on the one surface this issue exists to have changed. Six holds clean after. draggable="true" is not a competitor: no dragstart fires from a touch drag on this WebView at all. |
||
|
|
76e1c444cc |
feat(android): tap to play, hold to select
Phase 1 of #63, and the design the issue asks for as one piece is .planning/plans/active/019-android-touch-model.md. **A finger has no second button and no modifier keys**, so the primary action has to be the primary gesture: tap plays the row, and the hold that opened a context menu now enters selection mode with that row selected. Three decisions in it, and two diverge from the report. **The predicate is the pointer, not the platform or the viewport.** `pointerType === 'touch'`, per event, which is already how long-press.ts decided and is the only such test in the frontend. This is #64's rule -- named after the capability -- and it carries #64's warning: keyed on a width, an Android *tablet* at 600px gets click-selects/double-click-plays on a touchscreen, which is the inversion this issue exists to fix, on the platform it exists for. A touchscreen laptop cannot be described by a width at all. Per event, a mouse keeps desktop semantics on the very same row, and there is no second declaration of what a phone does. **There is no double-tap, and the number is why.** The report asks for single tap to play *and* double tap for the menu. Those cannot both be honoured: the first tap of a double tap is indistinguishable from a single tap until the interval expires, so "tap plays" becomes "tap waits". Measured on the device, the play command to TrackChanged is 155/123/85/56/91 ms -- median ~100 -- and the app's own DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction, 250ms of it spent deliberately doing nothing, on every track anyone plays, to reach a menu the hold already reaches. So the menu and the selection action bar are the same surface, which is also the platform's convention and removes a concept rather than adding one. **Tap-to-play and selection mode ship together**, because splitting them is a regression dressed as an increment: a touch user selects by tapping today and acts through the long-press menu, so moving tap to play on its own would leave a window with no way to select forty tracks at all. **What lets this reassign the hold without touching one of the fourteen context menus**: the layer announces `yj-tap` / `yj-long-press` (composed, cancelable) and acts on nothing. A component claims one with preventDefault. An **unclaimed long press still becomes a `contextmenu`**, so the card grids, Explore, the playlist rows and every other menu behave exactly as they did, and only lists that opt in get selection mode. An unclaimed *tap* does nothing at all and the click follows normally, which is what leaves every button in the app alone -- only a claimed tap has its click swallowed, or playing a track would also select it. **And the device found the one thing no browser tier can see.** Chrome 113's Android WebView fires its own `contextmenu` on a long press. long-press.ts stood down when a trusted one arrived, which was right while both paths ended in a context menu; they no longer do, so standing down means the gesture silently does the *old* thing. Measured, before the fix, holding a track row: {"log":["contextmenu isTrusted=true"], "state":{"bar":null,"menuActive":true,"selected":1}} `yj-long-press` was never announced, the menu opened, and all 26 tests passed -- dispatched pointer events do not make a browser synthesise one. So the native event is a **trigger, not a competitor**: the gesture is announced from it and only a claim suppresses it. Unclaimed it propagates untouched, which is the same "browser wins" outcome reached by asking instead of assuming. The tier could not find that and can hold it, because this module has always told its own events apart by identity rather than isTrusted, so an untrusted one from a test takes exactly the browser's path. Verified on the device by *performing* the gestures rather than describing the page -- `adb shell input tap` and `input swipe x y x y 700` reach the WebView as real pointer events, which is new here and is written down in the plan with the pixel mapping. Tap plays; a hold raises the bar with one selected and no menu; a tap toggles to two, back to one, and the mode ends with the last row; an album card still opens its context menu. 29 new tests. The e2e spec is rewritten to assert **both** halves -- the row selects, and a card elsewhere still opens the real menu -- because a spec that only checked the row would pass on a build that had silently broken the other thirteen. Phases 2-4 (swipe to queue, the other three surfaces, and what #67 inherits) are in the plan and not in this commit. |
||
|
|
4f628b1f52 |
fix(ui): raise the last controls below the touch floor
The rest of #186's second table, and one thing it could not have said. .section-toggle 187x15 autotag .folders-menu-trigger 32x18 autotag .back-button 32x32 artist-details Requests / Downloads tabs 85x34, 96x34 .search-mode-tab 89x26, 79x26 explore explore search input 325x18 in a 36px box **back-button was six controls, not one.** The issue names it in artist-details because that is the view the sweep opened; the same declaration is byte-identical in artist-details, genre-details, playlist-details, smart-playlist-details, explore-artist-details and explore-album-details, 32px in all six. So it is styles/back-button. css.ts now, adopted by each, and a source sweep fails on a seventh copy -- because the failure this invites is not a size changing, it is somebody adding a detail view and writing `.back-button` out again, which no device sweep would catch for the same reason this one did not. That is icon-language.test.ts's shape, and the argument for it here is the inverse of the column arrows': one declaration covering 36 controls is cheap to fix, and six declarations of one control are six chances to miss five. It is a real 44px box rather than padding with the width handed back: a detail header runs no fit pass, and this button has a visible background, so a hit area larger than the circle would be a control bigger than it looks. The size is #55's, reached there for the same reason -- "the way out is 44px on a phone". **The explore search box was two faults.** The row was 36px *and* the input inside it was 18, so eight pixels at each edge were not a target at all: a tap near the top of the box landed on the container and did nothing. The container is 44 and the input stretches to it. **The Downloads tabs take padding rather than a min-size**, because the mark for the selected tab is its bottom border -- a min-size centres the label and leaves the underline 10px beneath it. page-action-check-now (113x29) is in that table and is not here: it is a PageAction, so #195 raised it with the rest of the header's actions and touch-targets.test.ts already covers it. **The Downloads tabs needed a min-size as well as the padding, and CI is what said so.** Padding alone made them 44px on this machine and **43px in the container**: the total is 13 + 13 + 2 + whatever line box the font gives 13px text, and ubuntu:24.04's is a pixel shorter than Arch's. A height computed from a font's line box is not a height you control -- which is #195's "stated as a property on the strength of one engine" one layer down, in the same PR that recorded it. The padding stays, because it is what keeps the underline against the label; the min-size is the floor. Caught by the new test rather than by a person, which is the half of this that worked. Verified on the device, sweeping each view the way the issue was filed: explore, downloads, autotag and artist-details now report **one** control under the floor apiece, and it is the skip link, which #186 already ruled out as keyboard-only. .search-mode-tab 89x44 and 79x44, the search input 325x44, the Downloads tabs 85x44 and 96x44, .section-toggle 174x44, .folders-menu-trigger 44x44, .back-button 44x44. All 12 new tests fail on main, the source sweep naming all six copies. make ui-test 1041 pass; make e2e 236 pass on chromium, which is half an answer -- CI had the other half, and used it. Closes #186 |
||
|
|
2100f0022f |
fix(settings): raise every Settings control to the touch floor
#56 named 44px and #195 took the page header there. Settings is the other half of #186 and much the larger one: swept on the reference device (TLP301, 424x439) with all eleven config-sections expanded, **120 controls** were under the floor -- not the 93 the issue's table implies, and config-field is eight of them. The bulk is behind the disclosures, which is why nobody had counted it: 36 .column-arrow-btn 16x14 <- smallest in the app 29 .column-toggle 16x16 26 shortcut-capture button 80x25 8 download format checkbox 16x16 7 config-field select 335x30 6 wa-input / wa-button 204x20, 185x21 **The density argument, measured rather than guessed, and it is smaller than it looks.** The rows were already near the floor -- .column-item is 335x36 and .shortcut-row 335x37; it is the controls *inside* them that were 14-25px. So a control grows into the row it already occupies and the row goes 36 to 44. Measured after: the two column lists went 373->447 and 690->850, +234px over the whole page. Half a screen of extra scroll on a page that already scrolls, against 36 targets of 16x14. **Settings is cheaper than the header was, and for a stated reason.** There is no overflow fit on this page, so the header's "only width is contested" rule does not bind at all and nothing here needs padding with a negative margin. Height is a min-size, and the two square controls can simply be square. Three shapes, because one rule does not fit three kinds of control: **A native checkbox is targeted through its label.** It cannot grow its hit area without growing its paint, and a 44px checkbox is not what anyone wants -- so .column-label is a real <label for> now and the column's *name* is the target, 70x44 rather than 16x16. That is the argument config-field already makes one file over ("a real label association also makes the label text a click target, which is behaviour, not annotation"), and here it is the whole fix. The download formats already had the label; they only needed the height. **The arrows take padding, which is invisible.** They carry background: none and a transparent border, so 16x14 -> 44x44 changes nothing anyone can see until hover -- #186's Direction exactly. **Web Awesome's controls come from the library's own API.** Their height is decided inside somebody else's shadow root, and --wa-form-control-height is the variable that decides it. A custom property inherits through a shadow boundary, so a :host declaration reaches them; styles/wa-touch-floor.css.ts is that, once, adopted rather than written at :root in index.css -- a :root rule would be invisible to the component tier, which renders a component and no page stylesheet. **Two controls no sweep can see are fixed by name**, and they are the trap this issue keeps setting. config-field's toggle has an <input> that is opacity: 0; width: 0; height: 0, so a walk of every input skips it as a zero-sized node -- what a finger hits is the <label>, which measured **34x19**, smaller than anything in either of #186's tables and absent from both. It is 44x44 with the pill still painted at 2.5em x 1.4em and negative inline margins keeping it flush with the inputs above. And shortcut-capture's reset button renders only for a shortcut somebody has rebound, so a sweep of a fresh install never meets it. Verified on the device, same method as the sweep that filed it: 120 controls under the floor before, 42 after. All 42 are accounted for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four are wa-input's inner input at 204x**42**, which is the control measured *inside* its own 1px border (part=base is 238x44), and one is the skip link, which #186 already ruled out as keyboard-only. The e2e suite passes, top-bar-fit and header-action-overflow included -- but that is **chromium**, which is half an answer, and saying so is the whole of what #195's second commit was about. What can be argued rather than run: library-filter is the only thing here in a container that measures itself, and its width did not change. The fit measures inline size. Two page-header screenshots are refreshed because they are this issue's own debris -- #195's taller sort control, merged last session, with its references never re-recorded. app-sidebar's and now-playing's are deliberately left: they are unrelated drift, and blessing an unrelated screenshot is how the sidebar reference came to still list a destination #27 retired. That is #196. |
||
|
|
0d331666d6 |
fix(shell): make the header's touch targets cost no width
The first pass grew the two square controls to 44px as boxes, which
added 22px to the header. That fit at every width Chromium was checked
at and **clipped the overflow trigger at 320x600 in WebKit** -- the
engine closest to what ships, and the one no machine here can run:
every action is reachable at 320x600 (400% zoom)
- Array []
+ Array [ "more" ]
Two things were wrong, and only one of them was the code.
**The claim was checked on one engine and stated as a property.** The
previous commit said #69's fit "does not move ... the check rather than
the assumption", on the strength of running that spec against chromium
alone. CI runs both browsers precisely because they are not the same
answer.
**And the box was the wrong thing to grow**, which the issue already
said: "reached by growing the *hit* area rather than the visual weight
where the two can differ -- padding on the control, not size on the
icon". #69's pass measures inline size, so a taller control is free and
a wider one is not.
So height stays a box -- the header has the room and nothing measures
it -- and width is padding with a negative margin handing the space
back, which is the seek bar's shape from #187. Measured in the
component tier at 320px: the arrow's rect is 45x44 and it occupies 29,
the overflow trigger 44x44 occupying 38, the search button 44x44
occupying 40. Those three occupancies are what they were before any of
this, so the fit pass sees a header identical to main's and the
320px case cannot regress.
The arrow's target is lopsided for #187's reason: the select is 6px to
its left and there is open space to its right, so it takes the side
with nothing to steal from. The overflow trigger's can be symmetric,
the actions row having an 8px gap.
`search-trigger` is border-box, so its 44px min-width is the whole
target and the margin alone gives the four pixels back.
The new assertion is the one that would have caught this: every grown
control must carry negative inline margins, because that is what keeps
the box out of the fit. The rect assertions stay -- getBoundingClientRect
includes padding, so the target is still measured directly rather than
inferred.
|
||
|
|
6a5a3c33dc |
fix(shell): raise the page header's controls to the touch floor
#56 sized the playback transport for a thumb and named 44px; the queue header keeps it. Nothing else was resized, so the controls a user meets on *every* screen sat between a third and two thirds of the app's own floor. Measured on the reference device at 424x439: page-sort 99x23, page-sort-direction **28x21**, page-actions-more 38x27, and search-trigger 40x40. **Both questions the issue left open are answered by one measurement.** The header is 63px tall and its controls are 20-23px, so the vertical room was already there; the select and its direction arrow are 6px apart, so the horizontal room was not. That makes this min-size rather than padding with a negative margin, which is what the seek bar needed (#187), and the difference decides everything else. There the painted track had to stay thin, so the target was grown past its own box and had to be checked against its neighbours. Here the control *is* the target: the boxes are flex items, so the gap keeps them apart and **no two targets can overlap by construction**. From which: **There is no phone branch.** A 44px control on a desktop is merely large, and a second declaration of what a phone shows is a second thing to keep in step -- which is why this component has never had one. It also avoids a media query no tier here renders, which is exactly how the seek bar's phone rule came to be dead for months. **#69's overflow fit does not move.** That pass measures inline size, so the height costs it nothing, and only the two square controls grow the header's content -- by 22px in total. header-action-overflow.spec.ts passes unchanged at all four of its widths, which was the check rather than the assumption. Verified on the device that the count is still shown at 424px, so nothing has started yielding. search-trigger is the sharpest case and is fixed in the same pass: #57 created it as the phone's replacement for the header search box, so it exists *only* where there is a thumb, and it shipped at 40x40 under a comment calling that "the smallest a touch target should be". That was the floor restated four pixels short rather than a second opinion about it, and the comment now says so. Unlike #187 this can be measured rather than inferred: the controls are plain elements and the rule is a min-size, so it holds at every width and a real Chromium rendering a real page-header gives the actual answer. The tests fail with the device's own numbers -- 29x21, 38, 40. Verified on the device: every control in the header is now at least 44x44, and so is the phone's search button. **This is the Direction's first step, not all of it.** config-field's 93 Settings controls and explore-view's search row are the second pass; Settings is a form with one shape for every row and wants its own argument. #186 stays open for them. |
||
|
|
ec64dbded0 |
fix(player): give the seek bar a thumb-sized hit area
On now-playing-view -- the screen that exists so a phone has somewhere to seek from -- the slider measured 261x6 on the reference device. Six pixels is the whole of the drag target on the app's primary seeking affordance, against the 44px floor the app set for itself in #56 and holds to in the queue panel. **The phone rule had never applied**, which is why the issue read as "the thickening stops short" rather than "there is no thickening". seek-bar's stylesheet asked for a 12px track below 599px and then set 6px in a plain `wa-slider` rule *written after it*. A media query adds no specificity, so the plain rule won at every width: the source said 12 and the device said 6. That is index.css's documented rule -- "the phone section is last on purpose" -- met inside a component's own stylesheet, where nothing in any tier renders differently to say so. The block is last now, and the 12px track it always asked for is real. **And 12px is still under the floor**, so the target is built around the painted track rather than by thickening it. The two are allowed to differ and a slider is the clearest case where they should: a 44px progress bar would be wrong-looking and would cost the album art the vertical space #51 spent an issue recovering. Two things about how it is built, both settled by measurement on the device rather than by choosing a number. **The padding goes on ::part(slider), not on the host.** That is the issue's untested claim, and the answer is the pessimistic one: the inner div is what carries the gesture -- it holds the listener and the touch-action: none -- and it is exactly the host's size, so padding the host would grow a box that does not take the press. **The padding is asymmetric and the margins cancel it**, so the row does not grow by the difference. The seek row is 19px -- its clocks, not the track, decide that -- and the play button's top edge is 8px below it, while `.art` above is a non-interactive div. A symmetric 44px target reaches into the play button, and growing the row instead cost the art 25px of 143 when it was tried. So the target takes the space above. Verified on the device at 424x439: hit area 261x44 where it was 261x6, painted track 12px, seek row still 19px, album art still 143px, 7px of clearance left under the play button, a press 26px above the track seeks, and a hit test on the play button's top edge still reaches the play button. The desktop bottom bar is untouched: the rule is inside the phone query and that instance is display:none below 600px anyway. The test asserts the parsed stylesheet, on hover-affordance.test.ts's precedent and with the same limitation stated -- no tier here lays out a real wa-slider at a phone width, and a number measured on a phone is not a number CI can assert. What it holds is the shape: that the phone block is last, that padding plus track clears 44, that the margins cancel the padding, and that the growth is upward. All four are invisible on a desktop, and the first is exactly what a tidy-up undoes. Closes #187 |
||
|
|
75a24f98b6 |
fix(player): give Now Playing a layout that survives a short screen
Two things, and the first was a defect underneath the design question rather than an answer to it. **The album art was never square.** aspect-ratio is specified not to re-derive the width when max-height clamps the height, unlike an intrinsic ratio, which is preserved under both bounds. So a definite `width: min(100%, 60vh)` kept its width while the height was clipped and object-fit: cover cropped a square cover into the band -- 264x53 on the reference device, which is what #172's "39px of art" actually looked like. It is not only the phone either: the leftover exceeds the width only above ~843px of viewport, so every height from ~500 to ~843 drew a crop. Both maxes with auto sizes is the fix, chosen by measuring four candidate rules against Chrome 113 itself at five column heights. The placeholder cannot use that rule -- with no intrinsic size it collapses to its icon, 13x58 -- so it is driven from the height, with min-width: 0 because a flex item's automatic minimum is its content, and max-height: calc(100vw - 2rem) because a non-replaced box cannot express "the largest square that fits" and went 380x484 on a tall phone without it. **Then the reflow.** The stacked budget is fixed, so the art gets `height - 386` and that is 53px at 424x439. #172 named two ways out; a floor on the art scrolls the transport off the bottom, and controls never scrolling off is #51's own Direction and plan 018's promise -- so below 500px the art and the names share a row, where the art is bounded by the row's height rather than the column's leftover. 53px to 143px on the device, nothing scrolling, the transport untouched. 500 is where the two layouts cross rather than a round number, and it is keyed on height alone because it answers vertical room: a 900x450 window has the same problem and the same fix. |
||
|
|
7ba5d321f6 |
test(queue): pin the breakpoint listener the scrim rule rests on
The scrim's existence comes from matchMedia rather than a stylesheet, which only holds if the query is listened to — and the stub's addEventListener was a no-op, so deleting the listener left all 986 tests green. The stub records its listeners now and the new case carries a panel across the breakpoint in both directions. Watched failing with the listener removed. |
||
|
|
f126dd7397 |
fix(queue): draw the scrim only where it can be tapped
Below 600px `.panel-content` is `width: 100%`, so the scrim sat entirely underneath an opaque panel -- measured at 424x439, host, panel and scrim all 424x318. It dimmed nothing and dismissed nothing there while wearing `cursor: pointer`, so #24's tap-outside-to-close did not exist on the device it was drawn for. Of the issue's two directions this takes the second. A gutter is the drawer pattern and buys the affordance by taking width off a full-screen surface on a 424px viewport; #55 already made the queue a *screen* at that width, whose ways out are back and a 44px close button. So there is no scrim there rather than an unreachable one. Existence is `matchMedia` rather than `display: none`, on `job-band`'s rule: a hidden scrim is still an element carrying the handler. The 600-899 band, where the panel is a 320px column of a wider content area and the scrim has real uncovered pixels, is untouched. The e2e half asserts *absence* at 424x439 rather than clicking, because a phone-width case that clicks the scrim's centre hits the panel and passes on the broken build -- which the issue anticipates. Closes #171 |
||
|
|
d78830aa52 |
fix(ui): make the touch pen a corner chip, not a scrim over the art
Always-visible is not the same as always-in-the-way: the overlay is inset:0 at 50% black, so gating it on hover left every touch device with the artwork it is editing permanently darkened. It is only a hint — .cover-art-edit carries the click, so tapping the art always worked — while the × really is the only route to its action and stays. The chip borrows the remove button's size, disc and alpha. Also corrects the claim that no tier can render as a touch device: no committed one does, which is a choice about projects rather than a limit. |
||
|
|
a72d1f68ed |
fix(ui): keep a touch-only affordance reachable, or absent
Three controls are revealed by :hover and are the only route to their action on a device that has none. #68 hid the home card's play button on touch, which was right because tapping the card does the same thing; these are the opposite case, so hiding them removes the action outright and leaving them costs the same long-press flash #68 was filed for -- they are visibility:hidden / opacity:0, so on touch they are invisible controls that still take taps. track-details' cover-art overlay and remove, and shortcut-capture's reset, are always visible under `@media not all and (hover: hover)`. The queue row's remove is the third case the report names and takes the other treatment, because #60 has since landed: the row's context menu is a bottom sheet carrying "Remove from Queue", so the action is one long-press away and an always-visible X would spend part of a 424px row on something already reachable. It is display:none outside `(hover: hover) and (pointer: fine)` rather than visibility:hidden, which would leave a button holding its hit area and its place in the accessibility tree -- the trap this issue is about. The rule is not extracted into styles/ yet: that leaves two call sites of the always-visible form, under the four the report names. No tier here can render as a touch device, so the tests read the parsed stylesheet the way #68's does and say so; the touch and hover renderings were measured against the running app in a hasTouch context instead. Closes #137 |
||
|
|
11ba7b3180 |
build(frontend): sweep every stylesheet, not index.css by name
The hook fires on frontend/**/*.{ts,css} while the script read one hardcoded path, so a second stylesheet would have been silently unswept while the hook still went green over it. There is only index.css today, which is exactly when this is cheap to fix. Watched catching a planted nested rule in a second file.
|
||
|
|
7f8e185d7c |
build(frontend): fail css-check on a nested rule the phone drops
The device renders in Chrome 113, which predates relaxed CSS nesting, so
a nested rule whose selector starts with an element name is not a parse
error anyone would notice -- the rule simply does not exist, there and
nowhere else. Three were live in `index.css`, and the one that mattered
was the `text-overflow: ellipsis` on the bottom bar's title and artist,
which had therefore never truncated on the device. No tier here can see
the class at all: the component tier, the e2e tier and `make ui-visual`
all run a current engine, where the rule applies normally.
So `make css-check` carries a second script. It reads `index.css` and
the `css` literals in `src/**/*.ts` alike, since a shadow-root
stylesheet is parsed by the same engine, and it names the file, the line
and the fix -- a leading `&`, which is valid in both syntaxes.
The detection walks blocks rather than matching lines, and both things
it has to get right fall out of one rule: a rule is nested when a
*style* rule is somewhere above it, not when its immediate parent is a
block. That leaves `@media (...) { bottom-nav { ... } }` at the top
level alone, which is the majority of what a regex over the file would
report, and still flags the same rule inside an at-rule that is itself
inside a style rule. Strings and comments are read through, so a brace
in a `url()` is not a block.
The tree has no violation left, so the check would pass just as happily
over an empty glob: it refuses one, and `test/utils/css-nesting.test.ts`
pins the semantics that make the sweep mean something. The literal
scanner the two checks share is lifted into `css-literals.mjs`
unchanged, except that a `${}` substitution is now blanked keeping its
newlines so a line number survives it.
Closes #154
|
||
|
|
deea6ad06d |
test(player): pin the desktop timer gate, drop a leaked queue
Two gaps a review found. The this.phone gate on the interpolation interval is what CLAUDE.md says earns the matchMedia call, and every test passed without it — so it is asserted on the timer count now, since a desktop render is empty either way and cannot tell the two apart. Watched failing with the gate removed. The e2e spec left LONG_TRACK playing in a workers: 1 suite against one long-lived app, immediately before four other phone-* specs. Nine specs clear the queue in afterEach for that reason and phone-transport.spec.ts records the flake it caused. |
||
|
|
fba608fdbd |
docs(player): attribute the phone seek bar's removal correctly
The paragraph said #59 took the seek bar off the phone's transport. It was plan 016 B2 — audio-player.ts says so in the comment above the rule that does it, and CLAUDE.md's own #59 paragraph says #59 removed shuffle, repeat and the queue button. Wrong provenance in the file whose whole value is being right about which change did what. Also stop tracking .pi/journal.md. It is a scheduled run's scratch log, and this repo's memory is CLAUDE.md and .planning/ — a session log arriving inside a feature PR is a new convention landing sideways. |
||
|
|
f59490b113 |
feat(player): show progress on the phone's bar border
#59 took the seek bar off the phone's transport, so the one thing a mini player is expected to say without being opened -- how far through the song it is -- had nowhere left to be said. It is the shell's element and its own 2px grid row between `bottom-bar` and `bottom-nav`, because those two are separate components and either one drawing the line means reaching into the other's box. The fill is `scaleX()` off the same `PlaybackPositionChanged` the seek bar renders, with the same `trackChangeId`/`seq` guards and an interval that only interpolates *between* reports -- never its own clock, which is the rule that exists because a local counter drifted 30 s away from the backend across four keyboard seeks. It is `aria-hidden` and takes no pointer events at any depth: Now Playing's seek bar is what announces the position, and a 2px strip on the top edge of the tab bar is exactly where a thumb aiming at a tab lands. It renders nothing above 600px, from `matchMedia` rather than a media query, because a stylesheet cannot stop a 1 Hz interval running for the life of every desktop session about a line nobody can see. Its phone rule is at the foot of index.css beside `job-band`'s, not in the phone block above: a media query adds no specificity, so a `display: block` written before the `display: none` that takes it out of the desktop grid loses to it and the line never appears at all. Closes #58 |
||
|
|
ea3edde697 |
fix(explore): scroll the album page as one on a phone
`explore-album-details` was a fixed header over a scrolling tracklist, which is the desktop arrangement. At the reference device's 424x439 the header owned 253 of the panel's 318px and the list scrolled inside the 64 that were left, and the header's flex row squeezed `.album-info` to 112px beside a 200px cover -- so the title drew as one ellipsised glyph and two of the album's three primary actions were clipped by the component's own `overflow: hidden`: "Shuffle album" ended at x=443 in a 424px box, reachable by no gesture. Below 600px the host is the scroller and `.content` stops being one, so the header scrolls away and the page moves together; the header stacks art over info, so the info column has the row's whole width. The tracklist is plain DOM rather than a virtualizer, so nothing inside wants a scroll window of its own. Another `min-width: 0` was not the fix and the issue's own measurement says so: `.album-info` carries one and was shrinking as asked. Nor could `layout-overflow.spec.ts` see any of this -- `body.scrollWidth` equalled the viewport throughout, because the overflow was inside a component -- so the new spec measures each header control against the host's own box, which is `top-bar-fit.spec.ts`'s shape for the same reason. The phone block is last in the stylesheet on `index.css`'s rule: a media query adds no specificity, so above the rules it overrides every declaration in it would be silently dead. Closes #66 |
||
|
|
31dafb0ce0 |
test(shell): assert the surface, and sweep for a menu that skipped it
No tier here can reproduce the defect: this runner's Chromium and CI's WebKit both have the Popover API, so the popup is top-layered and looks perfectly correct, and a spec asserting "the menu is not clipped" would pass on the broken build. So these assert the mechanism -- that the surface is a native <dialog> at phone width -- which is the same move queue-as-a-screen.spec.ts makes about containment, for the same reason. The sweep is the more valuable half. A thirteenth menu written as a bare <wa-popup> would work in every tier here and be clipped on the device, so this reads every source file and fails on one outside a three-file allowlist, each entry carrying why. It found two call sites the by-hand conversion had missed. Four of the six behavioural tests fail on the build before this change; the two asserting the desktop popup cannot, because that behaviour was already there. Closes #60 |
||
|
|
9e7e7ce5a1 |
feat(shell): put every menu in the app through the one surface
Fourteen call sites, one tag name each and nothing else -- which is what menu-surface's shape buys: the host's panel is slotted into whichever presentation is up, so no item model, no keyboard model and no styling moved. The 48px rows come from contextMenuStyles, the one stylesheet every one of these hosts already includes, because the panel is the host's own light DOM and only the host's stylesheet can reach it. Two of the fourteen were found by the source sweep rather than by the conversion: queue-panel's add-to-playlist popup, which is a real menu. now-playing's cover preview is allowlisted instead -- it is a hover affordance in the bottom bar, so a touch device never opens it and nothing clips it. The playlist submenu had to come too, and that is the one place this change made something worse before it made it better. It is a placement="right-start" flyout anchored to its row, and making the menu full-width moved that anchor to x=0 -- so the flip put the picker at x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at all. Before the change the row started at x~245 and the same flip landed on screen. It is a sheet now and stacks over the first, which is also why menu-shown does not re-assert focus while it is open. The three hosts that do not use ContextMenuController -- page-header's overflow menu, playlist-view's hand-rolled menu, queue-panel's picker -- bind menu-dismiss themselves, or Escape would close the sheet and leave their own open flag set. page-header is included deliberately: the clipping does not bite there, since it opens downward from the top of a full-height view, but on a phone every action of an overflowing page lives in that menu at wa-dropdown-item defaults. One surface, so there is no second answer to what a menu looks like. |
||
|
|
9aaa8beb99 |
feat(shell): draw a context menu where it fits, not where it is anchored
On the reference device every context menu in the app is clipped, and the two halves of that are structural rather than incidental. Chrome 113 has no Popover API, so wa-popup takes its own documented fallback and positions with strategy: "fixed"; .main-panel carries contain: layout style paint, and paint containment clips fixed descendants. Measured at 424x439 before any of this: the main panel spans 0-318, the open menu spanned 191-401, and three of its seven items were cut off with no way to reach them. Rows were 29px against a 44px floor. menu-surface is one element with two presentations -- a wa-popup above 600px, a wa-dialog bottom sheet below it -- so the host keeps rendering the panel it always rendered and ContextMenuController keeps driving .active and .anchor as though it were talking to a popup. showModal() is Chrome 37 and uses the real top layer, so the sheet is immune by construction rather than by styling. Four things needed measuring on the hardware rather than reading. "A dialog escapes containment" was the premise and was untested here: every other dialog in this app is mounted in index.html, outside .main-panel. A probe dialog appended to track-list's shadow root paints to y=439, over the mini player and the tab bar. A native dialog's UA stylesheet centres it and caps its width, which drew a 354px panel in the middle of a 424px screen -- so four declarations in this component are pure undoing. wa-dialog focuses [autofocus] or itself on the frame after showModal(), and it cannot see our first menu item to prefer it: the panel is slotted, so its own querySelector stops at the <slot>. A longer retry budget does not fix that, because the first attempt succeeds and is then overwritten -- hence menu-shown and MenuKeyboard.refocus(). The budget became time-based anyway, since what is being waited for is another component's animation. And a dismissal has to travel back: wa-dialog closes itself on Escape, which would leave the controller believing the menu is open. The failure mode there is not a stuck sheet but the *next* long-press doing nothing, which reads as the gesture breaking. |
||
|
|
2be6fb3066 |
feat(player): draw no volume control where there is no volume
volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not. The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.
It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on. index.css keeps
its phone rule, which is now about room and says so.
Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport. The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].
Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing. The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.
volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer. Both of its tests were confirmed to fail on
the build before this.
Closes #64
Closes #172
|
||
|
|
867ced8c81 |
feat(player): leave the volume to the system where the system owns it
On Android the hardware keys are the volume control and the framework mixes our stream against the device level, so a second control inside the app moves something the user already moved. Where that is true the player's level sits at maximum, SetVolume / ChangeVolume / MuteToggle are refused, and nothing persists a level nobody chose: restore remembers the stored value instead of applying it, and saveState writes that same value back rather than recording the synthetic maximum. Mute is in that list because it is a level of zero by another name -- and because with no control rendered it would be the one state on such a platform the user could not get out of. The predicate is named after the capability rather than the platform, because that is what makes it testable. Only platformOwnsVolume is behind a build tag, in two files that declare nothing else; everything else is decided against Player.systemVolume, a field a test sets either way. That is mediacontrols' split, with androidpayload.go's reasoning for keeping the contract out of a tagged file, and the tagged pair is covered by a source sweep since no tier here compiles both halves. SetDuck is deliberately untouched: it applies its attenuation by re-applying the *user's* level through setVolumeLocked, so pinning that level to maximum leaves the offset arithmetic exactly as it was. It is the only thing that may still move the output on such a platform, and TestSystemVolumeStillDucks is that property rather than a comment. |
||
|
|
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 |