CLAUDE.md had grown to 4,046 lines, ~3,300 of them per-component write-ups: why a breakpoint is 500px, why a cap is a quarter, what a spec once missed. Every session loaded all of it, and the rules that apply to every change were buried among decisions that apply to one. Those write-ups were already duplicated as comments beside the code they describe -- every issue number they cite also appears in a code comment -- so they are deleted rather than moved. What is left is the tracker workflow, the commands, the verification tiers and a set of broad engineering rules distilled from them, each pointing at where its example lives. Three declined decisions recorded nowhere else go to NOTES.md, and three code comments that cited CLAUDE.md sections by name no longer do. Closes #256 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19 KiB
CLAUDE.md
Guidance for agents working in this repository. AGENTS.md is a symlink
to this file (make skill-check asserts it).
YellowJacket is a music player for desktop Linux/macOS and Android: Go backend, TypeScript/Lit frontend, bridged by Wails v3. Plays MP3, FLAC, OGG Vorbis and WAV.
Where things are written down. README.md is for users;
CONTRIBUTING.md is setup, build and the contributor workflow. This
file is the rules. The reasons for a specific shape live in a comment
beside the code that has it — the code here is heavily annotated, and
the comment usually cites the issue — so read the comments of what you
are changing before changing it. .planning/NOTES.md holds dated
measurements, gotchas and rejected ideas; the yellowjacket-dev skill
(.pi/skills/yellowjacket-dev/) holds the operational how-to for each
test tier. Packaging channels keep their own documents
(packaging/*/README.md, docs/android-release.md).
Issues and workflow
The Gitea tracker is the source of truth, shared with a collaborator
who cannot see this session. scripts/issue.sh is the whole interface
(list, mine, search, show, new, claim, unclaim,
comment, close, label, depends, labels; needs GITEA_TOKEN
with write:issue).
- Search before starting (
issue.sh search <terms>, which covers closed issues too). If nothing covers the work, open an issue first. - Claim before the first edit.
claimsets the assignee,Status/In Progressand a comment naming the branch and approach. If someone else holds it, talk to them rather than working around it. - File findings. A bug found along the way, or work deliberately not done, is an issue with a reproduction — not a sentence in chat.
- Labels are a taxonomy:
Kind/*,Area/*,Priority/*,Platform/*,Reviewed/Confirmed,Status/*(Status/*andReviewed/*are exclusive). #73 is the roadmap; work in its order. Hard blockers are Gitea dependencies plusStatus/Blocked. Closes #ngoes in a commit body, one issue per line. Gitea does not parse PR bodies, and a comma list half-works. After a merge, runissue.sh list --state openand close whatever did not take. Unclaim happens automatically on close (.gitea/workflows/unclaim.yml).mainis protected: feature branch and PR only. The issue number goes in the branch name and PR body, never the commit subject. A PR body carries a commit-to-issue table, the verification actually run, and aCloseslist (PR #83 is the shape).
.planning/ is design documents and measured history, not a queue:
NOTES.md, plans/completed/ (one recap per milestone), audits/,
and plans/active/ (in-flight work only; usually empty). A decision
reached on an issue that outlives it goes in NOTES.md. The autonomous
loop (.pi/skills/yj-loop/, plan 020) follows the same rules.
Commands
make dev # Hot-reload development (installs deps, generates code, cleans frontend)
make dev-debug # Same as dev but with YJ_LOG_LEVEL=debug
make dev-headless # Start headless in the background and return (SEED=<name> to seed)
make dev-stop # Stop it (SIGTERM, so shutdown hooks run)
make dev-logs # Tail .dev/app.log
make testdata # Generate the deterministic fixture music library
make bulkdata # Generate the ~50k-track measurement library (BULK_TRACKS=)
make sandbox-seed NAME=<n> # Build a seeded YJ_HOME by *running* the app
make sandbox-seed-bulk # Same, from the bulk library (minutes; it is a real scan)
make perf LABEL=<n> # Measure a running app; writes .dev/perf/<n>.json
make perf-compare BEFORE=<a> AFTER=<b> # Print the before/after table
make build-dev # Debug build with symbols
make build-prod # Production build (stripped and trimmed; no UPX)
make generate # Run code generators (sqlc + templ via go generate)
make bindings # Regenerate frontend/bindings (wails3)
make e2e # Playwright suite against a running dev-headless app
make e2e-setup # Install the e2e runner + its browser (once)
make ui-test # Vitest component/store suite in a real browser (no app)
make ui-visual # Same, including toMatchScreenshot comparisons
make ui-setup # Install the Vitest provider's own Chromium (once)
make bindings-check # Fail if frontend/bindings is stale vs the Go bindings
make skill-check # Fail if a doc names a make target that doesn't exist
make commit-check # Fail if a commit subject is not a Conventional Commit
make css-check # Fail on a nested CSS rule Chrome 113 would drop
make lint # golangci-lint v2 (strict), all three build configurations
make test # All tests with race detector, all three build configurations
make vulncheck # govulncheck for CVEs
make release-dry # What a release from here would ship
make setup # Install go tools, frontend deps, git hooks (lefthook)
Go tests: go test ./..., or go test -run TestName ./backend/player/.
Two tagged passes are not covered by ./... (make test runs all
three):
go test -tags indexbuild ./backend/explore/... ./cmd/...
go test -tags dev ./backend/testctl/...
Audio playback integration tests need YELLOWJACKET_INTEGRATION=1.
Verifying a change
Cheapest tier first. The skill says which tier a change needs and the exact commands.
- Go —
database.NewTestDB(t)is in-memory and built by the sameapplySchemaas production (but shares one connection, so it cannot see read-pool bugs). Table-driven. Assert a service's events withevents.WithSink(ctx, rec)(backend/queue/emit_test.go). Seed tracks withdatabase.InsertTestTrack. make ui-test— Vitest in real Chromium, no backend:frontend/test/support/wails-fake.tsreplaces the Wails IPC transport, so real bindings and stores run.setup.tsclearslocalStoragebetween tests.make e2e— Playwright againstmake dev-headless(the real app in Wails-tags servermode, no display) on:34115. Await backend events (window.__yjEvents.wait(...)), never timeouts. Dev builds withYJ_TESTCTL=1expose/__test/(health,db/snapshot,db/restore,emit,sql).make perf— for questions whose answer is a number, againstmake sandbox-seed-bulk. Not a pass/fail tier.make ui-visual— people only; baselines are machine-specific. A change that moves a component's geometry refreshes that baseline in the same commit, after reading the image.- Android — no CI tier sees the device. See the skill's
android-tier.md,make android-inspectandmake android-eval.
Fixtures (test_data/music_library_test/) are generated by make testdata and reached through internal/testfixtures by case. Seeds
and fixtures are produced by running the app and our own writers,
never hand-written.
A test must be able to fail on the broken build. Most bugs that survived here survived a test that was named for the behaviour but measured plumbing. So:
- Assert the user-visible property, not the internal bookkeeping (e.g.
aria-currenton the nav item, not the shell'sdata-active-view). - When asserting a marker is on the right element, also assert it is absent from the others.
- A source sweep asserts first that it read something; a sweep over an empty glob passes.
- A spec that depends on an order or a store state sets it explicitly.
- Where a tier genuinely cannot observe something (touch, Chrome 113, top-layer clipping), assert the mechanism and say so in the test.
Code generation
Never edit generated code: backend/database/sql/sqlcgen/ (sqlc, from
sql/queries/), *_templ.go (templ), frontend/bindings/ (wails3),
frontend/src/events.ts (from backend/events/events.go). Run make generate after changing .sql, .templ or events; make bindings
after changing a bound Go method. Pre-commit checks all of them.
Code style
- Go: golangci-lint v2 strict (
err113,nlreturn,wsl_v5,godot,sloglint,perfsprint); imports grouped stdlib → third-party →yellowjacket/....make lintandmake testmust use the same three tag sets. - TypeScript: strict, no implicit any, no unused locals/parameters.
- Comments explain why a shape is what it is, cite the issue, and are the primary design record. Keep that density.
- Commits: Conventional Commits (
scripts/commit-check.sh, whose type list must match.releaserc.yml). semantic-release reads only the type: a CI-only change isci:, neverfix(ci):—fixships a real release to Arch, Homebrew and the APK registry.
Engineering rules
These are the patterns this codebase converged on. Each one was learned from a bug; a change that breaks one needs an argument in its PR.
One definition, many readers
- A fact is stated in one place, and everything else reads it. A
second copy is a second thing to forget. Examples: the track
projection (
track_metadataview,trackFromRow), the catalog row (indexRowColumns/scanIndexRow), the view list (services/view-meta.ts), shortcuts (services/shortcut-meta.ts), icon meanings (utils/icon-language.ts), ownership (utils/ownership.ts), request status (utils/library-status.ts). - Where Go and TypeScript must both declare something, a test reads
both (e.g. valid track-list columns). Where a rule is about every
call site, a source sweep enforces it (
TestNoDirectRuntimeEmits,TestNoWritesOnTheReadPool,icon-language.test.ts,menu-surface.test.ts,make css-check). - Encodings are converted at one boundary. MBIDs are 16-byte BLOBs
known only to
backend/explore/mbid.go; nil slices/maps are normalised only infrontend/src/utils/binding.ts. SQLite does not coerce TEXT↔BLOB, so a missed conversion is a silently empty result, not an error — and that includes bound parameters, not just literals: a Gostringcursor against a BLOB column made the catalog merge loop forever while merging nothing (#258).
Data
- The schema is one description; there is no migration chain.
sql/schemas/*.sqlis the current shape.staleshape.gorepairs an old database at open by retiring stale tables — neverAuthoredones, and neverCacheones under theindexbuildtag (that catalog costs a ~205 GB re-download). - Every table is classified in
backend/datamap(Kind, Lifetime). Authored data is what a user cannot get back; cascading it needs an argued exemption. - Ownership is a file. "Do I have this" is answered by
audio_files, never by a metadata row or a flag. - Writes go through the writer.
QueryContextand friends use a query-only read pool; useExecContextorQueryRowWriter. - Query files are ASCII (sqlc rewrites by byte offset). A slice and
a named parameter don't compose in sqlc; filter in Go instead.
library_id = 0means every library. - Optional columns in a downloaded artifact are probed, not assumed
(
artifactHasTotalsis the pattern), on the writer connection. - Config zero values are the intended default, so a new key needs
no migration. A setter that can reject its value restores the old
one (or validates a candidate first):
Save()validates the whole config, so one bad value blocks every later save (#231).
Work, persistence and the network
- A durability write is submitted, not performed. The player and queue persist through ordered background writers carrying snapshots; never block a user action or hold a component lock on the single SQLite writer.
- Long-running work registers as a job (
backend/jobs): visible, pausable, cancellable. Register it after counting the work, so an empty pass shows nothing. - Background network work yields. Mark it with
WithBackgroundPriority(ctx)so it waits behind interactive requests on the shared rate limiters. - Record that you asked, not that you got an answer. An empty
upstream response is an answer; mark it, or the same work repeats on
every launch. The frontend caches absence the same way (
[]incredit-store,completeness-store). - A loop that must make progress checks that it did. A batch walk whose bound does not strictly advance, or a merge that lands fewer rows than it read, fails loudly — the failure mode otherwise is a progress bar that never moves and no error (#258).
- Don't fetch or store what nothing reads. Fetch what is displayed, record the rest as URLs.
- Every cache has a size ceiling, not just an age, and the ceiling
covers every reference to the data (capping one of two holders frees
nothing). Frontend caches are
LRUMaps registered withutils/cache-stats.ts. - Large downloads check the connection first (
netpolicy.go); an unknown network is not treated as metered.
Events
- Emit only through
events.Emit(ctx, name, data...). - An event's cost is part of its meaning.
TrackMetadataChangedmakes the frontend discard and refetch everything; never reuse it for something cheap. Payloads carry enough for a consumer to patch in place instead of invalidating. - An unchanged payload is not re-emitted (
emitStatus), so every mutation of a derived status must emit it itself. - Clocks come from the backend. The frontend's own timers only interpolate between reports (playback position).
Platform and build tags
- Wails lifecycle:
ServiceShutdown()takes no context (a method with one is silently never called). Cross-service wiring isbackend/startup.go, registered last — server mode emits noCommon.*application events, so nothing may depend on them. - Android recreates the activity, not the process.
main()latches onmainStartedas its first statement;ServiceShutdownnever runs there, so durability cannot depend on it. - Keep testable logic out of platform-tagged files. A tagged file
holds only the platform call or constant; the contract lives untagged
(
mediacontrols/androidpayload.go,SystemOwnsVolume), because nothing inmake testcompiles theandroidtag. cmd/indexbuildandcmd/indexexportbuild withCGO_ENABLED=0and must not link Wails (TestIndexToolsDoNotImportWails).
Frontend structure
Lit 3.2 + Web Awesome, singleton stores in src/store/, bindings
imported via @go/....
- Views are lazy chunks (
VIEW_LOADERS/DETAIL_LOADERSinindex.ts); a missing entry renders a blank page. The failure surfaces (notification-host,inline-notice,confirm-dialog) stay eager. - Primary views are cached, not unmounted, so
disconnectedCallbacknever fires for them. Register listeners, timers and subscriptions withlistenWhileActive/intervalWhileActive/whileActive. - Browser history is the only navigation stack. Back goes through
history.back(); which view is active isactive-view-store. - One surface per kind of UI: failures through
notification-store(pick the level; copy viautils/describe-error.ts); destructive actions throughconfirmAction(); every dialog is awa-dialog; every menu ismenu-surfacewithMenuKeyboard; every key binding goes through the shortcut service with a scope, never a component's own document listener. - Ask for what the caller uses, once. Batch per-row lookups into one call per frame; return paths rather than whole rows; group results so the caller keeps its order.
- Look up by identity, resolve indexes late. Build lookups keyed on
the store array's identity (
utils/track-index.ts), never.findin a loop. Selections are keyed by file path; an index is computed when used, because it goes stale on any re-sort or refetch. <lit-virtualizer>repaints only when its own properties change. CallrequestUpdate()on selection or playing-track changes; don't hoist per-render closures that are what currently triggers a repaint.- Work in
updated()states what it depends on. Guard it on the inputs that change its result.
Responsive layout and input
Size bands: phone below 600px, compact 600–899, desktop from 900. No action may be unreachable at any size; a control can leave a surface only if it is reachable elsewhere.
- Whether an element exists is
matchMedia; how it looks is CSS. Adisplay: noneelement still carries its handlers, test ids and timers. - Measure fit where content widths vary (ResizeObserver, every pass
starting from all-visible) instead of guessing a breakpoint.
scrollWidthignores right padding; measure children against the content box. - Every box that must shrink carries
min-width: 0. Phone-width rules go last in a stylesheet — a media query adds no specificity. - Behaviour keys on capability, never width or platform:
pointerTypefor mouse vs touch,(hover: hover) and (pointer: fine)for hover-revealed controls and tints,SystemOwnsVolumefor volume. - Touch gestures are announced, then claimed (
yj-tap,yj-long-press,yj-swipe-startfromutils/touch-gestures.ts; claim withpreventDefault()). - The Android WebView is Chrome 113: no Popover API (so
wa-popupis clipped bycontain: paint— use a dialog/menu-surface), no relaxed CSS nesting (start nested rules with&;make css-check), nolight-dark(). Chromium passing is not evidence about the device. - Overflow inside a component is invisible to page-level checks; measure each control against its container.
Accessibility
- A name is computed where the role is. Verify with
getByRoleor CDPAccessibility.getFullAXTree; Playwright snapshots don't print dialog names, andtitle/placeholderare fallbacks, not names. - A control that cannot act is absent, not a dead button.
aria-selectedneeds a role that supports it (listbox/optionfor selectable grids). Dimmed rows carryaria-disabled. - Live regions are in the DOM, empty, before their text arrives.
- Colour is never the only signal. Text colours are per-theme and
checked at 4.5:1 against every surface (
theme-contrast.test.ts); foregrounds on fills are computed (--yj-*-fg), because the accent is user-chosen. - Motion respects
prefers-reduced-motionover any app setting. - Querying inside a Web Awesome component waits for its first update, not the host's.
CI and release
ci.ymlis the only gate:check(commit-check, lint, test, tsc, ui-test, bindings-check, skill-check) ande2e(headless Chromium and WebKit). The runner has capacity 1.- Releases are cut by hand by dispatching
release.yml(usedry_run, ormake release-drylocally). It pushes a tag with a user PAT, which starts the four publishers (arch-package,homebrew-formula,android-apk,desktop-assets).@semantic-release/githuband@semantic-release/gitmust not be added; check rendered release notes, not the exit code. - The APK is never signed with a debug key — a certificate change forces users to uninstall and lose their library.
- Build: the Makefile is the front door; Taskfiles and the vendored
go tool wails3(on PATH viascripts/toolbin) sit behind it; output isbin/.build/android/is committed source. After refreshingbuild/assets frombuild/config.yml, recheck nfpm'shomepageandlicense. - Failing job logs:
GET /api/v1/repos/{owner}/{repo}/actions/runs/{run}/jobsand.../actions/jobs/{job_id}/logswithAuthorization: token $GITEA_TOKEN.