Commit Graph
7 Commits
Author SHA1 Message Date
logan 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
2026-08-20 17:17:23 -04:00
logan dc8db159f9 feat(player): centre the transport and show the volume inline
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m18s
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
2026-08-20 00:40:52 -04:00
logan d6b48fb3ac fix(player): stop the seek bar resizing as its clocks count
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m16s
Two different things moved it and they need different answers. Digits
in a proportional font are different widths, so 1:11 is narrower than
4:08 and the bar breathed once a second -- tabular figures fix that.
The character *count* changes too, at the hundredth minute and whenever
the right-hand clock is toggled to remaining and grows a minus sign,
which a figure width cannot fix -- so each clock reserves the widest
string this track can put in it.

The budget is per track rather than a constant: reserving six
characters on every track would push the slider in by a character at
each end to buy nothing.

Measured in the component tier: 4.5px of drift across three positions
before, none after.

Refs #13
2026-08-18 11:04:21 -04:00
logan b7831e3f15 fix(a11y): name the sliders and the progress bar where the role is
`a11y.md` lists `seek-bar` and `volume-control` under "what is already
correct" because both pass `aria-label`. Measured with
Accessibility.getFullAXTree against the running app on all eleven
views, both sliders compute a name of "": `wa-slider` puts
role="slider" on a div inside its own shadow root, pointing
aria-labelledby at an empty internal <label>, and that IDREF outranks
the host's aria-label. `volume-control` did not have the aria-label the
audit credits it with at all.

The name comes from `label` now, which is the library's own API — and
for a slider that is visible, so `styles/wa-slider-label.css.ts` hides
it by part. Preferred over reaching into the shadow root the way
name-dialog.ts must: if Web Awesome renames the part the label becomes
visible rather than silently nameless. The second rule in that file is
load-bearing — `#slider` takes an 8px margin the moment a label exists,
which grows the bar from 6px to 14px and moves the transport with it.

a11y.25 is the same family: wa-progress-bar maps `label` onto its inner
aria-label, falling back to the localised word "progress" — so it was
named after the widget rather than after the work, not unnamed.

The existing transport test asserted the host's aria-label and called
it an accessible name, so it was pinning the bug.
2026-08-13 01:48:43 -04:00
logan 5830b1ba17 test(frontend): cover the lifecycle, the voice and the repaints
Component and store cases for everything in this series, several of
which exist because the thing they pin is invisible everywhere else:

- `view-lifecycle` and `keyboard-reach` — a document listener count
  that does not grow across a simulated navigate cycle, and a tab
  sequence that reaches the sidebar and plays a row without a mouse.
- `notifications`, `notification-store`, `confirm-dialog`,
  `empty-states` — the four levels, the (level, region, key)
  coalescing window, and loading/failed/empty as three states.
- `card-grid-repaint` — fails if `artists-view`'s or `genres-view`'s
  per-render arrow functions are hoisted to stable fields, which is
  the audit's own recommendation and takes the cards from 1 highlighted
  to 0. It exists for no other reason.
- `lazy-track-details` — reads the five sources and fails on a
  returning static import, the same shape as `TestNoDirectRuntimeEmits`
  and for the same reason: the invariant is about what the code does
  *not* say.
- `now-playing` — a position report that changes nothing must not
  touch the DOM again, and a track change must. The first fails
  against the old unconditional `updated()`.
- `playlist-virtualization`, `list-render-cost`, `selection`, `icons`,
  and the store cases for the library-filter race, the never-settling
  waiter and the per-playlist patch.
2026-08-12 01:20:03 -04:00
logan 0ca37a31a6 fix(player): show mute in the volume indicator
Muting does not change the volume level, and VolumeChanged carried
nothing but that level — so pressing M silenced playback and left the
indicator showing the volume it still had. The UI had nothing to react
to.

Mute rides on its own event rather than widening the volume payload,
since the two are genuinely independent: a muted player at 40% is a
different state from a player at 0%, and only one of them comes back
when you unmute. The icon crosses out and dims, and the popup gains an
explicit Mute/Unmute so the keyboard shortcut is not the only way in.

MuteToggle also now takes the speaker lock (it was mutating the effects
chain from outside it) and refuses politely rather than dereferencing a
nil streamer when nothing has been loaded yet.
2026-08-11 01:14:47 -04:00
logan 5ca6cad45a feat(harness): agent-drivable dev harness and CI that gates
Build & publish Arch package / arch-package (push) Successful in 2m8s
CI / check (push) Failing after 1m56s
CI / e2e (push) Skipped
Search index maintenance / maintain-index (push) Successful in 13s
A coding agent could develop this repo's Go packages and could not
develop the application: every path to running YellowJacket ended in a
blocking GTK window, so 265 bound methods, 46 events, 33 component
directories and 13 stores had exactly one form of verification
available — `tsc --noEmit`.

The unlock is that `wails dev`'s dev server on :34115 serves the real
frontend with the real generated bindings against the same Go backend a
desktop window attaches to, so a plain Chromium under Xvfb gets a fully
functional app. Four test tiers now exist, cheapest first:

- `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app,
  no backend, no display. Works because `frontend/wailsjs/` is a pure
  passthrough to `window.go`/`window.runtime`, so faking just those two
  globals runs the real bindings and the real store code.
- `make test` — services in-process, asserting on the payload the
  frontend would receive, via a new `events.Emit` wrapper.
- `make dev-headless` + `playwright-cli` — the real app, driven
  interactively, with an event bridge on `window.__yjEvents` and a
  dev-only control surface at `/__test/`.
- `make e2e` — 19 of those flows frozen as Playwright specs.

`events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call
sites: wails' `getEvents` `log.Fatalf`s on any context without its
runtime, so those paths could not run under test and a background
worker could take the app down. Four packages had each hand-rolled the
same guard; nine more guarded on `ctx != nil`, which does not help.
`TestNoDirectRuntimeEmits` fails the build on a new one.

Fixtures are generated, not committed (`make testdata`), and seeds are
built by *running the app* — never by hand-writing config and DB rows,
which would be a second description of a valid YJ_HOME.

`.gitea/workflows/ci.yml` is the first workflow here that tests
anything; the other three only package, so `gitea_ci` reported only
packaging jobs and misled anyone asking whether a push was healthy.
Both jobs were prototyped to green in a bare ubuntu:24.04 container
before the YAML was written, which immediately caught `make lint`
linting three configurations that nothing builds: all three passes
omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch
still ships and Ubuntu 24.04 dropped.

Operational instructions live in `.pi/skills/yellowjacket-dev/`,
measured discoveries in `.planning/NOTES.md`, and architecture in
`CLAUDE.md` — split by tense, not by topic, because a topical split
gives every new fact two plausible homes. `make skill-check` fails a
commit if the skill cites a make target that does not exist.
2026-08-10 23:20:42 -04:00