Commit Graph
9 Commits
Author SHA1 Message Date
logan 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.
2026-08-20 20:02:50 -04:00
logan 018d857746 feat(shell): global back and forward in the top bar
The history stack has been global since the Android back gesture landed
-- every navigation is an entry and `popstate` restores any of them in
either direction. What the report describes as "back is tab-scoped" is
that the only way back was a detail view's own button, which leaves the
screen with the view it belongs to: click over to Tracks and the album
you were reading is still one entry away with nothing on screen saying
so.

`<nav-history>` is that affordance, plus `nav.back` / `nav.forward` on
Alt+Left / Alt+Right -- the browser's own combination, and clear of the
bare arrows that seek, since a binding matches on its full canonical
string.

Forward is not back negated, which is why the old `pushedEntries`
counter is gone rather than extended: `popstate` carries no direction
and fires identically both ways, so one counter decremented on every
pop reads a forward as a second back. Each entry carries its index and
the shell keeps the current one and a high-water mark, which also
survives a jump of more than one.

The buttons dispatch the events the rest of the app already dispatches
rather than calling `history` themselves -- the shell owns the guard
that stops a press at the root leaving the app, and a second caller
reaching for history is how the old `navStack` came to disagree with
the platform.

Below 900px the control stands down: the top bar is what runs out of
room first below that, and nothing becomes unreachable -- the shortcuts
are global at every width and the phone has the platform's gesture.

Closes #6
2026-08-19 18:08:55 -04:00
yonluandClaude Opus 5 162c68769f feat(wails): move the frontend onto v3's generated bindings
frontend/wailsjs/ is deleted and frontend/bindings/ takes its place —
a real TypeScript module tree nested by Go import path, generated by
wails3's static analyser rather than by building the app and running
it.  The @go alias absorbs the constant prefix, so a call site imports
'@go/library/library.js' and the codemod over all 93 sites was a
specifier rewrite plus splitting @go/models' namespaces into one
import per package.

The 12 SetContext bindings and the fake `context` model are gone, as
Phase 2's ServiceStartup port promised: 272 methods across 12
services, none of them plumbing.

@runtime/runtime is now a local shim (src/wails/runtime.ts) over
@wailsio/runtime, so the 22 EventsOn imports are untouched.  It
unwraps v3's WailsEvent into v2's callback shape, which is exact here:
nothing in backend/events passes more than one data argument, and v3
only packs arguments into a slice when there is more than one.

v3 tells the truth about two things v2 lied about, and that is most of
the diff.  A Go nil slice really does arrive as JSON null, and a Go
named string type really is an enum; v2 typed them as T[] and string.
utils/binding.ts states the app's actual contract — an absent list is
an empty list — once, at the boundary where it is true, and also drops
the CancellablePromise the app never cancels.  Four test fixtures
widen an enum field back to its value union.

Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts
still fakes window.go, which v3 does not have, so `make ui-test` is
broken and harness.test.ts fails to compile on EventsEmit.  That test
also asserts v2 ordering that no longer holds — v3's Events.Emit calls
the backend and does not notify in-page listeners at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 17:48:38 -04:00
yonluandClaude Opus 5 dc890d1fcc feat(library): remove a track from the library without deleting the file
RemoveFromLibrary deletes the audio_files rows the way the scan's own
orphan cleanup does and records each path in excluded_paths. The
exclusion is not an enhancement: without it the next scan finds the
file, sees no row and imports it again, so the button undoes itself.

The soft scan compares files on disk against rows in the database, so
surveyAudioFiles and countAudioFiles both take the exclusion set —
otherwise an excluded path makes the two disagree forever and queues a
full scan on every launch. Deleting a row cascades to queue_tracks, so
the removal calls the same CompactQueue hook RemoveLibrary does.

Also lands the requested badge: library-status-indicator is a button
again where it can act, utils/library-status.ts states once what owning
and wanting mean, and the long-declared queued state finally has a
producer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 13:12:01 -04:00
logan 1aa1598ecb feat(shortcuts): tell the key story once, and give the arrows back
Build & publish Arch package / arch-package (push) Successful in 2m1s
CI / check (push) Successful in 2m24s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Canceled after 8s
Decision 1 keeps the unmodified single-key bindings, and Settings was
the only place they were written down — three of the four categories of
them, because config-page listed the categories by hand, so the autotag
keys were written down nowhere at all. `?` now opens an overlay from
anywhere the app owns the keyboard, and both surfaces read one table
(services/shortcut-meta.ts, moved out of config-page's private static).

The other half is the same explanation from the other side. Phase 1
gave the arrow keys to the grid, correctly — but all six of them, and
no list in this app moves horizontally: track-list's own handler and
utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So
seeking stopped working from a focused row and nothing gained the keys.
Reproduced in the running app: two ArrowRights on a focused track row,
zero Player.Seek calls, against one per press from the body.

A shifted character no longer reports Shift, so the binding is `?` and
not `Shift+?` — the character already carries the shift, and a layout
where it does not is a layout where "Shift+?" is wrong anyway.
2026-08-12 12:33:50 -04:00
logan 24887d6840 fix(a11y): make Settings and the Downloads tabs keyboard-reachable
a11y.1 is the audit's last Critical and reproduced exactly: seven
config-section headers, seven bare `<div @click>`s with no tabindex,
no role and no aria-expanded, and every section collapsed by default —
so every setting in the app was behind a control that could not be
tabbed to. a11y.2 is the same bug in Downloads' two `<div class=tab>`s.

Both now follow patterns the app already had: a real
`<button aria-expanded aria-controls>` (explore-artist-details has five),
and a role=tablist/tab/tabpanel with a roving tab stop and
Left/Right/Home/End. The section body renders unconditionally and is
toggled with `hidden`, because aria-controls has to name an element
that exists and the slot's light-DOM children exist either way.

H-22's reorder ships with them: Libraries is first and the only
expanded section, Search Index — configured once, if ever — is second
to last. The Playback/Audio section H-22 also asks for is deliberately
not here: there is no output-device, gapless, crossfade or replay-gain
setting in backend/config to expose, and a section of controls that do
nothing is worse than admitting it does not exist.

Settings also stops advertising `tracklist.delete`, which was bound to
Delete and configurable in the UI while nothing listened for the event
it dispatched.
2026-08-12 12:12:33 -04:00
logan 7acb197daf feat(frontend): give a cached view a lifecycle and a keyboard owner
`index.ts` caches primary views and hides them with a class so
scrollTop survives navigation. Nothing else was told: `disconnectedCallback`
never fires for one, so everything written to clean up there never
cleans up. The worst case was not a leak — pressing `s` on Settings
skipped two albums out of the Autotag queue, and `a` on that same live
handler rewrites tags on disk.

- `utils/view-lifecycle.ts` is the missing half: `viewActivated` /
  `viewDeactivated`, with `listenWhileActive`, `intervalWhileActive`
  and `whileActive` torn down on the way out, and an off-screen view
  that does not render. `registerViewAware` gives a shared reactive
  controller the same treatment, because a controller cannot know
  whether its host is a cached view — `ContextMenuController` bound
  three document listeners in `hostConnected`, which for a cached host
  is "forever".
- `services/shortcut-scope.ts` publishes the ambient scope. Resolving
  scope from focus alone was not enough: this app is driven with the
  mouse, focus sits on `<body>`, and a focus-only rule would have made
  the panel keys work only after a click landed inside the panel.
- Global bindings yield to a focused control that owns the key —
  button, select, slider, checkbox, menu, grid row, or anything inside
  an open dialog — so the unmodified single-key bindings stop stealing
  Space and the arrows.
- `utils/roving-grid.ts` gives a card grid one tab stop moved with the
  arrows, since a card per tab stop makes a library-length tab
  sequence.
2026-08-12 01:18:34 -04:00
yonlu f5677628ef feat(16-01): add selectAll() to SelectionController and dispatch shortcut:select-all event
- Add selectAll() method to SelectionController that selects all items via host interface
- Change app.selectAll dispatch from document.execCommand('selectAll') to CustomEvent('shortcut:select-all')
2026-03-07 10:18:08 -05:00
yonlu 40d48151dd feat(09-02): add frontend keyboard shortcut service, store, and controller
- Create keyboard-shortcut-service.ts singleton with keydown listener
- Shadow DOM active element resolution for scope detection
- Text input suppression (only Escape passes through)
- Scope resolution: text-input > panel-specific > global
- Action dispatch to player/queue stores and Wails bindings
- Create shortcuts-store.ts with Wails persistence and event sync
- Create shortcuts-controller.ts for Lit component integration
- Export store and controller from store/index.ts
- Replace hardcoded Ctrl+F handler in index.ts with service
- Initialize service via import in frontend/index.ts
2026-03-06 21:48:52 -05:00