Commit Graph
6 Commits
Author SHA1 Message Date
logan 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
2026-08-19 21:35:03 -04:00
logan 57fbbdf0d2 feat(ui): a shell a phone can be held in
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column,
`bottom-nav` becomes the primary navigation, and the shell fits the
viewport instead of scrolling sideways out of it.

600 rather than the sidebar's own 900, because 900 is a laptop and the
answer there is a narrower sidebar, which is still a sidebar. Under 600
there is no room for one at all: 360px of viewport over a 200px nav is
not a layout.

**The tab bar is four destinations and a way to everything else.**
Three to five is where touch targets stop being thumb-sized -- eleven
over 360px is 32px each -- so the four are the ones plan 016's subset
says a phone is for, and "More" opens the *existing* `app-sidebar` in a
drawer rather than listing the destinations a second time. Two lists is
two places to add the next view to.

That reuse has a cost this found the hard way: a shared component
brings its `data-testid`s with it, so rendering the drawer's sidebar
unconditionally put a second `nav-home` (and ten siblings) in the DOM
and **failed 30 existing specs** with "resolved to 2 elements" -- on a
desktop viewport, where this element is `display: none` and the drawer
can never open. It renders only while the drawer is open, and the
component test asserts the absence, because the failure is invisible
from inside the component and lands in files nobody touched.

**What made the shell overflow was minimums, not padding.** Measured at
360px: the body was 652px wide, because a `min-width` in a flex row is
a hard floor and a grid item's implicit minimum is its content. So
`min-width: 0` on the boxes between the viewport and the content, and
each component stands its own non-essential parts down in its *own*
stylesheet -- search-bar's 200px floor, job-indicator's label (the
visible one; the live region that announces it is untouched),
audio-player's seek bar and volume. A media query inside a shadow root
is answered by the viewport, so this is the component saying what it
drops rather than the shell reaching in.

Volume goes because the hardware keys own it on a phone, which is the
same reason mediacontrols' Android handler implements no volume
callback. Seeking goes because 4px is not a thumb target; it belongs to
the full-screen now-playing view, which is the next phase.

An existing spec therefore asserts the opposite of what it did:
layout-overflow's 320px case used to require that the 464px behind
`overflow: hidden` could be *scrolled to*, which was the remedy
available while the shell had one layout. It reflows now -- 320px in a
320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
2026-08-16 23:19:26 -04:00
logan 49b1194333 fix(a11y): give the semantic colours a ramp, and every fill a foreground
The contrast pass found two things larger than itself, both recorded as
not-fixed. This is them.

The semantic colours were 'fixed across themes', and one fixed colour
cannot clear 4.5:1 against both a near-black and a near-white surface:
--yj-error measured 2.55:1 on dark's elevated, --yj-info 2.31:1, and
success and warning failed on dark and light both. They are split by the
question they answer. A *fill* is 'what colour is a danger button' --
red in every theme, unchanged -- and a *text* colour is 'what colour is
the word failed on this background', which is now per ramp.

Every fill also carries a computed foreground. White on the default
accent is 1.43:1, and the accent is a colour picker, so no fixed answer
survives it: --yj-accent-fg and the four semantic -fg values are derived
(white if white clears, else black), which keeps a red danger button
white and flips a green or amber one to black. Two accent buttons took
their foreground from --yj-bg-base, which inverts with the ramp -- that
is exactly the white-on-yellow 'Apply (A)' the light theme showed.

Accent used as text gets the same treatment through accentTextOn(),
which mixes along the hue until it clears the ramp's surface and stops.
On both dark ramps it returns the accent unchanged, so the dark themes
are visually untouched by that half.

Measured across three ramps and twelve views: 2237 nodes, 0 failing,
against 110 on dark and 50 on light before. Borders, outlines and
shadows were explicitly kept on the fill token -- a border is not text,
and the first pass of the rewrite moved 30 of them by accident.
2026-08-13 01:05:37 -04:00
logan 533c084f8a fix(a11y): make every text colour clear WCAG AA on every ramp
a11y.md flagged --yj-text-tertiary on --yj-bg-surface as 'borderline
(~4.1:1) but that needs a real measurement', and plan 007 parked it as
'worth measuring before planning'. Measured, against the rendered app
and then across all three background ramps: it failed AA in nine of
twelve text/surface combinations, as low as 2.31:1 on dark's overlay
and 2.55:1 on light's -- the app's most-used secondary text colour,
failing on every view. Not borderline. 110 failing nodes across twelve
views, now 0 of 659.

Three separate mechanisms, and only the first is the finding:

- The ramps. Tertiary is raised per ramp (#a6a6a6 dark, #949494 darker,
  #5c636a light), sized to the lightest surface it actually sits on and
  keeping its hue. Sizing it to bgOverlay too would need a grey lighter
  than secondary, so bgOverlay is documented as not a text surface and
  the one component that put text there uses primary.
- The avatar generator. hsl(hue, 45%, 35%) behind white initials failed
  for 35 of the 360 hues -- the yellow-green band -- so which artists
  were unreadable depended on how their names hashed. The two a sweep
  found were not the finding. 32% clears every hue.
- Jobs' local #ff6b6b, at 4.15:1 on elevated.

Pinned by a unit test over the palette table rather than a DOM sweep:
the ramps are pure data, and checking only what happens to be on screen
is exactly how the light ramp went unexamined. Note that make ui-visual
cannot see any of this -- the component tier renders the fallbacks,
because theme-store sets :root only in the real app.
2026-08-13 00:33:50 -04:00
logan 1ed4167634 feat(a11y): give the context menu a keyboard, and the app a voice
The context menu was the only route to Play, Add to Queue, Play Next,
Add to Playlist, Favourite and Track Details, and it opened on
right-click alone: the panel had no role=menu, so its six menuitems were
orphaned, nothing moved focus into it, and nothing handled arrows or
Escape (a11y.3). Phase 1 deferred this deliberately so it would land
with the dialogs, as one focus-management implementation.

MenuKeyboard is that model. It is standalone rather than part of
ContextMenuController because playlist-view renders a menu without the
controller, and the only thing worse than a menu with no keyboard model
is two menus with two of them. Shift+F10 and the ContextMenu key open it
from a focused row, anchored to that row, and focus returns there.

Three lists had no focused row to open it from, so they gained a roving
tab stop (utils/roving-rows.ts, written once rather than three times).
track-list keeps its own: it predates this, carries selection semantics
the other three do not have, and is pinned by its own tests.

Also the ARIA tail this is one story with: aria-sort on the column
headers (role=columnheader arrived in Phase 1 without it), listbox and
option on the four selectable grids — aria-selected on role=button is
invalid and was being dropped, so the state the whole ctrl/shift
interaction exists to produce was invisible — and live regions on the
four async surfaces that changed in silence.

Two things a reproduction taught that reading could not: the
wa-dropdown-items have not set their role when the host's updateComplete
resolves, so querying by role then finds nothing and the menu opens
without taking focus; and focus() on a popup that has not positioned
itself is a silent no-op.
2026-08-12 11:07:34 -04:00
yonluandClaude Opus 5 01bc5f2094 feat(jobs): surface background jobs with progress, logs and controls
Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.

- backend/jobs: registry with per-job ring-buffer logs, capability-driven
  controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
  back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
  the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
  page; delete the orphaned library-manager component

Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.

fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:42:22 -04:00