`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
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
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of
the phone's bottom bar -- 4px of height is not a thumb target, and a
phone's volume belongs to its hardware keys -- and promised them a
full-screen view. This is it, reached from a button over the mini
player's cover art.
**It composes the transport rather than reimplementing it.** The same
`seek-bar`, `player-controls` and `volume-control` the desktop bar
uses; a phone layout that copies them is a second transport to fix
every bug in, and the seek bar in particular carries interpolation
rules that took a plan of their own to get right. The seek bar
thickens its own track below the breakpoint, in its own stylesheet,
because the track size lives on a wa-slider inside its shadow root
where a custom property from the host cannot reach.
**It is a detail view, not a primary one.** It is somewhere you go and
come back from, so index.ts pushes the current view and Back pops it --
which is also why it is not a fifth tab: a tab you cannot leave by
pressing it again is not a tab.
Two things came from reading a screenshot rather than from a failing
test, and both were invisible to assertions that were individually
correct.
**The mini player was still under the full-screen view**, repeating it
in 4em of an 844px phone. index.css hides the bottom bar while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from index.ts, because the active view is
already published as an attribute. That takes the queue button with it,
so the view carries its own.
**And phase 1's shell rules had never applied.** A media query adds no
specificity, and the phone block sat above the plain rules it meant to
override, so at 390px the header kept its 2em gutters (32px), its 16px
gap and its 24px title, and the bottom bar kept a fixed 320px first
column. Nothing failed: the shell fits because of `min-width: 0` and
each component's own media query, which live in their own stylesheets
and have no later rule to lose to -- so what was dead was exactly the
cosmetic half no assertion looks at. The phone rules are one section at
the end of the file now, and it says why it is last. Measured after:
12px, 8px, 17.6px, `154px 187px 33px`.
`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.
`seek-bar` renders `PlaybackPositionChanged` instead of counting: its
setInterval survives only as interpolation *between* reports, stopped
and restarted by every one of them, so its error is bounded by a
second and is discarded rather than carried. Measured after: UI 00:34
/ backend 34 across two keyboard seeks, against 00:44 / 73 before.
The bar also stops lying about smaller things: the right-hand clock
carries a minus sign and toggles to total duration on click, and the
now-playing column starts at 320 px instead of 200, which is where
"The Orchestra Of" came from.
`now-playing.updated()` used to measure and rewrite its text geometry
on every pass — six querySelectors and a read/write interleave — while
the player store notifies at 1 Hz. It now runs only when its geometry
key changes: the rendered title, the rendered artist, both scroll
flags, or the ResizeObserver reporting a resize, with every read
before every write. Over six seconds of playback: 52 forced layouts
-> 2, and 3.2 ms -> 0.9 ms inside updated().
The scroll flags are in that key because `.will-scroll .scroll-content`
carries `padding-right: 2em`, so applying the class changes the
distance the marquee travels — -128 px before it, -158 px after. A
guard on the text alone leaves every first hover scrolling short, and
nothing in any test tier would have caught it.
The resize's document listeners now attach on mousedown and detach on
mouseup, rather than running on every pointer move in the app for the
life of the process.
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.
- Sidebar: convert all em-based padding/gap to px values, icon sizes to --yj-icon-md
- Now-playing: cover placeholder icon to --yj-icon-lg, text sizes to type tokens
- Search-bar: search icon to --yj-icon-sm, input font to --yj-text-md
- Audio-player: convert em-based gap/padding/margin to px values
- Seek-bar: tooltip font to --yj-text-lg, margin from em to px
- Volume-control: padding/margin from em to px, thumb sizes from em to px
- All components import designTokens and include in static styles