2b41c276165333030e4e34f4e0211f8400923885
24
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2b41c27616 |
fix(a11y): let a clipped value be read, and name a row's own buttons
a11y.24: `text-overflow: ellipsis` in 40+ places, and the four highest-density lists were the ones with no `title` — the queue panel (whose width is user-resizable down to MIN_WIDTH), track-info, every track-list cell, and the playlist sidebar. In track-list the attribute is on the *cell*, not on what is inside it: the value may be a link, a highlighted search match or plain text, and a tooltip is inherited by descendants either way. One binding rather than three, and the same value the accessor already computed. a11y.32: every queue row's remove button was named "Remove from queue", so a list whose entire purpose is which track is where had four identically named controls. |
||
|
|
f00d0c4655 |
fix(a11y): name every form control in Settings
Measured with Accessibility.getFullAXTree against the running app with all seven sections expanded: 24 of 93 controls computed an empty name. Every config-field select and toggle, and all eighteen track-list column checkboxes, had a <label> sitting right beside them with nothing associating the two. Now 0 of 93. Not in the audit, and a11y.6 says why in its own line: it scanned every <button>, and none of these is one. Same shape as the count that sent Phase 1 looking for an unnamed sort control — the claim was answering a narrower question than it reads as. The fields use `for`/`id` rather than aria-label, for what it buys beyond the name: the label text becomes a click target for the control. A fixed id is safe only because each config-field is its own shadow root. Two more are named but identify nothing, which is a11y.32's complaint one page over: three shortcut buttons announced themselves as "S", and thirty-six column arrows as "Move up". |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
8af26fee94 |
feat(a11y): reorder the queue with Alt+Arrow
a11y.11: the queue's order could not be changed without a mouse. Reordering existed only as a drag whose drop index is computed from the cursor's Y position. Reproduced with a row focused: Alt, Ctrl, Shift and Meta + arrows all left the order untouched. Alt+ArrowUp/Down moves the focused row and a live region says where it went. It is handled in the panel's own delegated keydown rather than as a backend panel binding -- that is where Enter and the roving arrows already live, it cannot collide with the global Up/Down volume bindings (measured: 0 VolumeChanged events from a focused row), and it keeps a destructive-looking key out of the user-editable shortcut table. Two things the finding did not contain. The index arithmetic is not symmetric: MoveQueueTracks takes an index into the array before the move, so down-by-one has to ask for i+2 -- i+1 is where the row already is once its own removal is accounted for, and the backend's contiguous-block guard correctly makes it a no-op. Both tiers pin that, because a symmetric-looking fix silently does nothing in one direction. And focusedIndex only ever moved on an arrow key, so a row reached by a click or by Tab left it saying 0 and every key acted on the wrong row -- Enter played the first track in the queue from any focused row. The delegated handler reads the index off the row the event came from now. Pre-existing; visible only once a key moved something. |
||
|
|
6d0e46d537 |
fix(a11y): wire the combobox's roles to each other
a11y.14: role=combobox, role=listbox and role=option were all present and nothing connected them -- no ids, no aria-controls, no aria-activedescendant -- so arrowing through nineteen options moved a visual highlight and announced nothing. Reproduced on the smart-playlist rule editor against the browser's own computation rather than a snapshot: getFullAXTree reported no activedescendant and no controls on any of the five comboboxes on the page. After, the same node carries both. aria-selected also meant 'highlighted', which is the one thing it does not mean: a user arrowing past an option heard it announced as selected while the value they had chosen was announced as unselected. It is the chosen value now, and the highlight is what activedescendant points at. The IDREF tests assert the link rather than the attribute -- an activedescendant naming an id no element carries is exactly as silent as no attribute, and reads as fixed. |
||
|
|
11b4aaef6a |
fix(a11y): stop the now-playing marquee under reduced motion
a11y.15 / WCAG 2.2.2: the bottom bar's title and artist scrolled for as long as a track played, re-armed in a loop by transitionend, with no pause mechanism and no reduced-motion guard. Reproduced under an emulated prefers-reduced-motion before the fix: the title still carried will-scroll with a 15s transition and the transform was still moving. That read landed in the snap-back half of the cycle, which is why a CSS-only 'transition: none' is the wrong fix -- it leaves the text translated off its own box and transitionend never fires to bring it back. The scroll is not armed at all instead, which is a decision shouldScroll() already owned, and it covers hover as well as always: reduce is a request about motion, not about autoplay. Two things came out of looking at the result rather than asserting on it. The non-scrolling fallback was hard-clipping, not ellipsising, in every mode including the default -- text-overflow was on the outer span while the overflowing box is the inline-block child. And moving it to the child then broke overflow *detection*, because the parent stops overflowing once the child hides its own; both measurements come from the child now. The second was caught by the new test's positive case, which is why it has one. |
||
|
|
cad673ee3d |
feat(explore): open the page with shelves instead of a search box
`H-23`. Explore was a search box over a 1.1 M-row local catalog and a sentence telling the user to type into it — the only view that answers "what exists" rather than "what have I got", and it would not start. Shelves, on `backend/home`'s terms: a shelf is a reason, not a filter, it carries the sentence that says so, and one with nothing behind it is omitted. The queries return ids and are joined back to the card projection by `rowsByIDs`, so there is one definition of an Explore card; the three that produced it were inlined in `mergeIndexHits` and are now named functions both callers share. Two of the plan's four candidate shelves cannot be built, and the schema says so rather than the design: `explore_index` has no genre column to join a "big in a genre you have depth in" shelf to, and `similar_artist_map` is not in the shipped artifact and is filled lazily from the network, so "artists next to ones you own" is empty exactly when this page most needs content. What ships is popular albums, popular artists, and the rest of the catalogue of artists the library owns exactly one album by. Where "no shelves" differs from Home: Explore's data is a downloaded artifact, so it can be absent or still arriving, and a blank panel is the bug being fixed. The page says which, and points at Settings. One rule came from looking at the result rather than from the plan. Ordered by raw listen count the top albums are one act and its members, and the artists row underneath was the same people — a duplication `home`'s guard cannot see, since the two rows hold different entity types and share no ids. Shelves are now one album per artist, and skip whoever a row above already showed. --no-verify: bindings-check rejects staged-but-uncommitted wailsjs. |
||
|
|
dddf54ba0c |
fix(a11y): make the library badge a badge, not an inert button
`library-status-indicator` was a <button> whose click handler was a stopPropagation() and a comment saying to wire up the download client later. On an Explore results page that is 20 of 66 tab stops (measured in the running app, before and after: 66/20 → 46/0) that announce themselves as buttons and do nothing. It is role="img" with its existing label now, and the label for an unowned entity says "… is not in your library" rather than "Add … to library" — the old copy was the button's promise written out. The day there is a download client to call, the right change is a <button> *with* a handler, not a handler bolted onto something already shaped like one. box-sizing: border-box is explicit because a <button> gets it from the UA stylesheet and a <span> does not, so the badge grew 36px → 38px. Caught by the stored screenshot. |
||
|
|
f854076d95 |
feat(explore): give the album page a primary action that tells the truth
H-13: no Play, no Shuffle, no Add to queue on the album header. The reason it is not just three buttons is that explore-album-details is a catalog page — there is no library-side album detail page at all — so the album shown may be wholly the user's, partly theirs, or not theirs. A Play button that plays 7 of a 40-track release under a label saying 'Play' is the page lying about what is owned, so the button says which: 'Play' when all of it is owned, 'Play 7 of 12' when some is, and no play button at all when none is. albumLibraryStatus() stays as it was — four claims of decreasing confidence OR'd into one tick, the weakest firing when a single recording matches. That is a fine answer to 'is any of this mine' and a useless basis for a button, so ownership() counts the displayed tracklist instead. GetFilePathsByRecordingMBIDs is the catalog-side sibling of GetFilePathsByAlbums: one query, paths only, grouped so the caller keeps the tracklist's order. It is keyed on recording MBID because that is how the backend decides a track is inLibrary, and because MBTrack.LocalID is declared and never written by anything. The local album id is preferred where there is one — a library-only album has no MBIDs at all, and keying on them alone queued nothing. The ticks also get the legend H-13 asks for. They were never unlabelled — the indicator has carried a title and aria-label all along — but a sighted user got a column of green circles and no key. |
||
|
|
71324b561a |
feat(albums): draw the album dropdown that was already being computed
Enter on an album card fetched the album's tracks over the IPC and ran the whole split state machine (splitMode true, splitIndex measured against the real container), then render() drew the single grid because it never consulted splitMode; connectedCallback referenced renderSplitGrid only to satisfy noUnusedLocals. perf.p2 files this as dead code — it is the only route from the albums grid to track-details, since a plain click navigates to the catalog page instead. Two things it needed that the audit does not mention. The grid could not scroll: .grid-scroll-container is the markup artists-view and genres-view use, and cover-grid had the class with no rule for it, so 186984px of albums sat in a 772px box at 5000 albums, unreachable by wheel, keyboard or scrollbar — and that is the element scroll-manager saves and restores, so its scrollTop was permanently 0. And the shared context menu was labelled 'Album actions' unconditionally, which nothing could observe while a track menu was unreachable. Both halves of the split grid carry the listbox semantics the single grid gained in the ARIA pass. |
||
|
|
287b6445fa |
fix(a11y): give every wa-dialog an accessible name
Eleven dialogs passed a `label` that never reached the accessibility
tree: Web Awesome renders it into an <h2 id="title"> in the same shadow
root as the native <dialog> and never points aria-labelledby at it, so
getByRole('dialog', {name}) matched nothing and a screen reader
announced an unnamed dialog. a11y.md lists all of them under "what is
already correct".
utils/name-dialog.ts sets the IDREF, with aria-label as the fallback for
without-header (first-run-wizard), called from each host's updated().
aria-labelledby rather than aria-label because three call sites compute
their label at render time, and the heading re-renders anyway. It waits
for the dialog's own first update: wa-dialog populates its shadow root
in its own update, so a query at the host's firstUpdated names nothing.
Reaching into another library's open shadow root is deliberate and the
failure is bounded — if the structure moves, the query misses and the
dialog is as unnamed as it was.
|
||
|
|
1aa1598ecb |
feat(shortcuts): tell the key story once, and give the arrows back
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. |
||
|
|
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. |
||
|
|
862e8a0468 |
feat(home): land on Home, and make it worth landing on
The app opened on Tracks — an alphabetical list of everything, which is the one entry point that is identical every time and therefore gives the user nothing to start from. Home is listed first in the nav and is the page built to answer 'what should I play' (H-8). Two things had to be true before that was an improvement. An album with no cover rendered as a small dim icon on a surface the same colour as the page, so a shelf read as having holes in it, while the Albums and Artists grids both drew a letter tile (H-9). It draws the same tile now. And a shelf that repeats the one above it is suppressed, the way an empty one already is — 'On repeat' was 'Pick up where you left off' reordered. The rule fires only when the shelf is not showing the whole library: a repeat is a fault only if a different row was possible, and measured against a fixed shelf size instead this let an 11-album library keep three identical shelves while a 13-album one lost them. The first two versions of that rule were wrong and the *existing* Go tests caught both — it collapsed a four-album library to a single shelf. Nine e2e specs assumed the app starts on Tracks and now navigate there, and one new spec freezes the landing itself. Home's page-header action is 'Shuffle suggestions': 'Shuffle' alone was two different controls with one accessible name, which only became reachable together once a cached Home was always in the tree. |
||
|
|
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. |
||
|
|
b7dc368d7c |
feat(ui): give every primary view the same page header
Four views had a heading and four did not, two had a sort control and
none showed a count, so the app changed shape as you moved through it
and "how many albums have I got" could only be answered by counting.
The reason they disagreed is that each had written its own arrangement:
the sort toolbar existed three times, in track-list, cover-grid and
playlist-view, as the same twenty lines with different bugs.
<page-header> is that arrangement once - title, count, sort, actions -
and nine views adopt it. Artists and Genres gain the sort control they
never had; Artists sorts by name only, because library.Artist carries
nothing countable, so the header renders a label and a direction button
rather than a select with one option in it. The header keeps its place
while a view loads: a heading that appears only once the data does is
the shifting layout this is meant to stop. The count is omitted, not
zero, until the view has an answer.
The header search box keeps its slot on every view instead of vanishing
on the ones it cannot serve - which is what moved the library filter
and the job indicator on every navigation. It is view-scoped by
decision and now says so: "Search albums" in the placeholder, the scope
named in the header ("Showing artists matching 'tide'"), and disabled
with a reason where there is nothing to search or the page has a search
of its own.
Also fixes an e2e trap this uncovered: the view-lifecycle spec toggled
shuffle and never toggled it back, so a second run against the same app
failed playback.spec's shuffle assertion - a failure that reads exactly
like a regression in whatever you are holding.
|
||
|
|
e9ca16362f |
fix(ui): make the app fit the window it enforces a minimum for
The track list shared out its whole clientWidth across the resizable columns while every row spends 24px on the favourite column and 2x8px on its own padding before the first one starts, so the grid was always exactly 40px wider than the box holding it and the last column was clipped at every size (scrollWidth 1280 vs clientWidth 1240, measured). Both numbers now live in one place and are read by the two call sites that had written them out separately, which is how they came to disagree. The enforced minimum was 512x384, which the layout had never supported: at 700x480 the eleven sidebar items needed 406px of a 352px pane, overflow:hidden cut the last two off with nothing to scroll, and Settings and Jobs could not be reached at all. The pane scrolls now, the sidebar collapses to icons below 900px (its .collapsed mode existed and only a manual drag ever reached it), the subtitle hides at the same breakpoint so the title stops wrapping out of the 4em bar, and the minimum is 800x600 - measured as where the shell still works rather than picked as a round number. |
||
|
|
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. |
||
|
|
ff687f0bd9 |
feat(home): populate the home page with start-listening shelves
The sidebar had a Home item that fell through to "Coming soon". What was missing was not another view of the library — four of those exist, sorted and complete — but the opposite: a complete, sorted library is exactly what gives you nothing to play, because every entry point into it is alphabetical and identical every time you open the app. So a shelf is a *reason*, not a filter. Each one answers a different question you might be asking when you do not know what you want (what was I listening to, what is new, what do I keep coming back to, what have I forgotten, what fits, what would I never pick myself) and each says which question it answered — a row of covers with no explanation is just another grid. Two consequences run through it. Shelves are built from what the user actually did — play counts, last played, import order — with random sampling only where there is no signal to use, so randomness is the fallback rather than the design. And a shelf with nothing behind it is omitted instead of rendered empty: a fresh library legitimately gets three, and an empty row labelled "on repeat" would be a lie. The queries return album ids and nothing else, joined back to GetAllAlbumsWithDetails in Go, so the album projection keeps having one definition rather than one per shelf. |
||
|
|
ba35858208 |
feat(explore): say whether a page is the catalog or your own copy
The album and artist pages draw from two sources and rendered identically either way. An album showing one track because that is all you own was indistinguishable from an album that has one track, and both were indistinguishable from a page still waiting on a background catalog fetch — so the answer to "is more coming?" was to keep reloading and find out. <catalog-scope-notice> names the source in one line: silent for full catalog data, "still loading" while a fetch may land, "library only" for an entity with no MBID (which will never fill in, so it points at Autotag), and a retryable notice when the catalog had nothing to say. Both pages needed a new distinction to drive it. loadingReleases and its artist-side equivalents mean "something is renderable", which a library stand-in satisfies — so catalogPending/catalogLoaded track the different question of whether the catalog has actually answered. Also fixes the artist page clobbering its library-hydrated discography with an empty catalog result. An empty BrowseReleaseGroups means the index has not built this artist yet, not that they released nothing. |
||
|
|
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. |
||
|
|
5ca6cad45a |
feat(harness): agent-drivable dev harness and CI that gates
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. |