Compare commits

..
Author SHA1 Message Date
logan 894b16d2f7 docs(player): attribute the phone seek bar's removal correctly
CI / check (push) Skipped
CI / e2e (push) Skipped
The paragraph said #59 took the seek bar off the phone's transport.
It was plan 016 B2 — audio-player.ts says so in the comment above the
rule that does it, and CLAUDE.md's own #59 paragraph says #59 removed
shuffle, repeat and the queue button. Wrong provenance in the file
whose whole value is being right about which change did what.

Also stop tracking .pi/journal.md. It is a scheduled run's scratch log,
and this repo's memory is CLAUDE.md and .planning/ — a session log
arriving inside a feature PR is a new convention landing sideways.
2026-08-21 10:37:49 -04:00
logan bd45e5d595 feat(player): show progress on the phone's bar border
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 8m55s
#59 took the seek bar off the phone's transport, so the one thing a
mini player is expected to say without being opened -- how far through
the song it is -- had nowhere left to be said.

It is the shell's element and its own 2px grid row between `bottom-bar`
and `bottom-nav`, because those two are separate components and either
one drawing the line means reaching into the other's box. The fill is
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
with the same `trackChangeId`/`seq` guards and an interval that only
interpolates *between* reports -- never its own clock, which is the
rule that exists because a local counter drifted 30 s away from the
backend across four keyboard seeks.

It is `aria-hidden` and takes no pointer events at any depth: Now
Playing's seek bar is what announces the position, and a 2px strip on
the top edge of the tab bar is exactly where a thumb aiming at a tab
lands. It renders nothing above 600px, from `matchMedia` rather than a
media query, because a stylesheet cannot stop a 1 Hz interval running
for the life of every desktop session about a line nobody can see.

Its phone rule is at the foot of index.css beside `job-band`'s, not in
the phone block above: a media query adds no specificity, so a
`display: block` written before the `display: none` that takes it out
of the desktop grid loses to it and the line never appears at all.

Closes #58
2026-08-21 03:43:37 -04:00
153 changed files with 1374 additions and 16048 deletions
+2 -4
View File
@@ -89,9 +89,7 @@ build/android/overlay.json
# into scripts/gitea-release.sh; the release page is the changelog.
.release-notes.md
# Agent session log and loop state: local scratch, not repo memory
# (that is CLAUDE.md and .planning/). journal is written by the
# scheduled backlog runs; loop/ is the autonomous loop's index and flags.
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
# and .planning/). Written by the scheduled backlog runs.
.pi/journal.md
.pi/schedule-prompts.json
.pi/loop/
-25
View File
@@ -1,25 +0,0 @@
---
name: diffreview
package: yj-loop
description: Scope-tight review of a loop PR's diff for correctness within the plan's stated scope. The understood-diff half of the critique fan-out.
model: qwen/deepseek-v4-pro-0813
thinking: medium
tools: read, bash, grep, find
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
---
You review a backlog-loop branch's diff for correctness within the
scope the plan claimed. This is the tight review: does the code do what
the plan said, correctly, without grabbing anything it said it would
not.
Read the issue, the plan comment, and the diff itself. Check each hunk:
correctness of the logic, the repo's conventions as `CLAUDE.md` states
them, tests added or extended, and whether the changed surface matches
its own documented contracts (bindings generated when signatures
changed, events emitted through `events.Emit`, lint grammar). Report:
**blockers**, **fix-worthy**, **optional**, with file and line, and the
smallest safe fix per item. Do not modify files. Do not re-litigate the
plan's scope choices — flag a scope creep, do not redesign it.
-29
View File
@@ -1,29 +0,0 @@
---
name: escalate
package: yj-loop
description: The loop's ceiling — re-runs a leg the two lower tiers failed, seeded with their written failure summaries. Fresh session, never parallel, once a day.
model: go/kimi-k3
thinking: max
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
skills:
- yellowjacket-dev
---
You are the escalation tier of the YellowJacket backlog loop. Both
lower tiers already failed at the leg you are here for; you receive
their written summaries (what each tried, what failed, what was
observed) plus the original leg contract from the orchestrator.
Start from the summaries, not from the original problem — they exist so
you are not anchored on the failed approaches. Read `CLAUDE.md` and
`.planning/NOTES.md` yourself: the trap that defeated them is usually
written in one of those two. `yellowjacket-dev` tells you how to run
the harness tiers.
You may delegate mechanical subtasks, never the leg. You produce the
same output the original leg contract demands — this is a re-run of the
leg, not a report about it. The loop spends you once per day; make the
evidence count: name exactly what was different this time and why it
cannot regress.
-33
View File
@@ -1,33 +0,0 @@
---
name: inspect
package: yj-loop
description: Mechanical gatherer for the backlog loop — dumps tracker, PR, CI and branch state verbatim into a digest. No judgement, no writes beyond the digest.
model: go/mimo-v2.5
thinking: off
tools: read, bash, grep, find
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
progress: true
---
You gather state for the YellowJacket backlog loop. You are the eyes of
the orchestrator: nothing you produce may be an opinion, and you never
edit the repo or the tracker.
Given a request for state, produce a digest with exactly these sections,
verbatim where the source is machine output:
- **Issues** — `scripts/issue.sh list | search` output as relevant.
- **Pull requests** — from the REST API, open PRs with head sha and
status.
- **CI** — latest runs for the branch/PR requested (REST API; the
`gitea_ci` tool's job_logs 404s on this instance, the REST endpoints
answer).
- **Branches** — `git ls-remote --heads origin`, grepped as asked.
- **State file** — `.pi/loop/state.json` contents, untouched.
Conventions: env `GITEA_TOKEN` is required; API base
`https://git.ljones.me/api/v1/repos/yonlu/yellowjacket`. If a source
fails, report the failure exactly — never guess its contents. Keep the
digest compact; raw output over prose.
-33
View File
@@ -1,33 +0,0 @@
---
name: plan
package: yj-loop
description: Writes the implementation plan for a claimed backlog issue, as a tracker comment. Designs on the repo's real shape, not from first principles.
model: glm/glm-5.3
thinking: high
tools: read, bash, grep, find, write
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
skills:
- yellowjacket-dev
---
You write the implementation plan for one claimed YellowJacket issue.
The plan becomes a comment on the issue; you do not push, claim, or
implement.
Read in order: `CLAUDE.md` (the constraints are load-bearing; where it
explains *why* a shape exists there is usually a test pinning it),
`.planning/NOTES.md` (rejected approaches are rejected forever — do not
resurrect one), `.planning/plans/active/`, `.pi/journal.md`, then the
issue and any comments on it. Skip nothing on the grounds that the
issue looks small: most of this repo's traps are written in exactly one
of those places.
The plan states: the change in one sentence; the files and components
it touches; the verification tiers the change demands (per the
`yellowjacket-dev` skill's table — name them all, a skipped tier is a
claim not a hope); what is deliberately out of scope; and the risks you
actually see. If the work is materially larger than the issue reports,
say so instead of planning around it. Keep it to a screen; the worker
reads this cold.
-28
View File
@@ -1,28 +0,0 @@
---
name: review
package: yj-loop
description: Fresh-context consequences review of a loop PR — what breaks that the diff did not say. Advisory only; findings, never edits.
model: glm/glm-5.3
thinking: medium
tools: read, bash, grep, find
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
---
You review a backlog-loop change for unintended consequences, from a
cold read of the repo. Parameterize nothing on the worker's own
reasoning; you inspect the diff itself.
Read: the issue, its plan comment, `CLAUDE.md`'s load-bearing shapes,
and the branch diff against origin/main. Then enumerate, each with file
and line: **blockers** (wrong, or breaks something the issue did not
ask to break), **fix-worthy** (would not ship with it if it were yours),
**optional**. For every fix-worthy item, the smallest safe change.
Your angles: does it violate a shape `CLAUDE.md` calls load-bearing; do
other call sites of the same surface break; do the tests assert the
behaviour or the plumbing; does any event's cost change (events carry
meaning in this app — an expensive event reused cheaply is a defect);
did anything non-obvious change owners. Do not modify files. Ignore
style dust unless it hides a bug.
-27
View File
@@ -1,27 +0,0 @@
---
name: scribe
package: yj-loop
description: The loop's clerk — commit messages, PR bodies, journal and changelog-sized entries, written from supplied facts. Prose only.
model: go/mimo-v2.5
thinking: off
tools: read, bash, write, edit
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
---
You write the loop's prose. The orchestrator supplies the facts; you
shape them; you decide nothing.
Forms you produce: Conventional Commit messages (imperative subject,
≤72 chars, body explains *why*, `Closes #n` one per line as instructed
— exactly the lines you are given), PR bodies (what the issue was, what
changed and why, which verification tiers ran with results, what was
deliberately not done, commit-to-issue table), `.pi/journal.md` entries
(facts: what was done, verified, left open), and `CLAUDE.md` updates
when told a shape changed (in that file's voice — load-bearing
paragraphs, never bullet lists of trivia).
Never invent a fact: a tier result you were not given is not run. Never
rephrase a `Closes` line. Keep every form compact; this repo's prose
density is a feature.
-34
View File
@@ -1,34 +0,0 @@
---
name: select
package: yj-loop
description: Picks the single next issue the backlog loop should take. Judgment leg on the tracker state; writes nothing to the tracker itself.
model: glm/glm-5.3
thinking: medium
tools: read, bash, grep, find
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
skills:
- yj-loop
- yellowjacket-dev
---
You choose which one issue the YellowJacket backlog loop works next. You
are given a fresh tracker digest. You write nothing to the tracker; the
orchestrator claims.
Read the selection rules in the `yj-loop` skill (priority order, #73's
sequence, busy states, collisions, verifiability, flakes, emulator
flag), then answer with exactly one of:
- `#n — <title>` and five lines of why this one beats the runner-up
(mentioning #73's phase if it speaks);
- `nothing qualifies` with the reason, if the open list is genuinely
empty of actionable work.
Rules that decide, in order of weight: `Priority/*` tier; #73's
explicit sequence; `Reviewed/Confirmed`; `Kind/Bug` over Enhancement
over Feature; verifiable in the tiers available (the emulator flag in
`.pi/loop/state.json` widens the ladder; device-only never reaches it);
no existing branch or open PR for it; nobody holds the claim. Pick one.
Uncertainty about the tracker state is a reason to say so, not to guess.
-30
View File
@@ -1,30 +0,0 @@
---
name: validate
package: yj-loop
description: Checks that the implemented work actually answers the issue's claim, against the acceptance evidence. Claim-first validation before any review.
model: glm/glm-5.3
thinking: medium
tools: read, bash, grep, find
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
skills:
- yellowjacket-dev
---
You validate one issue's implemented work — the branch diff, the
worker's handoff, and the issue itself — before review and merge.
Method: read the issue first and write down what would have to be true
for it to be answered. Then read the diff and the handoff, and check
each item against real evidence: command output, test names, files
touched. Green suites that never touch the reported surface are
findings, not passes. A tier the change demands but the handoff
does not show is a gap, regardless of what else is green. Anything
visual was checked by a model that can see; if no screenshot evidence
exists for a cosmetic change, say so.
Output: a verdict — `pass`, `pass with nits` (nits listed), `fail`
with each acceptance item marked met/unmet/unevidenced and the reason
in one line. You do not edit files. You do not trust the diff's self
description; you read it.
-27
View File
@@ -1,27 +0,0 @@
---
name: visual
package: yj-loop
description: Reads screenshots of the app for the loop — the only leg allowed to judge pixels. What the image actually shows, not what the change claims.
model: glm/glm-5.3-flash
thinking: minimal
tools: read, bash
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
skills:
- yellowjacket-dev
---
You are the loop's eyes. You look at screenshots the orchestrator gives
you (paths, or the running app's captures) and say what is actually in
them.
Report, per image: the view and state shown, whether the element the
issue is about is present and correct, anything clipped, misaligned,
missing or contradictory — measured against the issue's description,
not against the change's claim. Where the harness provides before/after
pairs, read the difference. Be specific in pixels.
You never edit code and never run the app tier yourself; you read
images and report. If an image is missing or cannot be read, say so —
that is evidence the validator needs, not a reason to guess.
-35
View File
@@ -1,35 +0,0 @@
---
name: work
package: yj-loop
description: The loop's implementer — builds the claimed issue from its plan comment, in the loop worktree, runs the tiers the change demands, and hands off with evidence. The single writer.
model: qwen/deepseek-v4-pro-0813
thinking: high
systemPromptMode: replace
inheritProjectContext: true
defaultContext: fresh
skills:
- yellowjacket-dev
---
You implement one YellowJacket issue from its plan comment, in the loop
worktree, on the claimed branch. You are the only writer. You do not
claim issues, do not open or merge PRs, do not push without being told
the PR contract is next.
Read in order: `CLAUDE.md`, `.planning/NOTES.md`, then the issue, its
plan comment, and the claim comment (which names the branch). Implement
what the plan says and nothing else. Match surrounding style. Follow
`CLAUDE.md`'s shapes rather than reasoning from first principles.
Verification is the `yellowjacket-dev` skill's tier table, all of the
tiers the change demands, run by you in this worktree. Before the e2e
tier check the harness port is free; if it is not, stop and say so —
never attach to another tree's app. Anything you discover that the
issue did not ask for becomes a new issue (`scripts/issue.sh new`),
never a bigger diff. If the work turns out materially larger than the
issue and plan say, stop and write what you found; do not hail-mary.
Hand off with: changed files, what was left undone and why, every
command run with its exit code, the verification evidence, surprises,
and any decision that needs the orchestrator. A handoff missing any of
that is a failed leg; the orchestrator cannot act on prose alone.
-28
View File
@@ -1,28 +0,0 @@
{
"context": "fresh",
"chain": [
{
"parallel": [
{
"agent": "yj-loop.review",
"phase": "Critique",
"label": "Consequences",
"as": "consequences",
"task": "Fresh-context consequences review of the loop's pending change. Issue, plan comment and branch: {task}. Read the issue, the plan comment, CLAUDE.md's load-bearing shapes, and the branch diff against origin/main. Enumerate blockers / fix-worthy / optional with file and line, smallest safe fix per item. Do not modify project/source files; returning findings through the configured output artifact is allowed.",
"output": "critique/consequences.md",
"outputMode": "file-only"
},
{
"agent": "yj-loop.diffreview",
"phase": "Critique",
"label": "Scope",
"as": "scope",
"task": "Scope-tight review of the loop's pending change. Issue, plan comment and branch: {task}. Read the issue, the plan comment and the diff. Does the code do what the plan said, correctly, within its claimed scope? Blockers / fix-worthy / optional with file and line, smallest safe fix per item. Do not modify project/source files; returning findings through the configured output artifact is allowed.",
"output": "critique/scope.md",
"outputMode": "file-only"
}
],
"concurrency": 2
}
]
}
-26
View File
@@ -1,26 +0,0 @@
---
description: One tick of the autonomous YellowJacket backlog loop
---
You are the orchestrator of the YellowJacket backlog loop, waking for
one tick. Work in this directory. Read `.pi/skills/yj-loop/SKILL.md`
first — it is the operating procedure and it binds you. The design
questions are answered in `.planning/plans/active/020-autonomous-backlog-loop.md`;
the skill is what you run.
One tick means:
1. Take the lock, reconcile, pick exactly one leg, execute it, journal,
release the lock.
2. Delegate every deliberative leg to its `yj-loop.*` agent by name —
the model is pinned in the agent file, never an argument. You hold
only claim, shipping polls, merge, housekeep.
3. Touch only what the loop created. If any rail in the skill is
untestable right now, the tick stops before acting, not after.
4. If the scheduler fires while you are mid-answer, finish this tick
only. Two ticks never overlap; the lock is yours.
Then report in three lines: the issue taken or continued, its state
after this tick, and any anomaly. Stop. Do not start another tick, do
not re-schedule, do not merge anything that is not in the state file as
this loop's own.
+2 -15
View File
@@ -130,13 +130,7 @@ reference, because you need them *before* the failure, not after.
what you otherwise get is `Property 'scroll' does not exist on type
'CSSResult'` pointing at a line of prose, or every test in the suite
failing to import. It went in after the trap cost a fourth session in
which its own warning had been read twice. **The same command carries
a second CSS check**: a nested rule whose selector starts with an
element name (`audio-player { … }` rather than `& audio-player { … }`)
is silently dropped by the device's Chrome 113 and by nothing else, so
every tier you can run renders it correctly. Run it after touching
`index.css` or any `css` literal; a rule directly inside a top-level
`@media` is not nested and is not flagged.
which its own warning had been read twice.
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
says it is not.** That endpoint 404s on this Gitea build. The REST
API answers, with the `GITEA_TOKEN` already in the environment:
@@ -158,7 +152,7 @@ only climb when it cannot.
| You changed | Run | Cost |
|---|---|---|
| A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app |
| …and it renders differently | `make ui-visual` | + 10 baselines, opt-in, never gates |
| …and it renders differently | `make ui-visual` | + 6 baselines, opt-in |
| Any Go code | `make test` | 3 passes, ~2 min |
| A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app |
| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s |
@@ -180,13 +174,6 @@ less than it looks.)
Two rules about climbing:
- **If you moved a component's geometry, run `make ui-visual` and
refresh that component's baseline in the same commit.** Nothing else
will: it is the one tier in this repo no hook and no CI job runs, and
it cannot be one — its references are machine-specific, measured in
[references/ui-tier.md](references/ui-tier.md). Four of them drifted
across three merges before anyone noticed (#196). Read the image;
never bless a reference you did not cause.
- **A component test passing is not the app rendering.** If you touched
anything in `frontend/src`, verify it in the real app too — start it
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
@@ -8,29 +8,13 @@ This tier answers "does the phone build run", nothing else. It is not a
spec tier, it does not run in CI, and the app is not a usable Android
player yet (plan 015 says why, at length).
## Two facts that make failure invisible
## Three facts that make failure invisible
There were three. The first was that **Go's stdout does not reach
logcat** — an Android app's fd 1 and 2 go to `/dev/null`, so every
`slog` line the app wrote was discarded, including the one naming the
error it was about to exit on. That is fixed (#160):
`backend/androidlog` is a `slog.Handler` over `__android_log_write`,
selected in `main()` by build tag, and the app's whole diagnostic
stream now arrives under the `yellowjacket` tag, which `make
android-logs` filters for.
What remains true about it is the part that misleads: **`setprop
log.redirect-stdio true` still does not help**, because it redirects
the *Java* runtime's `System.out` and the Go code is a c-shared native
library. Nothing that reaches logcat here does so through stdout, so
anything printed with `fmt.Println` is still lost. Log with `slog`.
The tag is a fixed string rather than the application id, and that is
load-bearing rather than tidy: the debug build carries
`applicationIdSuffix ".dev"` so it can be installed beside the release
app, and it is the only build whose WebView can be inspected — so a tag
derived from the id would be filtered out on the one build anybody
debugging this app is running.
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
**`os.Exit` is a silent death.** `main()` ends several failure paths in
`os.Exit(1)`. From Android's side that is a process that vanished:
@@ -50,10 +34,7 @@ the wrong question. `make android-smoke` asks the right one — is it the
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
followed immediately by a new pid doing the same thing. That means the
native library loaded, the JNI bridge came up, Go's `main()` ran, and
`main()` left. Work backwards through its `os.Exit(1)` paths — and
since #160, **read the `E/yellowjacket` line above it first**, because
every one of those paths logs the error before it exits. That line is
what #52 spent months without.
`main()` left. Work backwards through its `os.Exit(1)` paths.
## What to run
@@ -543,61 +524,6 @@ Four things about it, each of which costs an hour if met cold:
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
`POST_NOTIFICATIONS`) ahead of the launch skips it.
### Getting the app into a state worth measuring
A fresh install is **not** a neutral starting point, and three things
about it will each cost you a measurement.
**It downloads the real catalog.** `YJ_CORE_INDEX_URL` is stubbed in
`dev-headless.sh` and in CI and is *real* here, so the app spends its
first minutes fetching ~0.6 GB and `job-band` is **103px of a 439px
screen** while it does. Every vertical number taken in that state is
wrong -- one #51 measurement had the album art at 0px and it was
entirely this.
`__yj.call("explore.Service.StopIndexBuild", [])` stops it and returns
cleanly. **It then starts again within seconds.** So stop it
*immediately before* the measurement rather than once at the beginning,
and check `jobs.Service.GetJobs` afterwards -- an empty array is the
only proof. `jobs.Service.ClearFinishedJobs` tidies the finished rows
that otherwise keep the band open.
**A library added over the bridge does not dismiss the first-run
wizard.** `library.Library.AddLibrary` works and scans, but the wizard
checks for an existing library once, on mount, and its "Get Started"
button gates on a directory chosen *in the wizard* -- so it stays up
with a correctly disabled button over everything you are trying to
measure. Nothing is broken; reload the page and it is gone. This reads
exactly like a tap being swallowed, which is the expensive part.
**Scoped storage decides where the music can be.** `/sdcard/Music/...`
plus `pm grant <pkg> android.permission.READ_MEDIA_AUDIO` works and
`AddLibrary` takes the plain path; a push into
`/sdcard/Android/data/<pkg>/files/` looks like it worked and then is not
there. Some builds additionally want
`appops set <pkg> MANAGE_EXTERNAL_STORAGE allow`, and until they have it
the app opens the *system* "All files access" screen on launch -- so
`dumpsys window | grep mCurrentFocus` naming `com.android.settings` is
that, not a crash.
### A note on quoting `make android-eval`
`EXPR='...'` is a single-quoted shell word, so anything with a quote or
an apostrophe in it -- a file path like `Blazo, 49'ers - ...`, or a
snippet containing a string literal -- breaks in a way that reads as a
JavaScript error. Put the expression in a file and pass it positionally:
```bash
node ./scripts/android-eval.mjs "$(cat /tmp/probe.js)"
```
That is the same script `make android-eval` wraps, so nothing is lost.
Two things worth knowing about it: it does **not** await a promise, so
an async call has to park its result (`window.__r = ...`) and be read
back in a second eval; and the shim from the section below is lost on
every reload and every app restart, along with the devtools socket,
whose name carries the pid.
### Calling a binding on the device
**The runtime call does not go over HTTP on Android**, and this is worth
@@ -42,16 +42,11 @@ strings and identical specs produce different bytes on different builds.
playback and then clicks pause races the track ending and fails
against a correct UI. Use `LONG_TRACK` (90 s, `edge-lengths`) exported
from `e2e/support/fixtures.ts`.
- **WAV tracks scan like every other format.** #104 added
`backend/riff`, so the scan reads the `id3 ` chunk `backend/tagwriter`
writes and both WAVs come in fully tagged: "Field Recordings" is an
ordinary artist in the Artists view, with a "Test Tones" album and a
cover. They are therefore not an example of an untitled or albumless
track — the only two tracks with no album are
`unsorted/no-tags-at-all.mp3` and `unsorted/title-only.mp3`. Prose
written before #104 says the opposite and names
`TestWAVTagsAreNotReadableYet`, a test that change deleted; that is
dated history rather than a description of the app.
- **WAV tracks scan in untitled.** `backend/tagwriter` writes WAV tags
into a RIFF `id3 ` chunk and `dhowden/tag` has no RIFF parser, so
there is no "Field Recordings" artist in the Artists view. This is a
known open bug pinned by `TestWAVTagsAreNotReadableYet`; do not
"fix" a spec by asserting the broken behaviour elsewhere.
## Seeds
@@ -78,58 +78,9 @@ synchronously.
Microtasks and not a timer, deliberately: a timer hangs forever under
the suites that install fake ones.
## The visual tier does not gate, and that is measured (#196)
`make ui-visual` is the same suite with nine `toMatchScreenshot`
baselines switched on. **Nothing runs it but a person**, deliberately,
and the reason is a number rather than a preference: the committed
baselines were recorded on Arch, and replayed in a bare `ubuntu:24.04`
container — CI's `check` image — three of them fail for reasons that
have nothing to do with any component.
| baseline | Arch | ubuntu:24.04 |
|---|---|---|
| `page-header` filtered-by-search | passes | ratio 0.03 differ, against a 0.02 allowance |
| `track-info` | passes | ratio 0.03 differ |
| `seek-bar` | 1152×18 | 1152×17 |
The two references that were genuinely stale did not even agree about
their *new* size — `now-playing` renders 1152×65 on Arch and 1152×64 in
the container. So moving CI's `check` job from `make ui-test` to
`make ui-visual` is not a one-line change: it needs a second,
container-recorded baseline set, which every local run would then fail
against. That is the same trap the other way round, and a pre-push hook
is the same fault again — one machine's baselines against everybody
else's renderer.
So the tier stays local and opt-in, and the rule that replaces the gate
is:
- **A change that moves a component's geometry refreshes that
component's reference in the same commit, having read the image.**
Look at the PNG; the dimensions in the failure message are the cheap
half of the answer.
- **Never refresh a reference you did not cause.** #196 exists because
four of them drifted across three unrelated merges, and every red run
made the next person likelier to stop running the tier than to read
it.
- **State the world the shot is taken in.** The stores are singletons,
so a visual case that sets nothing photographs whatever the previous
case left behind — which is how the sidebar's baseline came to have
Tracks lit and `now-playing`'s to be playing from a dynamic mix.
- **Record one file with `make ui-visual-update UI_ARGS=<path>`**, and
check `git status` before committing either way. That filter is only
honoured since #204: the recipe was a bare `--update`, and vitest
takes the following positional as the flag's value, so the path was
swallowed and *every* baseline was re-recorded — blessing any stale
one in silence.
What the tier is worth, for the record: it is a *layout* check, blind to
colour (the component tier has no `:root`, so it renders the fallbacks —
`make ui-visual` passed unchanged through a whole palette rewrite,
twice), and it has caught one thing nothing else could — swapping
`library-status-indicator`'s `<button>` for a `<span>` lost the UA
stylesheet's `box-sizing` and grew the badge 36→38px.
Visual baselines are font-hinting and compositing sensitive, which is
why they are opt-in: they only mean anything on the machine that
recorded them.
## Bindings
-306
View File
@@ -1,306 +0,0 @@
---
name: yj-loop
description: Operating the autonomous backlog loop — the crank that works the YellowJacket tracker one issue at a time (tick mechanics, the state machine in Gitea, which agent and model take each leg, the escalation ladder, merge authority and the rails that stop it doing damage). Use whenever a scheduled tick fires, and when piloting or debugging the loop.
---
# The YellowJacket backlog loop
Design and arguments: `.planning/plans/active/020-autonomous-backlog-loop.md`.
This skill is the **operating procedure**; the plan is the reasoning.
`yellowjacket-dev` is the harness doctrine (tiers, seeds, traps); this
skill is the loop doctrine (who acts, on what model, with what authority).
Read the plan first, once. Then this file every tick.
## The one-sentence discipline
**Every leg is a fresh subagent session on a pinned tier; the token, the
tracker and the loop worktree are the only things passed between legs.
Never switch a model mid-session, never let two writers exist at once,
never keep state in a conversation.**
## Tick skeleton
A tick is one leg of the state machine, and the leg is picked by
reconciling first. Execute in this order:
1. **Lock.** `/tmp/yj-loop.lock` holds `pid + start-iso`. If a live
process owns it and is younger than 2 h: exit immediately, report
"tick skipped (lock held)". If the PID is dead, take the lock.
Remove it before every exit.
2. **Reconcile.** Fresh reads, never cached: open issues
(`scripts/issue.sh list`), PRs and CI via the REST API, branches via
`git ls-remote --heads origin`, `.pi/loop/state.json`. GITEA_TOKEN
refusing = the tick reports and exits; the identity rails below are
not optional.
3. **Pick the leg.** See the state machine below; the leg follows the
issue's lifecycle (claim→plan→…→merge→…→housekeep). Exactly one leg.
4. **Execute** — the leg table below says who acts and what they must
return.
5. **Journal** — one line per tick in the state file (issue, leg, result,
tick cost if leg reports it).
6. **Report** — three lines: issue taken or continued, its state now,
anomalies. Then stop. A tick that reports is a tick that can leave a
conversation behind.
## The state machine
The tracker is the truth. The state file (`.pi/loop/state.json`,
gitignored) is an index plus flags (`emulator`, `drain`); the tracker
wins every disagreement.
| Stage | Where it lives | Leg → actor |
|---|---|---|
| selected | nothing written until claim is possible | select |
| in flight | `Status/In Progress`, assignee, comment with branch+approach | claim (orchestrator, `scripts/issue.sh`) |
| plan done | plan as an issue comment | plan |
| implemented | commits on `origin/<branch>` | work |
| validated | handoff + a comment on the issue summarizing evidence | validate (+ visual) |
| critiqued | review findings applied or argued; fix commits on the branch | review + diffreview, fix round by work |
| shipped | PR open, body per the contract, CI green | ship (orchestrator + scribe) |
| merged | PR merged, issue closed (footer verified) | merge (orchestrator) |
| done | diary entries, unclaim happened | diary (scribe) |
| cleaned | stale own branches/PRs handled | housekeep (orchestrator, daily) |
## Legs and their agents
Delegation is by agent name; the model is pinned in the agent file and is
**not** an argument. Every leg prompt names: the issue, the evidence so
far (plan comment, handoffs), what the leg must produce, and its stop
rules. Never "go fix it" — the leg contract is in this file.
| Leg | Agent | Model (tier) | Produces |
|---|---|---|---|
| gather/mechanical dump | `yj-loop.inspect` | go/mimo-v2.5 (T0) | tracker/PR/CI/branch digest, verbatim |
| select next issue | `yj-loop.select` | glm/glm-5.3 (T2) | one issue + reasons, or "nothing qualifies" |
| plan | `yj-loop.plan` | glm/glm-5.3 (T2) | a plan comment on the issue |
| implement | `yj-loop.work` | qwen/deepseek-v4-pro-0813 (T1) | commits + a handoff (see contract below) |
| validate | `yj-loop.validate` | glm/glm-5.3 (T2) | pass/fail with evidence per acceptance item |
| visual evidence | `yj-loop.visual` | glm/glm-5.3-flash (T2) | what the screenshot actually shows |
| consequences review | `yj-loop.review` | glm/glm-5.3 (T2) | blockers / fix-worthy / optional findings |
| understood-diff review | `yj-loop.diffreview` | qwen/deepseek-v4-pro-0813 (T1) | same shape, scope-tight |
| escalation | `yj-loop.escalate` | go/kimi-k3 (T3) | same leg re-run, seeded with failure summary |
| prose (PR body, commit msgs, journal) | `yj-loop.scribe` | go/mimo-v2.5 (T0) | text only, from supplied facts |
**Model fallback on quota exhaustion.** The pinned models are the
intent, not a guarantee. The qwen token plan is a weekly pool and has
run dry mid-tick (`429 … 1-week quota exhausted`). When a leg's launch
fails with a 429, re-run it with a per-run `model` override one rung
down and journal the substitution — never spend the T3 escalation
model on a quota substitution. The qwen-pinned legs (`work`,
`diffreview`) fall back `qwen/deepseek-v4-pro-0813``go/deepseek-v4-pro`
`go/glm-5.3-flash`. Do **not** use the `deepseek/...` provider: it has
no models, only catalog overrides, and fails silently (empty artifact,
no session) — the model lives on the `go` gateway.
**Launch legs in the foreground.** The async subagent runner has died
without persisting a child session (nothing to resume) and emits
spurious "needs attention" nudges on runs that are already complete.
Foreground `subagent` calls are the reliable mode here. A worker that
dies mid-leg leaves uncommitted work: inspect the tree, then relaunch
to *complete* — never to re-implement.
Orchestrator-only legs: **claim** (`issue.sh claim --branch` — atomic,
refuses if held), **ship's PR/CI polling** (REST API below — `gitea_ci`
job_logs 404s on this Gitea; the REST endpoints are the way), **merge**
(API below), **housekeep**.
## Selection rules (`select`)
The rules from `.pi/prompts/next-issue.md` stay — priority order, #73's
sequence overriding labels where it speaks, skipping `Status/*` states
that mean busy, branch-collision check, verifiability, flakes. The
emulator flag **adds** emulator-verifiable Android issues; it never
reaches device-only ones. A "nothing qualifies" answer is a correct
tick, not a failure — report it and stop.
## The implementation contract (`work`)
The worker implements **from the plan comment**, in the loop worktree,
on the claimed branch, and nothing else:
- runs the tiers the change demands (`yellowjacket-dev` decides which —
the loop never outvotes it), including `npx tsc --noEmit`;
- e2e only if `ss -ltn | grep 34115` is empty; `make dev-headless
SEED=default` before and `make dev-stop` after;
- discoveries outside the issue become new issues (`issue.sh new`), never
bigger diffs; a materially-larger-than-implied issue stops the leg with
a comment and a label removal, not a hail-mary;
- handoff must state: changed files, what was left undone, commands run
with exit codes, verification evidence, surprises, decisions needing
approval. A handoff without that list is a failed leg.
## Validate and critique
Validation is **claim-first**: re-read the issue, then check each piece
of evidence against the acceptance items; a green suite that never
touched the reported surface is a finding. Screenshots go to `visual`,
never to a text-only tier.
Critique is the standing fan-out (`subagent` parallel: `yj-loop.review`
consequences + `yj-loop.diffreview` scope-tight, both fresh). The
orchestrator synthesizes: blockers and fix-worthy findings go back to
`work` as one bounded fix round (maximum three rounds total; then the
issue gets a `⟦loop⟧` comment stating what will not be fixed and why,
and the ship leg proceeds unless a finding is a blocker). Reviewers do
not edit files.
## Escalation ladder
When a leg fails twice on its tier, do not re-prompt bigger:
1. The failing session writes its summary: what it tried, what failed,
what it observed.
2. A **new** session on the next tier up is seeded with that summary and
the original leg contract.
3. T3 is the ceiling: fresh session, never parallel, **once per day**.
A day's escalation is spent — the issue waits until tomorrow.
Routing down is free; routing up is the budget.
## Ship and the PR body contract
Push the branch (SSH; never to `main`, never force). The PR body —
written by `scribe` from the validator's and reviewers' output — states:
what the issue was, what changed and why, **which verification tiers ran
and their results**, what was deliberately not done, the commit-to-issue
table, and `Closes #n`. `Closes` also sits one-per-line in a commit body
**inside the branch** — both, regardless of merge strategy, because the
pairing was measured.
Poll CI until `check` and `e2e` finish. On failure: read the log via
`GET /api/v1/repos/yonlu/yellowjacket/actions/runs/<run>/jobs` (per-step)
and `…/actions/jobs/<id>/logs` (full). Fix on the branch. **Two
consecutive identical failures = stop**: comment what is known on the
PR and the issue, leave both, report. Do not burn ticks on a red wall.
## Merge authority
Merge when, and only when, **all** hold:
- the PR was opened by this loop (it is in the state file's index);
- the protection contexts `CI / check` and `CI / e2e` are green on the
PR's head, read from the API, not from the PR page's badge;
- the PR reports mergeable;
- the critique leg ran and no open blocker stands;
- the branch is **not behind `origin/main`** — the protection's
`block_on_outdated_branch: true` refuses it anyway; never
`force_manually_merged` around it.
**Refresh before every merge.** In the loop worktree: `git fetch origin`
in the same breath, then `git merge origin/main` on the PR branch,
push. The fetch must be immediate — a cached `origin/main` merges
against the wrong base, CI goes green on it, and the merge comes back
405 "behind base", one whole CI cycle wasted (measured on the adoption
wave). A textual conflict
stops the leg there — as diff text, not as a failed merge click: hunks
the loop authored are resolved by the loop; anything else is left with
`⟦loop⟧` comment for a human, never forced. After any refresh push,
re-poll the PR's own required contexts on the **new head** before
merging.
**Merges happen one at a time**, each re-reading state — the previous
merge moved `main`, and the next PR's mergeability is recomputed at
its own turn.
```
curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket/pulls/<n>/merge \
-d '{"Do":"merge","merge_message_field":"default","force_manually_merged":false}'
```
**Afterwards watch the `push` run on `main`** — the CI the merge
started. A red main after a loop merge is a **halt**: comment what is
known on the offending PR, mark the state file, stop taking new issues.
That run is the only thing between a clean textual merge of
independently-written PRs and a self-contradicting main; no
mergeability check sees it. Only a green main lets the tick proceed (to
footer verification, below).
Footer verification: `scripts/issue.sh list --state open` and check
the footer took. Close stragglers with `issue.sh close`, naming the
merge commit. `unclaim.yml` handles the label; it is not instant;
reopening does not restore it. Merging fans out to nothing (releases
are the manual `release.yml`, which the loop never runs) — the
criticism stands before the merge because nothing stands after it.
## Rails — the loop's absolute rules
1. **Touch only its own.** Issues it claimed, branches it made, PRs it
opened. `issue.sh claim` enforces the front gate; never work around a
refusal.
2. **One writer, one issue.** The loop worktree is the only dirty tree.
3. **Never merge a PR it did not open.** Any merge that violates this is
a hard stop.
4. **Human work is holy.** Human branches, PRs, assignees: leave exactly
as found. Cleanup never names them.
5. **The token is identity.** If GITEA_TOKEN misbehaves, the tick stops.
6. **New findings are new issues**, never scope creep. The tracker
vocabulary (`Kind/`, `Area/`, `Priority/`) stays intact in one
taxonomy; use `scripts/issue.sh new` with correct labels.
7. **Conventional Commits**, enforced by `scripts/commit-check.sh`; the
type list and `.releaserc.yml`'s must agree — a loop commit is a
release grammar token even after months of no manual releases.
8. **Tiers over vibes.** `yellowjacket-dev`'s tier table decides what a
change must pass; a skipped tier is stated, never silent.
9. **Two strikes on CI, three rounds of critique, one kimi a day.** The
loop's patience is finite on purpose.
10. **Every leg writes its evidence.** A leg that leaves nothing behind
is indistinguishable from a leg that did not run — which is how the
next tick re-does it.
11. **The loop may not re-schedule itself** (the scheduler refuses it
anyway — treat as an invariant, not a limitation).
12. **Drain means drain.** `drain: true` = finish in flight, take
nothing new, then stop.
## Emulator mode
Flag `emulator: true` in the state file **and** an already-booted
emulator (`adb devices` answers) opts in: `make android` (build), `make
android-install`, `make android-smoke` (crash check — the same pid
surviving is the only signal that means started), `make
android-screenshot` and `make android-eval` as evidence for `visual`.
The loop never boots or stops an emulator; that is the user's machine.
Device-only issues stay open under either setting. One-time setup the
user performs: `make android-setup` (~3.5 GB, creates the `yj-test`
AVD), then `make android-emulator` per session.
## ON / OFF / drain
- **Worktree:** `git worktree add ~/.paseo/worktrees/loop/jumpy-hound
origin/main` (from any clone; branch from origin/main in the loop
tree, never `git checkout main`). **Provision it once before the
first push:** `make build-frontend` and `make testdata` — the pre-push
`go-test` hook needs `frontend/dist` (the `//go:embed` in `main.go`)
and the fixture library, and refuses the push without them.
- **Session:** pi in that worktree, `/name loop`. Add the job via
`/schedule-prompt` (name `yj-loop`, cron
`0 0 10-18 * * 1-5`, prompt: "Read `.pi/skills/yj-loop/SKILL.md` and
run exactly one tick. Stop.") — session-bound by default.
- **OFF:** toggle the job, or close the session. **ON:** `pi --resume
loop` in the worktree, job enabled. Courses of the tick appear in
that session's transcript.
- **Tune in:** the same resume. Talk to it only between; a tick is
atomic.
## Troubleshooting
- `issue.sh: GITEA_TOKEN is not set` or a 401 — the token is the whole
identity (rails 5). Stop, do not fall back to anything.
- `gitea_ci`'s job log 404s — the REST endpoints above answer; this is
a Gitea build, not a fault.
- A spec fails that the tier doc says can fail from stale backend state
— restart the app tier before believing it (`yellowjacket-dev`).
- A tick that "did nothing" — reconcile again; the tracker usually says
which leg it really is.
- The job did not fire — the scheduler fires only while a session is
open in its directory (documented); "the loop is off" is the correct
reading, not a bug.
- `error: object file … is empty` / `unpack-objects failed` / `bad
object refs/heads/…` during a fetch or checkout — the shared object
store was corrupted (a killed fetch leaves 0-byte object files, and a
local ref can end up pointing at the dead sha1). **Halt and report**;
do not retry, the churn only deepens it. Human repair: delete the
0-byte objects, `git fetch origin --prune`, delete any ref that
still dangles (`git update-ref -d refs/heads/<b>`), re-checkout the
worktree at `origin/main`, then `git fsck --full`.
-293
View File
@@ -4785,296 +4785,3 @@ broken build. The specs assert the *mechanism* — that the surface is a
native `<dialog>` at phone width — which is the same move
`queue-as-a-screen.spec.ts` makes about containment and for the same
reason.
## Now Playing was not drawing a small square, it was drawing a crop (measured 2026-08-21, TLP301 / Chrome 113 / 424x439)
#172 handed #51 a design question — whether the album art gets a floor
with the block scrolling, or whether the screen reflows below some
height. Measuring it first turned up a defect underneath the question,
and the defect is bigger than the phone.
**The art was never square.** `aspect-ratio` is specified not to
re-derive the width when `max-height` clamps the height — unlike an
intrinsic ratio, which CSS2.1 10.4 preserves under both bounds. So
`width: min(100%, 60vh)` made the width definite, the ratio derived a
height from it, `max-height: 100%` clipped that height, and the width
stayed where it was. `object-fit: cover` then cropped a square cover
into the band. On the device: **264x53**, a 5:1 strip. #172's own table
called it "39px of art" and the missing half is that those 39px were
263 wide.
**And it is not only the phone.** The leftover exceeds the width only
above ~843px of viewport, so every height from ~500 to ~843 drew a
crop too — most phones, and any short window. The e2e spec written for
this fails on the old build at 424x439 (263x39), 390x700 (358x315) and
900x500 (300x36), and *passes* at 412x869, which is the boundary
falling exactly where the arithmetic says it should.
**Both maxes with auto sizes is the whole fix**, and it was chosen by
asking Chrome 113 rather than by reasoning: a probe shadow root at
column heights of 288, 300, 451, 600 and 800 measured four candidate
rules. `max-width/max-height: 100%` with `width/height: auto` is square
at all five; the shipped rule cropped at four; `aspect-ratio` on the
box cropped at the tallest. A corollary that makes it free: `auto` will
not upscale past the natural size, and the largest tier `saveCoverArt`
keeps is 400px, so nothing is lost by never exceeding it.
**The placeholder cannot use that rule and needed its own**, which is
the part that would have shipped broken. It is not a replaced element,
so with no intrinsic size auto/auto collapses it to its icon —
measured at **13x58**, neither square nor the art's size. Three things
about the rule it did get:
- It is driven from the **height**, which is the axis that binds
everywhere this view is reached from.
- A flex item's automatic minimum is its content, so without
`min-width: 0` the icon's own width becomes a floor and the box goes
wider than it is tall the moment the row is shorter than the icon —
which is exactly the state a job band puts this screen in.
- **A non-replaced box cannot express "the largest square that fits" at
all**, because whichever max clamps does not re-derive the other. The
height-driven rule alone went **380x484** at 412x869 — a tall phone,
#51's other named device — and `max-height: calc(100vw - 2rem)` is
what closes it. That is sound here for the reason `60vh` was not: this
is a phone-width detail view, so its content box really is the
viewport less the host's own gutters, and it is a *max*, so if that
ever stopped being true the failure is a square bounded early rather
than a crop. `rem` and not `em` — the box sets `font-size: 3rem` for
the icon, so `2em` there is 96px.
**Then the design question, and the reflow is the answer.** The
stacked layout's budget is fixed — 48px of header, 143px of transport
since #64, 78px of names, 68px of padding and gaps — so the art gets
`height - 386`, which is 53px at 439. A floor on the art scrolls the
transport off the bottom, and "controls never scroll off" is #51's own
Direction and plan 018's promise. So below 500px the art and the names
share a row, where the art is bounded by the row's height rather than
by the column's leftover: **53px to 143px on the device**, measured on
the shipped build, with nothing scrolling and the transport untouched.
**500 is where the two layouts cross, not a round number.** In a row
the art is `height - 296` and the names get what is left of 392px, so
the names hold 176px at exactly 500 and less above it; stacked, the art
is `height - 386`, which passes 176px at 562. It is keyed on height
alone rather than on the phone's width because it is an answer to
vertical room — a 900x450 window has the same problem and the same fix.
Two things the audit found that are *not* this, and are filed:
**#186**, every control that is not the transport is under the 44px
floor (the sort direction arrow is 28x21, and `search-trigger` — which
exists only on a phone — is 40x40), and **#187**, the seek bar's drag
target is 6px tall.
**What the audit did not find is a reachability failure**, which is
worth recording because it is the promise plan 018 makes. At 424x439,
on every view -- the ten primary ones, the queue, `album-details`,
`artist-details`, Downloads and Autotag -- `documentElement.scrollWidth`
is 424 against a 424 viewport, no control sits outside a scrollable
ancestor, and a hit test at each control's centre reaches the control.
The width work of #57, #62, #55 and #59 holds; what was left was
vertical, and it was this screen.
**A number measured on the device is not a number CI can assert.** The
spec's floor on the art's height passed here at 114 and failed in CI at
**64**, and both are honest: this app is long-lived, so a job staged by
an earlier spec is still on screen, and `volume-control` renders in a
browser where it does not on Android. Both are chrome above and below
the view and both move the leftover. That is the same trap the entry
above about staged jobs describes, arriving as a *measurement* rather
than as a stuck job. The assertion is the mechanism now -- in a row the
art fills the row's height rather than being the leftover -- and the
53-to-143 stays on the issue, where it was measured.
The probe is worth keeping in mind for the next audit, because two of
its three checks needed a second pass to mean anything. "Painted
outside the viewport" flags a horizontally scrolling carousel -- the
home shelves -- so the real question is whether a *scrollable ancestor*
can bring the element back. And a hit test at a control's centre flags
everything below the fold in a scroll container, so it only says
something once the control is on screen. Both first drafts produced
long lists of nothing.
## Two traps that cost time on the device, both already written down (2026-08-21)
Recorded because both are in `android-tier.md` and I met them anyway.
**A fresh install downloads the real catalog**, so `job-band` is 103px
of a 439px screen and every vertical measurement is wrong. Worse, it
**restarts**: `explore.Service.StopIndexBuild` returns cleanly and the
job is `running` again within seconds, so it has to be stopped again
immediately before a measurement rather than once at the start.
`YJ_CORE_INDEX_URL` is stubbed in `dev-headless.sh` and in CI and is
real on a device.
**The first-run wizard does not re-check for a library it did not
create.** Adding one through `library.Library.AddLibrary` over the
bridge leaves the wizard up with its "Get Started" button correctly
disabled — it gates on a directory chosen *in the wizard*, and the
existing-library check runs once, on mount. A reload clears it. Nothing
is broken; it cost twenty minutes of believing a tap had been swallowed.
## The sheet's scroll fade, and where a scrim may not go (measured 2026-08-23, headless)
#207's answer. The affordance is two background layers on
`wa-dialog::part(body)` and the conditionality is
`background-attachment`, not a scroll listener: a cover of the sheet's
own colour painted at the end of the *content* (`local`) over a shadow
pinned to the box (`scroll`), so the cover scrolls up and hides the
shadow exactly when there is nothing more to see.
Measured at 424x360 (which is where a menu overflows on `main`, since
`main` does not yet carry #67's eighth item — at 424x439 the track
list's seven items are `scrollHeight` 364 against `clientHeight` 364,
fitting exactly). Pixel at x=300, dark ramp, `bgElevated` `#343a40`:
| y | before | more below | at the end of the list |
|---|---|---|---|
| 330 | 52,58,64 | 50,56,62 | 52,58,64 |
| 340 | 52,58,64 | 43,48,53 | 52,58,64 |
| 350 | 52,58,64 | 33,37,40 | 52,58,64 |
| 359 | 52,58,64 | 22,24,27 | 52,58,64 |
Three things worth keeping.
**A menu that fits draws nothing**, which is the same measurement: at
424x439 the sheet is flat 52,58,64 to its bottom edge, because with no
overflow the `local` layer's positioning area *is* the padding box and
the cover lands on top of the shadow.
**A scrim over a menu row is that row's text surface**, so the 4.5:1
rule reaches it and this is why the curve is steep rather than linear.
A row is 48px with its label centred; 32px of scrim already down to a
quarter strength at 14px puts about 0.06 at the label. Checked on the
light ramp (`bgElevated` `#e9ecef`, text `#212529`) by overriding the
two custom properties on `:root`: background at the label 205,207,210,
which is **9.9:1**. The first draft — a linear 48px at 0.8 — put ~0.375
on that label, 5.0:1, passing but visibly greyed. The bottom few pixels
go to ~2.4:1 in either draft and are deliberately below where any
label of a *fully visible* row sits; a label that lands there belongs
to the half-cut row, which is the thing being signalled.
**A dark scrim on a dark surface reads far worse in a shrunk screenshot
than on screen.** The first two probes (24px/0.45, then 32px/0.75) were
measurably present — 52,58,64 down to 30,33,37 — and invisible in the
inline preview. Crop the bottom 70px and scale it up before judging;
the pixel values are the honest answer either way.
## The phone's context sheet is now longer than the phone (measured 2026-08-23, headless at 424x439)
#67 moves two destinations into every row menu, and the track list's
menu is where that runs out of screen. Measured against the running
app at the reference viewport, one row selected:
| menu | items | first item top | last item bottom |
|---|---|---|---|
| queue panel | 7 | 95 | 431 |
| track list | 8 | 86 | **470** |
The viewport is 439. So the track list's last item — "Remove from
Library" — is below the fold. It is **not unreachable**: the sheet is a
`wa-dialog` whose body is `overflow-y: auto`, measured `scrollHeight`
412 against `clientHeight` 373, and scrolling it 39px brings that item
fully into view (383431). What it has is no *affordance*: nothing on
screen says the list continues.
Two things worth knowing before adding a ninth item anywhere.
**The limit was already reached, and this is what crossed it.** Seven
48px rows in a 373px body is 364px — the queue's menu fits with 8px to
spare and the track list's fitted exactly. Any item added to any of the
fourteen menus after #60 was going to be the one that overflowed; the
first one simply happened to be this.
**The measurement has to be taken with a row selected**, since the
`Go to` items are drawn for a single selection only, and on the *first*
track of the fixture library — which has no album (`01 Tone A`,
`02 Tone B`) — only "Go to Artist" appears. That is the 8 above; an
ordinary track makes it 9.
Filed as its own issue rather than fixed in #67's diff: it is a
property of the shared sheet (`components/menu-surface/`), not of the
items.
## The tap highlight is one inherited declaration (measured 2026-08-24)
`-webkit-tap-highlight-color` is an **inherited** property, and an
inherited property crosses a shadow boundary — so `html { … :
transparent }` in `index.css` reaches every shadow root in the app and
no component needs a rule of its own. Measured in the running app
(Chromium, `app-sidebar`'s `li button`, which is three shadow roots
from the document): `rgba(0, 0, 0, 0)` with the rule, and
`rgba(0, 0, 0, 0.18)` with it removed. That 0.18 grey over the bounding
rect of whatever was tapped is what #54 reported.
The same argument was already spent once and is worth not
re-deriving: `index.css`'s first rule is `*, *::before, *::after {
user-select: none }`, which for the same reason already covers the
shadow roots — #54's Findings ask for `user-select` on interactive
surfaces and it has been done since before the issue was filed.
**What the highlight was, on the surfaces that had nothing else, is the
press feedback.** Measured on a track row with the press rule removed
and the button held down: `rgba(255, 255, 255, 0.05)` — the *hover*
tint, arriving because the pointer is over the row, which is a
synthesised hover on a phone and outlives the press. With the rule:
0.12 while held, and the neighbouring row unchanged. So the press state
is part of removing the highlight rather than a separate polish item,
and the hover tints on those same surfaces moved behind
`(hover: hover) and (pointer: fine)`, which is #68's gate applied to a
tint rather than to a revealed control.
**`touch-action: manipulation` was considered and not taken.** The
Findings offer it for the 300ms tap delay; this app's viewport is
`width=device-width`, which is what removes that delay in Chrome, so
the stated benefit is not there to win. What it would change is the
gesture stack #63 tuned by measurement on the device (`pan-y` plus a
non-passive `preventDefault`), and that is not measurable from here.
## Art pop-in is measurable in a browser, if you count frames rather than milliseconds (measured 2026-08-24)
#65 is an Android report ("scrolling through albums, the art pops in")
and the desktop harness can measure it, which was not obvious: the
first attempt waited 220 ms after each scroll jump and found **zero**
blank covers on either build. The metric only discriminates at one and
two animation frames after the jump, which is where a pop-in actually
lives.
Protocol, on `make dev-headless SEED=bulk` (4 988 albums), ten
2 400px jumps of `.grid-scroll-container`, counting covers whose rect
intersects the viewport with `naturalWidth === 0`:
| build | blank at frame 1 | at frame 2 | at 50 ms |
|---|---|---|---|
| `main` | 254 / 258 | 214 / 258 | 0 |
| `main`, second run | 254 / 258 | 190 / 258 | 0 |
| prefetch | 117 / 258 | 77 / 258 | 0 |
| prefetch, second run | 118 / 258 | 96 / 258 | 0 |
Two things this protocol gets wrong if repeated carelessly. **A second
run in the same browser session measures the HTTP cache**, not the
build — the skill already warns about this for `make perf`, and it
applies to any image measurement; every row above is a fresh
`playwright-cli close` + `open`. And **the frontend is embedded**, so
comparing builds is a `git stash` *and* a rebuild, not a stash.
**The bulk library's covers are 300x300 and ~3.7 kB**, which is why
both builds are clean by 50 ms here and why the phone's number cannot
be inferred from this one — same caveat the skill already records
about full-size artwork.
**`rangeChanged` and `visibilityChanged` are different ranges**, and
the difference is the whole of this fix's value.
`@lit-labs/virtualizer` reports `_first`/`_last` (rendered, including
the ~1000px overhang) on the former and `_firstVisible`/`_lastVisible`
on the latter. Both grids listen to `visibilityChanged` for scroll
persistence, which wants the visible range and is correct; a prefetch
window measured from it lands mostly on cards that already exist.
Anchored there, the component test could see only one row past the
last rendered card.
**`_overhang` is not configurable.** It is a `protected` field set to
1000 in `BaseLayout` and read by every layout; there is no option on
`grid()`/`flow()` and no property on the element. The issue's Direction
("ask the virtualizer for a larger overscan") is therefore not
available without patching a private, which is why the request is
issued ahead of the element instead.
@@ -1,244 +0,0 @@
# 020 — The autonomous backlog loop
**Issue:** #236 (`Kind/Enhancement`, `Priority/Low`)
**Status:** active — phase 0, supervised pilot
**Relates:** #73 (the roadmap the loop follows), plan 005 (the harness the
loop drives). Cost and model-tier doctrine is the `pi-session-reference`
card handed to the session that designed this; the loop's copies of it
are deliberate one-paragraph summaries, not the authority.
A pi coding-agent configuration that, toggled on, works the Gitea tracker
one issue at a time — triage, claim, plan, implement, validate, critique,
PR, CI, merge, verify-close, diary — and then does it again. The tracker is
the state machine: whoever reads Gitea sees exactly where the loop is,
which is the property this document's rails exist to protect.
---
## The shape: a crank, not a resident brain
Half the design is that **nothing lives in a conversation**. Each tick is a
fresh, bounded unit of work; every transition writes evidence to Gitea
(label, comment, branch, PR) or to the loop's own state file; a tick that
dies mid-leg loses nothing, because the next tick resumes from what Gitea
says.
The other half is that **no leg trusts the one before it**. The worker
implements from the plan, not from the issue alone; the validator checks
the *claim*, not the green CI row; the merger merges only after reading the
protection contexts itself; the diary leg is what makes the next issue's
triage cheaper.
One issue in flight at a time. That is a pacing decision, not a
concurrency limit of the tooling — CI has a capacity-1 runner and the e2e
tier owns one headless port on this machine, so two writers would serialize
on infrastructure they cannot see and appear to be doing fine.
## The state machine
| Leg | Writes | Actor / model |
|---|---|---|
| reconcile | — | orchestrator + `inspect` (mimo-v2.5) |
| select | nothing on the tracker; decision logged in the tick transcript | `select` (glm-5.3) |
| claim | assignee + `Status/In Progress` + comment naming branch & approach | `scripts/issue.sh claim` |
| plan | plan as an issue comment | `plan` (glm-5.3) |
| implement | commits on the issue branch, in the loop worktree | `work` (qwen/deepseek-v4-pro-0813) |
| validate | verification evidence in the handoff | `validate` (glm-5.3), `visual` (glm-5.3-flash) for screenshots |
| critique | review findings; fix commits | `review` (glm-5.3) + `diffreview` (qwen) + fix round by `work` |
| ship | push, PR with body contract, CI read + fixes | orchestrator + `scribe` (mimo-v2.5) |
| merge | the merge; post-merge issue verification | orchestrator |
| diary | `.pi/journal.md`, `CLAUDE.md` if structural | `scribe` |
| housekeep | stale-branch/PR cleanup, state-file prune | orchestrator |
### Legs that are the orchestrator's alone
The orchestrator (the loop session) delegates every deliberative leg and
keeps three for itself because they are script-shaped and must not be
re-implemented by a model: claim (`issue.sh claim`, which refuses when
someone else holds the issue — the backstop), merge (API calls below), and
housekeep (branch deletion). If a tick does nothing else, it reconciles.
## Model routing
The routing authority is the card's four tiers, reproduced here as the
loop's assignment, not as an argument:
- **T0 `go/mimo-v2.5`** — mechanical gathering, commit/PR/journal prose,
any fan-out. Effectively free; wrong only where wrongness costs a
debugging session, so nothing above takes its word for a *fact*.
- **T1 `qwen/deepseek-v4-pro-0813`** — implement-from-a-written-plan,
understood-diff review, the orchestrator itself. The default session
model; half price 10:0020:00 EDT, which the cron is shaped around.
- **T2 `glm/glm-5.3`** — repo-scale reasoning: selection, planning,
consequences review, validation judgement. Weekly credits with no
rollover: the loop draws them every week by construction, which is the
correct posture. **`glm-5.3-flash`** for anything multimodal
(screenshots, UI inspection).
- **T3 `go/kimi-k3`** — escalation only: two lower tiers already failed,
or the issue is a named gnarly one. A fresh session seeded with the
failing tier's own summary, never a mid-session switch, never parallel,
at most once per day.
The invariant behind all four, from the card: **routing down is cheap,
routing up is expensive.** An implementation that stalls is escalated by
having the T1 session write *what it tried, what failed, what it observed*
and handing that to a new session one tier up. Escalating a session in
place is forbidden in both directions.
Fan-out is allowed on T0 and T1 only (the Go plan's $12/5 h constraint
makes T3 fan-out self-defeating). Critique is the one standing fan-out:
two reviewers, two angles, one synthesis.
## Scheduling
`0 0 10-18 * * 1-5` (local = EDT): hourly on weekdays inside Qwen's
half-price window, clear of the card's ⚠ 26am band (DeepSeek peaks, GLM
loses its off-peak discount — the window the old `yj-backlog` cron sat in,
which this replaces as the loop supersedes it).
- A tick takes a lock (`/tmp/yj-loop.lock`, PID + timestamp). An overrun
tick makes the next fire exit immediately; serialization survives
whatever the scheduler does with overlapping fires.
- ~9 ticks/day; an issue is 25 ticks; **one to two issues per day** is
the natural rate. That also paces the bills without a budget flag.
- The port check is part of reconcile: if `34115` is occupied, the tick
refuses any leg that needs the headless app and defers to the next
tick, without complaint. A human's interactive tier always wins.
## Runtime and ON/OFF
The scheduler (`pi-schedule-prompt`) fires only while a pi session is open
in the job's directory — that limitation is the switch:
- **Worktree:** `git worktree add` a dedicated clone at
`~/.paseo/worktrees/loop/jumpy-hound`. Loop edits happen only there; a
dirty tree there is the loop's business and nobody else's. **Provision
it once before its first push:** `make build-frontend` + `make testdata`
— the pre-push `go-test` hook needs both and refuses without them.
- **Session:** pi in that worktree, `/name loop`. The job is bound to that
session, so another pi elsewhere in the same directory does not
double-fire it.
- **ON:** resume the loop session (`pi --resume loop`) and enable the job.
**OFF:** toggle the job off in `/schedule-prompt`, or close the session.
**Drain** (stop taking new work, finish in flight): set `drain: true` in
the state file.
- **Tune in:** the same `pi --resume loop` — the chat transcript *is* the
loop's log, each tick's reasoning inline, each leg reporting in.
## Identity, claims, and what the loop may touch
The loop operates **as the owner** via `GITEA_TOKEN` (scopes: `read:user`,
`write:issue`, `write:pull`, `write:repository`); pushes ride SSH and need
no token. Every tracker comment the loop writes is prefixed `⟦loop⟧`, so
the collaborator reads it as the pump and not as a person.
It may only ever touch work it created: issues it claimed, branches it
made, PRs it opened. Two mechanisms make that enforced rather than
intentional: `issue.sh claim` refuses an issue somebody else holds, and
reconcile checks `git ls-remote --heads origin` so a branch name collision
from a concurrent session is caught before the first edit.
## Merge lifecycle
- **Only PRs the loop opened.** A collaborator's PR is never merged, never
commented on for pressure, never touched.
- **Every branch is refreshed against main before its merge**, in the
loop worktree — the refresh is where a textual conflict surfaces, as
diff text: hunks the loop authored are resolved there, anything else
is left to a human with a `⟦loop⟧` comment. The protection's
`block_on_outdated_branch` makes the refresh mandatory for adopted
(pre-loop) branches: behind `main`, a PR cannot merge at all.
Required contexts are re-polled on the refreshed head.
- **Merges are one at a time**, each re-reading state — the previous
merge moved `main`, and the next PR's mergeability is recomputed at
its own turn.
- **Post-merge, the `push` run on `main` is watched.** A red main after
a loop merge halts the loop. That run is the only guard against the
class no mergeability check sees: two PRs touching the same file,
merging cleanly, contradicting each other.
- The gate is the protection rule itself, read from the API: contexts
`CI / check*` and `CI / e2e*` green, PR mergeable. (Required approvals
is 0 today; if a second person changes protection rules, the merge
endpoint refuses and the tick stops and reports — human business.)
- `Closes #n` goes **in a commit body inside the branch, one line per
issue, and in the PR body**. Both, because a squash route and a merge
route parse different texts, and this pairing was measured: a comma
list partially matched, five of ten issues.
- After merging: verify against `issue.sh list --state open` that the
issue actually closed; close any straggler naming the merge commit.
`unclaim.yml` strips `Status/In Progress` automatically; it is not
instant, and a re-open does not restore it — the verification is
against the open list, not against the label.
- Merging to `main` fans out to nothing: releases are the manual
`release.yml`, which this loop never runs. The blast radius of a
merge is the main branch's CI, and the critique leg is what stands
before it.
## Verification contract
The tier table is `yellowjacket-dev`'s; the loop re-states nothing above
it except the *division of duty*: the worker runs the tiers the change
demands, and the validator re-reads the issue and checks that the tier
evidence actually answers the claim — a green suite that never touched
the reported surface is a finding, not a pass. Cosmetics are read by a
model that can see (`visual`, the multimodal tier); a change that moves
geometry refreshes its `ui-visual` baseline in the same commit.
`tsc --noEmit` is part of the gate and nothing else runs it. The e2e app
is seeded (`SEED=default`) and stopped after.
## Android / emulator mode
The loop is **device-free by default**: issues whose verification is
physical-device behaviour stay open for humans (the repo's own tags say
which those are). One step of the ladder exists for the rest:
- `{"emulator": true}` in `.pi/loop/state.json` **plus an already-booted
emulator** (`adb devices` answers) opts the loop into building the APK
and using `android-smoke` (crash verification), and `android-screenshot`
/ `android-eval` as rendering evidence for `visual`.
- The loop **never boots or stops an emulator** — that is the user's
machine and their gesture. Boot it with `make android-emulator`
(one-time `make android-setup`, ~3.5 GB, creates the AVD), and
`make android-emulator-stop` when done.
- Real-device-only issues are skipped under either setting.
## Budgets and pacing
Expected spend: dominated by the T1 implementation leg inside the
half-price window (pennies to tens of cents) and T2 on weekly credits;
T3 bounded at one fresh call per day. The card's numbers ($12 per rolling
5 h, $30/week as burst headroom not allowance, GLM reset weekly) are the
sanity cells; the loop's own weekly check compares against them rather
than against the month.
## Cleanup (housekeep leg, once per day)
- Loop-owned branches whose commits are in `origin/main`: deleted, local
and remote.
- Loop-owned PRs open >7 days or red on a second identical CI cause:
commented with what is known (`⟦loop⟧`), and left — never silently
deleted.
- Anything not the loop's (assignee, branch, PR): left exactly as found.
## Pilot phases
- **P0 — supervised.** One tick, user watching the transcript: reconcile,
select, claim, plan. No merge.
- **P1 — observed.** Two ticks ending in the loop's first merge, watched
through CI → merge → verify-close.
- **P2 — unattended.** The schedule left on. Weekly check against the
card's two-minute ritual.
- **Hard stops** (any of these halts the loop and leaves a comment, never
a silent retry): a tick dies twice with no explanation; a merge happens
for a PR the loop did not open; spend outside the cells above by 2×.
## Not now, on purpose
- **Parallel worktrees** — blocked on e2e's exclusive port; viable only
with per-worktree headless ports or CI-only e2e. The shape (
supervisor + per-issue worktrees) is the target, not the first cut.
- **Weekend batch refactors** — DeepSeek off-peak is real but is a
scheduling knob on top of a working pump.
- **More chain files** — the critique fan-out is a chain; the rest stay
orchestrator-legs until two weeks of unattended runs say which legs
are actually fixed-shape.
@@ -1,408 +0,0 @@
# 019 — The Android touch model
**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`)
**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
being diverged from)
**Status:** shipped (phases 1-4). Its one deliberate remainder is #200.
#73 puts #60 first in Phase 4 because it is "the presentation every
other item needs", and this is the next one. The Direction on #63 asks
for the interaction model to be designed as one piece before any of it
is built, because it *reassigns an existing gesture* rather than adding
one — `utils/long-press.ts` currently owns the 500ms hold, and every
context menu in the app is downstream of it.
This document is that design. Everything below is a measurement, or an
argument for one of the choices #63 leaves open.
---
## The mapping
| gesture | pointer is a finger | pointer is a mouse |
|---|---|---|
| single tap / click | **play the row** | select the row |
| double | — | play the row |
| long press (500ms) | **enter selection mode** | — |
| right-click | — | context menu |
| swipe right | **add to queue** | — |
| drag | reorder / drag to playlist | reorder / drag to playlist |
Three of those are #63's report unchanged. Two are decisions it left
open, and one is a deliberate divergence.
---
## Decision 1 — the predicate is the pointer, not the platform
#63 says "the row component needs a platform-aware interaction layer
rather than shared handlers". It needs an interaction layer; it should
not be platform-aware.
**The question a row has to answer is not "am I on Android" or "is the
viewport under 600px" but "what made this event".** `pointerType ===
'touch'`, read off the event that is being handled, which is already
how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`)
and is the only such test in the frontend today.
This is #64's rule — the predicate is named after the capability, not
the platform — and it carries #64's warning with it. Keyed on a width:
- an Android **tablet** at 600px or more gets click-selects /
double-click-plays on a touchscreen, which is the exact inversion
this issue exists to fix, on the platform it exists for;
- a **touchscreen laptop** cannot be described at all, because both
pointers are live in the same session on the same row;
- and a narrow desktop window gets phone semantics with a mouse.
Per event, all three are right for free, and there is no second
declaration of what a phone does — the thing CLAUDE.md declines to add
every time it comes up.
**Measured, so this is not an assumption about the WebView.** On the
reference device (TLP301, Android 14, WebView Chrome 113, 424x439),
driving a real tap with `adb shell input tap`:
```
[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]]
```
`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is
5, and `(pointer: coarse)` / `(hover: none)` both match.
---
## Decision 2 — there is no double-tap, and the number is why
#63 asks for *single tap → play* **and** *double tap → context menu*.
Those two cannot both be honoured. The first tap of a double tap is
indistinguishable from a single tap until the interval expires, so
"tap plays" necessarily becomes "tap waits to find out whether you
meant something else, then plays". The app already owns that constant:
`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS
= 250` for precisely this reason.
**What it would be added to, measured on the device.** Six runs, from
the play command to the backend's `TrackChanged`:
```
155, 123, 85, 56, 91 ms median ~100
```
So the app's primary interaction is ~100ms, and a double-tap
discriminator makes it ~350 — **3.5x, of which 250ms is spent
deliberately doing nothing** — paid on every track anyone ever plays,
in order to reach a menu.
It is also against the platform's convention, which counts for more
than usual here because this is the phone build and nothing else:
long-press is *how you select* on Android (Gmail, Files, Photos),
double-tap is zoom or nothing, and a list's menu is either the
long-press sheet or a per-row overflow.
**So the menu and the selection action bar become the same surface**,
which is the convention and removes a concept rather than adding one.
Long-press selects the row it was made on and raises the action bar;
the bar's actions *are* the context menu's actions, contextualised to
whatever is selected — one row or forty. #60's bottom sheet stays
behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and
`menu-surface` are reused rather than reimplemented.
---
## Decision 3 — tap-to-play and selection mode ship together
The obvious phase order is "tap plays first, it is the smallest
change". It is wrong, and the reason is a capability that exists today
and is easy to miss.
**A touch user can already multi-select**: tap selects (the desktop
semantics, which a finger currently gets), and the long-press menu then
acts on the selection. Move tap to play without shipping selection mode
in the same change and there is a window — a release, if it lands — in
which selecting forty tracks to add to a playlist is impossible on a
phone. That is a regression dressed as an increment.
So phase 1 is both, or neither.
---
## What the code looks like now
| surface | how it binds | selection |
|---|---|---|
| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` |
| `queue-panel` | delegated, same shape | `SelectionController` |
| `playlist-details` | per row | `SelectionController` |
| `smart-playlist-details` | per row | `SelectionController` |
All four already share `SelectionController`, and all four resolve a
row from an event by `data-index` / `data-file-path` on the row. So the
gesture layer has one shape to talk to, and "selection mode" is a flag
on the controller they already have rather than a fifth concept.
`utils/long-press.ts` is one document-capture listener that synthesises
a `contextmenu` — the seam that needed no component to opt in. **This
plan keeps that shape and changes what the gesture means**, which is
why it is a rewrite of that file rather than a second listener set: two
document listeners both claiming the 500ms hold is the fault the file's
own header warns about.
---
## Two measurements that decide the implementation
**`touch-action` is `auto` on both the virtualizer and the rows.** With
`auto` the browser owns panning on both axes, so a horizontal drag can
be claimed as a scroll and our gesture ends in `pointercancel`
mid-swipe. A row that wants a horizontal swipe has to declare
`touch-action: pan-y`: the browser keeps the vertical pan (which is the
virtualizer's scroll, and must stay native or the list stutters) and
hands us the horizontal axis. This is the single most likely way for
swipe-to-queue to "work in Chromium and not on the phone".
**The row is 424x52 on the device**, so a swipe threshold in px is a
fraction of a row height, not of a screen.
**And the third one was found by building phase 1 and then running it**
— it is not something any browser tier can report. Chrome 113's Android
WebView **fires its own `contextmenu` on a long press**. `long-press.ts`
stood down when a trusted one arrived, which was right while both paths
ended in the same place; once a hold can mean selection mode they end
in different places, and standing down means the gesture silently does
the *old* thing. Measured, before the fix:
```
{"log":["contextmenu isTrusted=true"],
"state":{"bar":null,"menuActive":true,"selected":1}}
```
`yj-long-press` was never announced at all, the context menu opened,
and all 26 tests in the component tier passed — dispatched pointer
events do not make a browser synthesise a `contextmenu`.
So the browser's event is a **trigger, not a competitor**: the gesture
is announced from it, and only a component that claims it suppresses
the native menu. Unclaimed, it propagates untouched. That is the same
"browser wins" outcome, reached by asking instead of assuming — and
verified both ways on the device, a track row entering selection mode
and an album card still opening its menu.
The tier could not *find* it and can *hold* it: a test cannot dispatch
a trusted event, but this module has always told its own apart by
identity rather than `isTrusted`, so an untrusted one from a test takes
exactly the browser's path.
---
## A tier note: this one can be driven, not only measured
`adb shell input tap|swipe` reaches the WebView as real pointer events,
which the log above is evidence of. So for the first time the Android
tier can *perform* the thing under test rather than describe the page
afterwards — a long press is `input swipe X Y X Y 600`, a swipe right
is `input swipe X Y X+N Y 120`.
Device CSS pixels from device pixels, on this phone:
`css = (device - 59) / 2.564` vertically, `css = device / 2.564`
horizontally (measured from the tap above: 200,300 arrived as 78,94).
This does not make the device a spec tier — it does not run in CI and
`make ui-test` still has to carry the assertions. It makes "does the
gesture actually fire on Chrome 113" answerable in seconds.
---
## Phases
**Phase 1 — the seam, tap-to-play, selection mode.** `utils/
touch-gestures.ts` replacing `long-press.ts`: pointer-typed
recognition of tap / long-press / horizontal swipe, dispatched as
composed custom events so a delegated listener in any shadow root
still works. `SelectionController` gains a mode. `track-list` acts on
tap and enters the mode on long press. The action bar.
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
finding above and a reveal-and-snap affordance. **Shipped**; what the
device said about it is the section below.
**Phase 3 — the other three surfaces**, which is mostly wiring, since
they already share the controller. **Shipped**, and it was not entirely
wiring — see below.
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
row are a single-click target inside a row whose single tap now plays;
that conflict is #67's, and this plan should not pre-empt its answer
beyond making tap-to-play win on touch. **Shipped.**
## Phase 3 was not symmetric, in two places
**A tap on a queue row plays that position**, not the list. Copying
`track-list`'s tap — which sets the queue to the list the row is in —
would rebuild the queue *from* the queue, discarding its source, its
shuffle order and everything a user had inserted by hand. It reads as a
no-op and is not one.
**The queue panel has no swipe, deliberately.** A right swipe means
*add to the queue* everywhere else it exists, and a queue row is
already in the queue; the only thing it could mean there is *remove*,
which is the same gesture with the opposite effect one screen away —
the fault `utils/icon-language.ts` exists to have fixed for glyphs.
Removing a queue row is on the row itself (the ×), on its bottom sheet
since #60, and on the selection bar this phase gave it. The assertion
is that its rows do **not** carry `data-swipe`, so a swipe there cannot
silently become a second meaning for the app's one horizontal gesture.
And the affordance became `utils/swipe-to-queue.ts` rather than being
copied twice. Three lists want it; three copies of "how far is far
enough" is three chances for them to disagree, which is what
`utils/library-status.ts` and `utils/ownership.ts` each exist to have
stopped happening. The shared stylesheet is keyed on `[data-swipe]`
rather than on a class name, because the three lists call their rows
two different things and the `touch-action` half of the device fix has
to reach all of them.
## Phase 4 was already true, which is why it is asserted
A claimed tap has its click swallowed at document capture, so an
`explore-link` inside the row never sees one and tap-to-play wins with
no rule of its own. Nothing in the suite would have failed if that
stopped covering the link, and the symptom — tapping a track's *title*
navigating to its album instead of playing it — is one a phone user
meets constantly and a mouse user never does.
**Its test was vacuous when written**, in the way this file keeps
finding: the tap helper dispatched `pointerdown` and `pointerup` and no
`click`, so there was nothing to swallow and the assertion held on any
build. It sends the trailing click now, which also strengthened phase
1's "a tap plays and does not also select". The fixture needed an MBID
for the same reason — without one the link asks the backend for a local
album first and gives up when nothing answers, so "it did not navigate"
was true of a working build and a broken one alike.
---
## What phase 2 measured, which was not what phase 2 predicted
The `touch-action` finding above is **half** of the answer, and
shipping only that half would have been the exact failure it warns
about. Driving a real finger with `adb shell input swipe` across a
track row, three values, all three on the device:
```
touch-action: auto pointerdown, 1 move, pointercancel
touch-action: pan-y pointerdown, 2 moves, pointercancel
touch-action: none pointerdown, 2 moves, pointercancel
```
`touchmove` kept firing in all three. So **Chrome 113's WebView
cancels the pointer stream ~16px into any drag whatever `touch-action`
says**, and a swipe recognised from `pointermove` — which is what the
rest of this module is built on — is a swipe that dies 16px in.
The other half is a **non-passive `touchmove` calling
`preventDefault()`**: with it, the same swipe ran to 12 moves and a
`pointerup` at full travel. And both halves are required, which was
measured rather than assumed — with the `preventDefault` in place and
`touch-action` back at `auto`, the gesture died after **one** move.
The reading is that `auto` lets the browser commit to a horizontal pan
on the first move past slop, before any threshold of ours can have
been crossed, while `pan-y` leaves it undecided long enough for the
second move to claim it.
`none` is the one value to avoid: the list stopped scrolling at all.
With the shipped pair, a vertical drag still scrolls the virtualizer
81px on the same run that a horizontal one survives.
**`draggable="true"` is not a competitor**, which is the other thing
the device was asked. No `dragstart` fires from a touch drag on this
WebView at all, so the drag-to-playlist attribute on every row needs no
pointer-type gate.
### And it found a phase 1 defect that no tier can see
The native `contextmenu` arrives in **either** order, and phase 1 only
handled one of them. `nativeSeen` covers the browser's menu arriving
*during* the hold. The reverse — our 500ms timer firing first, a
component claiming it, and Chrome delivering its own `contextmenu`
5070ms *later* — was suppressed by nothing, so the context menu
opened on top of the selection bar. Measured over four holds:
```
hold 1 yj-long-press, then contextmenu isTrusted=true menu open
hold 2 yj-long-press clean
hold 3 yj-long-press, then contextmenu isTrusted=true menu open
hold 4 yj-long-press clean
```
Two in four, on the one surface #63 exists to have changed, and
invisible to both browser tiers because neither synthesises a
`contextmenu` from a dispatched press. A press that has produced its
outcome now suppresses a late one whichever branch it took; six holds
on the fixed build, six clean.
### The rules phase 2 settled
- **A swipe is not a selection.** It queues the row it was made on,
unless that row is one of several *explicitly* selected — the same
rule the context menu answers with, because a bar reading "40
selected" beside a gesture that quietly queues one of them is two
answers to one question. It never changes the selection, which is
where it differs from a right-click.
- **Rightward only.** Nothing is bound to a leftward swipe and
claiming one would take a gesture away to do nothing with it.
- **The commit threshold is a fraction of the row** (0.3, floor 72px),
because the row is 424x52 on this device and a bare pixel count is a
fraction of a row height on one screen and a third of the width on
the next.
- **The affordance is not only a colour** (WCAG 1.4.1, the rule the
playing-row marker exists for): the pane carries the queue icon and
words, the words change at the threshold ("Add to queue" → "Release
to add" → "Added"), and the outcome is announced in a live region.
- **The row does not move; its cells do.** `.track-row` is
`contain: strict` with `overflow: hidden`, so a pane held at the
row's original position while the row translates is a pane at a
negative offset inside a clipping box and is simply not painted.
Sliding the cells needs no wrapper element in a row that is already
a grid.
- **The travel is written to the row's own style, not rendered.** One
render at the start, one at the threshold, one at the end; a
virtualizer re-rendering every visible row per frame of one finger's
travel is the thing `perf.m1` is about.
## Open questions
1. **Does selection mode have an escape other than the bar's own
close?** *Settled: Escape, here; back, not here.*
Escape leaves the mode, from `selection-bar` rather than from each
of the four hosts — that element exists only while the mode does, so
it is the one place a dismissal can be attached and detached with
the thing it dismisses. It is the same documented exception the
overlaid queue's Escape is: **a dismissal, not a shortcut**, so it
is not a panel-scoped binding.
The back gesture is the half that is *not* done, and deliberately.
The obvious version — `selection-bar` pushing a history entry — is
precisely the fault `navStack` was deleted for: the shell owns the
stack (#6/#55) and is the only thing that calls `pushState`, so that
two stacks cannot disagree about what one press means. Four lists
each reaching for `history` is four stacks. It is also wrong on its
own terms, since a mode is per-component and a user who enters one,
navigates away and returns has an entry for a mode that no longer
exists. #55 settled the shape for a *place*; a mode is not one,
which is why it could not simply inherit that answer.
What it wants is one shell-owned register of dismissible surfaces,
which would retro-fit the queue overlay, the dialogs and this alike
rather than adding a fourth private answer. **#200.**
2. **Does a tap on a row's favourite icon still toggle it in normal
mode?** *Settled in phase 1: yes.* A control inside the row keeps
its own tap — the gesture is simply not claimed there, so the click
behind it falls through untouched. It is the same rule the shortcut
service has for a focused control that owns a key, and it is what
keeps the 44px favourite target (#56) from becoming a 44px play
target. The queue row's × is the second instance of it.
+25 -595
View File
@@ -6,22 +6,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
YellowJacket is a cross-platform desktop music player built with Go (backend) and TypeScript/Lit (frontend), using the Wails framework to bridge them. It supports MP3, FLAC, OGG Vorbis, and WAV playback.
**The three prose documents are split by reader, not by topic** (#50).
`README.md` is the landing page and answers *a user's* questions only —
what it does, which channel installs it on which platform, where its
data lives — with three screenshots in `docs/images/`, captured from the
fixture library (`make sandbox-seed NAME=default``make dev-headless
SEED=default`) so they can be retaken by anyone. `CONTRIBUTING.md` holds
what used to be the second half of that README — prerequisites, the
system libraries, the build and codegen commands, which verification
tier a change demands, the tracker workflow and the commit grammar. This
file stays the deep reference both of them point at, and is the only one
of the three that explains *why* a shape is what it is. A fact that
belongs to a user goes in one place; the packaging channels keep their
own documents (`packaging/*/README.md`, `docs/android-release.md`) and
are linked rather than summarised, because a version-restart note copied
into the README is a second copy to keep true.
## Issues
**The tracker is the source of truth for what is wanted and what is
@@ -138,17 +122,6 @@ has started is a second, staler answer to "what are we doing next".
Numbering is sequential and stable across status moves (a plan keeps
its `NNN-` prefix). Abandoned plans are deleted.
**The autonomous loop** (plan 020, `.pi/skills/yj-loop/`) is the pi
configuration that works the tracker one issue at a time — a cron tick
in a dedicated worktree and session, with the tracker labels as its
state machine. It claims with `issue.sh` like anyone, merges only PRs
it opened once the protection contexts are green, and files what it
finds. Its switch is `.pi/schedule-prompts.json` (gitignored): it runs
only while that pi session is open, and that limitation is the whole
on/off design. Where a loop discovery contradicts this file, this file
is wrong and should be fixed by the diary leg — the loop never quietly
decides otherwise.
## Commands
```bash
@@ -172,7 +145,7 @@ 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 skill-check # Fail if .pi/ documents a make target that doesn't exist
make commit-check # Fail if a commit subject is not a Conventional Commit
make lint # golangci-lint v2 (strict), all three build configurations
make test # All tests with race detector, all three build configurations
@@ -289,26 +262,6 @@ real store code. A binding carries an **ID**, not a name
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives
that map from the generated tree rather than writing it down.
**A test file does not get its own origin, so `setup.ts` clears
`localStorage` between tests.** `@vitest/browser-playwright` opens one
BrowserContext per session and runs several files in it one after
another, so everything a component persists — the track list's sort and
column widths, the cover size, `now-playing`'s scroll mode — is still
there when the next file mounts the same component. Which files share a
tab, and in what order, changes run to run, so the symptom is a spec
that fails about one test in three and passes every time it is run on
its own: #138 cost three scheduled runs, one of them a PR whose diff
held no frontend code at all. Measured on the build before the fix, a
single full run started **24** tests with storage already set. Two
things follow. The clear is safe precisely because the leak is
sequential — files in a session do not overlap, so it cannot wipe
storage a concurrently-running file is in the middle of using — and it
belongs in `setup.ts` rather than in the specs that write, because the
spec that *reads* is never the one that knows. And a spec whose
assertion depends on an order still **states that order** rather than
inheriting a default, or the next change to a default is the same
mystery again.
**`frontend/bindings/` is generated by `wails3`, not `go generate`**, so
the pre-commit codegen check does not cover it. `make bindings-check`
(~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook)
@@ -673,28 +626,6 @@ rather than renaming them.
one of the shell's rows, which is what the skip link is absolutely
positioned to avoid.
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
**A setter that can reject its argument puts the old value back**, and
that is a correctness rule rather than hygiene (#231). `Save()`
validates the *whole* config, so a value left behind by a failed write
does not merely fail its own call: it fails every later save, of every
unrelated setting — theme, launch page, shortcuts, libraries — for the
rest of the session. Nothing reaches disk, so a restart clears it,
which is exactly what makes the fault invisible and unreportable. One
rejected track-list column list was enough to stop the app saving
anything at all.
Two shapes are safe and a third is the trap. A setter that assigns and
*then* validates snapshots the field first and restores it on the
error path — seven do. `SetLibraryDirectory` is the better shape where
the value can be built on its own: it validates a candidate *before*
assigning, so there is nothing to undo. And a setter whose argument no
validation inspects needs neither — the bools, the favourites playlist
id and the shortcut bindings, plus `SetViewVisible`, which refuses an
unknown, non-hideable or launch-page view up front so
`GeneralConfig.Validate` never sees one it would fail on. Which set a
new setter joins is decided by whether its own `Validate` can reject
it, not by preference.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
D-Bus on desktop Linux, a MediaSession on Android, a no-op stub
@@ -1162,9 +1093,9 @@ not the fix and cannot be: that function is the `document` listener for
`navigate`, so it is an infinite loop.
**It is a store rather than an event, because a component that mounts
after a navigation still has to know.** `bottom-nav`'s "More" sheet
after a navigation still has to know.** `bottom-nav`'s "More" drawer
creates its `<app-sidebar>` on open, and that copy had heard no
`navigate` at all — standing on Albums, it opened highlighting
`navigate` at all — standing on Albums, the drawer opened highlighting
Home. An event has no answer for a listener that was not there.
**A detail view is not a view here**, so the destination it was opened
@@ -1265,7 +1196,7 @@ and then vanishing.
than a general rule about phones.** `PHONE_COLUMN_IDS` is the precedent
for "what a phone shows is a different question", and it would apply —
except that `bottom-nav`'s "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own sheet one
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's committed subset; this
only removes from it, and "More" is never filtered because it is how
everything else stays reachable.
@@ -1471,7 +1402,7 @@ descendants. On the reference device the main panel spans 0-318 of a
items cut off, with no way to reach them. `showModal()` is Chrome 37
and uses the real top layer, so a dialog is immune by construction.
Seven things about it are load-bearing.
Six things about it are load-bearing.
**"Dialogs are fine" needed checking, because every other dialog in
this app is mounted in `index.html`** — outside `.main-panel` — so it
@@ -1500,47 +1431,6 @@ doing nothing, which reads as the gesture breaking. `menu-dismiss` is
that signal; the three surfaces that do not use `ContextMenuController`
bind it themselves.
**A sheet that scrolls says so, and `background-attachment` is what
asks whether it does** (#207). The sheet is capped at 85vh — a surface
covering the whole screen is a page, not a sheet — so a long menu's
body scrolls, and for three phases it scrolled *silently*: measured at
424x439, eight items ended at y=470 with the fold at 439, and where the
cut lands on a row boundary the sheet ends in a clean edge that reads
as the end of the list. The fade is two background layers on
`wa-dialog::part(body)` — a shadow pinned to the box (`scroll`) under a
cover of the sheet's own colour painted at the end of the *content*
(`local`), which scrolls up over the shadow exactly when there is
nothing more to see. So it is absent on a menu that fits, present the
moment one does not, and gone again at the end of the list, with no
scroll listener and nothing reaching into `wa-dialog`'s shadow root for
the scroller. **The curve is steep because the rows under it stay
live**: a scrim over a menu item is that item's text surface, and the
4.5:1 rule applies to it — 32px already down to a quarter strength at
14px spends its weight below the last legible label, measured at 9.9:1
on the light ramp, whose `bgElevated` is `#e9ecef`.
**And the phone has two sheets, so that rule is one file both read**
(#210). `bottom-nav`'s "More" is capped at the same 85vh and overflows
for the same reason — measured at 424x439 with eight destinations,
`scrollHeight` 412 against `clientHeight` 373, and eleven items at 48px
would be 528, since #25 makes the count the user's. So the two layers
live in `styles/sheet-scroll.css.ts` and each host says only what is
local to it: the colour, handed over as `--yj-sheet-surface` on the same
box, because the nav sheet paints the sidebar's `--yj-bg-surface` and
the context sheet the menus' `--yj-bg-elevated` — a shared rule that
hard-coded either would draw that seam across the other one.
The half that is not the fade is what makes it visible: **nothing inside
the sheet may repaint the surface**, because these are background layers
on the scroller and an opaque child covers them. `menu-surface` already
had it from the other side (`.context-menu-panel[data-sheet]` is
`background-color: transparent`); `app-sidebar`'s host paints
`--yj-bg-surface`, which in the shell is its own background and in the
sheet is a second copy of the sheet's, so `bottom-nav` turns it off.
Measured at 424x439 with the fade adopted and that rule missing: a flat
52,58,64 to the bottom edge with 39px still below, which is the defect
unchanged and every assertion about `background-attachment` passing.
**The playlist submenu is a sheet too, and it had to be.** It is a
`placement="right-start"` flyout, and making the menu full-width moved
its anchor — measured at x 182 to 0, entirely off-screen, so "Add to
@@ -1559,193 +1449,18 @@ never opens). **The sweep found two of the fourteen**; twelve were
converted by hand.
**And a menu opens from a finger, through the event it already has.**
`utils/touch-gestures.ts` is one document-capture listener installed
once from `index.ts``utils/long-press.ts` until #63 replaced it,
rather than adding a second listener claiming the same 500 ms hold. It
**announces** rather than acts: `yj-tap`, `yj-long-press` and
`yj-swipe-start` are composed and cancelable, and a component claims
one with `preventDefault()`. That is what let #63 reassign the hold
without touching one of the fourteen context menus: an *unclaimed*
`yj-long-press` still becomes a synthetic `contextmenu`, so all six
components that bind one — delegated on a virtualizer, per row, per
card — behave exactly as they did, and only the lists that opt in get
selection mode. The target is `composedPath()[0]` rather than
`utils/long-press.ts` is one document-capture listener installed once
from `index.ts`: a touch that holds still for 500 ms dispatches a
synthetic `contextmenu` at the touch point, so all six components that
bind one — delegated on a virtualizer, per row, per card — gained the
gesture without changing. The target is `composedPath()[0]` rather than
`elementFromPoint`, which stops at the outermost shadow host and so
reaches a delegated listener and no per-row one; and the click that
ends a *claimed* gesture is swallowed, keyed on the gesture rather than
on a time window so the first tap on the menu it opened is not eaten
too.
Three things about it are load-bearing, and all three were found on the
device rather than in a tier.
**A browser that fires its own long-press `contextmenu` is a trigger,
not a competitor.** Chromium does, WebKit and the WebView vary. The old
rule was to stand down when a trusted one arrived, which was right
while both paths ended in a context menu and is wrong the moment a hold
can mean something else — standing down silently does the *old* thing.
So the gesture is announced from the native event, and only a component
that claims it suppresses that event. Ours and the browser's are told
apart by **identity** rather than `isTrusted`, since no test can
dispatch a trusted event.
**That arrives in either order, and both have to be handled.** The
native `contextmenu` mid-hold is one case; the other is our own 500 ms
timer firing first and Chrome delivering its menu **5070 ms later**,
which nothing suppressed — measured over four holds on the reference
phone, two took that order, so the context menu opened over the
selection bar intermittently, on the one surface #63 changed. A press
that has produced its outcome therefore suppresses a late
`contextmenu` whichever branch it took.
**A horizontal swipe runs on touch events, and needs two things that
look like one.** Chrome 113's WebView cancels the *pointer* stream
~16 px into any drag — measured at `auto`, `pan-y` and `none` alike,
one or two `pointermove`s and then `pointercancel`, while `touchmove`
kept firing throughout. So the recogniser is `touchmove`, the surface
declares **`touch-action: pan-y`** *and* a claimed swipe calls
**`preventDefault()`** on a non-passive listener. Neither works alone:
with the `preventDefault` in place but `touch-action` back at `auto`
the gesture died after one move, because `auto` lets the browser commit
to a horizontal pan before any threshold can be crossed. `none` is the
value to avoid — it takes the list's own vertical scrolling with it.
**Both are correct in Chromium either way**, which is why this is
written down rather than tested. The tie breaks toward scrolling, in
that order: vertical drift past the tolerance vetoes the swipe for the
rest of the press (a scroll that curves is still a scroll), and a
gesture that is not *strictly* more horizontal than vertical is the
scroller's.
**And what a finger *means* on a row is the inversion of what a mouse
means, decided per event** (#63). A click selects and a double-click
plays; a tap **plays** and a hold enters **selection mode**, in which a
tap toggles. The predicate is `pointerType`, never a viewport width and
never a platform flag — #64's rule, and with #64's warning: keyed on a
width, an Android tablet over 600px gets desktop semantics on a
touchscreen, a touchscreen laptop cannot be described at all, and a
narrow desktop window gets phone semantics with a mouse.
There is deliberately **no double-tap**, which #63 asked for. The first
tap of one is indistinguishable from a single tap until the interval
expires, so tapping would have to wait `DOUBLE_CLICK_GRACE_MS` before
acting — 250ms on top of a measured ~100ms play, 3.5x the app's primary
interaction, to reach a menu a hold already reaches. So the menu and
the action bar are the same surface: `components/selection-bar/` is
presentational (a count and a list of actions, no store, no selection),
`SelectionController` carries the mode for all four selecting surfaces,
and #60's bottom sheet is the overflow behind "More" — so
`contextMenuStyles`, `MenuKeyboard` and `menu-surface` are reused
rather than reimplemented.
Three things about it are load-bearing. **A control inside a row keeps
its own tap**: the gesture is simply not claimed there, so the click
behind it falls through, which is what stops the 44px favourite target
(#56) becoming a 44px play target — the queue row's × is the second
instance. **A swipe right queues**, and its affordance is
`utils/swipe-to-queue.ts` once rather than in each of the three lists
that draw it: the row does not move, its *children* do (a row here is
`contain: strict` with `overflow: hidden`, so a pane held at the row's
original position while the row translates is at a negative offset
inside a clipping box and is not painted), the travel is written to the
row's own style rather than rendered, and the threshold is a fraction
of the row because the row is 424x52 on the reference device. **The
queue panel takes the tap and the hold and refuses the swipe**, because
a right swipe means *add to the queue* everywhere it exists and a queue
row is already in it — the only thing it could mean there is *remove*,
which is the same gesture with the opposite effect one screen away.
A tap there plays that *position*, too: setting the queue to the queue
reads as a no-op and discards its source, its shuffle order and
anything inserted by hand.
Escape leaves the mode, from `selection-bar` rather than from each
host, since that element exists only while the mode does — the same
exception the overlaid queue's Escape is, *a dismissal, not a
shortcut*. The platform's back gesture deliberately does **not** reach
it: the shell owns the history stack and four lists each reaching for
`history` is four stacks, which is the fault `navStack` was deleted
for. That wants one shell-owned register of dismissible surfaces, which
is #200.
**A control revealed by `:hover` is gated on the device having hover,
and which way round depends on whether it is the only route to its
action.** The gate itself is not optional: a touch long-press
synthesises a hover state in the WebView, so every one of these flashed
into view during the 500 ms hold above — a control appearing because
the user was reaching for a different one. Where the action is reachable
another way the control is **absent** on a touch device (the home card's
play button, #68; the queue row's remove, which the row's bottom-sheet
menu carries since #60), and that is `display: none` outside
`(hover: hover) and (pointer: fine)` rather than `opacity: 0` or
`visibility: hidden`, both of which leave a button holding its hit area
and its place in the accessibility tree. Where the control is the
**only** route it is instead always visible under
`@media not all and (hover: hover)``track-details`'s cover-art
overlay and remove, `shortcut-capture`'s reset (#137) — because hiding
it takes the action away entirely.
**Always-visible is not the same as always-in-the-way.** The cover-art
overlay is `inset: 0` at 50% black, which is fine as a hover state and
is not fine as the permanent appearance of the artwork being edited —
and it is only a *hint*, since `.cover-art-edit` carries the click and
tapping the art always worked. Off hover it becomes a corner chip in
the remove button's own language. The × beside it stays full-size,
because that one really is the only route to its action.
One thing to know before checking either: **no *committed* tier renders
as a touch device.** CDP's `Emulation.setEmulatedMedia` does not reach
the component tier's iframe, and the e2e projects are Desktop Chrome
and Desktop Safari, neither of which has touch — a Playwright project
using a mobile descriptor would report `hover: none`, so this is a
choice not to carry one rather than a thing that cannot be done. So
`hover-affordance.test.ts` asserts the *parsed stylesheet* — which rule
sits inside which media query — and says so; the regression it exists
for is someone hoisting a rule out of its query as a tidy-up, which
nothing on a desktop renders differently.
**The web view's own tap highlight is gone, and what replaced it is a
press state** (#54). `-webkit-tap-highlight-color` is an *inherited*
property, so one declaration on `html` in `index.css` reaches every
shadow root in the app and takes away the grey box a phone drew over
the bounding rect of whatever was tapped — measured at
`rgba(0, 0, 0, 0.18)` with the rule removed. `user-select` is the same
argument and was already done: `index.css`'s first rule is `*, *::before,
*::after { user-select: none }`, which reaches the shadow roots for the
same reason.
Three things about it are load-bearing.
**Removing the highlight removes the only touch feedback several
surfaces had**, so the press state is part of the same change rather
than a later polish item: the four lists' rows, `bottom-nav`'s tabs,
`app-sidebar`'s destinations (which are also the phone's "More" sheet)
and the shared `contextMenuStyles` menu item all take
`--yj-press-overlay` on `:active`. The cards already had one
(`transform: scale(0.97)`) and are untouched.
**A press selector carries a state class or it does nothing where it
matters.** A row is `.track-row.selected.active`, so a bare
`.track-row:active` is one class short of it and the press is invisible
on exactly the row a phone is most likely to press — the one it has
just selected. The rule is last and lists `.selected:active` /
`.active:active` beside the bare form.
**And the hover tints on those same surfaces moved behind
`(hover: hover) and (pointer: fine)`**, which is #68's gate applied to
a tint rather than to a revealed control and for the same mechanism: a
hold synthesises a hover in the WebView, so an ungated tint arrives
because a finger touched the row and stays there after it has gone —
measured, since with the press rule removed a held row reads
`rgba(255, 255, 255, 0.05)`, the hover tint, rather than nothing.
`touch-action: manipulation` was considered and declined: the 300ms
delay it is offered for is already absent on a `width=device-width`
viewport, and what it would really change is the gesture stack #63
tuned by measurement on a device this session cannot measure.
The split of tiers is `hover-affordance.test.ts`'s: `press-feedback.
test.ts` reads the parsed stylesheet, because `:active` cannot be
forced there either, and `native-touch-feel.spec.ts` *measures* — it
holds the button down on a real row of the real list, and it is the
only tier that loads `index.css` at all.
reaches a delegated listener and no per-row one; a browser that fires
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
vary) wins, ours being told from theirs by **identity** rather than
`isTrusted`, since no test can dispatch a trusted event; and the click
that ends the gesture is swallowed, keyed on the gesture rather than on
a time window so the first tap on the menu it opened is not eaten too.
Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through
@@ -1903,21 +1618,6 @@ is not it.** A `placeholder` is an accname fallback, so an
Explore's search box — the audit's own `a11y.26` — as clean. A sweep
for *empty* names cannot see a *weak* one.
**`title` is the same trap one rung lower, and it defeats the obvious
spec as well as the obvious sweep.** `queue-panel`'s Clear queue and
Add queue to playlist were named by `title` alone, so
`getByRole('button', { name: 'Clear queue' })` matched them **before**
the fix as well as after — a `getByRole` assertion, which is what
catches every other nameless control in this app, would have been
green on the broken build. `title` is the *last* fallback in the
accname order, so content put inside the button later silently
outranks it, and it is the one name a phone cannot show, having no
hover. The property is therefore asserted as *the name is not the
tooltip*: `queue-overlay.spec.ts` removes the `title` attributes and
asks again, which is 1 and 1 with `aria-label` and was measured at 0
and 0 without it. The `title`s stay, because on a desktop they are
also the tooltip for an icon-only control and that is a different job.
**The shell scrolls sideways and not down.** `body` is
`overflow-x: auto; overflow-y: hidden`, and both halves are measured.
Vertically there is nothing to fix: the middle grid row is `1fr` and
@@ -1960,7 +1660,7 @@ listing the destinations again — but rendering it unconditionally put a
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
specs failed with "strict mode violation: resolved to 2 elements" on a
desktop viewport where the element is not even visible. It renders only
while the sheet is open, and `bottom-nav.test.ts` asserts its absence
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
before that.
**The tab bar is four destinations and a way to the rest.** Three to
@@ -1969,33 +1669,6 @@ is 32px each. Which four is plan 016's committed subset, and everything
else — Settings included, because a phone still needs it — is behind
"More".
**And "More" rises from the bottom, on #60's sheet rather than a
second one** (#71). It was a `wa-drawer placement="start"`: a 200px
column of a 424px screen, opening away from the thumb that asked for
it, with the rest of its 400px band empty. It is the *same element*
with `placement="bottom"` and `without-header`, which is what keeps
the change to where it comes from — `wa-drawer` renders a native
`<dialog>` and opens it with `showModal()`, so #60's containment
finding carries over with nothing new to prove, and the focus trap,
Escape, tap-outside and `wa-after-hide` all come along. Measured at
424x439: 424 wide, 373 tall (85vh, so there is an outside to tap),
48px rows.
Three things about it are load-bearing. **The sidebar is mounted
rather than re-listed as data**, which the issue offers as the
alternative: the shell's own `<app-sidebar>` is `display: none` below
600px rather than removed, so a second list drawing `nav-*` handles is
the duplication above, and it would be a second place to add the next
view to. **There is one scroller, and it is the sheet's body** — the
reported "only part of the screen scrolls under my finger" is three
nested ones (the dialog, its body, and the sidebar's own
`overflow-y: auto` host), so which box a drag moves depends on where
the finger landed; `overscroll-behavior: contain` is the other half.
And **`expanded` means the host owns the box, not just the labels**:
`app-sidebar` writes an *inline* width and caps itself at 400px, which
beats any rule the host could write, so the width, the scrolling and
the mouse-only resize handle all follow that attribute.
**There are three supported size bands, and the queue is part of the
promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom
nav, reflows, fits 320px exactly), **Compact** 600899 (icon sidebar),
@@ -2360,26 +2033,9 @@ one that closes the queue — the reported defect moved one press later,
which looks exactly like a press that did nothing.
**And the way out is 44px on a phone.** With the panel spanning the
whole width there is no scrim there at all, so the close button is the
only pointer route out of a full-screen surface; it was **25×21px**.
**The scrim is drawn only where it can be tapped** (#171). Below 600px
`.panel-content` is `width: 100%`, so the scrim sat entirely underneath
an opaque panel — measured at 424×439, host, panel and scrim all
424×318 — dimming nothing and dismissing nothing while wearing
`cursor: pointer`. #24's tap-outside-to-close cannot exist on a surface
with no outside, and the screen above is what answers it instead: back,
and a 44px close button. The alternative — a gutter, which is the
drawer pattern — was declined, because it buys the affordance by taking
width off a full-screen surface on a 424px viewport. Two things about
it are load-bearing. Its **existence** is `matchMedia`, not
`display: none`, on `job-band`'s rule: a hidden scrim is still an
element carrying the dismissal handler. And **the 600899 band is
untouched**, where the panel is a 320px column of a wider content area
and the scrim has real uncovered pixels — which is why the e2e half
asserts *absence* at 424×439 rather than clicking, since a phone-width
case that clicks the scrim's centre hits the panel and passes on the
broken build.
whole width the scrim has no uncovered pixels at all, so the close
button is the only pointer route out of a full-screen surface; it was
**25×21px**.
What this does **not** fix is `page-header` overflowing on its own:
at 900×600 "New Smart Playlist" is still clipped to 114 of 162px with
@@ -2411,54 +2067,6 @@ toggled from `index.ts` would be a second expression of the same fact.
The view therefore carries its own queue button, because that button
lives in the bar it hides.
**And below 500px of height its art and its names share a row** (#51).
The stacked arrangement's budget is fixed — 48px of header, 143px of
transport since #64, 78px of names, 68px of padding and gaps — so the
art gets `height - 386`, which at the reference device's 424x439 is
**53px**: the one thing a Now Playing screen exists to show, smallest
on it. #172 named the two ways out and this is the second, because the
first — a floor on the art with the block scrolling — scrolls the
transport off the bottom, and *controls never scroll off* is #51's own
Direction and plan 018's promise. Sideways the art is bounded by the
row's height instead of by the column's leftover: **53px to 143px**,
measured on the device, nothing scrolling, the transport untouched.
Three things about it are load-bearing.
**500 is where the two layouts cross rather than a round number.** In a
row the art is `height - 296` and the names get what is left of 392px,
so the names hold 176px at exactly 500 and less above it; stacked, the
art is `height - 386`, which passes 176px at 562. Below 500 the row is
the bigger art *and* the readable one — above it the column is, which
is why a tall phone keeps the arrangement it has. It is keyed on height
alone and not on the phone's width, because it answers vertical room: a
900x450 window has the same problem and the same fix.
**The art was not a small square, it was a crop, and that was never
only the phone.** `aspect-ratio` is specified not to re-derive the
width when `max-height` clamps the height — unlike an intrinsic ratio,
which is preserved under both bounds — so a definite `width: min(100%,
60vh)` kept its width while the height was clipped, and `object-fit:
cover` cropped a square cover into the band: **264x53** on the device.
The leftover exceeds the width only above ~843px of viewport, so every
height from ~500 to ~843 drew one too. `max-width`/`max-height: 100%`
with `width`/`height: auto` is the fix and is the replaced-element
path; it also never upscales past the natural size, and the largest
tier `saveCoverArt` keeps is 400px, so nothing is lost.
**The placeholder needs its own rule, and a non-replaced box cannot
express this one.** With no intrinsic size, auto/auto collapses it to
its icon (13x58, measured). It is driven from the height instead, with
`min-width: 0` because a flex item's automatic minimum is its content —
without it the icon's width becomes a floor the moment the row is
shorter than the icon, which is exactly what a job band does to this
screen. And since whichever max clamps does not re-derive the other, a
height-driven box goes **380x484** on a tall phone; `max-height:
calc(100vw - 2rem)` closes it, which is sound here for the reason
`60vh` was not — this is a phone-width detail view, so its content box
really is the viewport less the host's gutters, and it is a *max*, so
the failure mode is a square bounded early rather than a crop.
**The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint
@@ -2533,31 +2141,6 @@ a list or a detail view:
and dropped if a second click arrives, because the title is the
widest thing in a row and double-clicking a row plays it. Rows do
not need to know links exist.
**Below 600px a name is not a link, and the row's menu is where it
went** (#67). Every sentence above is a *desktop* compromise: the
double-click grace means nothing on touch, a few characters of text
is not a touch target, and since #63 a claimed `yj-tap` has its click
swallowed, so the link was unreachable as well as fiddly. The rule is
in the utility rather than at twenty call sites, and
`utils/go-to-menu.ts` is the other half — "Go to Artist" / "Go to
Album", drawn under exactly the condition the link is not, from
`explore-link`'s own exported routing so an untagged artist reaches
the library page by the same lookup.
Three things about it are load-bearing. **Suppressing a link without
a menu behind it is not a smaller affordance**, it is a destination
the phone cannot reach — so `keepOnPhone` is the documented exception
for the three surfaces with no row menu (`now-playing-view`,
`explore-album-details`' header credit, `top-results-row`), and
nothing else may pass it. **One row or none**: the items are the Play
item's rule one step on, since "go to the album" of five different
albums means nothing. And **there is no "Go to Genre"**, because
there is no genre link anywhere to lose — that would be new
navigation rather than a replacement, and belongs in its own issue.
`track-list` is the one list that gains rather than moves: its phone
column set stacks title over artist as plain text already, so those
names have never been links there.
- **`<catalog-scope-notice>`** is how a detail page admits what it is
showing: catalog data (silent), a library stand-in while a fetch is
in flight, library-only because the entity has no MBID, or a failed/
@@ -2771,38 +2354,11 @@ Five things about it are load-bearing, and four of them fail silently:
correctly. Confidently wrong is worse than absent here, which is the
same rule `Known` exists for.
One gap this did not close and #104 did: **`dhowden/tag` has no RIFF
reader**, so nothing the tag writer put in a WAV's `id3 ` chunk was
visible to `metadata.ExtractTags` — not the totals and not the title
either, on files the app itself had just tagged. `wav_test.go` read
that chunk itself, which is why no test noticed: a round trip asserted
through the writer's own parser is a test of the writer.
`backend/riff` is where the container is now read, and it is its own
package because the alternative is an import cycle — `tagwriter`
imports `metadata`, so `metadata` cannot reach back for `parseRIFF`.
`backend/tagtotals` is the precedent.
Three things about it are load-bearing. **The two readers are
deliberately different**: `Parse` holds every chunk in memory, which is
what rewriting a file needs, and a WAV's audio *is* a chunk — so the
scan path uses `ID3Chunk`, which seeks over what it is not looking for.
**The container decides, before `tag.ReadFrom` rather than after it
fails**, because that library's last resort is an ID3v1 trailer and a
WAV carrying both would otherwise be read by the wrong one. And **an
untagged WAV is a file with no tags, not a file with a problem**: no
chunk, an RF64 container or a tag holding no frames all read as empty
metadata with no `TagReadWarning`, since the scanner's filename
fallback is the right answer and a warning would put a fault on a file
that has none.
The gap was pinned by a test that said so, which failed the moment the
reader learned and carried the instructions for what to update in its
own comment. So it is deleted, `TestFixturesMatchManifest` no longer
skips `wav`, and `totals_test.go`'s WAV case goes through
`metadata.ExtractTags` like the other three formats. The fixture
library's two WAV tracks scan with their tags and their cover now,
which is a change to what every seeded tier sees.
One gap this did not close, and it is older: **`dhowden/tag` has no
RIFF reader**, so nothing the tag writer puts in a WAV's `id3 ` chunk
is visible to `metadata.ExtractTags` — not the totals and not the title
either. `wav_test.go` reads that chunk itself, which is why no test
ever noticed.
**The absence is what gets marked, not the presence.** The tracklist
put a green tick against every owned track and a legend underneath
@@ -2960,31 +2516,6 @@ missing half; `catalogFailed` is the only route to `unavailable` now,
and the timer is a 60 s backstop for a genuine hang rather than the
verdict.
**On a phone that page is one scroll container, and the header is in
it** (#66). It was built as a fixed header over a scrolling tracklist,
which is the desktop arrangement: at the reference device's 424×439 the
header owned **253 of the panel's 318px** and the list scrolled inside
the 64 that were left. Below 600px the *host* is the scroller and
`.content` stops being one, so the whole page moves together — which is
only available because this tracklist is plain DOM rather than a
virtualizer, and because `.main-panel > *` already gives the host a
definite height.
Three things about it are load-bearing. **Another `min-width: 0` was
not the fix**: `.album-info` carries one and was shrinking exactly as
asked, to 112px beside a 200px cover — so the title drew as `G…` and
"Shuffle album" ended at x=443 inside a 424px box, clipped by the
component's own `overflow: hidden` and reachable by no gesture. A row
with a fixed-size sibling has to **stack** at that width, or the column
that must shrink has nothing to be wide with. **`layout-overflow.spec.ts`
cannot see any of this** — `body.scrollWidth` equalled the viewport
throughout, because the overflow was *inside* a component; the spec
measures each header control against the host's own box, which is
`top-bar-fit.spec.ts`'s shape for the same reason. And **the phone block
is last in the stylesheet**, on `index.css`'s rule: a media query adds
no specificity, so written above the plain rules it overrides every
declaration in it is silently dead.
**Activating a row plays the list the row is in, from that row.** A
double-click — and Play on a single row's context menu — queues the
list as *displayed* with `startIndex` on that row, not a queue of one
@@ -3308,27 +2839,6 @@ its own duplicates apart) — and changing either is invisible against an
existing `YJ_HOME`, whose `config.toml` already holds the old list, so
`make sandbox-seed NAME=default` before believing the app.
**And the *valid* columns are declared twice too, which is the pair
that drifted.** `tracklist.AllColumnIDs` is what the backend accepts;
`COLUMN_DEFS` is what the frontend knows how to draw, and they are not
the same set — `titleArtist` is a definition and not a choice, since it
is the phone's stacked column and is picked by width in
`PHONE_COLUMN_IDS`. Settings built its list from `Object.keys(
COLUMN_DEFS)` and so offered it: **two rows both called "Track Name"**
(#197), the second unselectable, because ticking it sends a column set
Go rejects with `unknown track-list column ID` and `config-page`
swallows that into a `console.error`. `CONFIGURABLE_COLUMN_IDS` is what
the configurator reads now, derived from a `configurable` flag on the
definition, and `settings-column-list.test.ts` reads Go's own list out
of the source rather than writing it down a third time — the rule being
about every column, so checking one checks nothing.
One thing it does **not** fix, because it is reachable from any invalid
input rather than from that row: `SetTrackListColumns` assigns before it
validates, so a rejected list stays in memory and `Save()` validates the
whole config — one tick and **no setting saves for the rest of the
session**, silently. That is #231.
**Event-driven communication**: Backend emits events via Wails runtime; frontend stores subscribe to them. Event names are constants in `backend/events/`.
`frontend/src/events.ts` is **generated** from `backend/events/events.go`
@@ -3510,46 +3020,6 @@ rather than searching it — the store replaces that array when its
contents change and shares the unchanged members, which is the same
signal `track-list`'s memoized caches key on.
**And the right tier arriving late still reads as no art at all**, so
the two grids ask for it before the card exists (#65).
`utils/image-prefetch.ts` warms the images a scroll is about to reach,
from `cover-grid`'s and `artists-view`'s virtualizers. Measured on the
50 000-track bulk seed over ten 2 400px jumps: of 258 covers arriving
in view, **254 were still blank one frame later and 214 two frames
later**; with the prefetch, 117 and 77. Both builds are clean by 50 ms
on a desktop with 3.7 kB fixture covers, which is where the reference
device's slower engine and 27 kB covers spend their pop-in.
Four things about it are load-bearing.
**The overscan the obvious fix asks for does not exist.**
`@lit-labs/virtualizer`'s `_overhang` is a hard-coded 1000px
`protected` field on `BaseLayout` with no configuration surface, so
raising it means monkey-patching a private. 1000px is about two
screens on a 439px viewport, and the *image* cannot be requested until
the card it lives in is rendered — which is what this asks for
instead.
**It hangs off `rangeChanged`, not `visibilityChanged`.** Those report
different ranges: visibility is what is on screen, and the virtualizer
has already rendered that 1000px past it. Anchored to the visible
range the window is spent on cards that already exist and have already
asked for their own art — measured as the difference between the
prefetch reaching one row past the last card and reaching a full
window past it.
**It is not the `LRUMap` path, and saying so is the bound.** That
ceiling holds Explore's base64 data URLs in JS; a library cover is a
plain URL under `Cache-Control: immutable` (the filenames are content
hashes), so what retains the bytes is the browser's own cache. What
this module retains is the *set of URLs already asked for*, capped at
512 and reported to `window.__yjCacheStats()` — 497 entries and 15 407
chars after the run above.
**The prefetch asks for what the card will draw.** `artists-view`'s
tier ladder moved into `artistAvatarURL()` so the two cannot disagree;
a second copy would be a warm cache for a tier nothing renders.
**The same rule, on the selection path, was the worst stall in the
app.** Five components turned selected file paths back into tracks with
`filePaths.map(fp => tracks.find(…))`, so "Select all → Edit tags" at
@@ -3734,25 +3204,6 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
`applySchema` production uses so the two cannot diverge. Test audio fixtures live in `test_data/music_library_test/`. Table-driven tests are the norm.
**`make ui-visual` is the one tier nothing but a person runs, and it
cannot become one.** Its ten `toMatchScreenshot` baselines were recorded
on a developer's Arch box; replayed in a bare `ubuntu:24.04` container
— CI's `check` image — three of them fail on font metrics and
compositing alone (`track-info` and one `page-header` shot at a 0.03
mismatch ratio against a 0.02 allowance, `seek-bar` one pixel shorter),
and two components disagree about their own height between the two
machines. So CI keeps running `make ui-test`, which is the same suite
with the comparisons off, and a pre-push hook would be the same fault
with the machines swapped. What replaces the gate is a rule, in
`.pi/skills/yellowjacket-dev/references/ui-tier.md`: **a change that
moves a component's geometry refreshes that component's baseline in the
same commit, having read the image, and never one it did not cause**.
That is #196, which was four stale references accumulated across three
unrelated merges — a red tier nobody could read, which is how it stayed
red. A visual case must also **state the world it photographs**, since
the stores are singletons and a case that sets nothing records whatever
the previous one left in them.
## Git Workflow
Feature branches and PRs are the only way in: **`main` is a protected
@@ -4020,27 +3471,6 @@ android-inspect` forwards the WebView's devtools socket and `make
android-eval` asks the real page — raw CDP, because `connectOverCDP`
calls `Browser.setDownloadBehavior` and a WebView refuses it.
**One of those gaps is checked rather than remembered.** A nested rule
whose selector starts with an element name is not a parse error anyone
would notice on 113 — the rule simply does not exist, there and nowhere
else, which is how the bottom bar's `text-overflow: ellipsis` came to
have never truncated on the device. `make css-check`
(`frontend/scripts/check-css-nesting.mjs`, a pre-commit hook and a CI
step) fails on one, over every `frontend/*.css` and the `css` literals
alike — a glob rather than `index.css` by name, because the hook fires
on `frontend/**/*.{ts,css}` and a sweep that names one file goes green
over a stylesheet it never opened — and
says the fix is a leading `&` — valid in both syntaxes, so no nested
rule here has a reason to omit it. Two things it has to get right, and
both follow from asking whether a *style* rule is anywhere above rather
than what the immediate parent is: `@media (…) { bottom-nav { … } }` at
the top level is an ordinary rule and is the majority of what a regex
over the file would report, while the same rule one level inside
`.bar { @media (…) { … } }` is nested and is flagged. The check is the
cheap version of the answer; a build-time downlevel (Lightning CSS
targeting 113) would fix the class permanently and is a dependency and
a build step rather than twenty lines.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.
-173
View File
@@ -1,173 +0,0 @@
# Contributing to YellowJacket
This is the contributor's half of the [README](README.md): how to build it, how
to check a change, and how a change gets in. [`CLAUDE.md`](CLAUDE.md) is the
deep reference — the architecture, and the reasons behind the shape of it —
and is worth reading before a change of any size, because most of this
codebase's traps are written down there and nowhere else.
## Building from source
YellowJacket is [Go](https://go.dev/) with a [Lit](https://lit.dev/)/TypeScript
frontend, bridged by [Wails v3](https://wails.io/).
| Tool | Version |
|------|---------|
| Go | 1.25+ |
| Node.js | 22+ |
| pnpm | 10+ |
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
The Wails v3 CLI resolves from the `tool` block in `go.mod`, so there is nothing
to install globally; `make setup` fetches it with the rest of the tooling.
On Linux, install the system libraries Wails needs. v3 builds against GTK4 +
WebKitGTK 6.0 by default:
```bash
sudo apt-get install libasound2-dev libgtk-4-dev libwebkitgtk-6.0-dev # Debian/Ubuntu
sudo pacman -S alsa-lib gtk4 webkitgtk-6.0 # Arch
```
A machine without `webkitgtk-6.0` can still build with `-tags gtk3` against the
older WebKit2GTK 4.1 stack, but that is an escape hatch, not what CI or a
release builds. macOS and Windows need no extra system packages. Run
`go tool wails3 doctor` to check your environment.
```bash
make setup # install tooling, frontend packages and the git hooks
make dev # run with hot-reload
make build-dev # debug build with symbols
make build-prod # production build (stripped and trimmed)
make android # the arm64 APK, into bin/
```
The `Makefile` is the front door and carries a one-line description against
each target; `Taskfile.yml` and `build/<platform>/Taskfile.yml` are the build
implementation behind it and are not called directly.
## Generated code
Two generators run from `go generate ./...`, which `make generate` wraps:
**sqlc** turns `backend/database/sql/queries/` into Go in
`backend/database/sql/sqlcgen/`, and **templ** turns `.templ` files into
`*_templ.go` beside them. Never edit either output by hand — run
`make generate` after touching a `.sql` or a `.templ` file.
The TypeScript bindings in `frontend/bindings/` are generated by `wails3`
rather than by `go generate`, so they are a separate step: `make bindings`
regenerates them and `make bindings-check` fails if they are stale.
`frontend/src/events.ts` is generated too, from `backend/events/events.go`.
A pre-commit hook checks that all of this is fresh, so the usual way to meet it
is a failing commit rather than a bug.
## Checking a change
Run the tier the change actually demands, not the cheapest one.
| Change | Command |
|---|---|
| Go | `make lint` and `make test` — both cover all three build configurations |
| A frontend component or store | `make ui-test` (Vitest in a real Chromium, no backend) |
| A user-visible flow | `make e2e`, against a running `make dev-headless` |
| CSS | `make css-check` — see the Chrome 113 note below |
| Anything cosmetic | look at a screenshot; several bugs here were invisible to every assertion and obvious in an image |
`make test` needs the fixture library, which is generated rather than
committed — it runs `make testdata` itself (about a second).
The end-to-end tier drives the real app with no display at all: `make
dev-headless` starts it in the background on `:34115` (add `SEED=<name>` for a
seeded library, built by `make sandbox-seed NAME=<name>`), `make dev-logs` tails
it and `make dev-stop` stops it. **Check the port before starting one** — if
`:34115` is already answering, someone else's app is there, and a green result
about their build is worse than no result.
Two smaller checks exist because the failure they catch is silent:
`make bindings-check` (stale generated bindings) and `make css-check`, which is
two passes — one fails on a `css` literal ended early by a backtick inside a
comment, the other on a nested CSS rule that begins with a bare element
selector. Chrome 113 is what the reference Android device renders with, and it
drops such a rule without a word.
`make vulncheck` runs govulncheck over the module.
## The issue tracker is the source of truth
Work is described by issues before it is described by branches, and the tracker
is shared with people who cannot see your terminal.
- **Search before starting**, closed issues included: `./scripts/issue.sh search
<terms>`. "That was fixed three weeks ago" is the cheapest possible answer.
- **Claim before the first edit**, not before the commit:
`./scripts/issue.sh claim <n>` sets the assignee, applies `Status/In Progress`
and comments with the branch, so the work is visibly taken *while it is being
done*. It refuses if somebody else holds it — talk to them rather than working
around it.
- **If no issue covers the work, open one first** (`./scripts/issue.sh new`).
- **Findings get filed.** A bug tripped over on the way to something else is an
issue with a reproduction, not a wider diff and not a sentence in a chat log.
- **#73 is the roadmap** and states the order the backlog should be worked in.
`scripts/issue.sh` is the whole interface (`list`, `mine`, `search`, `show`,
`new`, `claim`, `unclaim`, `comment`, `close`, `label`, `depends`, `labels`) and
wants a `GITEA_TOKEN` with `write:issue`. The labels are a taxonomy rather than
tags: `Kind/*`, `Area/*`, `Priority/*`, `Platform/*`, plus `Reviewed/*` and
`Status/*`, of which the last two are exclusive scopes.
## Commits and pull requests
`main` is protected, so a branch and a PR are the only way in. Branch from
`origin/main`, and name the branch after the issue (`fix/140-…`, `feat/25-…`).
Commit subjects are [Conventional Commits](https://www.conventionalcommits.org/)
— `type(scope): subject`, imperative, ≤72 characters — and are enforced by a
`commit-msg` hook and by CI (`make commit-check`). This is load-bearing rather
than decorative: semantic-release reads the **type** to decide the next version,
so a CI-only change is `ci:` and never `fix(ci):`, which would ship a patch
release. `make release-dry` prints what a release would cut right now.
**The closing keyword goes in the commit body**, one issue per line, because
Gitea parses commit messages that reach `main` and does not parse the PR body:
```
docs: rewrite the README as a landing page
<why>
Closes #50
```
A PR body carries a commit-to-issue table, the verification you actually ran
(with results), and a `Closes` list for whoever reads it.
## Style
- **Go** — golangci-lint v2, strict: `err113` (static errors), `nlreturn`,
`wsl_v5`, `godot`, `sloglint`, `perfsprint`, and imports grouped stdlib →
third-party → `yellowjacket/…` by gci.
- **TypeScript** — strict mode, no implicit `any`, no unused locals or
parameters.
- Match the surrounding code. Where `CLAUDE.md` explains why something is shaped
the way it is, that shape is load-bearing and there is usually a test pinning
it.
Hooks do most of the enforcing (`lefthook.yml`, installed by `make setup`):
pre-commit runs vet, lint, the codegen checks, the frontend typecheck and the
two CSS checks in parallel; pre-push runs the Go suite and the UI tier,
deliberately one after the other rather than together.
## Where the rest of the documentation is
- [`CLAUDE.md`](CLAUDE.md) — architecture and constraints, in depth.
- [`docs/PROFILING.md`](docs/PROFILING.md) — Go pprof and frontend profiling.
- [`docs/android-release.md`](docs/android-release.md) — the APK, its signing
key, and what the release workflow checks.
- [`docs/index-cache.md`](docs/index-cache.md) — the search-index build cache
and why it has a snapshot.
- [`packaging/arch/README.md`](packaging/arch/README.md),
[`packaging/homebrew/README.md`](packaging/homebrew/README.md) — the two
package channels.
- `.planning/` — design documents and measured history, not a queue. The queue
is the tracker.
+5 -13
View File
@@ -160,11 +160,8 @@ ui-watch: ## Same suite, in watch mode
ui-visual: ## Run the suite including screenshot comparisons
@cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS)
# `--update=true`, never a bare `--update`: vitest takes the following
# positional as the flag's value, so `--update <path>` swallows the path
# and re-records every baseline in the repo instead of the one named.
ui-visual-update: ## Re-record the screenshot baselines (UI_ARGS=<path> to filter)
@cd frontend && YJ_VISUAL=1 npx vitest run --update=true $(UI_ARGS)
ui-visual-update: ## Re-record the screenshot baselines
@cd frontend && YJ_VISUAL=1 npx vitest run --update $(UI_ARGS)
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
@cd frontend && pnpm install && npx playwright install chromium
@@ -175,24 +172,19 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
bindings-check: ## Fail if the generated bindings are stale
@./scripts/bindings-check.sh
# Two CSS traps that report a long way from their cause, or not at all.
# A backtick inside a comment in a css`` literal ends the literal, and
# what you get back is a type error about CSSResult, or every test in
# the suite failing to import. Four sessions, three plans. And a nested
# rule starting with an element name is dropped by the device's
# Chrome 113 in silence -- no tier here runs an engine that can see it.
# Instant.
# the suite failing to import. Four sessions, three plans. Instant.
.PHONY: css-check
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
@cd frontend && node scripts/check-css-literals.mjs
@cd frontend && node scripts/check-css-nesting.mjs
# .pi/ and CLAUDE.md document commands, and a doc that documents a
# command wrongly is worse than no doc: an agent runs it confidently.
# Every command in them is a make target on purpose, so this is
# checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the
# two harnesses cannot drift onto two descriptions of one project.
skill-check: ## Fail if the docs name a missing make target, or AGENTS.md is not a symlink
skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md is not a symlink
@./scripts/skill-check.sh
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
+80 -107
View File
@@ -2,139 +2,112 @@
*Music how it was meant to bee.*
YellowJacket plays the music you already own. Point it at your folders and it
scans them, reads the tags and the cover art, and gives you a library you can
browse, search, queue and tidy up — on your own machine, with no account, no
streaming service and no telemetry.
YellowJacket is a fast, cross-platform desktop music player for your local
collection. It plays your files, keeps your library tidy, and helps you discover
and organize your music — all in a clean, responsive interface. No accounts, no
streaming, no telemetry: just your music on your machine.
It plays **MP3**, **FLAC**, **OGG Vorbis** and **WAV**, on **Linux** and
**Android**, and builds from source on **macOS**.
Runs on **Linux**, **macOS**, and **Windows**.
![The track list, with something playing](docs/images/library.png)
## Features
## What it does
### Play your music
- Plays **MP3, FLAC, OGG Vorbis, and WAV**
- Play, pause, seek, and volume control with a mute toggle
- Gapless, glitch-free seeking backed by a read-ahead buffer
- A queue you can add to, reorder, and shuffle, with play-next support
- Shuffle and repeat (off / all / one)
- Picks up right where you left off — remembers your track, position, and volume between sessions
- Media-key and MPRIS support on Linux, so your desktop's playback controls just work
**Plays your files.** Play, pause, seek and volume with a mute toggle; a
read-ahead buffer so seeking is instant rather than gappy; a queue you can add
to, reorder and shuffle, with play-next; shuffle and repeat (off / all / one).
It remembers the track, the position and the queue between sessions, and it
answers your desktop's media keys — MPRIS on Linux, a media notification and
lock-screen controls on Android.
### Keep your library organized
- Point it at your music folders and it scans them automatically
- Reads tags and embedded cover art, and de-duplicates artwork so it isn't stored twice
- Incremental sync — only new or changed files get reprocessed, and deleted files are cleaned up
- Browse by **album**, **artist**, or **genre**, or search across everything
- Mark favorites and see what you've been listening to with play history
- Edit track tags directly when something's off
**Keeps the library tidy.** It scans the folders you give it and rescans only
what changed, so a big library costs its full scan once. It de-duplicates
embedded cover art rather than storing the same image a hundred times, notices
files that have gone away, and spots duplicate tracks. Browse by album, artist
or genre, search across everything, mark favourites, and see what you have been
playing.
### Playlists
- Create playlists, drag tracks in, and reorder them
- **Smart playlists** that build themselves from rules (by genre, rating, play count, and more)
- Pin a default playlist and spot duplicate tracks at a glance
**Playlists, and playlists that write themselves.** Drag tracks in and reorder
them, or describe what you want — genre, play count, how long since you played
it — and let a smart playlist keep itself up to date.
**Explore and auto-tag, from the MusicBrainz catalog.** Explore browses artists,
releases and genres from the catalog rather than only from what you own, so an
album page can tell you that you have nine of its twelve tracks. Auto-tag
matches your files against MusicBrainz and fills in the metadata that is
missing, with a review step before anything is written to disk. Lyrics search
finds a track from a line you remember.
Explore needs its catalog, which is a one-off ~0.6 GB download from
**Settings → Search Index**. It asks first on a metered connection, and
everything else in the app works without it.
### Discover and clean up (powered by MusicBrainz)
- **Explore** — browse artists, releases, and genres from the MusicBrainz catalog, not just what's already in your library
- **Auto-tag** — match your files against MusicBrainz to fill in correct artist, album, and track metadata, with a review step before anything is written
- **Lyrics search** — find a track by a line you remember
## Install
Every download comes from the
Download the latest build for your platform from the
[releases page](https://git.ljones.me/yonlu/yellowjacket/releases).
### Linux
| Platform | Download |
|----------|----------|
| Linux | `yellowjacket-linux-amd64` |
| macOS | `yellowjacket-darwin-universal.app.zip` (Apple Silicon + Intel) |
| Windows | `yellowjacket-windows-amd64.exe` |
Download `yellowjacket-<version>-linux-amd64.tar.gz` from the latest release and
unpack it. It holds the binary, a `.desktop` entry and an icon.
Prefer to build it yourself? See [Building from source](#building-from-source).
On **Arch**, install it from the package registry instead and get updates with
the rest of your system — the one-time key import and `pacman.conf` block are in
[`packaging/arch/README.md`](packaging/arch/README.md):
```bash
sudo pacman -Sy yellowjacket
```
### Android
Install the APK from the release page, or from the URL below, which always
points at the newest build:
```
https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk
```
That URL needs no credentials, so [Obtainium](https://obtainium.imranr.dev/) can
poll it directly and keep the app up to date. The build is `arm64-v8a` only, and
[`docs/android-release.md`](docs/android-release.md) says why.
### macOS
Homebrew builds it from source on your own Mac — there is no prebuilt `.app`,
because a signed macOS bundle needs a macOS machine to produce it and the
release runner is a Linux container.
```bash
brew install shadow-puppet/yellowjacket/yellowjacket
```
See [`packaging/homebrew/README.md`](packaging/homebrew/README.md).
### Windows
Not published. It cross-compiles cleanly, but no Windows build of this app has
ever been *run*, and nothing here can exercise one — so shipping it would be a
promise that cannot be kept. You can still build it yourself: see
[`CONTRIBUTING.md`](CONTRIBUTING.md).
### Coming from a 1.x install?
Versions restarted at **0.0.1** when releases became automatic, which every
package manager reads as a downgrade. It costs one reinstall, once — the details
are with each channel: [Homebrew](packaging/homebrew/README.md#upgrading-from-1x-needs-a-reinstall-once),
[Android](docs/android-release.md#the-1x-installs-cannot-be-upgraded-to-00x).
## First run
## Getting started
1. Launch YellowJacket.
2. Add the folder your music lives in — the first-run wizard asks, and
**Settings → Libraries** is where you add more later.
3. Watch the scan finish. It reports progress, and you can browse while it runs.
4. Queue something and press play.
2. Open **Settings** and add the folder(s) where your music lives.
3. Let the initial scan finish — you'll see progress as it works.
4. Browse by album, artist, or genre, queue something up, and press play.
Your library and settings stay on your machine:
Your library and settings are stored locally:
| | Linux / macOS | Windows |
|---|---|---|
| Config | `~/.config/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\config` |
| Library data | `~/.local/share/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\data` |
Setting `YJ_HOME` moves both, which is how you keep a second library separate.
## Building from source
## More screenshots
YellowJacket is built with [Go](https://go.dev/) and a
[Lit](https://lit.dev/)/TypeScript frontend, bridged by the
[Wails](https://wails.io/) framework.
An album page knows what you own, and says so:
**Prerequisites**
![An album page, with two discs and the transport playing](docs/images/album.png)
| Tool | Version |
|------|---------|
| Go | 1.25+ |
| Node.js | 22+ |
| pnpm | 10+ |
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
The home page suggests somewhere to start rather than opening on a wall of
everything:
The Wails v3 CLI resolves from the `tool` block in `go.mod`, so there is nothing
to install globally; `make setup` fetches it with the rest of the tooling.
![The home page's shelves](docs/images/home.png)
On Linux, install the system libraries Wails needs. v3 builds against GTK4 +
WebKitGTK 6.0 by default:
## Contributing, and the rest of the documentation
```bash
sudo apt-get install libasound2-dev libgtk-4-dev libwebkitgtk-6.0-dev # Debian/Ubuntu
sudo pacman -S alsa-lib gtk4 webkitgtk-6.0 # Arch
```
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — build it from source, run the tests,
and how a change gets in.
- [`CLAUDE.md`](CLAUDE.md) — the deep reference: the architecture and the reasons
behind the shape of it.
- [The issue tracker](https://git.ljones.me/yonlu/yellowjacket/issues) is what
is wanted and what is being worked on; **#73** is the roadmap.
- [Releases](https://git.ljones.me/yonlu/yellowjacket/releases) double as the
changelog — every one is generated from the commits it contains.
A machine without `webkitgtk-6.0` can still build with `-tags gtk3` against the
older WebKit2GTK 4.1 stack, but that is an escape hatch, not what CI or a
release builds.
macOS and Windows need no extra system packages. Run `go tool wails3 doctor` to
check your environment.
**Build**
```bash
make setup # install tooling and git hooks
make dev # run with hot-reload
make build-prod # produce a release binary
```
More detail for contributors lives in [`CLAUDE.md`](./CLAUDE.md) — the
architecture, the conventions and the reasons behind them. What is
being worked on is [the issue
tracker](https://git.ljones.me/yonlu/yellowjacket/issues); #73 is the
roadmap.
-55
View File
@@ -1,55 +0,0 @@
//go:build android
// The write itself, and nothing else. Everything decidable off a phone
// is in androidlog.go; see the package comment for why.
package androidlog
/*
#cgo LDFLAGS: -llog
#include <stdlib.h>
#include <android/log.h>
*/
import "C"
import (
"log/slog"
"unsafe"
)
// The priorities in androidlog.go are android/log.h's own values, and
// these are what says so. A constant expression that would be negative
// does not compile as a uint, so a renumbered header fails the build
// here rather than logging everything at the wrong severity -- which is
// the failure that would otherwise be invisible, since logcat would
// happily print whatever number it was handed.
const (
_ = uint(C.ANDROID_LOG_VERBOSE - PrioVerbose)
_ = uint(PrioVerbose - C.ANDROID_LOG_VERBOSE)
_ = uint(C.ANDROID_LOG_DEBUG - PrioDebug)
_ = uint(PrioDebug - C.ANDROID_LOG_DEBUG)
_ = uint(C.ANDROID_LOG_INFO - PrioInfo)
_ = uint(PrioInfo - C.ANDROID_LOG_INFO)
_ = uint(C.ANDROID_LOG_WARN - PrioWarn)
_ = uint(PrioWarn - C.ANDROID_LOG_WARN)
_ = uint(C.ANDROID_LOG_ERROR - PrioError)
_ = uint(PrioError - C.ANDROID_LOG_ERROR)
_ = uint(C.ANDROID_LOG_FATAL - PrioFatal)
_ = uint(PrioFatal - C.ANDROID_LOG_FATAL)
)
// New returns the handler main() installs on Android.
func New(opts *slog.HandlerOptions) slog.Handler {
return NewHandler(opts, write)
}
// write hands one line to liblog.
func write(prio int, tag, msg string) {
cTag := C.CString(tag)
defer C.free(unsafe.Pointer(cTag))
cMsg := C.CString(msg)
defer C.free(unsafe.Pointer(cMsg))
C.__android_log_write(C.int(prio), cTag, cMsg)
}
-250
View File
@@ -1,250 +0,0 @@
// Package androidlog routes slog to logcat.
//
// **An Android app's fd 1 and 2 go to /dev/null**, so every line this
// app writes with slog is discarded on that platform -- including the
// one naming the error it is about to os.Exit on. #52 is what that
// cost: a process that vanished with no tombstone, no AndroidRuntime
// stack and nothing in `logcat -b crash`, at Priority/Critical for
// months, whose entire diagnosis was one slog.Error main.go was
// already writing.
//
// The platform's own sink is __android_log_write, which is a handful
// of cgo -- and cgo compiled by nothing `make lint` or `make test`
// runs, since the only toolchain that builds the android tag is a
// cross-compiler and the only thing that runs it is a phone. So the
// split here is the one backend/mediacontrols/androidpayload.go makes,
// pushed as far as it will go: **everything except the write itself is
// in this file, untagged**. The priority mapping, the formatting, the
// chunking and the handler's own attr and group bookkeeping are
// ordinary Go that `go test` exercises on any platform; android.go is
// fifteen lines that hand a string to liblog.
package androidlog
import (
"bytes"
"context"
"log/slog"
"strconv"
"strings"
"sync"
)
// Tag is what logcat labels these lines with.
//
// It is a constant of ours rather than the application id, because the
// debug build carries `applicationIdSuffix ".dev"` so that it can be
// installed beside the release app -- so a tag derived from the package
// name is a *different* tag on the one build that can be inspected, and
// the filter that is supposed to show these lines would hide them on
// exactly the build used to look for them.
const Tag = "yellowjacket"
// Android's priorities, from android/log.h. These are the values
// __android_log_write takes; android.go asserts at compile time that
// they still match the header, so a renumbered platform is a build
// failure here rather than a warning silently logged as an error.
const (
PrioVerbose = 2
PrioDebug = 3
PrioInfo = 4
PrioWarn = 5
PrioError = 6
PrioFatal = 7
)
// maxPayload is how much of one line liblog will carry.
//
// The kernel logger's entry is 4068 bytes for the tag, the message and
// their two NULs together, and what does not fit is **dropped without
// comment** -- so a long line would be truncated in the middle of the
// thing worth reading. 3500 leaves room for the tag and for the "(N/M)"
// a continuation carries.
const maxPayload = 3500
// WriteFunc is the platform sink: one already-formatted line, at one
// priority, under one tag.
//
// It is a parameter rather than a package-level function so that the
// handler can be driven by a test on a machine with no liblog at all.
type WriteFunc func(prio int, tag, msg string)
// Priority maps a slog level onto an Android one.
//
// slog's levels are open -- a caller may define its own at any int --
// so this is a banding rather than a lookup: anything below Info is
// debug, anything at or above Error is error. A custom level between
// two of the standard ones lands in the band beneath it, which is what
// slog's own level naming does.
func Priority(level slog.Level) int {
switch {
case level < slog.LevelDebug:
return PrioVerbose
case level < slog.LevelInfo:
return PrioDebug
case level < slog.LevelWarn:
return PrioInfo
case level < slog.LevelError:
return PrioWarn
default:
return PrioError
}
}
// Handler formats records with slog's own TextHandler and hands each
// line to a WriteFunc.
//
// It delegates the formatting rather than doing it, because WithAttrs
// and WithGroup are the half of slog.Handler that is easy to get subtly
// wrong -- and a logger whose groups are wrong is a logger nobody reads.
// What it does own is what logcat needs and TextHandler does not know
// about: the priority, and the fact that a line has a maximum length.
type Handler struct {
write WriteFunc
// mu guards buf, which the delegate writes into. slog.Handler is
// documented as safe for concurrent use.
mu *sync.Mutex
buf *bytes.Buffer
delegate slog.Handler
}
// NewHandler builds a handler over an arbitrary sink.
//
// The time and the level are dropped from the formatted line: logcat
// stamps every entry with both, and repeating them costs a quarter of
// the width of a phone-sized terminal to say the same thing twice.
func NewHandler(opts *slog.HandlerOptions, write WriteFunc) *Handler {
buf := &bytes.Buffer{}
inner := &slog.HandlerOptions{}
if opts != nil {
*inner = *opts
}
user := inner.ReplaceAttr
inner.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && isBuiltin(a) {
return slog.Attr{}
}
if user != nil {
return user(groups, a)
}
return a
}
return &Handler{
write: write,
mu: &sync.Mutex{},
buf: buf,
delegate: slog.NewTextHandler(buf, inner),
}
}
// isBuiltin reports whether an attr is slog's own time or level,
// rather than a caller's attribute that happens to share the name.
//
// ReplaceAttr cannot tell those apart by key. It is called with an
// empty group path for the built-ins *and* for every top-level
// attribute, so a key comparison alone silently eats a caller's own
// "level" or "time" -- which is not hypothetical: the probe that
// verified this package on the device logged one, and the attribute
// vanished. The kinds are what separate them, because slog builds the
// built-ins as slog.Any(LevelKey, r.Level) and slog.Time(TimeKey, ...)
// and an attribute value of type slog.Level is not something a caller
// passes by accident.
func isBuiltin(a slog.Attr) bool {
switch a.Key {
case slog.TimeKey:
return a.Value.Kind() == slog.KindTime
case slog.LevelKey:
_, ok := a.Value.Any().(slog.Level)
return ok
default:
return false
}
}
// Enabled reports whether the level is worth formatting.
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.delegate.Enabled(ctx, level)
}
// Handle formats one record and writes it out, in as many entries as
// its length demands.
func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.buf.Reset()
if err := h.delegate.Handle(ctx, rec); err != nil {
return err
}
prio := Priority(rec.Level)
for _, line := range Chunk(strings.TrimRight(h.buf.String(), "\n")) {
h.write(prio, Tag, line)
}
return nil
}
// WithAttrs returns a handler carrying the given attributes.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h.derive(h.delegate.WithAttrs(attrs))
}
// WithGroup returns a handler that qualifies subsequent attributes.
func (h *Handler) WithGroup(name string) slog.Handler {
return h.derive(h.delegate.WithGroup(name))
}
// derive shares the buffer and its mutex with the parent.
//
// They must be shared rather than copied: the delegate returned by
// WithAttrs writes into the *same* buffer this one does, so a second
// mutex would guard nothing and two loggers derived from one would
// interleave their bytes into a single line.
func (h *Handler) derive(delegate slog.Handler) *Handler {
return &Handler{
write: h.write,
mu: h.mu,
buf: h.buf,
delegate: delegate,
}
}
// Chunk splits a formatted record into entries liblog will carry
// whole.
//
// A record short enough to fit is returned as it is, which is nearly
// every record; the numbering only appears where something was going
// to be silently truncated anyway. It splits on bytes rather than runes
// because the limit is a byte count -- a multi-byte rune straddling the
// boundary is a mojibake character in a log line, against a lost one.
func Chunk(msg string) []string {
if len(msg) <= maxPayload {
return []string{msg}
}
var parts []string
for rest := msg; rest != ""; {
n := min(maxPayload, len(rest))
parts = append(parts, rest[:n])
rest = rest[n:]
}
numbered := make([]string, 0, len(parts))
for i, p := range parts {
numbered = append(
numbered,
"("+strconv.Itoa(i+1)+"/"+strconv.Itoa(len(parts))+") "+p,
)
}
return numbered
}
-355
View File
@@ -1,355 +0,0 @@
package androidlog_test
import (
"log/slog"
"strings"
"sync"
"testing"
"yellowjacket/backend/androidlog"
)
// entry is one call to the sink.
type entry struct {
prio int
tag string
msg string
}
// recorder is the platform write, on a machine with no platform.
type recorder struct {
mu sync.Mutex
entries []entry
}
func (r *recorder) write(prio int, tag, msg string) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, entry{prio: prio, tag: tag, msg: msg})
}
func (r *recorder) only(t *testing.T) entry {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
if len(r.entries) != 1 {
t.Fatalf("want exactly one entry, got %d: %v", len(r.entries), r.entries)
}
return r.entries[0]
}
func newLogger(r *recorder, level slog.Level) *slog.Logger {
return slog.New(androidlog.NewHandler(
&slog.HandlerOptions{Level: level},
r.write,
))
}
// TestPriorityMapsEveryLevel pins the level banding.
//
// This is the one thing in #160 that a wrong answer hides rather than
// breaks: logcat prints whatever priority it is handed, so an Error
// filed as Info is a line that is present, correct and invisible to
// every filter anyone would use to look for it.
func TestPriorityMapsEveryLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
level slog.Level
want int
}{
{"below debug is verbose", slog.LevelDebug - 1, androidlog.PrioVerbose},
{"debug", slog.LevelDebug, androidlog.PrioDebug},
{"info", slog.LevelInfo, androidlog.PrioInfo},
{"warn", slog.LevelWarn, androidlog.PrioWarn},
{"error", slog.LevelError, androidlog.PrioError},
// slog's levels are open, so a caller may sit between two of
// the named ones. Each lands in the band beneath it, which is
// what slog's own level naming does ("INFO+2").
{"between info and warn", slog.LevelInfo + 2, androidlog.PrioInfo},
{"between warn and error", slog.LevelWarn + 1, androidlog.PrioWarn},
{"above error", slog.LevelError + 4, androidlog.PrioError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := androidlog.Priority(tt.level); got != tt.want {
t.Errorf("Priority(%v) = %d, want %d", tt.level, got, tt.want)
}
})
}
}
// TestPrioritiesAreTheHeadersValues pins the constants themselves.
//
// android.go asserts these against android/log.h at compile time, but
// only a cross-compiler ever builds that file. This is the assertion
// that runs in CI, and the numbers are written out longhand on purpose
// -- comparing a constant to itself would pass on any renumbering.
func TestPrioritiesAreTheHeadersValues(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
got int
want int
}{
{"verbose", androidlog.PrioVerbose, 2},
{"debug", androidlog.PrioDebug, 3},
{"info", androidlog.PrioInfo, 4},
{"warn", androidlog.PrioWarn, 5},
{"error", androidlog.PrioError, 6},
{"fatal", androidlog.PrioFatal, 7},
} {
if tt.got != tt.want {
t.Errorf("%s priority = %d, want %d", tt.name, tt.got, tt.want)
}
}
}
// TestRecordReachesTheSink is the whole point of the package: a line
// written with slog arrives, under the app's tag, at the right
// priority.
func TestRecordReachesTheSink(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Error("application error", "err", "boom")
got := rec.only(t)
if got.prio != androidlog.PrioError {
t.Errorf("priority = %d, want %d", got.prio, androidlog.PrioError)
}
if got.tag != androidlog.Tag {
t.Errorf("tag = %q, want %q", got.tag, androidlog.Tag)
}
if !strings.Contains(got.msg, "application error") {
t.Errorf("message %q does not carry the message", got.msg)
}
if !strings.Contains(got.msg, `err=boom`) {
t.Errorf("message %q does not carry the attribute", got.msg)
}
}
// TestTheTagIsNotTheApplicationID guards the trap the tag exists to
// avoid.
//
// The debug build carries `applicationIdSuffix ".dev"`, so it is
// installed as app.yellowjacket.dev -- and it is the *only* build whose
// WebView can be inspected, so it is the build anyone debugging this
// app is running. A tag derived from the application id therefore
// differs between the build being looked at and the build the filter
// was written for, which is the failure this whole issue is about
// wearing a different hat.
func TestTheTagIsNotTheApplicationID(t *testing.T) {
t.Parallel()
if strings.Contains(androidlog.Tag, ".") {
t.Errorf(
"tag %q looks like an application id; it must be stable "+
"across the debug suffix",
androidlog.Tag,
)
}
// Logcat's tag field is 23 bytes. A longer one is truncated, and a
// truncated tag matches no filter.
if len(androidlog.Tag) > 23 {
t.Errorf("tag %q is %d bytes, over logcat's 23", androidlog.Tag, len(androidlog.Tag))
}
}
// TestTimeAndLevelAreDropped checks the formatting decision.
//
// logcat stamps every entry with a timestamp and a priority letter, so
// carrying slog's own is the same information twice on a 424px screen.
func TestTimeAndLevelAreDropped(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Warn("scan finished", "files", 1577)
got := rec.only(t).msg
if strings.Contains(got, "time=") {
t.Errorf("message %q still carries a timestamp", got)
}
if strings.Contains(got, "level=") {
t.Errorf("message %q still carries a level", got)
}
if !strings.Contains(got, "files=1577") {
t.Errorf("message %q lost its attributes with them", got)
}
}
// TestACallersOwnLevelAttrSurvives is a regression, and it was found on
// the phone rather than here.
//
// Dropping slog's built-in time and level by key alone also drops a
// caller's attribute of the same name, because ReplaceAttr sees an
// empty group path for both. The probe that verified this package on
// the device wrote slog.Info("...", "level", "info") and logcat showed
// the message with no attributes at all.
func TestACallersOwnLevelAttrSurvives(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Info("probe", "level", "info", "time", "soon")
got := rec.only(t).msg
for _, want := range []string{"level=info", "time=soon"} {
if !strings.Contains(got, want) {
t.Errorf("message %q lost the caller's %q", got, want)
}
}
// And slog's own are still gone: the built-in level renders as a
// bare word like INFO, never as the caller's value.
if strings.Contains(got, "level=INFO") {
t.Errorf("message %q carries slog's own level", got)
}
}
// TestLevelIsHonoured checks that Enabled reaches the delegate.
func TestLevelIsHonoured(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelWarn)
log.Info("not this one")
log.Warn("this one")
if got := rec.only(t).msg; !strings.Contains(got, "this one") {
t.Errorf("wrong record survived: %q", got)
}
}
// TestGroupsAndAttrsSurvive covers the half of slog.Handler this
// delegates rather than implements -- the reason it delegates at all.
func TestGroupsAndAttrsSurvive(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelInfo).
With("component", "player").
WithGroup("track")
log.Info("loaded", "path", "/sdcard/Music/a.flac")
got := rec.only(t).msg
for _, want := range []string{
"component=player",
"track.path=/sdcard/Music/a.flac",
} {
if !strings.Contains(got, want) {
t.Errorf("message %q is missing %q", got, want)
}
}
}
// TestDerivedHandlersDoNotInterleave is why derive shares the buffer's
// mutex rather than taking a new one.
//
// Two loggers derived from one write into the same buffer, so a second
// mutex would guard nothing and a concurrent pair would splice each
// other's bytes into a single line -- which reads as corrupted logs
// under load and as nothing at all in a test that logs once.
func TestDerivedHandlersDoNotInterleave(t *testing.T) {
t.Parallel()
rec := &recorder{}
base := newLogger(rec, slog.LevelInfo)
var wg sync.WaitGroup
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
log := base.With("worker", i).WithGroup("g")
for range 50 {
log.Info("tick", "n", i)
}
}()
}
wg.Wait()
rec.mu.Lock()
defer rec.mu.Unlock()
if len(rec.entries) != 8*50 {
t.Fatalf("got %d entries, want %d", len(rec.entries), 8*50)
}
for _, e := range rec.entries {
if strings.Count(e.msg, "msg=tick") != 1 {
t.Fatalf("interleaved line: %q", e.msg)
}
}
}
// TestChunkLeavesShortLinesAlone is the common case: no numbering
// appears on a record that was never going to be truncated.
func TestChunkLeavesShortLinesAlone(t *testing.T) {
t.Parallel()
got := androidlog.Chunk("msg=short")
if len(got) != 1 || got[0] != "msg=short" {
t.Errorf("Chunk(short) = %q, want the input unchanged", got)
}
}
// TestChunkSplitsWhatWouldBeTruncated covers the case liblog drops
// silently.
func TestChunkSplitsWhatWouldBeTruncated(t *testing.T) {
t.Parallel()
const n = 9000
long := strings.Repeat("x", n)
parts := androidlog.Chunk(long)
if len(parts) < 2 {
t.Fatalf("a %d-byte line was not split", n)
}
var payload strings.Builder
for i, p := range parts {
if len(p) > 4000 {
t.Errorf("part %d is %d bytes, over liblog's entry", i, len(p))
}
_, rest, found := strings.Cut(p, ") ")
if !found {
t.Fatalf("part %d carries no (n/m) marker: %q", i, p)
}
payload.WriteString(rest)
}
if payload.String() != long {
t.Errorf("the parts do not reassemble into the input")
}
}
-39
View File
@@ -303,24 +303,6 @@ func (c *Config) GetLibraryDirectory() string {
return string(c.Library.DirectoryPath)
}
// A rejected setter puts the old value back, and that is not tidiness
// (#231). Save validates the *whole* config, so a value left behind by
// a failed write does not merely fail its own call: it fails every
// later save, of every unrelated setting, silently and for the rest of
// the session. Nothing reaches disk, so a restart clears it -- which
// is exactly what makes the fault hard to see and impossible to report.
//
// The setters below that assign and then validate therefore snapshot
// the field first and restore it on the error path. SetLibraryDirectory
// is the other safe shape and the better one where the value can be
// built on its own: it validates a candidate *before* assigning
// anything, so there is nothing to undo.
//
// Not every setter needs either. A bool, an int64 and the shortcut
// bindings pass through no validation that can reject them, and
// SetViewVisible refuses an unknown, non-hideable or launch-page view
// up front, so GeneralConfig.Validate never sees one it would fail on.
// SetLibraryDirectory validates and saves a new library directory,
// then emits the LibraryConfigChanged event so listeners (e.g. the
// Library scanner) can react.
@@ -378,14 +360,11 @@ func (c *Config) SetScanConcurrency(mode string) error {
c.Library.ApplyDefaults()
}
previous := c.Library.ScanConcurrency
c.Library.ScanConcurrency = library.ScanConcurrency(
mode,
)
if err := c.Library.Validate(); err != nil {
c.Library.ScanConcurrency = previous
return fmt.Errorf(
"invalid scan concurrency mode: %w", err,
)
@@ -476,12 +455,9 @@ func (c *Config) SetThemeAccentColor(
c.Theme.ApplyDefaults()
}
previous := c.Theme.AccentColor
c.Theme.AccentColor = color
if err := c.Theme.Validate(); err != nil {
c.Theme.AccentColor = previous
return fmt.Errorf(
"invalid theme accent color: %w", err,
)
@@ -512,12 +488,9 @@ func (c *Config) SetThemeBackgroundShade(
c.Theme.ApplyDefaults()
}
previous := c.Theme.BackgroundShade
c.Theme.BackgroundShade = theme.BackgroundShade(shade)
if err := c.Theme.Validate(); err != nil {
c.Theme.BackgroundShade = previous
return fmt.Errorf(
"invalid theme background shade: %w", err,
)
@@ -571,12 +544,9 @@ func (c *Config) SetDefaultPage(page string) error {
c.General.ApplyDefaults()
}
previous := c.General.DefaultPage
c.General.DefaultPage = View(page)
if err := c.General.Validate(); err != nil {
c.General.DefaultPage = previous
return fmt.Errorf(
"invalid default page: %w", err,
)
@@ -621,12 +591,9 @@ func (c *Config) SetQueueFallback(mode string) error {
c.General.ApplyDefaults()
}
previous := c.General.QueueFallback
c.General.QueueFallback = QueueFallback(mode)
if err := c.General.Validate(); err != nil {
c.General.QueueFallback = previous
return fmt.Errorf(
"invalid queue fallback: %w", err,
)
@@ -834,12 +801,9 @@ func (c *Config) SetTrackListColumns(
c.TrackList = &tracklist.Config{}
}
previous := c.TrackList.Columns
c.TrackList.Columns = columns
if err := c.TrackList.Validate(); err != nil {
c.TrackList.Columns = previous
return fmt.Errorf(
"invalid track-list columns: %w", err,
)
@@ -937,12 +901,9 @@ func (c *Config) SetFavoritesIconStyle(
c.Favorites.ApplyDefaults()
}
previous := c.Favorites.IconStyle
c.Favorites.IconStyle = favorites.IconStyle(style)
if err := c.Favorites.Validate(); err != nil {
c.Favorites.IconStyle = previous
return fmt.Errorf(
"invalid favorites icon style: %w", err,
)
-262
View File
@@ -1,262 +0,0 @@
package config
import (
"log/slog"
"path/filepath"
"testing"
"yellowjacket/backend/library"
"yellowjacket/backend/tracklist"
)
// newSavableConfig builds a loaded, valid config in a temp directory,
// so Save() writes rather than refusing with errSaveBeforeLoad.
//
// The library directory is real and set, because Config.Validate only
// validates the Library section when DirectoryPath is non-empty -- an
// empty one would hide a poisoned ScanConcurrency from the whole-config
// save that is the symptom under test.
func newSavableConfig(t *testing.T) *Config {
t.Helper()
c := &Config{
logger: slog.Default(),
filePath: filepath.Join(t.TempDir(), "config.toml"),
Library: &library.Config{
DirectoryPath: library.Directory(t.TempDir()),
},
}
c.applyDefaults()
if err := c.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
if err := c.Save(); err != nil {
t.Fatalf("Save() on a fresh config error: %v", err)
}
return c
}
// TestSetterRejectionDoesNotPoisonTheConfig is the whole of #231.
//
// Every setter here assigns to the in-memory config and then validates.
// When the validation rejects the argument, the rejected value has to go
// back -- not because the caller sees it (it gets an error either way),
// but because Config.Save() validates the *whole* config. A value left
// behind by a failed setter therefore fails every later save, of every
// unrelated setting, silently and for the rest of the session.
//
// So each case asserts three things in order: the setter reports the
// error, the getter still reports the old value, and an unrelated save
// still works. The third is the one the user feels.
func TestSetterRejectionDoesNotPoisonTheConfig(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// reject calls the setter with an argument its own Validate
// refuses.
reject func(*Config) error
// read reports the value the setter writes, so the rollback is
// asserted on the config rather than only on the save.
read func(*Config) string
}{
{
name: "scan concurrency",
reject: func(c *Config) error {
return c.SetScanConcurrency("telepathy")
},
read: (*Config).GetScanConcurrency,
},
{
name: "theme accent colour",
reject: func(c *Config) error {
return c.SetThemeAccentColor("not-a-hex")
},
read: (*Config).GetThemeAccentColor,
},
{
name: "theme background shade",
reject: func(c *Config) error {
return c.SetThemeBackgroundShade("chartreuse")
},
read: (*Config).GetThemeBackgroundShade,
},
{
name: "default page",
reject: func(c *Config) error {
return c.SetDefaultPage("nowhere")
},
read: (*Config).GetDefaultPage,
},
{
name: "queue fallback",
reject: func(c *Config) error {
return c.SetQueueFallback("improvise")
},
read: (*Config).GetQueueFallback,
},
{
name: "favorites icon style",
reject: func(c *Config) error {
return c.SetFavoritesIconStyle("asterisk")
},
read: (*Config).GetFavoritesIconStyle,
},
{
name: "track-list columns",
reject: func(c *Config) error {
// titleArtist is a drawing definition, not a
// configurable column (#197), so it is exactly what
// the frontend used to be able to send.
return c.SetTrackListColumns([]tracklist.Column{
{ID: "titleArtist"},
})
},
read: func(c *Config) string {
return columnIDs(c.GetTrackListColumns())
},
},
{
name: "track-list columns, duplicated",
reject: func(c *Config) error {
// The route #197 closed was one invalid id; a
// duplicate is the one still reachable from a client
// that assembles the list itself.
return c.SetTrackListColumns([]tracklist.Column{
{ID: tracklist.ColTrackName},
{ID: tracklist.ColTrackName},
})
},
read: func(c *Config) string {
return columnIDs(c.GetTrackListColumns())
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
before := tc.read(c)
if err := tc.reject(c); err == nil {
t.Fatal("setter accepted an invalid value, want an error")
}
if after := tc.read(c); after != before {
t.Errorf(
"value after a rejected write = %q, want the previous %q",
after, before,
)
}
// The symptom: an unrelated setting can no longer be saved.
if err := c.SetPopupVolume(true); err != nil {
t.Errorf("an unrelated setter failed after a rejected write: %v", err)
}
if err := c.Save(); err != nil {
t.Errorf("Save() failed after a rejected write: %v", err)
}
})
}
}
// TestRejectedSetterLeavesNothingOnDisk pairs with the sweep above: the
// rollback must not be undone by what the file already holds, so a
// config reloaded from disk after a rejected write agrees with memory.
func TestRejectedSetterLeavesNothingOnDisk(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
if err := c.SetThemeAccentColor("#123456"); err != nil {
t.Fatalf("SetThemeAccentColor() error: %v", err)
}
if err := c.SetThemeAccentColor("not-a-hex"); err == nil {
t.Fatal("SetThemeAccentColor accepted a non-colour, want an error")
}
reloaded := &Config{logger: slog.Default(), filePath: c.filePath}
reloaded.applyDefaults()
if err := reloaded.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
if got := reloaded.GetThemeAccentColor(); got != "#123456" {
t.Errorf("accent colour on disk = %q, want %q", got, "#123456")
}
if c.GetThemeAccentColor() != reloaded.GetThemeAccentColor() {
t.Errorf(
"in-memory accent %q disagrees with disk %q after a rejected write",
c.GetThemeAccentColor(), reloaded.GetThemeAccentColor(),
)
}
}
// TestSetLibraryDirectoryValidatesBeforeAssigning pins the precedent the
// seven rolled-back setters follow: this one has always built and
// validated a candidate before assigning, so a bad path never reaches
// the config at all.
func TestSetLibraryDirectoryValidatesBeforeAssigning(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
before := c.GetLibraryDirectory()
if err := c.SetLibraryDirectory(filepath.Join(t.TempDir(), "no-such-dir")); err == nil {
t.Fatal("SetLibraryDirectory accepted a missing directory, want an error")
}
if after := c.GetLibraryDirectory(); after != before {
t.Errorf("library directory = %q, want the previous %q", after, before)
}
if err := c.Save(); err != nil {
t.Errorf("Save() failed after a rejected library directory: %v", err)
}
}
// TestSetViewVisibleRefusesBeforeAssigning covers the other setter left
// out of the rollback pass: it guards its own argument up front, so
// GeneralConfig.Validate never sees a view it would reject.
func TestSetViewVisibleRefusesBeforeAssigning(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
if err := c.SetViewVisible("no-such-view", false); err == nil {
t.Fatal("SetViewVisible accepted an unknown view, want an error")
}
if err := c.SetViewVisible(c.GetDefaultPage(), false); err == nil {
t.Fatal("SetViewVisible hid the launch page, want an error")
}
if err := c.Save(); err != nil {
t.Errorf("Save() failed after a refused view visibility change: %v", err)
}
}
// columnIDs renders a column list for comparison in the table above.
func columnIDs(cols []tracklist.Column) string {
ids := make([]byte, 0, len(cols)*8)
for i, col := range cols {
if i > 0 {
ids = append(ids, ',')
}
ids = append(ids, col.ID...)
}
return string(ids)
}
+1 -2
View File
@@ -40,8 +40,7 @@ WHERE id = ? AND (mbid IS NULL OR mbid = '');
-- name: GetAlbumsWithPendingReleaseMBID :many
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '')
LIMIT ?;
AND (mbid IS NULL OR mbid = '');
-- name: DeleteAlbum :exec
DELETE FROM albums WHERE id = ?;
+2 -3
View File
@@ -331,7 +331,6 @@ const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBI
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '')
LIMIT ?
`
type GetAlbumsWithPendingReleaseMBIDRow struct {
@@ -339,8 +338,8 @@ type GetAlbumsWithPendingReleaseMBIDRow struct {
PendingReleaseMbid sql.NullString
}
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context, limit int64) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID, limit)
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
if err != nil {
return nil, err
}
+3 -21
View File
@@ -7,7 +7,6 @@ import (
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
)
@@ -18,21 +17,6 @@ import (
// stubYtDlp writes an executable script that echoes the given stdout
// and returns it as a provider config binary path.
//
// The write is held under syscall.ForkLock, and that is not tidiness:
// the kernel refuses to exec a file that is open for writing anywhere
// in the process, and these tests are parallel, so a *sibling* test's
// fork can duplicate this descriptor in the moment it is open and
// carry it past our close — the exec a moment later then fails with
// ETXTBSY, "text file busy". That is #146, seen once in CI and once
// locally, on trees containing no Go at all. Closing sooner is not
// available (os.WriteFile has already closed the file before anything
// execs it) and O_CLOEXEC does not help, because the window is between
// another goroutine's fork and its own exec. ForkLock is the lock
// syscall.forkExec takes across that fork, so holding it here means no
// child can exist while the descriptor does. Measured on this helper
// under 12 concurrent writers: 176-189 of 2400 execs refused without
// it, 0 of 2400 with it.
func stubYtDlp(t *testing.T, script string) string {
t.Helper()
@@ -42,11 +26,9 @@ func stubYtDlp(t *testing.T, script string) string {
path := filepath.Join(t.TempDir(), "yt-dlp")
syscall.ForkLock.Lock()
err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o700)
syscall.ForkLock.Unlock()
if err != nil {
if err := os.WriteFile(
path, []byte("#!/bin/sh\n"+script), 0o700,
); err != nil {
t.Fatalf("write stub: %v", err)
}
+31 -35
View File
@@ -2,7 +2,6 @@ package explore
import (
"context"
"database/sql"
"log/slog"
"math"
"sort"
@@ -13,7 +12,6 @@ import (
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
@@ -281,32 +279,37 @@ func (e *Service) BackfillReleaseGroupMBIDs() {
go e.backfillReleaseGroupMBIDs(e.ctx)
}
// pendingReleaseMBIDs is the albums this pass has work to do on.
//
// It is separate from the pass, and returns its error rather than
// logging it, so that a test can assert the statement runs against the
// real schema. That is not a general preference -- it is this
// statement's history: it named `release_groups`, a table plan 013
// renamed to `albums`, so it failed on every launch since e7748f1 and
// the pass returned quietly having done nothing. A test of the pass
// as a whole cannot see that, because a query error and an empty
// library are the same early return.
func (e *Service) pendingReleaseMBIDs(
ctx context.Context,
) ([]sqlcgen.GetAlbumsWithPendingReleaseMBIDRow, error) {
return e.db.ReadQueries.GetAlbumsWithPendingReleaseMBID(
ctx, releaseGroupMBIDBackfillMaxPerRun,
)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
pending, err := e.pendingReleaseMBIDs(ctx)
rows, err := e.db.QueryContext(
"SELECT id, pending_release_mbid FROM release_groups "+
"WHERE (mbid IS NULL OR mbid = '') "+
"AND pending_release_mbid IS NOT NULL AND pending_release_mbid != '' "+
"LIMIT ?",
releaseGroupMBIDBackfillMaxPerRun,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: query failed", "error", err)
return
}
type pendingRow struct {
id int64
releaseMBID string
}
var pending []pendingRow
for rows.Next() {
var p pendingRow
if err := rows.Scan(&p.id, &p.releaseMBID); err == nil {
pending = append(pending, p)
}
}
_ = rows.Close()
if len(pending) == 0 {
return
}
@@ -332,7 +335,7 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
job.progress(i, len(pending))
release, err := e.mb.LookupRelease(ctx, p.PendingReleaseMbid.String)
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
if err != nil || release.ReleaseGroupMBID == "" {
// Left alone rather than cleared: LookupRelease caches its
// answer (success or a release with no group) for 7 days,
@@ -341,19 +344,12 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
continue
}
// The writer, not ReadQueries: an UPDATE issued on the
// query-only pool fails at runtime with "attempt to write a
// readonly database".
if err := e.db.Queries.ResolveAlbumPendingReleaseMBID(
ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{
String: release.ReleaseGroupMBID,
Valid: true,
},
ID: p.ID,
},
); err != nil {
_, err = e.db.ExecContext(
"UPDATE release_groups SET mbid = ?, pending_release_mbid = NULL "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '')",
release.ReleaseGroupMBID, p.id,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
}
}
-250
View File
@@ -1,250 +0,0 @@
package explore
import (
"database/sql"
"log/slog"
"strconv"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The release-group MBID backfill queried `release_groups`, a table
// plan 013 renamed to `albums`, so it failed on its first statement on
// every launch from e7748f1 until #189 -- and the pass swallowed that,
// because a query error and an empty library are the same early
// return. Nothing noticed for two reasons worth keeping in mind:
//
// - the statement was **raw SQL**, so sqlc never read it. Every other
// statement in the repo was renamed by the same change because sqlc
// reads sql/schemas/ and cannot generate against a table that is not
// declared. The two sqlc queries this now calls were written by 013
// and left uncalled.
// - it needs no network and no fixture library to reproduce. The
// failure is at prepare time.
// seedPendingAlbum inserts an album whose files carried a release MBID
// but no release-group MBID, which is what `library.updateMBIDs`
// leaves behind for this pass to resolve.
func seedPendingAlbum(
t *testing.T,
db *database.DB,
name, pendingMBID string,
) int64 {
t.Helper()
res, err := db.ExecContext(
"INSERT INTO albums (name, artist_credit, pending_release_mbid) "+
"VALUES (?, ?, ?)",
name, "Test Artist", pendingMBID,
)
if err != nil {
t.Fatalf("insert albums row: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
return id
}
func newPendingTestService(db *database.DB) *Service {
return &Service{db: db, logger: slog.Default()}
}
// TestPendingReleaseMBIDsRunsAgainstTheRealSchema is the regression.
//
// It asserts the statement *runs*, which is the whole of what was
// broken: against the old raw SQL this returns
// "no such table: release_groups" rather than a row.
func TestPendingReleaseMBIDsRunsAgainstTheRealSchema(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
want := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 {
t.Fatalf("got %d pending albums, want 1", len(pending))
}
if pending[0].ID != want {
t.Errorf("got album id %d, want %d", pending[0].ID, want)
}
if got := pending[0].PendingReleaseMbid.String; got != "release-mbid-1" {
t.Errorf("got pending mbid %q, want %q", got, "release-mbid-1")
}
}
// TestOnlyUnresolvedAlbumsAreReturned pins the two conditions that make
// the pass idempotent, since between them they are what stops it doing
// the same MusicBrainz lookups on every launch forever.
func TestOnlyUnresolvedAlbumsAreReturned(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
pendingID := seedPendingAlbum(t, db, "Still Pending", "release-mbid-1")
// Already resolved: it has a real MBID, so there is nothing to
// look up even though a marker is still sitting on it.
resolved := seedPendingAlbum(t, db, "Already Resolved", "release-mbid-2")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "rg-mbid", Valid: true},
ID: resolved,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
// Never had a release MBID to resolve in the first place, which is
// most of a library.
seedPendingAlbum(t, db, "Nothing Pending", "")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 || pending[0].ID != pendingID {
t.Fatalf(
"got %d albums %v, want only the unresolved one (%d)",
len(pending), pending, pendingID,
)
}
}
// TestResolvingClearsTheMarker is the other half: once the lookup has
// answered, the album must stop being a candidate, or the pass repeats
// the same live MusicBrainz call on every launch.
func TestResolvingClearsTheMarker(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
// The writer, deliberately: this is an UPDATE, and the read pool
// would refuse it at runtime.
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "resolved-rg-mbid", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 0 {
t.Fatalf("a resolved album is still a candidate: %v", pending)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "resolved-rg-mbid" {
t.Errorf("album mbid = %q, want the resolved one", album.Mbid.String)
}
if album.PendingReleaseMbid.Valid &&
album.PendingReleaseMbid.String != "" {
t.Errorf(
"the pending marker survived as %q",
album.PendingReleaseMbid.String,
)
}
}
// TestAResolvedMBIDIsNeverOverwritten covers the guard in the UPDATE.
//
// The pass runs against rows it read earlier, and a rescan can resolve
// an album from its tags in between -- a real MBID from the file must
// win over one this pass inferred from a release.
func TestAResolvedMBIDIsNeverOverwritten(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "from-the-tags", Valid: true},
ID: id,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "from-the-backfill", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "from-the-tags" {
t.Errorf(
"album mbid = %q, want the tagged one to have won",
album.Mbid.String,
)
}
}
// TestThePassIsBounded checks the LIMIT.
//
// Each row costs a live MusicBrainz lookup on a 1 req/s limiter shared
// with every page the user can open, so an unbounded read is a run that
// lasts as long as the library is untagged. The sqlc query 013 wrote
// had no LIMIT; the raw statement it was replacing did.
func TestThePassIsBounded(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
for i := range releaseGroupMBIDBackfillMaxPerRun + 10 {
seedPendingAlbum(
t, db,
"Album "+string(rune('A'+i%26))+strconv.Itoa(i),
"release-mbid-"+strconv.Itoa(i),
)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != releaseGroupMBIDBackfillMaxPerRun {
t.Errorf(
"got %d albums, want the run bounded at %d",
len(pending), releaseGroupMBIDBackfillMaxPerRun,
)
}
}
-8
View File
@@ -74,14 +74,6 @@ func ExtractTags(path string) (*TrackMetadata, error) {
// ExtractTagsFromReader reads metadata from an io.ReadSeeker.
func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
// The container decides, so this is asked before tag.ReadFrom and
// not after its failure: a WAV's tags live in a RIFF chunk that
// dhowden/tag cannot see, and its fallback -- an ID3v1 trailer --
// would otherwise outrank them.
if meta, ok := wavTags(r); ok {
return meta, nil
}
m, err := tag.ReadFrom(r)
if err != nil {
// No tags found is not necessarily an error - return empty metadata
-68
View File
@@ -1,68 +0,0 @@
package metadata
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"yellowjacket/backend/riff"
)
// wavTags reads the ID3v2 tag a WAV carries in its RIFF "id3 " chunk,
// which is where backend/tagwriter puts it and where dhowden/tag --
// having no RIFF reader at all -- cannot look. Without this a WAV
// scans as an untagged file however carefully it was tagged.
//
// ok is false when r is not a RIFF/WAVE container, and the read
// position is restored either way so the caller can carry on.
func wavTags(r io.ReadSeeker) (*TrackMetadata, bool) {
start, err := r.Seek(0, io.SeekCurrent)
if err != nil {
return nil, false
}
id3Data, chunkErr := riff.ID3Chunk(r)
if _, err := r.Seek(start, io.SeekStart); err != nil {
return nil, false
}
switch {
case chunkErr == nil:
return wavTagsFrom(id3Data), true
// Not ours to read: let the ordinary dispatch have the file.
case errors.Is(chunkErr, riff.ErrNotRIFF), errors.Is(chunkErr, riff.ErrNotWAVE):
return nil, false
// A RIFF container we cannot get a tag out of -- no chunk, an RF64
// file, a truncated header. That is a file with no readable tags,
// which is what the scanner's filename fallback is for.
default:
return &TrackMetadata{}, true
}
}
// wavTagsFrom parses the bytes of a WAV's ID3v2 chunk.
func wavTagsFrom(id3Data []byte) *TrackMetadata {
meta, err := extractID3v2Lenient(bytes.NewReader(id3Data))
if err != nil {
// A tag holding no frames is not a damaged tag: writing every
// field back out empty leaves one, and warning about it would
// put a fault on a file that has none.
if errors.Is(err, ErrTagsUnreadable) {
return &TrackMetadata{}
}
return &TrackMetadata{
TagReadWarning: fmt.Errorf("%w: %w", ErrTagsUnreadable, err),
}
}
// extractID3v2Lenient names MP3, being the recovery path for one.
meta.FileFormat = strings.ToUpper(strings.TrimPrefix(string(WAV), "."))
return meta
}
-58
View File
@@ -47,39 +47,6 @@ type BufferedStreamer struct {
// seek bar and suppresses its interpolation.
starved int
starvedSince time.Time
// underruns accumulates for the life of this streamer, where
// starved is reset by every arriving sample.
//
// The two answer different questions and only the first was being
// asked. starved is a *stall* detector: it exists to end a track
// whose source has died, so it forgets a run the moment audio
// resumes -- which is exactly the case this counts. A hundred 20ms
// underruns a minute never approach the give-up threshold and were
// invisible to the log, the UI and every test tier, while being
// audible as static: an underrun is served as a run of zeros
// spliced into the waveform, and a step discontinuity at each edge
// is what a click is.
underruns UnderrunStats
}
// UnderrunStats is what the ring buffer missed, cumulatively.
//
// Samples rather than milliseconds because this type does not know the
// sample rate -- the player does, and converts at the point of
// reporting.
type UnderrunStats struct {
// Runs is the number of *episodes*: transitions from healthy into
// starved. Calls is how many Stream calls were served with
// silence, and Samples is how much silence that was.
//
// Runs is the count that means something audible. One episode is
// one pop however many calls it spans, and the ratio of the two is
// how long the average episode was -- which is what separates
// "clicking" from "dropping out".
Runs int64
Calls int64
Samples int64
}
// The silence fill is bounded by both a duration and a run of calls,
@@ -259,13 +226,6 @@ func (bs *BufferedStreamer) Stream(
// but only for a bounded stretch, because "forever" is
// reported upward as healthy playback and there is no watchdog
// above this to notice otherwise.
if bs.starved == 0 {
bs.underruns.Runs++
}
bs.underruns.Calls++
bs.underruns.Samples += int64(len(samples))
bs.starved++
if bs.starvedSince.IsZero() {
@@ -309,20 +269,6 @@ func (bs *BufferedStreamer) Stream(
return n, true
}
// Underruns returns the cumulative underrun count.
//
// It is a snapshot rather than a live view, and it is read from
// outside the audio callback: counting happens in Stream, under the
// lock it already takes, because that path has a real-time deadline
// and anything that allocates or formats on it is a cause of the
// defect it is measuring rather than a measurement of it.
func (bs *BufferedStreamer) Underruns() UnderrunStats {
bs.mu.Lock()
defer bs.mu.Unlock()
return bs.underruns
}
// Err returns any error encountered by the source streamer.
func (bs *BufferedStreamer) Err() error {
bs.mu.Lock()
@@ -352,10 +298,6 @@ func (bs *BufferedStreamer) Flush() {
// resetStarvationLocked forgets an underrun run. Must be called with
// bs.mu held.
//
// Deliberately does not touch bs.underruns: forgetting the run is what
// makes starved a stall detector, and remembering it is the whole
// point of the counter beside it.
func (bs *BufferedStreamer) resetStarvationLocked() {
bs.starved = 0
bs.starvedSince = time.Time{}
-279
View File
@@ -1,279 +0,0 @@
package player
import (
"sync"
"testing"
"time"
)
// An underrun is audible and nothing counted it (#135).
//
// The distinction these tests exist for is that `starved` and
// `underruns` disagree on purpose. `starved` is a stall detector: it is
// reset by every arriving sample, because its job is to end a track
// whose source has died and a source that is merely slow must not be
// cut short (TestUnderrunsDoNotAccumulateAcrossASlowSource, next
// door). That reset is exactly what made the audible case invisible --
// a hundred short underruns a minute never approach the give-up
// threshold, and each one is a run of zeros spliced into the waveform
// with a step discontinuity at both edges.
// TestAnEmptyRingIsCounted is the measurement itself: silence served
// for a missing sample is recorded rather than merely tolerated.
func TestAnEmptyRingIsCounted(t *testing.T) {
t.Parallel()
// A source that never produces is the cleanest way to make the
// ring empty on demand; the stall budget is far longer than the
// handful of calls below.
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
if got := bs.Underruns(); got != (UnderrunStats{}) {
t.Fatalf("a fresh streamer already reports %+v", got)
}
buf := make([][2]float64, 256)
for range 3 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
got := bs.Underruns()
if got.Calls != 3 {
t.Errorf("Calls = %d, want 3", got.Calls)
}
if got.Samples != int64(3*len(buf)) {
t.Errorf("Samples = %d, want %d", got.Samples, 3*len(buf))
}
// Three consecutive silent calls are one episode, not three. That
// is the number that means something audible: one interruption is
// one pop however many callbacks it spans.
if got.Runs != 1 {
t.Errorf("Runs = %d, want 1 -- an unbroken run is one episode", got.Runs)
}
}
// TestSilenceIsWhatIsCounted pins what an underrun actually does to the
// waveform, which is the reason to count it at all.
func TestSilenceIsWhatIsCounted(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 64)
for i := range buf {
buf[i] = [2]float64{0.5, 0.5}
}
n, ok := bs.Stream(buf)
if !ok || n != len(buf) {
t.Fatalf("Stream = (%d, %v), want (%d, true)", n, ok, len(buf))
}
for i := range buf {
if buf[i] != ([2]float64{}) {
t.Fatalf("sample %d is %v, want silence", i, buf[i])
}
}
if got := bs.Underruns().Samples; got != int64(len(buf)) {
t.Errorf("counted %d samples of silence, wrote %d", got, len(buf))
}
}
// TestSeparateEpisodesAreSeparateRuns is the counter's whole shape:
// audio arriving between two underruns makes them two, because that is
// two interruptions and two clicks.
func TestSeparateEpisodesAreSeparateRuns(t *testing.T) {
t.Parallel()
// A source that yields nothing until it is fed, so the ring can be
// emptied, filled and emptied again on demand.
src := &gatedStreamer{}
bs := NewBufferedStreamer(src, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
starve := func() {
t.Helper()
for range 2 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
}
// feed lets exactly one bufferful through and drains it, so the
// ring is empty again on return. Allowing more would mean the
// starve() after it drained real audio instead of underrunning,
// which is what the first version of this test did -- it reported
// one episode and looked like the counter was wrong.
feed := func() {
t.Helper()
src.allow(len(buf))
// The read-ahead is a goroutine, so wait for real samples
// rather than assuming they have landed.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
n, ok := bs.Stream(buf)
if ok && n > 0 && buf[0] != ([2]float64{}) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the source never delivered a sample")
}
starve()
feed()
starve()
if got := bs.Underruns().Runs; got < 2 {
t.Errorf(
"Runs = %d, want at least 2 -- audio in between makes two "+
"episodes, not one",
got,
)
}
}
// TestTheStallResetDoesNotClearTheCounter is the regression this file
// is really about.
//
// resetStarvationLocked runs on every arriving sample and on every
// Flush. If it cleared the cumulative count too, the counter would
// report zero on exactly the workload it exists to measure -- a stream
// that underruns repeatedly but always recovers -- which is
// indistinguishable from healthy playback and is what the code did
// before #135.
func TestTheStallResetDoesNotClearTheCounter(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
before := bs.Underruns()
if before.Runs == 0 {
t.Fatal("nothing was counted, so the reset cannot be tested")
}
// Both of the ways a run is forgotten.
bs.mu.Lock()
bs.resetStarvationLocked()
bs.mu.Unlock()
bs.Flush()
if got := bs.Underruns(); got != before {
t.Errorf(
"forgetting the stall run also discarded the count: %+v, "+
"want %+v",
got, before,
)
}
}
// TestUnderrunDeltaNeverGoesBackwards covers the one arithmetic trap in
// the reporting side.
//
// The counter belongs to the streamer and the streamer is replaced on
// every track, so a baseline carried across a track change is the
// previous track's total subtracted from a fresh zero. The load path
// resets the baseline, and this clamps as well -- a negative count in a
// log line reads as a broken instrument, which would discredit the
// measurement rather than merely mis-state it.
func TestUnderrunDeltaNeverGoesBackwards(t *testing.T) {
t.Parallel()
tests := []struct {
name string
now UnderrunStats
last UnderrunStats
want UnderrunStats
}{
{
name: "ordinary progress",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 2, Calls: 10, Samples: 1000},
want: UnderrunStats{Runs: 3, Calls: 30, Samples: 3000},
},
{
name: "nothing happened",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
want: UnderrunStats{},
},
{
name: "a new streamer, with a stale baseline",
now: UnderrunStats{},
last: UnderrunStats{Runs: 9, Calls: 90, Samples: 9000},
want: UnderrunStats{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := underrunDelta(tt.now, tt.last); got != tt.want {
t.Errorf("underrunDelta = %+v, want %+v", got, tt.want)
}
})
}
}
// gatedStreamer produces only what it has been allowed to, and
// otherwise stalls without ending -- so a test can decide exactly when
// the ring runs dry.
type gatedStreamer struct {
mu sync.Mutex
remaining int
}
func (g *gatedStreamer) allow(n int) {
g.mu.Lock()
defer g.mu.Unlock()
g.remaining += n
}
func (g *gatedStreamer) Stream(samples [][2]float64) (int, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.remaining <= 0 {
return 0, true
}
n := min(len(samples), g.remaining)
for i := range n {
samples[i] = [2]float64{0.25, 0.25}
}
g.remaining -= n
return n, true
}
func (g *gatedStreamer) Err() error { return nil }
+10 -90
View File
@@ -39,21 +39,16 @@ type Player struct {
// via the queue).
mu sync.Mutex
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
// lastUnderruns is the previous report, so the 1 Hz log can say
// what happened in the last second and stay quiet when nothing did.
// It is reset with the streamer, in loadFileLocked.
lastUnderruns UnderrunStats
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
control *beep.Ctrl
volume *effects.Volume
speakerStreamer beep.Streamer
@@ -297,78 +292,9 @@ func (p *Player) emitPositionIfPlaying() {
return
}
p.reportUnderrunsLocked()
p.emitPositionLocked()
}
// underrunDelta is what happened since the last report.
//
// It clamps at zero rather than subtracting blind, because the counter
// belongs to the *streamer* and the streamer is replaced on every
// track: a baseline carried across that boundary is the previous
// track's total subtracted from a fresh zero, which is negative. That
// is repaired at the load (lastUnderruns is reset with the streamer)
// and clamped here as well, because a negative count in a log line
// reads as a broken instrument and would discredit the measurement
// this exists to make.
func underrunDelta(now, last UnderrunStats) UnderrunStats {
return UnderrunStats{
Runs: max(0, now.Runs-last.Runs),
Calls: max(0, now.Calls-last.Calls),
Samples: max(0, now.Samples-last.Samples),
}
}
// reportUnderrunsLocked logs what the ring buffer missed, at most once
// a second and only when the number moved. Must be called with p.mu
// held.
//
// **An underrun is audible and nothing counted it** (#135). The ring
// serves silence when it is empty, so a run of zeros is spliced into
// the waveform and the step discontinuity at each edge is a click; a
// series of short ones is static. Everything that makes one likelier
// is worse on a phone than on a desktop -- slower storage, a governor
// that parks cores, background work, GC -- and no tier here can see it,
// since CI's audio device is a null sink chosen because it keeps time.
//
// Three things about the reporting are deliberate.
//
// **It is on the 1 Hz position ticker rather than in Stream.** Stream
// runs on the speaker callback's real-time deadline, and a log line
// there would allocate, format and write on the exact path whose
// missed deadline is the defect -- measuring by making it worse.
//
// **An unchanged count is not logged.** That is emitStatus' rule one
// package over: a healthy player is silent, so anything in the log is
// news, and the line appears exactly while it is popping. Reading it
// off a device means `make android-logs` with the audio audible.
//
// **It is Info rather than Debug**, because the default level is Info
// and a phone has no convenient way to set YJ_LOG_LEVEL -- a debug
// line here would be a counter nobody on the affected platform can
// read, which is the shape of the bug that made #160 necessary.
func (p *Player) reportUnderrunsLocked() {
if p.buffered == nil {
return
}
stats := p.buffered.Underruns()
if stats == p.lastUnderruns {
return
}
since := underrunDelta(stats, p.lastUnderruns)
p.lastUnderruns = stats
slog.Info("audio underrun",
"runs", since.Runs,
"calls", since.Calls,
"silenceMs", speakerSampleRate.D(int(since.Samples)).Milliseconds(),
"trackRuns", stats.Runs,
"trackSilenceMs", speakerSampleRate.D(int(stats.Samples)).Milliseconds(),
)
}
// emitPositionLocked pushes the current position to the frontend.
// Must be called with p.mu held.
func (p *Player) emitPositionLocked() {
@@ -558,12 +484,6 @@ func (p *Player) updateStreamers(
p.resampled, int(speakerSampleRate)*2,
)
// The counter belongs to the streamer, so the baseline it is
// reported against has to go with it -- otherwise the first report
// of a new track is the previous track's total subtracted from
// zero, which is negative and looks like the instrument is broken.
p.lastUnderruns = UnderrunStats{}
// wrap in ctrl streamer to allow play/pause
p.control = &beep.Ctrl{Streamer: p.buffered}
-186
View File
@@ -1,186 +0,0 @@
// Package riff reads the chunk layout of a RIFF/WAVE container.
//
// It exists because both halves of WAV tagging need it and neither can
// import the other: backend/tagwriter writes a WAV's tags into a RIFF
// "id3 " chunk and already imports backend/metadata, which is what has
// to read them back out. backend/tagtotals is the precedent.
//
// The two readers here are deliberately different. Parse holds every
// chunk's data in memory, which is what rewriting a file needs; a WAV's
// audio *is* the "data" chunk, so doing that on the scan path would
// read every library file in full. ID3Chunk seeks over what it is not
// looking for instead. Both walk the same headers.
package riff
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
)
// Sentinel errors describing a container this package will not read.
var (
ErrRF64NotSupported = errors.New("RF64 files are not yet supported")
ErrNotRIFF = errors.New("not a RIFF file")
ErrNotWAVE = errors.New("not a WAVE file")
ErrNoID3Chunk = errors.New("no ID3 chunk in RIFF file")
)
// Chunk holds a single RIFF sub-chunk (ID + raw data).
type Chunk struct {
ID [4]byte
Data []byte
}
// IsID3 reports whether id is that of an ID3v2 RIFF chunk. Both
// lowercase "id3 " and uppercase "ID3 " are accepted.
func IsID3(id [4]byte) bool {
return strings.ToLower(string(id[:3])) == "id3"
}
// Parse reads every RIFF sub-chunk from r, in order, starting at the
// reader's current position. It rejects RF64 files and non-WAVE
// containers with descriptive errors. The parser is lenient: it
// tolerates a missing final padding byte and ignores the declared
// RIFF size.
func Parse(r io.Reader) ([]Chunk, error) {
if err := readContainer(r); err != nil {
return nil, err
}
var chunks []Chunk
for {
id, size, err := nextHeader(r)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
// Copied rather than allocated up front, as ID3Chunk does: the
// size is four bytes off the file, so a truncated one is free to
// declare a chunk larger than the whole of itself.
var data bytes.Buffer
if _, err := io.CopyN(&data, r, int64(size)); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
}
chunks = append(chunks, Chunk{ID: id, Data: data.Bytes()})
// Odd-length chunks have a padding byte. Lenient: if the
// read fails (e.g. EOF), just break rather than error.
if size%2 != 0 {
var pad [1]byte
if _, err := r.Read(pad[:]); err != nil {
break
}
}
}
return chunks, nil
}
// ID3Chunk returns the payload of the ID3v2 chunk of the RIFF/WAVE
// container at the reader's current position, seeking over every other
// chunk rather than reading it. It returns ErrNoID3Chunk when the
// container carries no such chunk, and leaves the read position
// unspecified either way.
func ID3Chunk(r io.ReadSeeker) ([]byte, error) {
if err := readContainer(r); err != nil {
return nil, err
}
for {
id, size, err := nextHeader(r)
if errors.Is(err, io.EOF) {
return nil, ErrNoID3Chunk
}
if err != nil {
return nil, err
}
if !IsID3(id) {
// Odd-length chunks carry a padding byte. Seeking past
// the end of the file is not an error; the next header
// read is what reports the end.
if _, err := r.Seek(int64(size)+int64(size%2), io.SeekCurrent); err != nil {
return nil, fmt.Errorf("skip chunk %q: %w", id, err)
}
continue
}
// Copied rather than allocated up front: a truncated file is
// free to declare a chunk larger than the whole of itself.
var data bytes.Buffer
if _, err := io.CopyN(&data, r, int64(size)); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
}
return data.Bytes(), nil
}
}
// readContainer consumes the 12-byte RIFF/WAVE header at the reader's
// current position.
func readContainer(r io.Reader) error {
var magic [4]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return fmt.Errorf("read RIFF magic: %w", err)
}
if string(magic[:]) == "RF64" {
return ErrRF64NotSupported
}
if string(magic[:]) != "RIFF" {
return fmt.Errorf("%w: got %q", ErrNotRIFF, magic)
}
// Read (and discard) RIFF size — lenient, do not enforce.
var riffSize uint32
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
return fmt.Errorf("read RIFF size: %w", err)
}
var form [4]byte
if _, err := io.ReadFull(r, form[:]); err != nil {
return fmt.Errorf("read WAVE form type: %w", err)
}
if string(form[:]) != "WAVE" {
return fmt.Errorf("%w: got %q", ErrNotWAVE, form)
}
return nil
}
// nextHeader reads one sub-chunk header. It returns io.EOF once the
// chunks are exhausted, including for a header cut short.
func nextHeader(r io.Reader) ([4]byte, uint32, error) {
var id [4]byte
_, err := io.ReadFull(r, id[:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return id, 0, io.EOF
}
if err != nil {
return id, 0, fmt.Errorf("read chunk ID: %w", err)
}
var size uint32
if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
return id, 0, fmt.Errorf("read chunk size for %q: %w", id, err)
}
return id, size, nil
}
-254
View File
@@ -1,254 +0,0 @@
package riff_test
import (
"bytes"
"encoding/binary"
"errors"
"runtime"
"testing"
"yellowjacket/backend/riff"
)
// chunk is one sub-chunk to put in a test container.
type chunk struct {
id string
data []byte
}
// buildRIFF assembles a container from magic, form type and chunks,
// padding odd-length chunks the way a writer must.
func buildRIFF(magic, form string, chunks []chunk) []byte {
var body bytes.Buffer
body.WriteString(form)
for _, c := range chunks {
body.WriteString(c.id)
_ = binary.Write(&body, binary.LittleEndian, uint32(len(c.data)))
body.Write(c.data)
if len(c.data)%2 != 0 {
body.WriteByte(0)
}
}
var out bytes.Buffer
out.WriteString(magic)
_ = binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
out.Write(body.Bytes())
return out.Bytes()
}
func TestID3Chunk_FindsTheTagPastTheAudio(t *testing.T) {
t.Parallel()
tests := []struct {
name string
chunks []chunk
want string
}{
{
name: "after an odd-length chunk",
chunks: []chunk{
{id: "fmt ", data: make([]byte, 16)},
{id: "LIST", data: []byte("INFOodd")},
{id: "data", data: make([]byte, 200)},
{id: "id3 ", data: []byte("ID3vTAG")},
},
want: "ID3vTAG",
},
{
// The chunk ID is written both ways in the wild, and the
// writer accepts either, so the reader must too.
name: "uppercase ID3",
chunks: []chunk{
{id: "data", data: make([]byte, 8)},
{id: "ID3 ", data: []byte("upper")},
},
want: "upper",
},
{
name: "first chunk",
chunks: []chunk{
{id: "id3 ", data: []byte("first")},
{id: "data", data: make([]byte, 8)},
},
want: "first",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
r := bytes.NewReader(buildRIFF("RIFF", "WAVE", tc.chunks))
got, err := riff.ID3Chunk(r)
if err != nil {
t.Fatalf("ID3Chunk: %v", err)
}
if string(got) != tc.want {
t.Errorf("chunk data: got %q, want %q", got, tc.want)
}
})
}
}
func TestID3Chunk_RejectsWhatItCannotRead(t *testing.T) {
t.Parallel()
tests := []struct {
name string
bytes []byte
want error
}{
{
name: "no ID3 chunk",
bytes: buildRIFF("RIFF", "WAVE", []chunk{{id: "data", data: []byte{1, 2}}}),
want: riff.ErrNoID3Chunk,
},
{
name: "no chunks at all",
bytes: buildRIFF("RIFF", "WAVE", nil),
want: riff.ErrNoID3Chunk,
},
{
name: "not RIFF",
bytes: []byte("ID3\x03\x00\x00\x00\x00\x00\x00\x00\x00"),
want: riff.ErrNotRIFF,
},
{
name: "not WAVE",
bytes: buildRIFF("RIFF", "AVI ", []chunk{{id: "id3 ", data: []byte("x")}}),
want: riff.ErrNotWAVE,
},
{
name: "RF64",
bytes: buildRIFF("RF64", "WAVE", []chunk{{id: "id3 ", data: []byte("x")}}),
want: riff.ErrRF64NotSupported,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := riff.ID3Chunk(bytes.NewReader(tc.bytes))
if !errors.Is(err, tc.want) {
t.Errorf("ID3Chunk error: got %v, want %v", err, tc.want)
}
})
}
}
// A file cut short mid-chunk is a file with no tag, not a reason to
// allocate the size it claims: the declared size is four bytes any
// truncation can leave saying 4 GB.
func TestID3Chunk_ToleratesATruncatedFile(t *testing.T) {
t.Parallel()
full := buildRIFF("RIFF", "WAVE", []chunk{
{id: "data", data: make([]byte, 64)},
{id: "id3 ", data: []byte("tag")},
})
t.Run("cut inside the audio", func(t *testing.T) {
t.Parallel()
_, err := riff.ID3Chunk(bytes.NewReader(full[:32]))
if !errors.Is(err, riff.ErrNoID3Chunk) {
t.Errorf("ID3Chunk error: got %v, want %v", err, riff.ErrNoID3Chunk)
}
})
t.Run("cut inside the tag", func(t *testing.T) {
t.Parallel()
if _, err := riff.ID3Chunk(bytes.NewReader(full[:len(full)-2])); err == nil {
t.Error("ID3Chunk: got nil error for a truncated tag chunk")
}
})
}
// Parse is the writer's half and reads every chunk into memory, which
// is what preserving them needs.
func TestParse_ReadsEveryChunkInOrder(t *testing.T) {
t.Parallel()
raw := buildRIFF("RIFF", "WAVE", []chunk{
{id: "fmt ", data: make([]byte, 16)},
{id: "LIST", data: []byte("INFOodd")},
{id: "id3 ", data: []byte("tag")},
})
chunks, err := riff.Parse(bytes.NewReader(raw))
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := []string{"fmt ", "LIST", "id3 "}
if len(chunks) != len(want) {
t.Fatalf("chunk count: got %d, want %d", len(chunks), len(want))
}
for i, id := range want {
if got := string(chunks[i].ID[:]); got != id {
t.Errorf("chunk %d: got %q, want %q", i, got, id)
}
}
if !riff.IsID3(chunks[2].ID) || string(chunks[2].Data) != "tag" {
t.Errorf("id3 chunk: got %q", chunks[2].Data)
}
// The padding byte after an odd chunk is not part of its data.
if string(chunks[1].Data) != "INFOodd" {
t.Errorf("odd chunk data: got %q, want %q", chunks[1].Data, "INFOodd")
}
}
// A chunk size is four bytes read off the file, so a truncated or
// malformed WAV is free to declare a chunk larger than the whole of
// itself. Parse must grow with what arrives rather than with what was
// claimed.
//
// This measures the allocation instead of the error because the error
// is the same either way: a build sizing its buffer from the header
// reports the truncation correctly, having asked the allocator for a
// gigabyte on the way. Deliberately not parallel — TotalAlloc is
// process-wide, and a test paused beside another one is measuring it
// too.
func TestParse_DoesNotAllocateWhatAChunkClaims(t *testing.T) {
// Large enough that a header-sized buffer is unmistakable, in a
// container of a few dozen bytes.
const declared = 1 << 30
var raw bytes.Buffer
raw.WriteString("RIFF")
_ = binary.Write(&raw, binary.LittleEndian, uint32(declared+12))
raw.WriteString("WAVE")
raw.WriteString("data")
_ = binary.Write(&raw, binary.LittleEndian, uint32(declared))
raw.WriteString("and then the file ends")
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
if _, err := riff.Parse(bytes.NewReader(raw.Bytes())); err == nil {
t.Fatal("Parse: got nil error for a chunk larger than the file holding it")
}
runtime.ReadMemStats(&after)
if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 {
t.Errorf("Parse allocated %d bytes reading a %d-byte file whose chunk header claimed %d",
grew, raw.Len(), declared)
}
}
-69
View File
@@ -52,75 +52,6 @@ func UseHomeOverride(base string) {
_ = os.Setenv(envHomeOverride, base)
}
// envTempDir is the variable Go's os.TempDir() reads, and through it
// every library in the process that asks for a temporary file.
const envTempDir = "TMPDIR"
// tempDirName is the subdirectory of the app's own storage that
// becomes that answer.
const tempDirName = "tmp"
// UseTempDir gives the process a temporary directory that exists.
//
// **Android has no /tmp and hands an app no TMPDIR**, and Go's
// os.TempDir() falls back to "/tmp" when the variable is unset -- so
// every library in the process that wants scratch space is handed a
// path that has never existed. SQLite is the one that noticed: an
// INSERT ... SELECT large enough to spill returned
// SQLITE_IOERR_GETTEMPPATH (disk I/O error 6410), which is how the
// champion search index came to fail its rebuild on every launch while
// the app otherwise looked healthy (#190).
//
// It is the *class* that is fixed here rather than that statement.
// Anything that spills fails the same way on that platform -- large
// sorts, large joins, VACUUM -- so the repair belongs at the process's
// one answer to the question rather than at each caller. The
// alternative considered was PRAGMA temp_store = MEMORY, which is
// cheaper and more local and is a promise that every future spill fits
// in RAM on a phone; the catalog is the largest thing in this app and
// that is not a promise worth making silently.
//
// The rules are UseHomeOverride's, for the same reasons. **An empty
// base is a no-op**, because that is what
// application.Mobile.StoragePath() returns on desktop -- so this needs
// no build tag and changes nothing off mobile, where /tmp is real. And
// **an explicit TMPDIR wins**, so anyone who set one deliberately gets
// what they asked for; nothing sets it on the platform this exists for.
//
// It returns its error rather than swallowing it because a temp
// directory that could not be created is the same silent failure one
// step earlier, and since #160 a log line on that platform is
// something a person can actually read.
func UseTempDir(base string) error {
if base == "" || os.Getenv(envTempDir) != "" {
return nil
}
dir := filepath.Join(base, tempDirName)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("could not make the temp directory %s: %w", dir, err)
}
// Writability is checked rather than assumed: the whole failure
// this repairs is a directory that is named and cannot be used, and
// MkdirAll on an existing unwritable directory succeeds.
probe, err := os.CreateTemp(dir, "probe")
if err != nil {
return fmt.Errorf("temp directory %s is not writable: %w", dir, err)
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
if err := os.Setenv(envTempDir, dir); err != nil {
return fmt.Errorf("could not set %s: %w", envTempDir, err)
}
return nil
}
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
path, err := resolveUserDirPath(dt)
-113
View File
@@ -87,116 +87,3 @@ func TestUseHomeOverride(t *testing.T) {
})
}
}
// UseTempDir carries UseHomeOverride's two rules for the same reasons,
// plus one of its own: the directory it names has to be usable.
//
// **The only tier that can compile the platform this exists for is a
// phone**, so everything decidable off one is decided here -- which is
// androidpayload.go's discipline, and is why the platform call is a
// parameter rather than something this package reaches for. The
// device's half is a single measurement: no /tmp, no TMPDIR (#190).
func TestUseTempDir(t *testing.T) {
t.Run("an empty base is a no-op", func(t *testing.T) {
// This is the desktop case in full: StoragePath() answers ""
// off mobile, where /tmp is real and must be left alone.
t.Setenv(envTempDir, "")
if err := UseTempDir(""); err != nil {
t.Fatalf("UseTempDir(\"\") = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s = %q, want it untouched", envTempDir, got)
}
})
t.Run("an explicit TMPDIR wins", func(t *testing.T) {
const chosen = "/somewhere/deliberate"
// The base is taken before TMPDIR moves, because t.TempDir()
// reads TMPDIR too -- which is the same fact this function is
// about, met from the other side.
base := t.TempDir()
t.Setenv(envTempDir, chosen)
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != chosen {
t.Errorf("%s = %q, want the explicit %q", envTempDir, got, chosen)
}
})
t.Run("points at a real directory under the base", func(t *testing.T) {
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
got := os.Getenv(envTempDir)
want := filepath.Join(base, tempDirName)
if got != want {
t.Fatalf("%s = %q, want %q", envTempDir, got, want)
}
// The whole failure being repaired is a temp directory that is
// named and does not exist, so naming one is not enough.
info, err := os.Stat(got)
if err != nil {
t.Fatalf("the temp directory was named but not created: %v", err)
}
if !info.IsDir() {
t.Fatalf("%s is not a directory", got)
}
})
t.Run("os.TempDir then answers with it", func(t *testing.T) {
// The point of setting the variable at all: this is what every
// library in the process reads, SQLite's driver included.
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.TempDir(); got != filepath.Join(base, tempDirName) {
t.Errorf("os.TempDir() = %q, want the directory we made", got)
}
})
t.Run("an unwritable directory is an error, not a silent success", func(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root can write anywhere, so there is nothing to refuse")
}
base := t.TempDir()
// MkdirAll on an existing directory succeeds whatever its
// mode, so without the write probe this case would set TMPDIR
// to a directory nothing can use -- which is the bug again,
// one directory over.
if err := os.Mkdir(filepath.Join(base, tempDirName), 0o500); err != nil {
t.Fatalf("prepare the unwritable directory: %v", err)
}
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err == nil {
t.Fatal("UseTempDir accepted a directory it cannot write to")
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s was set to %q despite the failure", envTempDir, got)
}
})
}
+4 -5
View File
@@ -13,10 +13,9 @@ import (
// indistinguishable from never having written one. So these assert the
// round trip through the *reader the scan uses*, not the bytes.
//
// WAV was the exception until #104 -- dhowden/tag has no RIFF reader,
// so metadata.ExtractTags could not see a WAV's ID3 chunk and this
// case read the chunk itself, which is a test of the writer wearing
// the shape of a round trip. All four go through the scanner now.
// WAV is the exception and it is not this change's: dhowden/tag has no
// RIFF reader at all, so metadata.ExtractTags cannot see a WAV's ID3
// chunk -- which is why every other test here reads that chunk itself.
func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
t.Parallel()
@@ -92,7 +91,7 @@ func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
},
{
name: "wav",
read: viaScanner,
read: readWavID3Tags,
write: func(t *testing.T, dir string) string {
t.Helper()
+105 -11
View File
@@ -8,22 +8,116 @@ import (
"io"
"log/slog"
"os"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/fileutil"
"yellowjacket/backend/riff"
)
// errFileTooLargeForWAV is the one RIFF error that belongs to the
// writer; reading rejects a container in backend/riff.
var errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
// Sentinel errors for WAV RIFF operations.
var (
errRF64NotSupported = errors.New("RF64 files are not yet supported")
errNotRIFF = errors.New("not a RIFF file")
errNotWAVE = errors.New("not a WAVE file")
errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
)
// riffChunk holds a single RIFF sub-chunk (ID + raw data).
type riffChunk struct {
id [4]byte
data []byte
}
// parseRIFF reads all RIFF sub-chunks from r. It rejects RF64 files
// and non-WAVE containers with descriptive errors. The parser is
// lenient on read: it tolerates missing padding bytes and ignores
// the declared RIFF size.
func parseRIFF(r io.ReadSeeker) ([]riffChunk, error) {
// Read 4-byte container magic.
var magic [4]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return nil, fmt.Errorf("read RIFF magic: %w", err)
}
if string(magic[:]) == "RF64" {
return nil, errRF64NotSupported
}
if string(magic[:]) != "RIFF" {
return nil, fmt.Errorf("%w: got %q", errNotRIFF, magic)
}
// Read (and discard) RIFF size — lenient, do not enforce.
var riffSize uint32
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
return nil, fmt.Errorf("read RIFF size: %w", err)
}
// Read 4-byte form type.
var form [4]byte
if _, err := io.ReadFull(r, form[:]); err != nil {
return nil, fmt.Errorf("read WAVE form type: %w", err)
}
if string(form[:]) != "WAVE" {
return nil, fmt.Errorf("%w: got %q", errNotWAVE, form)
}
// Read sub-chunks until EOF.
var chunks []riffChunk
for {
var chunkID [4]byte
_, err := io.ReadFull(r, chunkID[:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read chunk ID: %w", err)
}
var chunkSize uint32
if err := binary.Read(r, binary.LittleEndian, &chunkSize); err != nil {
return nil, fmt.Errorf("read chunk size for %q: %w", chunkID, err)
}
data := make([]byte, chunkSize)
if _, err := io.ReadFull(r, data); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", chunkID, err)
}
chunks = append(chunks, riffChunk{id: chunkID, data: data})
// Odd-length chunks have a padding byte. Lenient: if the
// read fails (e.g. EOF), just break rather than error.
if chunkSize%2 != 0 {
var pad [1]byte
if _, err := r.Read(pad[:]); err != nil {
break
}
}
}
return chunks, nil
}
// isID3ChunkID returns true if id represents an ID3v2 RIFF chunk.
// Both lowercase "id3 " and uppercase "ID3 " are accepted.
func isID3ChunkID(id [4]byte) bool {
s := strings.ToLower(string(id[:3]))
return s == "id3"
}
// writeRIFF writes a complete RIFF/WAVE container to w, preserving
// the given chunks in order and appending the id3Data as the final
// "id3 " chunk. Returns errFileTooLargeForWAV if the result would
// exceed the 4 GB RIFF limit.
func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
// Calculate total RIFF payload size:
// 4 bytes (WAVE form type)
// + for each preserved chunk: 8 (header) + len(data) + padding
@@ -31,7 +125,7 @@ func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
riffPayload := uint64(4)
for _, c := range chunks {
sz := uint64(len(c.Data))
sz := uint64(len(c.data))
riffPayload += 8 + sz
if sz%2 != 0 {
@@ -68,7 +162,7 @@ func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
// Write each preserved chunk.
for _, c := range chunks {
if err := writeChunk(w, c.ID, c.Data); err != nil {
if err := writeChunk(w, c.id, c.data); err != nil {
return err
}
}
@@ -131,7 +225,7 @@ func writeWavTags(
return fmt.Errorf("open wav for reading: %w", err)
}
allChunks, err := riff.Parse(f)
allChunks, err := parseRIFF(f)
// Close immediately — we need the handle released before
// AtomicWrite creates the replacement file.
@@ -143,13 +237,13 @@ func writeWavTags(
// Separate preserved chunks from existing ID3 data.
var (
preserved []riff.Chunk
preserved []riffChunk
existingID3 []byte
)
for _, c := range allChunks {
if riff.IsID3(c.ID) {
existingID3 = c.Data
if isID3ChunkID(c.id) {
existingID3 = c.data
} else {
preserved = append(preserved, c)
}
+17 -103
View File
@@ -12,7 +12,6 @@ import (
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/metadata"
"yellowjacket/backend/riff"
)
// createTestWAV builds a minimal valid WAV file with an optional
@@ -271,88 +270,6 @@ func TestWriteWavTags_PartialUpdate(t *testing.T) {
assertStrField(t, "Composer", meta.Composer, "Original Composer")
}
// The writer has always been correct and the reader could not see it:
// a WAV tagged by this app scanned as an untagged file, so editing
// tags, autotagging a folder or importing a WAV download all appeared
// to work and changed nothing the library could show (#104). So this
// asserts the write through metadata.ExtractTags -- the reader the
// scan uses -- rather than through the id3 chunk.
func TestWriteWavTags_ReadBackByTheScanner(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := createTestWAV(t, dir, "scanner.wav", nil)
art := tinyJPEG(t)
changes := TagChanges{
FieldTitle: "Some Song",
FieldArtist: "Some Artist",
FieldAlbum: "Some Album",
FieldAlbumArtist: "Some Album Artist",
FieldGenre: "Rock",
FieldYear: 2024,
FieldTrackNumber: 3,
FieldComposer: "Some Composer",
FieldCoverArt: art,
}
if err := writeWavTags(testLogger(), path, changes); err != nil {
t.Fatalf("writeWavTags: %v", err)
}
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
if meta.TagReadWarning != nil {
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
}
assertStrField(t, "Title", meta.Title, "Some Song")
assertStrField(t, "Artist", meta.Artist, "Some Artist")
assertStrField(t, "Album", meta.Album, "Some Album")
assertStrField(t, "AlbumArtist", meta.AlbumArtist, "Some Album Artist")
assertStrField(t, "Genre", meta.Genre, "Rock")
assertStrField(t, "Composer", meta.Composer, "Some Composer")
assertStrField(t, "FileFormat", meta.FileFormat, "WAV")
assertIntField(t, "Year", meta.Year, 2024)
assertIntField(t, "TrackNumber", meta.TrackNumber, 3)
if !strings.HasPrefix(meta.TagFormat, "ID3v2") {
t.Errorf("TagFormat: got %q, want an ID3v2 version", meta.TagFormat)
}
if meta.Picture == nil {
t.Fatal("expected cover art, got nil")
}
if !bytes.Equal(meta.Picture.Data, art) {
t.Errorf("picture data mismatch: got %d bytes, want %d",
len(meta.Picture.Data), len(art))
}
}
// An untagged WAV is a file with no tags, not a file with a problem:
// the scanner falls back to the filename and must not be handed a
// warning to surface about it.
func TestUntaggedWav_ReadsAsEmptyWithoutAWarning(t *testing.T) {
t.Parallel()
path := createTestWAV(t, t.TempDir(), "bare.wav", nil)
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
if meta.TagReadWarning != nil {
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
}
assertStrField(t, "Title", meta.Title, "")
}
func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Parallel()
@@ -365,7 +282,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open original: %v", err)
}
origChunks, err := riff.Parse(origFile)
origChunks, err := parseRIFF(origFile)
_ = origFile.Close()
if err != nil {
@@ -375,7 +292,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// Record original chunk data by ID string.
origData := map[string][]byte{}
for _, c := range origChunks {
origData[string(c.ID[:])] = c.Data
origData[string(c.id[:])] = c.data
}
// Write a tag to trigger RIFF rewrite.
@@ -392,7 +309,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open after write: %v", err)
}
newChunks, err := riff.Parse(newFile)
newChunks, err := parseRIFF(newFile)
_ = newFile.Close()
if err != nil {
@@ -403,7 +320,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
origNonID3 := 0
for _, c := range origChunks {
if !riff.IsID3(c.ID) {
if !isID3ChunkID(c.id) {
origNonID3++
}
}
@@ -411,7 +328,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
newNonID3 := 0
for _, c := range newChunks {
if !riff.IsID3(c.ID) {
if !isID3ChunkID(c.id) {
newNonID3++
}
}
@@ -442,17 +359,17 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// in chunks and its data matches want byte-for-byte.
func checkChunkPreserved(
t *testing.T,
chunks []riff.Chunk,
chunks []riffChunk,
idStr string,
want []byte,
) {
t.Helper()
for _, c := range chunks {
if string(c.ID[:]) == idStr {
if !bytes.Equal(c.Data, want) {
if string(c.id[:]) == idStr {
if !bytes.Equal(c.data, want) {
t.Errorf("chunk %q data changed: got %d bytes, want %d",
idStr, len(c.Data), len(want))
idStr, len(c.data), len(want))
}
return
@@ -513,7 +430,7 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
buf.WriteString("WAVE")
// Minimal ds64 chunk (required for RF64 but we just need
// enough bytes for riff.Parse to hit the RF64 rejection).
// enough bytes for parseRIFF to hit the RF64 rejection).
buf.WriteString("ds64")
_ = binary.Write(&buf, binary.LittleEndian, uint32(28)) //nolint:mnd
buf.Write(make([]byte, 28)) //nolint:mnd
@@ -537,12 +454,9 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
// readWavID3Tags extracts ID3v2 metadata from a WAV file by parsing
// the RIFF structure and reading the id3 chunk with bogem/id3v2.
//
// metadata.ExtractTags reads a WAV since #104 and is what the round
// trips assert through. This stays for the two cases that are about
// the bytes rather than about the scan: a tag with every frame
// cleared, which no reader reports as anything, and the chunk
// preservation test, which is already parsing the container itself.
// dhowden/tag's ReadFrom does not support WAV files, and its
// ReadID3v2Tags fails on empty tags (after clearing all frames).
// Using bogem/id3v2.ParseReader handles all cases correctly.
func readWavID3Tags(
t *testing.T,
path string,
@@ -556,17 +470,17 @@ func readWavID3Tags(
defer func() { _ = f.Close() }()
chunks, err := riff.Parse(f)
chunks, err := parseRIFF(f)
if err != nil {
t.Fatalf("riff.Parse: %v", err)
t.Fatalf("parseRIFF: %v", err)
}
// Find the id3 chunk.
var id3Data []byte
for _, c := range chunks {
if riff.IsID3(c.ID) {
id3Data = c.Data
if isID3ChunkID(c.id) {
id3Data = c.data
break
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

+4 -59
View File
@@ -42,10 +42,10 @@ const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
* because the number this issue is about (a button 48px wider than the
* box holding it) is not in the accessibility tree at all.
*/
const headerFit = (page: import('@playwright/test').Page, view = 'playlist-view') =>
page.evaluate((tag) => {
const headerFit = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const root = document
.querySelector(`[data-testid="main-content"] ${tag}`)
.querySelector('[data-testid="main-content"] playlist-view')
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
if (!root) return null;
@@ -76,7 +76,7 @@ const headerFit = (page: import('@playwright/test').Page, view = 'playlist-view'
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
].map((i) => i.textContent?.trim() ?? ''),
};
}, view);
});
test.describe('the page header never clips an action', () => {
test.beforeEach(async ({ app }) => {
@@ -316,58 +316,3 @@ test.describe('the page header never clips an action', () => {
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
});
});
/**
* The Tracks header carries the play-all/shuffle-all pair (#31), so
* the promise above has to hold for it too the same per-button
* measurement, one view over. Its two actions are the whole of the
* header's declared set, and the pair is what plays the list the row
* is in, so a button rendered 20px of its 90px is a queue of nothing.
*/
const TRACK_ACTIONS = ['Play all', 'Shuffle all'];
test.describe('the Tracks header never clips an action', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
});
for (const vp of VIEWPORTS) {
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
await expect
.poll(async () => (await headerFit(app, 'track-list'))?.clipped)
.toEqual([]);
const fit = (await headerFit(app, 'track-list'))!;
expect(fit.overflow).toBeLessThanOrEqual(0);
// Between them, buttons and menu account for both actions —
// not "it fits" but "nothing was dropped to make it fit".
expect([...fit.buttons, ...fit.menu].sort()).toEqual(
[...TRACK_ACTIONS].sort(),
);
});
}
/**
* The pair's names, through the accessibility tree a shadow query
* measures, but it cannot say what a screen reader is offered.
*/
test('both actions are named controls', async ({ app }) => {
for (const label of TRACK_ACTIONS) {
await expect(
app.getByRole('button', { name: label, exact: true }),
).toBeVisible();
}
});
});
+127
View File
@@ -0,0 +1,127 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks the app's own listeners, on the app's own DOM, from the
* events a touch would produce and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
-140
View File
@@ -1,140 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* The web view's own tap highlight, and what replaced it (#54).
*
* Two halves, and each is here because no other tier can see it.
*
* **The highlight is killed by one declaration on `html`**, which
* reaches the app's shadow roots because `-webkit-tap-highlight-color`
* is inherited and inheritance crosses a shadow boundary. That is a
* property of `index.css`, and `index.css` is loaded by the real app
* and by nothing else the component tier mounts a component with no
* page stylesheet at all, which is the same reason the theme's ramps
* are invisible to it.
*
* **The press state is measured rather than read.** The component tier
* asserts the shape of the stylesheet (which rule is inside which
* query, and that the press selector carries a state class), because
* `:active` cannot be forced there. Here there is a real pointer: hold
* the button down on a real row of the real list and read what the row
* became. That is the assertion that would fail if the rule were
* hoisted, renamed, or lost to `.selected`.
*
* What neither half is, is the device. Chrome 113's WebView is where
* the grey box was reported and where a finger is; the numbers from it
* are on the PR.
*/
type Page = import('@playwright/test').Page;
/** The phone this work was measured against, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
/** The computed tap-highlight colour of a node inside a shadow root. */
const tapHighlight = (page: Page, host: string, inner: string) =>
page.evaluate(
([hostSel, innerSel]) => {
const el = document
.querySelector(hostSel!)
?.shadowRoot?.querySelector(innerSel!);
if (!el) return null;
return getComputedStyle(el).getPropertyValue(
'-webkit-tap-highlight-color',
);
},
[host, inner],
);
test.describe('the tap highlight', () => {
test('is transparent inside a shadow root, from one rule on html', async ({
app,
browserName,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
const row = await tapHighlight(app, 'track-list', '.track-row');
expect(row).not.toBeNull();
// The property is a WebKit extension that only iOS honours, so an
// engine is free not to report one at all. Chromium always does —
// measured at rgba(0, 0, 0, 0.18) with the rule removed, which is
// the grey box the report describes — so the assertion is not
// skippable there, and nothing this app can do makes the property
// disappear on an engine that has it.
test.skip(
row === '',
`${browserName} reports no -webkit-tap-highlight-color to read`,
);
expect(row).toBe('rgba(0, 0, 0, 0)');
});
});
test.describe('the press state that replaced it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await expect(app.locator('track-list').first()).toBeVisible();
});
test.afterEach(async ({ app }) => {
await app.mouse.up();
await app.setViewportSize({ width: 1440, height: 900 });
});
test('shows on the row being pressed, and on that row only', async ({
app,
}) => {
const rows = await app.evaluate(() => {
const found = document
.querySelector('track-list')
?.shadowRoot?.querySelectorAll('.track-row');
if (!found || found.length < 2) return null;
const rect = found[1]!.getBoundingClientRect();
return {
x: Math.round(rect.x + rect.width / 2),
y: Math.round(rect.y + rect.height / 2),
};
});
expect(rows).not.toBeNull();
const backgrounds = () =>
app.evaluate(() => {
const found = document
.querySelector('track-list')!
.shadowRoot!.querySelectorAll('.track-row');
return {
pressed: getComputedStyle(found[1]!).backgroundColor,
neighbour: getComputedStyle(found[2]!).backgroundColor,
};
});
await app.mouse.move(rows!.x, rows!.y);
await app.mouse.down();
const held = await backgrounds();
// The press overlay, from the theme rather than from a literal in
// a component: rgba(255, 255, 255, 0.12) on both dark ramps.
expect(held.pressed).toBe('rgba(255, 255, 255, 0.12)');
expect(held.neighbour).not.toBe(held.pressed);
await app.mouse.up();
});
});
-209
View File
@@ -1,209 +0,0 @@
import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The album page on a phone (#66).
*
* Two faults, and neither was visible to `layout-overflow.spec.ts`:
* that spec asserts the *shell* needs no sideways scrolling, and the
* shell was correct throughout `body.scrollWidth === clientWidth`
* while `explore-album-details` itself measured 443 inside a 424px box
* and clipped two of the album's three primary actions with its own
* `overflow: hidden`. So the measurement here is **per control against
* the component's box**, which is the same shape `top-bar-fit.spec.ts`
* needed for the same reason.
*
* The other half is the scroll: the page was a fixed header over a
* scrolling tracklist, so at the reference device's 424x439 the header
* owned 253 of the panel's 318px and the list scrolled in the 64 that
* were left. It is one scroll container below 600px, which is a
* property of the *host* rather than of `.content`.
*
* The engine is the caveat this tier cannot close: the reference device
* renders in Chrome 113 and this is Chromium/WebKit. A flex direction
* and a scroll container are nowhere near that engine's documented gaps
* (relaxed nesting, the Popover API, `light-dark()`), but "it renders
* at that size in Chromium" is not evidence about the phone.
*/
/** The phone this was measured on, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
const details = (page: Page) => page.locator('explore-album-details');
/** The page's own boxes, read from inside its shadow root. */
const geometry = (page: Page) =>
page.evaluate(() => {
const host = document.querySelector('explore-album-details');
const sr = host?.shadowRoot;
if (!host || !sr) return null;
const box = (sel: string) => {
const el = sr.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { width: Math.round(r.width), right: Math.round(r.right) };
};
const content = sr.querySelector('.content');
return {
hostWidth: host.clientWidth,
hostScrollWidth: host.scrollWidth,
// The host is the scroller below 600px, so the page is taller
// than its box rather than the tracklist being a window inside it.
hostScrolls: host.scrollHeight > host.clientHeight,
contentScrolls: content
? content.scrollHeight > content.clientHeight
: null,
header: box('.album-header'),
play: box('[data-testid="album-play"]'),
shuffle: box('[data-testid="album-shuffle"]'),
queue: box('[data-testid="album-queue"]'),
title: (() => {
const el = sr.querySelector('.album-title-text');
return el ? el.scrollWidth <= el.clientWidth + 1 : null;
})(),
};
});
test.describe('the album page on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await openFirstAlbum(app);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
});
test('keeps every action inside its own box', async ({ app }) => {
const geo = await geometry(app);
expect(geo).not.toBeNull();
// "Shuffle album" ended at x=443 in a 424px component and could not
// be reached by any gesture; "Add to queue" at 440.
for (const action of ['play', 'shuffle', 'queue'] as const) {
expect(
geo?.[action],
`${action} is rendered`,
).not.toBeNull();
expect(
geo?.[action]?.right ?? 0,
`${action} ends inside the page`,
).toBeLessThanOrEqual(geo?.hostWidth ?? 0);
}
expect(geo?.hostScrollWidth).toBe(geo?.hostWidth);
expect(geo?.header?.width).toBe(geo?.hostWidth);
});
test('gives the title the row rather than one glyph of it', async ({
app,
}) => {
// `.album-info` was squeezed to 112px beside the art, so an album
// called *Glass Harbour* drew as `G…`. It carries `min-width: 0`
// and was shrinking as asked — the row had to stack.
expect(await geometry(app).then((g) => g?.title)).toBe(true);
});
test('scrolls as one page, with the header scrolling away', async ({
app,
}) => {
const before = await geometry(app);
expect(before?.hostScrolls).toBe(true);
expect(before?.contentScrolls).toBe(false);
const headerTop = () =>
app.evaluate(
() =>
document
.querySelector('explore-album-details')
?.shadowRoot?.querySelector('.album-header')
?.getBoundingClientRect().top ?? 0,
);
expect(await headerTop()).toBeGreaterThanOrEqual(0);
// A wheel gesture, not `scrollTop`: `overflow: hidden` still permits
// programmatic scrolling, so a probe that assigns it passes on the
// build this exists to fail.
await details(app).hover();
await app.mouse.wheel(0, 250);
await expect.poll(headerTop).toBeLessThan(-100);
});
test('is the desktop arrangement again above the breakpoint', async ({
app,
}) => {
await app.setViewportSize({ width: 1024, height: 800 });
// The same element, re-laid-out: one component with two
// arrangements, not a phone-only copy.
await expect
.poll(async () => (await geometry(app))?.hostScrolls)
.toBe(false);
const arrangement = await app.evaluate(() => {
const sr = document.querySelector('explore-album-details')?.shadowRoot;
const header = sr?.querySelector('.album-header');
const content = sr?.querySelector('.content');
return {
direction: header ? getComputedStyle(header).flexDirection : null,
contentOverflow: content ? getComputedStyle(content).overflowY : null,
};
});
expect(arrangement.direction).toBe('row');
expect(arrangement.contentOverflow).toBe('auto');
});
});
/** Albums → the second card, which navigates to the album page. */
async function openFirstAlbum(app: Page): Promise<void> {
// Below 600px the sidebar is gone; the tab bar is the navigation.
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
await expect.poll(() => cardCount(app)).toBeGreaterThan(1);
// Dispatched rather than clicked: the card lives in a virtualizer
// inside a shadow root, and Enter expands the dropdown instead.
await app.evaluate(() => {
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card')[1]
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expect(
details(app).locator('[data-testid="album-play"]'),
).toBeVisible();
}
async function cardCount(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
);
}
-135
View File
@@ -1,135 +0,0 @@
import {
test,
expect,
callBinding,
openTheQueue,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* #67 a name is not a link on a phone, and the menu is where it went.
*
* The queue panel is the surface this is visible on: its rows draw a
* track title and an artist credit as `explore-link`s at every width,
* unlike `track-list`, whose phone column set stacks title over artist
* as plain text already.
*
* **The pair is what makes either assertion mean anything.** A link
* that is gone and a menu item that never arrived is not a smaller
* affordance it is a destination the phone cannot reach, which is
* what plan 018's "no action is unreachable at any supported size"
* refuses. So each test asserts the phone and the desktop in the same
* breath: text *and* an item here, a link *and* no item there.
*
* The desktop half is also the regression guard for the change: menus
* above the breakpoint must be exactly what they were, because the name
* beside them is still a link and a menu that repeats the row is
* furniture.
*/
/** The reference device's real viewport, not a resized desktop. */
const DEVICE = { width: 424, height: 439 };
/** Wide enough that the queue is a column beside the content. */
const DESKTOP = { width: 1280, height: 800 };
const row = (app: Page, index: number) =>
app.locator(`queue-panel .track-item[data-index="${index}"]`);
/** The queue panel's own context menu, as a list of item labels. */
async function menuLabels(app: Page): Promise<string[]> {
return app.evaluate(() =>
[
...document
.querySelector('queue-panel')!
.shadowRoot!.querySelectorAll('wa-dropdown-item'),
].map((item) => item.textContent?.replace(/\s+/g, ' ').trim() ?? ''),
);
}
/**
* Queue three tracks that have an album, for the reason
* `queue-selection.spec.ts` states at length: `explore-link` routes a
* title to its *album's* page and renders plain text where it cannot
* route, so a track with no album answers this file's question with
* the wrong "no link".
*/
async function queueThree(app: Page): Promise<void> {
const paths = await app.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string; Album: string; ArtistName: string }[];
return tracks
.filter((t) => t.Album !== '' && t.ArtistName !== '')
.slice(0, 3)
.map((t) => t.FilePath);
});
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
}
/** Open the row's context menu and read the items back. */
async function openRowMenu(app: Page, index: number): Promise<string[]> {
await row(app, index).click({ button: 'right' });
await expect
.poll(async () => (await menuLabels(app)).length)
.toBeGreaterThan(0);
return menuLabels(app);
}
test.describe('an inline name and the menu that replaces it', () => {
test.afterEach(async ({ app }) => {
await app.keyboard.press('Escape');
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* an empty queue is the state we were asking for */
});
await app.setViewportSize(DESKTOP);
});
test('a queue row is plain text on a phone and carries the destination', async ({
app,
}) => {
await app.setViewportSize(DEVICE);
await queueThree(app);
await openTheQueue(app);
await expect(row(app, 0)).toBeVisible();
// The name is text: nothing in the row is a link at all.
await expect(app.locator('queue-panel .track-item .explore-link')).toHaveCount(
0,
);
const labels = await openRowMenu(app, 0);
expect(labels).toContain('Go to Artist');
expect(labels).toContain('Go to Album');
});
test('the same row on a desktop is a link, and its menu is untouched', async ({
app,
}) => {
await app.setViewportSize(DESKTOP);
await queueThree(app);
await openTheQueue(app);
await expect(row(app, 0)).toBeVisible();
await expect(
row(app, 0).locator('.track-title .explore-link'),
).toHaveCount(1);
const labels = await openRowMenu(app, 0);
expect(labels).not.toContain('Go to Artist');
expect(labels).not.toContain('Go to Album');
});
});
-283
View File
@@ -1,283 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* Now Playing on a short screen (#51).
*
* #51 asks for a layout that "survives" ~424x439 with the controls
* never scrolling off. #172 measured why it did not the stacked
* layout's budget is fixed, so the art gets whatever is left, and that
* was 39px before #64 and 53px after it.
*
* **Two separate claims are asserted here, and only one of them is
* about the phone.**
*
* The first is that the art is *square*. It was not: `aspect-ratio` is
* specified not to re-derive the width when `max-height` clamps the
* height, so the art was drawn as a letterbox band and `object-fit:
* cover` cropped the cover to it — 264x53 on the reference device. The
* leftover only exceeds the width above ~843px of viewport, so this
* was every height from ~500 to ~843 as well: most phones, and any
* short window. That is ordinary CSS rather than a Chrome 113 quirk,
* so this tier can see it, and the heights below are chosen to cover
* the range rather than the one device.
*
* The second is the reflow: below 500px the art and the names sit side
* by side, which is what takes the art from 53px to 143px. That is
* asserted as a *relation between boxes* the art beside the names,
* not above them because the pixel count is a consequence of the
* arrangement and would pin this file to one device's chrome.
*
* **What this tier cannot see** is the device's engine: CI's Chromium
* and WebKit are current, and #60's clipping showed what that costs.
* Nothing here depends on Chrome 113 behaviour the sizing rules were
* checked against the device itself, at column heights of 288, 300,
* 451, 600 and 800, and the numbers are on #51.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
/**
* A tall phone, above the reflow's 500px. Roughly a Pixel 7, which is
* #51's other named device and was not attached so what is checked
* here is the layout it *should* get, not that device.
*/
const TALL_PHONE = { width: 412, height: 869 };
/** Inside the crop's old range and above the reflow: a short window. */
const SHORT_WINDOW = { width: 390, height: 700 };
/**
* The height the layout reflows at. Written down once here because the
* specs have to know which arrangement to *wait* for, not only which
* to assert.
*/
const REFLOW_AT = 500;
/** Put a track in the player, so the view has art and names to lay out. */
async function stageATrack(page: Page): Promise<void> {
await page.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string }[];
await window.__yjEvents.call(
'queue.Queue.SetQueue',
[tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }],
10_000,
);
});
}
/** Open the full-screen view and wait for the shell to say so. */
async function openNowPlaying(page: Page): Promise<void> {
await page.evaluate(() => {
document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
}),
);
});
await expect(page.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'now-playing',
);
// The attribute is the shell's bookkeeping and lands before the view
// has a track, so measuring on it alone races the first layout --
// which showed up as a 60x5 art on the first spec of a cold run.
//
// Waiting for a non-zero box is not enough on its own either: a
// previous test leaves the *other* arrangement on screen, and a
// stale column satisfies "has a size" perfectly. So the wait is for
// the arrangement this viewport should have, which is the thing
// every assertion below depends on. Found by this file passing one
// test at a time and failing in file order.
const wantRow = (page.viewportSize()?.height ?? 0) <= REFLOW_AT;
await page.waitForFunction(
(row: boolean) => {
const v = document.querySelector('now-playing-view');
const stack = v?.shadowRoot?.querySelector('.stack');
const el = v?.shadowRoot?.querySelector('.art img, .art .placeholder');
const t = v?.shadowRoot?.querySelector('.transport');
if (!el || !t) return false;
// A build with no `.stack` at all is the one before this change,
// and the squareness assertions are still meaningful against it
// -- so this waits for the arrangement only where there is one to
// wait for. Otherwise reverting the component to check that these
// tests bite produces eight timeouts instead of the measurements
// that make the case.
if (stack) {
const dir = getComputedStyle(stack).flexDirection;
if (dir !== (row ? 'row' : 'column')) return false;
}
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && t.getBoundingClientRect().height > 0;
},
wantRow,
);
}
/**
* The boxes this file reasons about, read in one evaluate.
*
* It reaches into the view's shadow root rather than using locators
* because the question is geometric where these boxes are *relative
* to each other* and a testid per edge would be four locators and
* four round trips to say one thing.
*/
async function boxes(page: Page) {
return page.evaluate(() => {
const v = document.querySelector('now-playing-view');
if (!v || !v.shadowRoot) return null;
const rect = (sel: string) => {
const el = v.shadowRoot!.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: r.left, right: r.right, top: r.top, bottom: r.bottom,
width: r.width, height: r.height,
};
};
return {
// Whichever of the two the track has; both carry the sizing.
art: rect('.art img') ?? rect('.art .placeholder'),
artBox: rect('.art'),
stack: rect('.stack'),
meta: rect('.meta'),
transport: rect('.transport'),
scrollHeight: v.scrollHeight,
clientHeight: v.clientHeight,
};
});
}
test.describe('Now Playing survives a short screen', () => {
test.beforeEach(async ({ app }) => {
await stageATrack(app);
});
/**
* The crop, at four heights spanning the range it covered. This is
* the assertion that fails on the build before this change: at
* 424x439 the art measured 264x53.
*/
for (const vp of [DEVICE, SHORT_WINDOW, TALL_PHONE, { width: 900, height: 500 }]) {
test(`draws the art square at ${vp.width}x${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
await openNowPlaying(app);
const b = await boxes(app);
expect(b, 'now-playing-view did not mount').not.toBeNull();
expect(b!.art, 'neither art nor placeholder rendered').not.toBeNull();
const { width, height } = b!.art!;
expect(width, 'the art has no width').toBeGreaterThan(0);
// One pixel of slack for sub-pixel layout, and no more: the
// defect this guards was a 5:1 band.
expect(
Math.abs(width - height),
`art is ${Math.round(width)}x${Math.round(height)}, not square`,
).toBeLessThanOrEqual(1);
});
}
/**
* The promise #51 states and plan 018's matrix repeats. A floor on
* the art with the block scrolling was the other option on #172 and
* this is why it was not taken.
*/
test('never scrolls the transport off the bottom', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.transport!.bottom).toBeLessThanOrEqual(DEVICE.height);
expect(
b!.scrollHeight,
'the view scrolls, so the transport can be moved off screen',
).toBeLessThanOrEqual(b!.clientHeight + 1);
});
/**
* The reflow itself, as a relation rather than a measurement: below
* 500px the names are *beside* the art, above it they are below.
*/
test('puts the names beside the art below 500px', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.left,
'the names are not to the right of the art',
).toBeGreaterThanOrEqual(b!.artBox!.right - 1);
});
test('keeps the names below the art on a tall phone', async ({ app }) => {
await app.setViewportSize(TALL_PHONE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.top,
'the names are not below the art',
).toBeGreaterThanOrEqual(b!.artBox!.bottom - 1);
});
/**
* What the reflow actually does, stated as a mechanism rather than
* as a number: in a row the art is bounded by the row's *height*,
* so it fills it where in a column it is the leftover after the
* names, which is what made it 53px.
*
* **The pixel count is deliberately not asserted here.** Two drafts
* tried. The first compared the art against the column's leftover
* computed from the boxes on screen and passed on the broken build,
* because the subtraction goes negative when the names are taller
* than the art precisely the defect. The second put a floor of
* 100px on it, passed locally at 114 and **failed in CI at 64**: this
* app is long-lived, so a job staged by an earlier spec is still on
* screen, and the volume control renders here where it does not on
* Android. Both are chrome above and below this view, and both move
* the leftover. A test that asserts how much room CI happened to
* have is a test about the runner.
*
* The device numbers 53px to 143px are on #51, measured there,
* which is the only tier that can honestly produce them.
*/
test('fills the row with the art rather than the leftover', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.stack, 'there is no row to fill').not.toBeNull();
expect(
Math.abs(b!.artBox!.height - b!.stack!.height),
'the art does not fill the row, so it is still a leftover',
).toBeLessThanOrEqual(1);
});
});
-14
View File
@@ -79,20 +79,6 @@ test.describe('the progress line sits on the border between the bars', () => {
await play(app);
});
/*
* Every test here starts a LONG_TRACK and the suite is workers: 1,
* fullyParallel: false against one long-lived app so without this
* the four phone-* specs that follow alphabetically inherit a playing
* queue. phone-transport.spec.ts records where that lesson came from:
* the fault first showed up as a flake in a spec about something else.
*/
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* already empty */
});
await app.setViewportSize(DESKTOP);
});
test('spans the width, between the mini player and the tab bar', async ({
app,
}) => {
-52
View File
@@ -98,58 +98,6 @@ test.describe('the shell on a phone', () => {
).toBeVisible();
});
test('draws "More" as a sheet on the bottom edge (#71)', async ({ app }) => {
await app.getByTestId('tab-more').click();
await expect(app.getByTestId('nav-drawer').locator('app-sidebar'))
.toBeVisible();
// What the report is about is geometry, and geometry is what no
// other assertion here can see: the side drawer was a 200px column
// opening away from the thumb that asked for it, with the rest of
// its 400px band empty. Measured rather than screenshotted, since
// the failure is a number.
//
// Polled, because a sheet *arrives*: the drawer's show animation
// translates it a full height below the fold, so a measurement
// taken the moment its content is visible reports a box hanging
// 412px off the bottom of the screen. Asking for the settled
// number is the assertion; asking once is a race.
const measure = () => app.evaluate(() => {
const nav = document.querySelector('bottom-nav');
const drawer = nav?.shadowRoot?.querySelector('wa-drawer');
const dialog = drawer?.shadowRoot?.querySelector('[part~="dialog"]');
const sidebar = nav?.shadowRoot?.querySelector('app-sidebar');
const row = sidebar?.shadowRoot?.querySelector('li button');
const box = dialog?.getBoundingClientRect();
return {
left: Math.round(box?.left ?? -1),
right: Math.round(box?.right ?? -1),
bottom: Math.round(box?.bottom ?? -1),
height: Math.round(box?.height ?? -1),
row: Math.round(row?.getBoundingClientRect().height ?? -1),
viewport: [window.innerWidth, window.innerHeight],
};
});
await expect
.poll(async () => (await measure()).bottom)
.toBe(PHONE.height);
const sheet = await measure();
expect(sheet.left).toBe(0);
expect(sheet.right).toBe(sheet.viewport[0]);
// A surface covering the whole screen is a page, not a sheet --
// which is also what leaves an outside to tap on, the only pointer
// route out of it (#171 is the same question one surface over).
expect(sheet.height).toBeLessThan(sheet.viewport[1]);
// 48px rows, from #186's touch floor and #60's context sheet.
expect(sheet.row).toBeGreaterThanOrEqual(48);
});
for (const vp of [PHONE, SMALL_PHONE]) {
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
-220
View File
@@ -1,220 +0,0 @@
import { test, expect, callBinding, resetEvents, waitForEvent } from '../support/fixtures.js';
type Page = import('@playwright/test').Page;
/**
* Play-all/Shuffle-all, asserted on what the backend queued rather than
* on playback pixels.
*
* `SetQueue` reports the queue through `QueueChanged`, and `GetState`
* says exactly what it holds: the tracks in order, whether shuffle is
* on, and the `Source` the "Playing from" link is built from. That is
* the honest contract here the buttons are only as good as the queue
* they build, and the queue is only as good as the source it names.
*/
interface QueueState {
tracks: { filePath: string; title: string }[];
currentIndex: number;
shuffleMode: boolean;
source: { type: string; id: number; label: string };
}
const TRACKS_SOURCE = { type: 'tracks', id: 0, label: 'All Tracks' };
const getQueue = (app: Page) =>
callBinding<QueueState>(app, 'queue.Queue.GetState');
/** The track paths a rendered track list shows, in row order. */
function displayedPaths(app: Page, scope: string): Promise<string[]> {
return app
.locator(`${scope} [data-testid="track-row"]`)
.evaluateAll((els) =>
els.map((el) => el.getAttribute('data-file-path') ?? ''),
);
}
/** Leave shuffle in a known state. The mode persists across specs in
* one backend process, so a test that asserts on it has to set it. */
async function setShuffleMode(app: Page, on: boolean): Promise<void> {
const state = await getQueue(app);
if (state.shuffleMode !== on) {
await resetEvents(app);
await callBinding(app, 'queue.Queue.ToggleShuffle');
await waitForEvent(app, 'QueueModeChanged');
}
}
test.describe('play-all/shuffle-all on the track list', () => {
test.beforeEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* the queue is clearable on every build these specs run against */
});
await setShuffleMode(app, false);
});
test('Tracks Play all queues the displayed list with an honest source', async ({
app,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await expect(
app.locator('track-list [data-testid="track-row"]').first(),
).toBeVisible();
const paths = await displayedPaths(app, 'track-list');
await resetEvents(app);
await app.getByTestId('page-action-play-all').click();
await waitForEvent(app, 'QueueChanged');
const state = await getQueue(app);
expect(state.tracks.map((t) => t.filePath)).toEqual(paths);
expect(state.currentIndex).toBe(0);
expect(state.shuffleMode).toBe(false);
expect(state.source).toEqual(TRACKS_SOURCE);
});
test('Tracks Shuffle all turns shuffle on and keeps the source', async ({
app,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(
app.locator('track-list [data-testid="track-row"]').first(),
).toBeVisible();
const paths = await displayedPaths(app, 'track-list');
await resetEvents(app);
await app.getByTestId('page-action-shuffle-all').click();
await waitForEvent(app, 'QueueChanged');
const state = await getQueue(app);
expect(state.tracks.map((t) => t.filePath)).toEqual(paths);
expect(state.shuffleMode).toBe(true);
expect(state.source).toEqual(TRACKS_SOURCE);
});
});
test.describe('play-all on an embedded track list', () => {
test.beforeEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {});
await setShuffleMode(app, false);
});
test('a genre page queues the genre with its name as the source', async ({
app,
}) => {
await app.getByTestId('nav-genres').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'genres',
);
const first = app.locator('genres-view .genre-card').first();
await expect(first).toBeVisible();
await first.click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'genre-details',
);
await expect(
app.locator('genre-details [data-testid="track-row"]').first(),
).toBeVisible();
const genreName = (await app
.locator('genre-details .genre-title')
.textContent())?.trim();
const paths = await displayedPaths(app, 'genre-details');
await resetEvents(app);
await app
.locator('genre-details [data-testid="page-action-play-all"]')
.click();
await waitForEvent(app, 'QueueChanged');
const state = await getQueue(app);
expect(state.tracks.map((t) => t.filePath)).toEqual(paths);
expect(state.currentIndex).toBe(0);
expect(state.source).toEqual({ type: 'genre', id: 0, label: genreName });
});
});
test.describe('play-all on the library artist page', () => {
test.beforeEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {});
await setShuffleMode(app, false);
});
test('an artist page queues album paths in album order with the artist source', async ({
app,
}) => {
const artists = await callBinding<{ ID: number; Name: string }[]>(
app,
'library.Library.GetArtists',
[0],
);
const first = artists[0]!;
await app.evaluate(
([id, name]) => {
document.dispatchEvent(
new CustomEvent('navigate', {
detail: {
view: 'artist-details',
artistId: id,
artistName: name,
},
bubbles: true,
composed: true,
}),
);
},
[first.ID, first.Name] as const,
);
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'artist-details',
);
await expect(app.getByTestId('artist-play-all')).toBeEnabled();
const albums = await callBinding<{ ID: number }[]>(
app,
'library.Library.GetAlbumsByArtist',
[first.Name, 0],
);
const byAlbum = await callBinding<Record<string, string[]>>(
app,
'library.Library.GetFilePathsByAlbums',
[albums.map((a) => a.ID), 0],
);
const expected: string[] = [];
for (const album of albums) {
expected.push(...(byAlbum[String(album.ID)] ?? []));
}
await resetEvents(app);
await app.getByTestId('artist-play-all').click();
await waitForEvent(app, 'QueueChanged');
const state = await getQueue(app);
expect(state.tracks.map((t) => t.filePath)).toEqual(expected);
expect(state.source).toEqual({
type: 'artist',
id: first.ID,
label: first.Name,
});
});
});
+1 -1
View File
@@ -137,7 +137,7 @@ test.describe('queue', () => {
});
test('shuffle and repeat toggles report their state', async ({ app }) => {
const shuffle = app.getByRole('button', { name: 'Shuffle', exact: true });
const shuffle = app.getByRole('button', { name: 'Shuffle' });
await resetEvents(app);
await shuffle.click();
+2 -29
View File
@@ -179,8 +179,8 @@ test.describe('the queue is a screen where it covers the content', () => {
*/
/**
* With the panel spanning the whole width there is no scrim here at
* all (#171), so the close button is the only pointer route out of a
* With the panel spanning the whole width the scrim has no uncovered
* pixels, so the close button is the only pointer route out of a
* full-screen surface. Measured at 424×439 before #55: **25×21px**.
*/
test('offers a way out a thumb can hit', async ({ app }) => {
@@ -194,33 +194,6 @@ test.describe('the queue is a screen where it covers the content', () => {
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
});
/**
* #171 and it draws no scrim, because there is nowhere to tap.
*
* `.panel-content` is `width: 100%` here, so the scrim sat entirely
* underneath it: measured at 424×439, host, panel and scrim all
* 424×318. #24's tap-outside-to-close cannot exist on a surface with
* no outside, and a `cursor: pointer` layer nobody can reach is a
* claim the component cannot keep.
*
* Asserted as absence rather than by clicking, for the reason the
* issue gives: a naive phone case clicks the scrim's centre and hits
* the panel, so it passes on the build this exists to fail. The scrim
* is still real between 600 and 899px, which `queue-overlay.spec.ts`
* asserts at 900×600 by clicking it.
*/
test('draws no scrim, because a screen has no outside to tap', async ({
app,
}) => {
await openTheQueue(app);
const scrim = await queue(app).evaluate(
(el) => el.shadowRoot!.querySelector('.scrim') !== null,
);
expect(scrim).toBe(false);
});
});
/**
-56
View File
@@ -157,62 +157,6 @@ test.describe('an overlaid queue says it is over the content', () => {
});
});
/**
* #170 the other two buttons in that same row.
*
* Clear queue and Add queue to playlist predate the close button and
* were named by a `title` attribute and nothing else. Unlike the
* sliders in `control-names.spec.ts`, that is not a *missing* name:
* `title` is the last fallback in the accname order, so
* `getByRole('button', { name: 'Clear queue' })` matched them before
* this fix as well as after it measured, 1 and 1. A sweep for empty
* names cannot see a weak one, which is `a11y.26`'s complaint and the
* reason this file could have grown a green test that proved nothing.
*
* So the name is asserted twice, and the second assertion is the one
* that fails on the broken build. Taking the tooltip away and asking
* again is the property in words: **the name is not the tooltip**. It
* is what makes the button survive content being put inside it later,
* and it is the only one of the two a phone has there is no hover on
* the surface #55 turned into a full screen. Measured on `main` before
* the fix: 0 and 0.
*
* Both buttons are disabled here, because the queue starts empty and
* naming is not enablement. A disabled button is still in the
* accessibility tree, which is exactly where the complaint was.
*/
test.describe('the queue header says what its actions do', () => {
const ACTIONS = ['Clear queue', 'Add queue to playlist'];
test('names both of the older actions', async ({ app }) => {
await openQueue(app);
for (const name of ACTIONS) {
await expect(
app.getByRole('button', { name, exact: true }),
).toHaveCount(1);
}
});
test('and the names do not come from the tooltip', async ({ app }) => {
await openQueue(app);
await app.locator('#queue-panel').evaluate((el) => {
for (const button of el.shadowRoot!.querySelectorAll(
'.header-action-button',
)) {
button.removeAttribute('title');
}
});
for (const name of ACTIONS) {
await expect(
app.getByRole('button', { name, exact: true }),
).toHaveCount(1);
}
});
});
/**
* The inline panel is the mode that already worked, and the one every
* other queue spec is written against. It keeps its resize handle and
+6 -11
View File
@@ -103,17 +103,12 @@ async function queueSixAndOpen(app: Page): Promise<void> {
*
* `explore-link` routes a track name to its *album's* page, so a
* track with no album renders a name that navigates nowhere and
* the fixture library deliberately contains two,
* `unsorted/no-tags-at-all.mp3` and `unsorted/title-only.mp3`.
* (It contained four until #104: the two WAVs under `Field
* Recordings/Test Tones` had been tagged on disk all along and
* scan in with their album now, so they are ordinary tracks and
* not examples of this.) Which tracks arrive first is
* `audio_files.id` order, i.e. the order the **scan** inserted
* them, which depends on concurrency and directory traversal:
* locally the first eight all had albums and the spec passed twice
* over, and CI rebuilds its seed with a real scan and got a
* different eight.
* the fixture library deliberately contains two (`01 Tone A`,
* `02 Tone B`). Which tracks arrive first is `audio_files.id`
* order, i.e. the order the **scan** inserted them, which depends
* on concurrency and directory traversal: locally the first eight
* all had albums and the spec passed twice over, and CI rebuilds
* its seed with a real scan and got a different eight.
*
* Asking for what the test needs is the fix. It is not a
* narrowing: every assertion here wants an ordinary track, and
-311
View File
@@ -1,311 +0,0 @@
import { test, expect, callBinding } from '../support/fixtures.js';
/**
* The touch gestures against the real app (plan 019, #63; long-press
* from plan 016 B2).
*
* The component tier proves the gestures in isolation, against markup
* it built itself. What it cannot prove is the half that made this one
* document listener instead of six: that the announced gesture reaches
* the handler a *real* component bound `track-list` delegates on the
* `lit-virtualizer` rather than binding per row and that the real
* menu opens from it, a path with its own history of opening and then
* refusing to work (see `menu-keyboard.spec.ts`).
*
* **Both halves of the reassignment are here, and the second is the
* one that matters.** #63 makes a hold on a *track row* mean selection
* mode; every other surface in the app keeps the context menu it has
* had, because an unclaimed `yj-long-press` still becomes a
* `contextmenu`. A spec that only checked the row would pass on a
* build that had silently broken the other thirteen menus.
*
* The pointer events are dispatched rather than performed: this
* project runs Desktop Chrome and Desktop Safari, neither of which has
* touch. So this is honest about what it checks the app's own
* listeners, on the app's own DOM, from the events a touch would
* produce and not about a real finger. The finger is the Android
* tier, and it found something this cannot see: Chrome 113's WebView
* fires its own `contextmenu` on a long press, which is why the module
* announces the gesture from a native event rather than standing down.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** A component's menu panel, or null while it is not rendered. */
const panel = (page: Page, host: string) =>
page.evaluate((tag) => {
const el = document
.querySelector(tag)
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
}, host);
/** How many tracks the selection bar says are selected, or null. */
const selectionCount = (page: Page) =>
page.evaluate(() => {
const bar = document
.querySelector('track-list')
?.shadowRoot?.querySelector('selection-bar');
return bar ? (bar as unknown as { count: number }).count : null;
});
/**
* Press an element, optionally dragging partway through the shape of
* a scroll that begins on a row, which must be neither gesture and
* optionally lifting, which is what makes it a tap rather than a hold.
*/
async function press(
page: Page,
selector: { host: string; inner: string },
opts: { driftY?: number; lift?: boolean } = {},
): Promise<void> {
await page.evaluate(
({ host, inner, drift, lift }) => {
const el = document
.querySelector(host)
?.shadowRoot?.querySelector(inner);
if (!el) throw new Error(`no ${inner} in ${host} to press`);
const box = el.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
if (lift) send('pointerup');
},
{
host: selector.host,
inner: selector.inner,
drift: opts.driftY ?? 0,
lift: opts.lift ?? false,
},
);
}
// `.track-row`, not `[role="row"]`: the column header is a row too, and
// it is the *first* one — a press on it is correctly ignored, which
// reads exactly like the gesture not working.
const TRACK_ROW = { host: 'track-list', inner: '.track-row' };
test.describe('a hold on a track row selects it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('raises the selection bar rather than the context menu', async ({
app,
}) => {
await expect.poll(() => selectionCount(app)).toBeNull();
await press(app, TRACK_ROW);
await expect
.poll(() => selectionCount(app), { timeout: HELD + 2000 })
.toBe(1);
// The gesture is claimed, so the menu this hold used to open must
// not also be up -- on a phone that would be a sheet over the bar.
expect(await panel(app, 'track-list')).toBeNull();
});
test('is neither gesture when the press turns into a scroll', async ({
app,
}) => {
await press(app, TRACK_ROW, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await selectionCount(app)).toBeNull();
expect(await panel(app, 'track-list')).toBeNull();
});
});
test.describe('a hold anywhere else still opens the menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
// The property that let #63 reassign the hold without touching one
// of the fourteen context menus: unclaimed, it is what it was.
// Without this half, breaking all of them passes the suite.
await expect.poll(() => panel(app, 'cover-grid')).toBeNull();
await press(app, { host: 'cover-grid', inner: '[role="option"]' });
await expect
.poll(() => panel(app, 'cover-grid'), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
});
});
/**
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
*
* The component tier has the rule this obeys one row is a position,
* several are a choice against a queue that is a fake. What is only
* true here is that the gesture reaches the *real* queue: `AddTracks`
* is a Go method, the queue is persisted, and "the row was added"
* is a question only the backend can answer.
*
* **It is Chromium-only, and that is a property of the browser rather
* than a gap.** The gesture runs on touch events, because Chrome 113's
* WebView cancels the pointer stream ~16px into any drag whatever
* `touch-action` says. Desktop WebKit implements no `TouchEvent`
* constructor at all touch events are a mobile-Safari surface so
* the events this needs cannot be built there. Skipping loudly is
* better than a spec that quietly asserts nothing on half the matrix,
* which is what `layout-overflow.spec.ts` and `back-navigation.spec.ts`
* were each doing when they were green on a broken build.
*/
test.describe('a swipe right on a track row queues it', () => {
test.beforeEach(async ({ app, browserName }) => {
test.skip(
browserName !== 'chromium',
'desktop WebKit has no TouchEvent constructor to build the gesture from',
);
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* Drag the first row sideways by a fraction of its own width and
* lift. `fraction` is against the row, because the commit threshold
* is a number of pixels here would be a second declaration of it,
* right on one viewport and wrong on the next.
*/
const swipeFirstRow = (page: Page, fraction: number) =>
page.evaluate((f) => {
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to swipe');
const box = row.getBoundingClientRect();
const y = box.top + box.height / 2;
const at = (x: number) =>
new Touch({
identifier: 1,
target: row,
clientX: box.left + x,
clientY: y,
});
const send = (type: string, points: Touch[]) =>
row.dispatchEvent(
new TouchEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
touches: points,
changedTouches: points.length > 0 ? points : [at(0)],
}),
);
send('touchstart', [at(0)]);
for (const step of [0.25, 0.5, 0.75, 1]) {
send('touchmove', [at(box.width * f * step)]);
}
send('touchend', []);
}, fraction);
/** How many tracks the backend says are in the queue. */
const queueLength = async (page: Page) => {
const state = await callBinding<{ tracks: unknown[] }>(
page,
'queue.Queue.GetState',
);
return state.tracks?.length ?? 0;
};
test('adds exactly one track to the real queue', async ({ app }) => {
const before = await queueLength(app);
await swipeFirstRow(app, 0.6);
await expect.poll(() => queueLength(app)).toBe(before + 1);
// Queued, not played: a swipe is not a tap, and the difference is
// what is on screen afterwards.
expect(
await app.getByTestId('main-content').getAttribute('data-active-view'),
).toBe('tracks');
});
test('does nothing when the finger did not get far enough', async ({
app,
}) => {
const before = await queueLength(app);
await swipeFirstRow(app, 0.1);
await app.waitForTimeout(400);
expect(await queueLength(app)).toBe(before);
});
});
-20
View File
@@ -7,26 +7,6 @@
html {
height: 100%;
/* #54. The web view's own tap highlight -- the grey box a phone
draws over the bounding rect of whatever was tapped -- gone in
one declaration, because `-webkit-tap-highlight-color` is an
*inherited* property and an inherited property crosses a shadow
boundary. So this reaches every one of the app's shadow roots
without a rule in any of them; before it, exactly one component
(`library-status-indicator`) set it and the box appeared
everywhere else.
What it costs is the only touch feedback several surfaces had,
which is why the rows, the tab bar and the shared menu items
grew a `:active` state in the same change: removing the wrong
feedback and leaving none is not an improvement. The cards
already had one (`transform: scale(0.97)`).
`user-select` is the same argument one rule up and was already
done: the `*` rule at the top of this file is inherited into the
shadow roots too. */
-webkit-tap-highlight-color: transparent;
}
body {
+5 -8
View File
@@ -70,7 +70,7 @@ import '@store/theme-store';
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installTouchGestures } from '@utils/touch-gestures';
import { installLongPressContextMenu } from '@utils/long-press';
import { openQueue, queuePanelElement } from '@utils/open-queue';
import { installTopBarFit } from './src/services/top-bar-fit';
import {
@@ -87,13 +87,10 @@ setBasePath('/dist/webawesome');
// the session.
registerBundledIcons();
// Every touch gesture in the app, installed once rather than per
// component (plan 019). Harmless on a desktop: it acts on
// `pointerType === 'touch'` only, per event, so a mouse on a
// touchscreen keeps click-selects / double-click-plays on the very
// same row. An unclaimed long press still becomes a `contextmenu`,
// which is what leaves all fourteen menus untouched by #63.
installTouchGestures();
// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();
// The top bar decides what it can afford to show (#143). Here rather
// than in a component because the bar is light DOM in index.html and
+79 -5
View File
@@ -23,23 +23,97 @@
* as the literal contains an unterminated `/*`. Nothing else produces
* that, and a legitimate literal cannot contain one.
*/
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
const TAGS = ['css', 'html', 'svg'];
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/** Strip `${...}` substitutions, which may legitimately contain anything. */
function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
}
return out;
}
function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
const problems = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
for (const { tag, body, line } of taggedLiterals(src, TAGS)) {
let match;
while ((match = tagPattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
const body = stripSubstitutions(src.slice(open + 1, close));
const opens = (body.match(/\/\*/g) ?? []).length;
const closes = (body.match(/\*\//g) ?? []).length;
if (opens > closes) problems.push({ file, line, tag });
if (opens > closes) {
problems.push({
file,
line: lineOf(src, open),
tag: match[2],
});
}
}
}
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env node
/**
* Fail on a nested rule whose selector starts with an element name.
*
* See `css-nesting.mjs` for what the phone does with one. No tier here
* can see it: the component tier, the e2e tier and `make ui-visual` all
* run a current Chromium, where the rule applies normally, so the only
* report is a screenshot of the device which is how the bottom bar's
* title came to have never truncated there.
*
* It covers `index.css` and the `css` literals in the components alike,
* because a shadow-root stylesheet is parsed by the same engine.
*/
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
import { findBareNestedRules } from './css-nesting.mjs';
const problems = [];
// Every stylesheet, not `index.css` by name: the hook that runs this
// fires on `frontend/**/*.{ts,css}`, so naming one file promises a
// coverage the sweep does not deliver -- a second stylesheet would be
// silently unswept while the hook still went green over it. There is
// only `index.css` today, which is exactly when this is free to fix.
const stylesheets = globSync('*.css', { cwd: process.cwd() });
if (stylesheets.length === 0) {
console.error('css-nesting-check: no stylesheet matched *.css');
process.exit(1);
}
for (const file of stylesheets) {
for (const { line, selector } of findBareNestedRules(
readFileSync(file, 'utf8'),
)) {
problems.push({ file, line, selector });
}
}
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
// A sweep over an empty glob passes, and this one is expected to find
// nothing, so "it found nothing" has to mean it looked.
if (sources.length === 0) {
console.error('css-nesting-check: no sources matched src/**/*.ts');
process.exit(1);
}
for (const file of sources) {
const src = readFileSync(file, 'utf8');
for (const literal of taggedLiterals(src, ['css'])) {
for (const { line, selector } of findBareNestedRules(literal.body)) {
problems.push({ file, line: literal.line + line - 1, selector });
}
}
}
if (problems.length > 0) {
for (const p of problems) {
console.error(
`${p.file}:${p.line}: nested rule "${p.selector.split('\n')[0]}" starts ` +
'with an element name — write it as "& ' +
`${p.selector.split('\n')[0]}"`,
);
}
console.error(
`\ncss-nesting-check: ${problems.length} problem(s). ` +
'Chrome 113 (the device) drops a nested rule that does not start ' +
'with a symbol; the leading & is valid in both syntaxes.',
);
process.exit(1);
}
console.log(
`css-nesting-check: ${stylesheets.length} stylesheet(s) + ${sources.length} files, no bare nested rules`,
);
-103
View File
@@ -1,103 +0,0 @@
/**
* Finding the `css` tagged templates in a TypeScript source.
*
* Two checks read them the unterminated-comment one and the nesting
* one and a second scanner would be a second thing to keep in step
* with how a template literal actually ends.
*/
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
export function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/**
* Strip `${...}` substitutions, which may legitimately contain anything.
*
* Newlines inside them are kept, so a line number taken from the
* stripped text still names the right line of the file it came from.
*/
export function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
else if (text[i] === '\n') out += '\n';
}
return out;
}
/** The 1-based line number of `index` in `src`. */
export function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
/**
* Every tagged template literal in `src` whose tag is in `tags`.
*
* `body` has its substitutions stripped and `line` is the line its
* opening backtick sits on, so `line + (n - 1)` is the file line of the
* body's own line `n`.
*/
export function taggedLiterals(src, tags) {
const pattern = new RegExp(`(^|[^\\w$.])(${tags.join('|')})\``, 'g');
const found = [];
let match;
while ((match = pattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
found.push({
tag: match[2],
body: stripSubstitutions(src.slice(open + 1, close)),
line: lineOf(src, open),
});
}
return found;
}
-119
View File
@@ -1,119 +0,0 @@
/**
* A nested rule whose selector starts with an element name is silently
* dropped on the phone.
*
* The device renders in Chrome 113, which predates relaxed CSS nesting
* (Chrome 120): before that a nested selector had to start with
* something that could not be read as the beginning of a declaration,
* so `.bottom-bar { audio-player { … } }` is not a parse error anyone
* would notice the inner rule simply does not exist, on the phone and
* only on the phone. Three were live in `index.css`, one of them the
* `text-overflow: ellipsis` on the bottom bar's title, which had
* therefore never truncated on the device.
*
* `& audio-player` is valid in both syntaxes, so no nested rule here
* has any reason to omit it.
*
* Two things the detection has to get right:
*
* - **A rule directly inside an at-rule is not nested.**
* `@media (…) { bottom-nav { … } }` at the top level is an ordinary
* rule and is fine and it is the majority of the matches a regex
* over the file would produce. What decides it is whether a *style*
* rule is somewhere above, not what the immediate parent is: inside
* `.bar { @media (…) { audio-player { … } } }` the inner rule is
* nested, at-rule in between or not.
* - **A declaration is not a rule.** `background: url(…)` and any
* string or comment can hold a brace, so this tracks them rather than
* matching lines.
*/
/** Does this selector start with an identifier, rather than a symbol? */
function startsWithIdent(selector) {
return /^[A-Za-z_\u00A0-\uFFFF]/.test(selector);
}
/**
* Every nested style rule in `css` whose selector starts with an
* element name, as `{ line, selector }` with a 1-based line.
*/
export function findBareNestedRules(css) {
const found = [];
/** The blocks we are inside, innermost last: 'style' or 'at'. */
const stack = [];
/** The text since the last `{`, `}` or `;` — a prelude, if a `{` follows. */
let prelude = '';
let preludeLine = 1;
let line = 1;
const startPrelude = () => {
prelude = '';
preludeLine = line;
};
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (c === '\n') {
line++;
if (prelude.trim() === '') preludeLine = line;
prelude += c;
continue;
}
if (c === '/' && css[i + 1] === '*') {
const end = css.indexOf('*/', i + 2);
const comment = css.slice(i, end === -1 ? css.length : end + 2);
line += (comment.match(/\n/g) ?? []).length;
i += comment.length - 1;
if (prelude.trim() === '') preludeLine = line;
continue;
}
if (c === '"' || c === "'") {
let j = i + 1;
while (j < css.length && css[j] !== c) {
if (css[j] === '\\') j++;
j++;
}
prelude += css.slice(i, j + 1);
i = j;
continue;
}
if (c === '{') {
const selector = prelude.trim();
const kind = selector.startsWith('@') ? 'at' : 'style';
if (
kind === 'style' &&
stack.includes('style') &&
startsWithIdent(selector)
) {
found.push({ line: preludeLine, selector });
}
stack.push(kind);
startPrelude();
continue;
}
if (c === '}') {
stack.pop();
startPrelude();
continue;
}
if (c === ';') {
startPrelude();
continue;
}
prelude += c;
}
return found;
}
@@ -11,21 +11,9 @@ import {
GetArtistImageCachedPath,
GetArtistMBID,
} from '@go/explore/service.js';
import { GetFilePathsByAlbums } from '@go/library/library.js';
import { libraryStore } from '@store/library-store';
import { notificationStore } from '@store/notification-store';
import { dict } from '@utils/binding';
import { playAll } from '@utils/play-all';
import { describeError } from '@utils/describe-error';
import { ICON_PLAY, ICON_SHUFFLE } from '@utils/icon-language';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js';
import '../notifications/inline-notice';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
/** The region the artist header's own failures are rendered in. */
const ArtistRegion = 'library-artist';
@customElement('artist-details')
export class ArtistDetails extends LitElement {
@@ -52,7 +40,7 @@ export class ArtistDetails extends LitElement {
/** Tracks the store's cached array reference to detect refreshes. */
private lastAlbumsRef: library.Album[] | null = null;
static override styles = [designTokens, backButton, css`
static override styles = [designTokens, css`
:host {
display: flex;
flex-direction: column;
@@ -78,6 +66,31 @@ export class ArtistDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -142,39 +155,6 @@ export class ArtistDetails extends LitElement {
);
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.header-action {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: var(--yj-text-md, 13px);
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.header-action:hover {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.header-action:disabled {
opacity: 0.5;
cursor: default;
}
/* ====================================
* Content
* ==================================== */
@@ -189,23 +169,6 @@ export class ArtistDetails extends LitElement {
height: 100%;
}
/* Phone widths: the header's flex row squeezed .artist-info to
* nothing, so the title ellipsised away entirely and the
* actions clipped against the host's own overflow the album
* page's fault one detail view over (#66). The pair takes its
* own row instead. Written last, because a media query adds no
* specificity and a rule placed above the plain ones it
* overrides is silently dead. */
@media (max-width: 599px) {
.artist-header {
flex-wrap: wrap;
}
.header-actions {
flex-basis: 100%;
margin-left: 0;
}
}
`];
override connectedCallback() {
@@ -363,45 +326,6 @@ export class ArtistDetails extends LitElement {
return name.charAt(0).toUpperCase();
}
/**
* Play every track on this artist's albums, in album order.
*
* One `GetFilePathsByAlbums` call returns the paths grouped by
* album id; the caller owns the ordering, so they are flattened in
* `this.albums` order rather than by id.
*/
private async playAllTracks(shuffle: boolean): Promise<void> {
if (this.albums.length === 0) return;
try {
const libId = libraryStore.getSelectedLibraryId() ?? 0;
const ids = this.albums.map((a) => a.ID);
const byAlbum = await dict(
GetFilePathsByAlbums(ids, libId),
);
const paths: string[] = [];
for (const id of ids) {
paths.push(...(byAlbum[id] ?? []));
}
playAll(
paths,
{
type: 'artist',
id: this.artistId,
label: this.artistName,
},
shuffle,
);
} catch (error) {
console.error('Could not play artist:', error);
notificationStore.inline(ArtistRegion, {
text: describeError(error, 'Could not play this artists tracks.'),
});
}
}
/* ================================================================
* Rendering
* ================================================================ */
@@ -451,38 +375,12 @@ export class ArtistDetails extends LitElement {
`
: ''}
</div>
<div class="header-actions">
<button
class="header-action"
data-testid="artist-play-all"
?disabled=${this.albums.length === 0}
@click=${() =>
void this.playAllTracks(false)}
>
<wa-icon name=${ICON_PLAY}></wa-icon>
Play all
</button>
<button
class="header-action"
data-testid="artist-shuffle-all"
?disabled=${this.albums.length === 0}
@click=${() =>
void this.playAllTracks(true)}
>
<wa-icon name=${ICON_SHUFFLE}></wa-icon>
Shuffle all
</button>
</div>
</div>
<div class="content">
<cover-grid
.externalAlbums=${this.albums}
></cover-grid>
</div>
<inline-notice
region=${ArtistRegion}
testid="artist-play-message"
></inline-notice>
`;
}
}
@@ -7,7 +7,6 @@ import {
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
RangeChangedEvent,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
@@ -31,7 +30,6 @@ import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import { prefetchImageWindow } from '@utils/image-prefetch';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -584,26 +582,6 @@ export class ArtistsView
* Scroll position persistence
* ================================================================ */
/**
* Warm the avatars just past the rendered range (#65).
*
* `rangeChanged` is the rendered range and `visibilityChanged` is
* what is on screen; the virtualizer has already drawn about
* 1000px past the latter, so that is the wrong anchor to measure a
* prefetch window from. It is deliberately outside the
* `restoringScroll` guard below: a restored scroll lands in the
* middle of the grid, which is exactly when nothing around it is
* cached.
*/
private onRangeChanged = (e: RangeChangedEvent) => {
prefetchImageWindow(
this.cachedGridEntries,
e.first,
e.last,
(entry) => this.artistAvatarURL(entry.artist),
);
};
/**
* Save the first visible item index on scroll.
*/
@@ -1167,16 +1145,7 @@ export class ArtistsView
* Helpers
* ================================================================ */
/**
* The image this artist's card will draw, or `''` for the initial
* placeholder.
*
* Split out of `renderArtistAvatar` so the prefetch (#65) asks for
* exactly what the card is going to ask for a second copy of the
* tier ladder would be a second thing to keep in step, and warming
* the wrong tier is a download that buys nothing.
*/
private artistAvatarURL(artist: library.Artist): string {
private renderArtistAvatar(artist: library.Artist) {
const needed = (this.imageSize ?? 176) * window.devicePixelRatio;
let imageURL = '';
@@ -1203,12 +1172,6 @@ export class ArtistsView
) ?? '';
}
return imageURL;
}
private renderArtistAvatar(artist: library.Artist) {
const imageURL = this.artistAvatarURL(artist);
if (imageURL) {
return html`<img
class="avatar-image"
@@ -1568,7 +1531,6 @@ export class ArtistsView
.keyFunction=${(entry: ArtistEntry) => entry.artist.ID}
.layout=${this.gridLayout}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
</div>
${this.renderContextMenu()}
@@ -45,6 +45,18 @@ export class SeekBar extends LitElement {
private showRemaining: boolean = true;
static override styles = [designTokens, waSliderLabel, css`
/* 12px below the phone breakpoint. The bottom bar's seek bar is
display:none there (016 B2 phase 1), so the only instance a
viewport media query can reach at that width is the full-screen
now-playing view's -- which is exactly the one a thumb uses.
The track size lives on wa-slider inside this shadow root, so a
custom property set by the host would not reach it. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
}
wa-slider {
--track-size: 6px;
flex: 1;
@@ -68,57 +80,6 @@ export class SeekBar extends LitElement {
background: var(--yj-bg-base, black);
}
/* The phone's seek bar, and this block is last on purpose.
A media query adds no specificity, so this lived above the plain
"wa-slider" rule and lost to it at every width: the 12px track it
asks for had never once applied, and the bar measured 261x6 on
the device while the source said 12. That is index.css's rule
("the phone section is last on purpose") met inside a component's
own stylesheet, and nothing renders differently in any tier here
to say so.
The bottom bar's seek bar is display:none below this width (016
B2 phase 1), so the only instance a viewport media query can
reach is the full-screen now-playing view's -- which is exactly
the one a thumb uses. The desktop bar keeps its 6px, where a
mouse is precise and the thickness is right.
The painted track and the thing you can hit are allowed to
differ, and a slider is the clearest case where they should: 12px
is a progress bar you can see, and 44px is the app's touch floor
(#56). A 44px-*thick* bar would be wrong-looking and would cost
the album art the vertical space #51 spent an issue recovering.
Two things about how the target is built.
The padding goes on ::part(slider) rather than on the host,
because that inner div is what carries the gesture -- it has the
listener and the touch-action: none, and it is exactly the host's
size, so padding the host would grow a box that does not take the
press.
The padding is asymmetric and the margins cancel it, so the row
does not grow by the difference. Both halves are measured: the
seek row is 19px (its clocks, not the track, decide that) and the
play button's top edge is 8px below it, so the target takes the
space *above*, where .art is a non-interactive div. Growing the
row instead cost the art 25px of 143. Verified on the device at
424x439: hit area 44px, painted track 12px, row still 19px, art
still 143px, 8px of clearance left under the play button, a press
26px above the track seeks, and a hit test on the play button's
top edge still reaches the play button. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
wa-slider::part(slider) {
padding-block: 28px 4px;
margin-block: -28px -4px;
}
}
#seek-bar-container {
display: flex;
justify-content: space-between;
@@ -250,20 +250,13 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
/* Collapsible-section toggle used in the Pending header
transparent button that inherits the header's type.
187x**15** before this (#186), which was the smallest
control measured anywhere in the app until the column
arrows were counted. It is transparent and full-width
already, so the floor costs it a height and nothing
else. */
transparent button that inherits the header's type. */
.section-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1;
min-width: 0;
min-block-size: 44px;
padding: 0;
background: transparent;
border: 0;
@@ -281,8 +274,6 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* 32x18, and it has no background until hover -- so the
padding out to a square target is invisible (#186). */
.folders-menu-trigger {
background: transparent;
border: 0;
@@ -290,8 +281,6 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
font-size: 1.1rem;
line-height: 1;
padding: 0.1rem 0.4rem;
min-inline-size: 44px;
min-block-size: 44px;
border-radius: 3px;
cursor: pointer;
}
@@ -304,10 +293,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
.folders-refresh-trigger {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
min-inline-size: 44px;
min-block-size: 44px;
}
.folders-refresh-trigger:disabled {
+11 -116
View File
@@ -4,7 +4,6 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import { designTokens } from '../../styles/tokens.css';
import { sheetScrollFade } from '../../styles/sheet-scroll.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
import { ICON_PLAYLIST } from '@utils/icon-language';
@@ -28,43 +27,15 @@ interface Tab {
* three to five items before the targets stop being thumb-sized
* 360 px over eleven sidebar entries is 32 px each so the four here
* are the ones plan 016's subset says a phone is *for*, and "More"
* opens the existing `<app-sidebar>` in a sheet. That is deliberately
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
* a reuse rather than a second nav: two lists of destinations is two
* places to add the next view to, and the sidebar already carries the
* drag-to-navigate behaviour, the active state and the labels.
*
* **"More" rises from the bottom, and it is the same sheet a context
* menu is** (#71). It was a `wa-drawer` sliding in from the side: a
* 200px column of a 424px screen, opening away from the thumb that
* asked for it, with three nested scrollers in it the dialog, its
* body, and the sidebar's own `overflow-y: auto` host which is the
* "only part of the screen scrolls under my finger" in the report.
*
* Three things about the replacement are load-bearing.
*
* **It is the same element with another `placement`, not a new
* surface.** `wa-drawer` renders a native `<dialog>` and opens it with
* `showModal()`, which is exactly what `menu-surface`'s sheet relies
* on Chrome 37, the real top layer so #60's containment finding
* carries over with nothing new to prove, and the focus trap, Escape,
* tap-outside and `wa-after-hide` all come along unchanged.
*
* **The body is the only scroller**, with `overscroll-behavior:
* contain`, and the sidebar is told to stop being one. Nesting them is
* what makes a drag scroll the wrong box.
*
* **The sidebar is still mounted rather than re-listed as data**,
* which the issue offers as an alternative. Its `data-testid` per
* destination is the reason: the shell's own sidebar is `display:
* none` below 600px rather than removed, so a second list drawing
* `nav-*` handles is the duplication this component already renders
* conditionally to avoid and it would be a second place to add the
* next view to, with its own copy of #25's visibility filter.
*
* It emits the same bubbling, composed `navigate` event the sidebar
* does, so `index.ts` needs no knowledge of it, and it listens for that
* event globally for the same reason the sidebar does: a navigation it
* did not send (a card click, a detail view, the sheet) still has to
* did not send (a card click, a detail view, the drawer) still has to
* move the highlight.
*/
@customElement('bottom-nav')
@@ -118,17 +89,6 @@ export class BottomNav extends LitElement {
color: var(--yj-accent, #ffd43b);
}
/* The press state (#54). This bar is the phone's primary
navigation and had no feedback of its own at all -- what a
tap produced was the web view's tap highlight, a grey box
over the whole 48px cell, which index.css has now taken
away. The .active rule above is which tab you are *on*; this
is the tab being pressed, so they are a colour and a
background rather than two colours. */
button:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
@@ -144,76 +104,15 @@ export class BottomNav extends LitElement {
white-space: nowrap;
}
/* The sheet. --size is the drawer's own API for the axis its
placement uses, so auto is what makes it hug its content
instead of being a fixed 25rem band; the rest is the shape
the menu-surface context sheet already has, so a phone meets
one sheet rather than two. 85vh for its reason too: a surface
covering the whole screen is a page, not a sheet. */
wa-drawer {
--size: auto;
}
wa-drawer::part(dialog) {
max-height: 85vh;
border-radius: 12px 12px 0 0;
/* The sidebar paints its own surface, so the sheet takes
that colour rather than the menus' elevated one: two
greys in one sheet is a seam across the middle of it. */
background-color: var(--yj-bg-surface, #212529);
/* One scroller, and it is the body below. The dialog's own
overflow: auto is what let the sheet scroll as well as
its content, and it is also what would square off the
corners this rule just rounded. */
overflow: hidden;
}
/* And this list does not fit (#210): measured at 424x439 with
the seed's eight destinations, the body is scrollHeight 412
against clientHeight 373, and eleven items at 48px would be
528 -- the count is the user's since #25. So the sheet says
where the fold is, with styles/sheet-scroll.css's two layers
rather than a second answer to the question #207 settled for
the context sheet. The colour is the local half: the sidebar
paints --yj-bg-surface, so the cover does too, or the fade
draws the menus' grey across the bottom of this one. */
wa-drawer::part(body) {
padding: 0;
/* A scroll that reaches the end of this list must not
become a scroll of the page underneath it. */
overscroll-behavior: contain;
/* The sheet sits on the bottom edge, so the last
destination would otherwise be under the home indicator
on a gesture-navigation phone -- the same allowance the
bar itself makes above. */
padding-bottom: env(safe-area-inset-bottom, 0);
--yj-sheet-surface: var(--yj-bg-surface, #212529);
${sheetScrollFade}
}
/* And the sheet paints that surface once. The sidebar's host
paints the same grey -- which in the shell is the sidebar's
own background and here is a second, opaque copy of the
sheet's, drawn *over* the body's layers. So the fade was
painted and then covered: measured at 424x439 before this
rule, the last 32px read a flat 52,58,64 with 39px still
below. menu-surface meets the same requirement from the
other side, where .context-menu-panel[data-sheet] is
background-color: transparent; nothing changes visually
here, because the colour underneath is the one being
removed. */
app-sidebar {
background-color: transparent;
}
/* A sheet is dragged at with a thumb, so it says where its top
edge is. Decorative: the destinations are below it. */
.grip {
width: 36px;
height: 4px;
margin: 8px auto 4px;
border-radius: 2px;
background: var(--yj-text-tertiary, #888);
/* The sidebar sizes itself inline and collapses to icons
below 900px, which is every phone. In the drawer there
is room for the labels, so it is told not to. */
height: 100%;
}
`];
@@ -248,7 +147,7 @@ export class BottomNav extends LitElement {
private visibilityCtrl = new ViewVisibilityController(this);
/**
* Whether the sheet has been asked for.
* Whether the drawer has been asked for.
*
* The sidebar inside it is rendered only while this is true, and
* that is not an optimisation. `app-sidebar` carries a
@@ -290,17 +189,15 @@ export class BottomNav extends LitElement {
override updated() {
// Web Awesome renders its heading into its own shadow root and
// never points aria-labelledby at it, so the sheet would
// never points aria-labelledby at it, so the drawer would
// otherwise be announced unnamed -- the same fix, and the same
// reason, as every wa-dialog in the app. A drawer's shadow root
// has the same shape, so the helper needs no change; under
// `without-header` there is no heading to point at, which is
// that helper's documented `aria-label` path.
// has the same shape, so the helper needs no change.
nameDialog(this.drawer);
}
private onGlobalNavigate = () => {
// A navigation from inside the sheet is the sheet's job done.
// A navigation from inside the drawer is the drawer's job done.
// The highlight is not this listener's business any more.
this.drawerOpen = false;
};
@@ -366,14 +263,12 @@ export class BottomNav extends LitElement {
</nav>
<wa-drawer
placement="bottom"
without-header
placement="start"
label="All views"
data-testid="nav-drawer"
?open=${this.drawerOpen}
@wa-after-hide=${this.onDrawerHide}
>
<div class="grip"></div>
${this.drawerOpen
? html`<app-sidebar expanded></app-sidebar>`
: nothing}
@@ -85,21 +85,6 @@ export class ConfigField extends LitElement {
gap: 0.5em;
}
/* Every control here meets the app's 44px touch floor (#186).
This is the shape every row in Settings uses, so it is the
one rule that covers the most controls -- and it is the
*cheapest* place to reach the floor, because there is no
overflow fit on this page. The page header's had one (#69),
which is why that pass had to grow padding and hand the
width back with a negative margin; here the control is a
block in a column and a taller box costs nothing but the
height it takes.
Measured on the reference device before this: the select
335x30, the text and number inputs the same, the browse
button 30 tall, the colour swatch 33x33 and the toggle
**34x19**. */
input[type='text'],
input[type='number'] {
background: var(--yj-bg-elevated, #343a40);
@@ -110,7 +95,6 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
min-width: 0;
min-block-size: 44px;
flex: 1;
}
@@ -133,7 +117,6 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
min-block-size: 44px;
flex: 1;
}
@@ -156,7 +139,6 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
min-block-size: 44px;
}
button:hover {
@@ -176,12 +158,8 @@ export class ConfigField extends LitElement {
}
input[type='color'] {
/* border-box, or the 2px border makes this 48 and the
assertion below reads as passing by four pixels of
border rather than by the rule. */
box-sizing: border-box;
width: 44px;
height: 44px;
width: 2.5em;
height: 2.5em;
border: 2px solid var(--yj-border, #444);
border-radius: 4px;
padding: 0;
@@ -209,32 +187,12 @@ export class ConfigField extends LitElement {
display: flex;
align-items: center;
justify-content: space-between;
min-block-size: 44px;
}
/* The toggle is the one control here whose target and paint
must differ, and it is also the one no sweep can see.
Its <input> is opacity: 0; width: 0; height: 0, so a
walk of every input on the page skips it as a zero-sized
node -- the thing a finger actually hits is this <label>,
which measured **34x19**. That is smaller than anything in
#186's original table and it is absent from it for exactly
that reason.
A 44px pill is not what a switch should look like, so the
box is 44px and the paint is not: .toggle-slider is a
2.5em x 1.4em child centred in it rather than an absolute
fill. The negative inline margins hand the extra width back
to the layout, so the pill stays flush with the right edge
of the inputs in the rows above it -- the header pass's
shape, used here for alignment rather than for a fit. */
.toggle-switch {
display: grid;
place-items: center;
inline-size: 44px;
block-size: 44px;
margin-inline: calc((2.5em - 44px) / 2);
position: relative;
width: 2.5em;
height: 1.4em;
}
.toggle-switch input {
@@ -244,10 +202,9 @@ export class ConfigField extends LitElement {
}
.toggle-slider {
position: relative;
position: absolute;
cursor: pointer;
inline-size: 2.5em;
block-size: 1.4em;
inset: 0;
background: var(--yj-bg-overlay, #495057);
border-radius: 1em;
transition: background 0.2s;
@@ -51,7 +51,7 @@ import type { BackgroundShade } from '@store/theme-store';
import type { IconStyle } from '@store/favorites-store';
import {
COLUMN_DEFS,
CONFIGURABLE_COLUMN_IDS,
ALL_COLUMN_IDS,
} from '@components/track-list/columns';
import './config-field';
@@ -176,13 +176,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
cursor: pointer;
transition: background-color 0.15s ease;
white-space: nowrap;
/* The app's 44px touch floor (#56, #186), stated once for
all 41 buttons this page renders rather than per class.
Height is free here: Settings has no overflow fit, so
the header's "only width is contested" rule does not
bind, and the two classes that need more than a height
say so below. */
min-block-size: 44px;
}
button:disabled {
@@ -516,24 +509,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
margin: 0;
}
/* The two column lists are the densest thing in the app, and
the density argument is why they are shaped the way they
are rather than simply grown (#186).
Measured on the reference device: the row was already
335x36 -- it is the controls *inside* it that were 16x16 and
**16x14**, the smallest anywhere in this app, 36 of them.
So the fix grows the controls into the row they already
occupy and only takes the row from 36 to 44, which over the
two lists (10 and 19 items) is 232px of extra scroll on a
439px screen. Growing each control to its own 44px row
instead would have cost four screens. */
.column-item {
display: flex;
align-items: stretch;
align-items: center;
gap: 0.5em;
padding: 0 0.75em;
min-block-size: 44px;
padding: 0.5em 0.75em;
border-bottom: 1px solid
var(--yj-border-subtle, #333);
font-size: 0.85em;
@@ -551,19 +531,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* A native checkbox cannot grow its hit area without growing
its paint, and a 44px checkbox is not what anyone wants. So
the target is the label instead: .column-label is a real
<label for> now, which makes the column's *name* the thing
you tap -- ~250x44 rather than 16x16.
That is the argument config-field already makes one file
over for its own labels: "a real label association also
makes the label text a click target for the control, which
is behaviour, not annotation". Here it is the whole fix. */
.column-toggle {
cursor: pointer;
align-self: center;
accent-color: var(
--yj-accent,
#ffd43b
@@ -572,32 +541,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.column-label {
flex: 1;
display: flex;
align-items: center;
cursor: pointer;
min-block-size: 44px;
}
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
/* The row stretches its children so the label can be a
full-height target; this is text, not a target. */
align-self: center;
}
.column-arrows {
display: flex;
align-items: stretch;
gap: 0.15em;
margin-left: auto;
}
/* 16x14 before this, and they carry background: none and a
transparent border -- so padding out to 44px grows the
target and changes nothing anyone can see until hover,
which is precisely what #186's Direction asks for. */
.column-arrow-btn {
background: none;
border: 1px solid transparent;
@@ -607,8 +564,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
font-size: 0.65em;
line-height: 1;
padding: 0.2em 0.35em;
min-inline-size: 44px;
min-block-size: 44px;
transition:
color 0.15s,
border-color 0.15s;
@@ -756,11 +711,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
padding: 0.2em 0.4em;
letter-spacing: 2px;
border-radius: 4px;
/* Square, so it needs the width too -- the shared rule
above only gives it a height. It was 31x31, and it is
the only route to "Remove library", which is the case
#55 settled one component over: the way out is 44px. */
min-inline-size: 44px;
}
.overflow-btn:hover {
@@ -1640,7 +1590,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
...this.trackListCtrl.columnIds,
];
const disabledIds = CONFIGURABLE_COLUMN_IDS.filter(
const disabledIds = ALL_COLUMN_IDS.filter(
(id) => !enabledIds.includes(id),
);
@@ -2053,7 +2003,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="view-${v.id}"
type="checkbox"
class="column-toggle"
aria-label="Show ${v.label} in the navigation"
@@ -2065,9 +2014,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
(e.target as HTMLInputElement).checked,
)}
/>
<label class="column-label" for="view-${v.id}">
<span class="column-label">
${v.label}
</label>
</span>
${note
? html`<span class="view-note">${note}</span>`
: nothing}
@@ -2263,7 +2212,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="column-${id}"
type="checkbox"
class="column-toggle"
aria-label="Show the ${columnLabel} column"
@@ -2274,12 +2222,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
id,
)}
/>
<label
<span
class="column-label"
for="column-${id}"
>
${columnLabel}
</label>
</span>
<span
class="column-arrows"
>
@@ -8,7 +8,6 @@ import '@awesome.me/webawesome/dist/components/switch/switch.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import { waTouchFloor } from '../../styles/wa-touch-floor.css';
import type {
DownloadDescriptor,
DownloadProvider,
@@ -137,7 +136,6 @@ export class DownloadClients extends LitElement {
static override styles = [
designTokens,
waTouchFloor,
css`
:host {
display: block;
@@ -241,17 +239,12 @@ export class DownloadClients extends LitElement {
margin-top: 0.4em;
}
/* The checkbox is 16x16 and cannot grow without becoming
a 44px checkbox, but it is already wrapped in the label
that names it -- so the label is the target and only
needs the height (#186). Eight of them. */
.format-option {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 0.9em;
cursor: pointer;
min-block-size: 44px;
}
`,
];
@@ -25,11 +25,6 @@ export class ShortcutCapture extends LitElement {
:host {
display: inline-block;
}
/* 80x25, twenty-six of them -- the most numerous control on
the Settings page after the column lists (#186). The floor
is a height here and nothing else: the width was already
past it, and the type stays where it is so a shortcut still
reads as a key rather than as a button. */
button {
font-family: inherit;
font-size: var(--yj-text-sm, 13px);
@@ -40,7 +35,6 @@ export class ShortcutCapture extends LitElement {
color: var(--yj-text-primary, #eee);
cursor: pointer;
min-width: 80px;
min-height: 44px;
text-align: center;
transition:
border-color 0.15s,
@@ -67,11 +61,6 @@ export class ShortcutCapture extends LitElement {
opacity: 0.7;
}
}
/* Reset renders only for a rebound shortcut, so a sweep of a
freshly-installed app never sees it -- it is not in #186's
tables for that reason, and it is a touch target the moment
anybody uses the feature. It also has no background, so the
padding out to 44px is invisible. */
.reset-btn {
font-size: var(--yj-text-xs, 11px);
padding: 2px 6px;
@@ -80,8 +69,7 @@ export class ShortcutCapture extends LitElement {
background: transparent;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-width: 44px;
min-height: 44px;
min-width: auto;
opacity: 0;
transition: opacity 0.15s;
}
@@ -91,17 +79,6 @@ export class ShortcutCapture extends LitElement {
.reset-btn:hover {
color: var(--yj-accent-text, #ffd43b);
}
/*
* Reset is the only way to put a rebound shortcut back, so where
* the device has no hover it is always visible rather than an
* invisible button holding its hit area. The inverse of #68's
* rule, which applies where the hover control is redundant.
*/
@media not all and (hover: hover) {
.reset-btn {
opacity: 1;
}
}
`;
private handleClick = () => {
@@ -8,7 +8,6 @@ import {
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
RangeChangedEvent,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
@@ -31,7 +30,6 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import { prefetchImageWindow } from '@utils/image-prefetch.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { AlbumSelectionManager } from './album-selection.js';
@@ -57,8 +55,6 @@ import {
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
import { goToMenuItems } from '../../utils/go-to-menu';
import type { GoToTarget } from '../../utils/go-to-menu';
import { creditStore } from '@store/credit-store';
import {
createAlbumArtDragImage,
@@ -912,31 +908,6 @@ export class CoverGrid
);
};
/**
* Warm the covers just past the rendered range (#65).
*
* `rangeChanged` rather than `visibilityChanged`, because the two
* report different ranges and only one of them is the right
* anchor: visibility is what is on screen, and the virtualizer has
* already rendered about 1000px past that. Measured from the
* visible range this would spend most of its window on cards that
* already exist and have already asked for their own art.
*
* The entry lists are memoized, so asking for one here costs a
* reference compare.
*/
private onRangeChanged = (e: RangeChangedEvent) => {
const entries = this.splitMode
? this.getBeforeEntries()
: this.buildGridEntries();
prefetchImageWindow(entries, e.first, e.last, (entry) =>
entry.album.CoverArtPath
? this.getCoverUrl(entry.album)
: '',
);
};
/* ====================================================================
* Virtualizer items
* ==================================================================== */
@@ -2030,7 +2001,6 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
`;
}
@@ -2065,7 +2035,6 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
<album-dropdown
@@ -2115,30 +2084,6 @@ export class CoverGrid
);
}
/**
* The artist an album card's menu can navigate to — the card's own
* credit line, which stops being a link below the phone breakpoint
* (#67).
*
* A *track* target gets nothing: the dropdown's rows carry no
* links of their own, and the album they sit under is the card
* that opened them.
*/
private get goToTarget(): GoToTarget | undefined {
if (this.contextMenuTarget.kind !== 'album') return undefined;
const album = this.albums.find(
(a) => a.ID === this.contextMenuAlbumId,
);
if (!album) return undefined;
return {
artistName: album.ArtistName,
artistMBID: album.ArtistMBID,
};
}
private renderContextMenu() {
const { ctxMenu } = this;
@@ -2254,11 +2199,6 @@ export class CoverGrid
</wa-dropdown-item>
`
: nothing}
${goToMenuItems(this.goToTarget, {
onSelect: () => ctxMenu.close(),
onHover: () =>
ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -93,26 +93,8 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
}
/* 85x34 and 96x34 before this (#186). A tab is the only
route to the panel it names, so it is the last control
that should be hard to hit -- and the underline that
marks the active one is drawn on the bottom border,
which a taller box moves further from the label. So the
height goes on *padding*, keeping the border against
the label rather than 10px below a centred one.
The min-size is the floor and is not redundant: padding
alone made this 44px here and **43px in CI**, because
the total is 13 + 13 + 2 + whatever line box the font
gives 13px text, and ubuntu:24.04's is a pixel shorter
than this machine's. A height computed from a font's
line box is not a height you control -- the same
mistake #195 made about a layout property measured on
one engine, one layer down, and caught here by the test
rather than by a person. */
.tab {
min-block-size: 44px;
padding: 13px 14px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
@@ -2,7 +2,6 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { unownedLabel, unownedStyles } from '@utils/ownership';
import {
@@ -43,7 +42,6 @@ import type * as autotagservice from '@go/autotagservice/models.js';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { queueStore } from '../../store/queue-store';
import type { QueueSource } from '../../store/queue-store';
import { playAll } from '@utils/play-all';
import { notificationStore } from '../../store/notification-store';
import '../notifications/inline-notice';
import {
@@ -357,7 +355,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
srOnly,
@@ -382,6 +379,25 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -876,67 +892,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
.track-row .track-request {
flex-shrink: 0;
}
/* The phone (#66)
*
* **This block is last on purpose**, for index.css's
* reason: a media query adds no specificity, so a rule
* written above the plain one it overrides loses to it and
* every declaration here is silently dead.
*
* Two faults, one shape. The page is a fixed header over a
* scrolling tracklist the desktop arrangement so at the
* reference device's 424x439 the header owned 253 of the
* panel's 318px and the tracklist scrolled inside the 64px
* that were left. And the header's flex row squeezed
* .album-info to 112px, so the title drew as one ellipsised
* glyph and two of the album's three primary actions were
* clipped by the host's own overflow: Shuffle album ended
* at x=443 in a 424px box, unreachable by any gesture.
*
* .album-info carries min-width: 0 and was shrinking as
* asked, so another one is not the fix the row has to
* stack, or the info column has nothing to be wide with.
*
* The scroller moves to the host and .content stops being
* one, which is what makes the header scroll away; the
* tracklist is plain DOM rather than a virtualizer, so
* nothing inside wants a scroll window of its own. */
@media (max-width: 599px) {
:host {
overflow-y: auto;
}
.album-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
}
/* Stacked, the art is the whole of the header's width
* budget and its 200px square is 45% of the reference
* device's height. It is still what identifies the
* album, so it shrinks rather than going. */
.cover-art-container {
width: 140px;
height: 140px;
}
/* A column flex item takes its content's width from
* align-items: flex-start above, which would leave the
* actions wrapping inside a box narrower than the row
* they now have to themselves. */
.album-info {
align-self: stretch;
}
.content {
flex: 0 0 auto;
overflow-y: visible;
padding: 16px 16px 24px;
}
}
`,
];
@@ -2772,11 +2727,20 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/** Play what the user owns of this release, optionally shuffled. */
private playOwned(shuffle: boolean): void {
const paths = this.ownedFilePaths();
// The button is only rendered when there is something to play,
// so an empty set here is not a state the user can reach. The
// shuffle-mode semantics live in `playAll`, shared with the
// play-all/shuffle-all pair on every track list.
playAll(this.ownedFilePaths(), this.queueSource(), shuffle);
// so an empty set here is not a state the user can reach.
if (paths.length === 0) return;
// `shuffleStart` only picks a random first track when shuffle
// mode is *already* on — it does not turn it on — so the mode
// has to be set before the queue, not after.
if (shuffle && !queueStore.getState().shuffleMode) {
queueStore.toggleShuffle();
}
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
}
/** Append what the user owns of this release to the queue. */
@@ -3183,14 +3147,10 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return html`
${artist
? html`<div class="album-artist">
<!-- keepOnPhone: the page header is not a row and
has no menu of its own, so this credit is the
only route from an album to its artist (#67). -->
${creditLink(
creditStore.credits(this.releaseGroupMBID),
artist,
artistMbid,
{ keepOnPhone: true },
)}
</div>`
: nothing}
@@ -3,7 +3,6 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import {
LookupArtist,
BrowseReleaseGroups,
@@ -30,7 +29,6 @@ import { libraryStore } from '../../store/library-store';
import { downloadStore } from '../../store/download-store';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { goToMenuItems } from '../../utils/go-to-menu';
import { describeError } from '../../utils/describe-error';
import {
GetAlbumsByArtist,
@@ -268,7 +266,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
@@ -292,6 +289,25 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -2706,16 +2722,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
<!-- The track title links to its album, and below the phone
breakpoint it is plain text (#67). The artist is this
page, so there is nothing to go to. -->
${goToMenuItems(
{ albumName: track.releaseName, albumMBID: track.releaseGroupMbid ?? '' },
{
onSelect: () => this.ctxMenu.close(),
onHover: () => this.ctxMenu.closePlaylistSubmenu(),
},
)}
`;
}
@@ -23,8 +23,6 @@ import { queueStore } from '../../store/queue-store';
import { notificationStore } from '../../store/notification-store';
import '../notifications/inline-notice';
import { creditLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { goToMenuItems } from '../../utils/go-to-menu';
import type { GoToTarget } from '../../utils/go-to-menu';
import { creditStore } from '@store/credit-store';
import { describeError } from '../../utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -56,28 +54,10 @@ export const ExploreRegion = 'explore';
* is present only when owned that's what gates the playback items,
* while `mbid` (always present) is what "View on MusicBrainz" uses, so
* a catalog-only card still gets a menu with somewhere useful to go.
*
* `goTo` is the names the card draws -- an artist credit, and for a
* recording row the release its title links to. Below the phone
* breakpoint those are plain text, so the menu is where they went
* (#67); an album card carries no album of its own, because tapping
* the card is already that.
*/
type ExploreMenuTarget =
| {
kind: 'album';
mbid: string;
localId?: number;
title: string;
goTo?: GoToTarget;
}
| {
kind: 'recording';
mbid: string;
localId?: number;
title: string;
goTo?: GoToTarget;
};
| { kind: 'album'; mbid: string; localId?: number; title: string }
| { kind: 'recording'; mbid: string; localId?: number; title: string };
type ThumbnailRequest = explore.ThumbnailRequest;
type MBSearchResult = explore.MBSearchResult;
type LyricsResult = explore.LyricsResult;
@@ -276,18 +256,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
margin-bottom: 10px;
}
/* 89x26 and 79x26 before this (#186). */
.search-mode-tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-block-size: 44px;
padding: 5px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
@@ -312,7 +289,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
border-radius: 6px;
padding: 0 12px;
gap: 8px;
min-height: 44px;
height: 36px;
max-width: 520px;
transition: border-color 0.15s ease;
}
@@ -387,15 +364,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
flex-shrink: 0;
}
/* The input measured 325x**18** and the box around it 36,
which is two faults rather than one (#186): the row was
under the floor, and the input did not fill it, so eight
of those pixels were not a target at all. The container
is 44 and the input stretches to it -- a tap anywhere in
the box now lands on the input rather than beside it. */
input {
flex: 1;
align-self: stretch;
background: none;
border: none;
outline: none;
@@ -409,21 +379,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
color: var(--yj-text-tertiary, #888);
}
/* No background until hover, so the target grows and the
glyph does not. It is inside a 44px box already, hence
the width alone. */
.clear-button {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 0;
min-inline-size: 44px;
margin-inline-end: -12px;
font-size: var(--yj-text-sm);
flex-shrink: 0;
}
@@ -1406,9 +1370,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
${goToMenuItems(target.goTo, {
onSelect: () => this.ctxMenu.close(),
})}
</div>
`
: nothing}
@@ -2211,10 +2172,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
goTo: {
artistName: rg.artistCredit,
artistMBID: rg.artistMbid ?? '',
},
})}
role="button"
tabindex="0"
@@ -2227,10 +2184,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
goTo: {
artistName: rg.artistCredit,
artistMBID: rg.artistMbid ?? '',
},
},
)}
>
@@ -2309,12 +2262,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: r.mbid,
localId: r.localId,
title: r.title,
goTo: {
artistName: r.artistCredit,
artistMBID: r.artistMbid ?? '',
albumName: r.releaseName ?? '',
albumMBID: r.releaseGroupMbid ?? '',
},
})}
@keydown=${(e: KeyboardEvent) =>
this.onCardKeydown(
@@ -2325,12 +2272,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: r.mbid,
localId: r.localId,
title: r.title,
goTo: {
artistName: r.artistCredit,
artistMBID: r.artistMbid ?? '',
albumName: r.releaseName ?? '',
albumMBID: r.releaseGroupMbid ?? '',
},
},
)}
>
@@ -2,14 +2,12 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { EventsOn } from '@runtime/runtime';
import {
AddLibrary,
GetAllLibrariesWithTrackCounts,
} from '@go/library/library.js';
import { describeError, explainError } from '@utils/describe-error';
import { nameDialogsIn } from '@utils/name-dialog';
import { Events } from '../../events';
import { pickDirectory } from '../../utils/pick-directory';
/**
@@ -21,13 +19,6 @@ import { pickDirectory } from '../../utils/pick-directory';
* prompting the user to pick their music folder, registers it through the
* library CRUD API, and dismisses itself. AddLibrary emits LibraryAdded
* and kicks off the initial scan automatically.
*
* **The dismissal follows the library existing, not the button being
* pressed.** `AddLibrary` emits `LibraryAdded` whoever calls it, so the
* wizard waits on the state it exists to wait for rather than on a step
* in its own flow a library arriving by any other route (Settings, a
* direct call) leaves a full-screen modal up otherwise, intercepting
* every pointer event.
*/
@customElement('first-run-wizard')
export class FirstRunWizard extends LitElement {
@@ -46,18 +37,9 @@ export class FirstRunWizard extends LitElement {
/** Error message from a failed pick/save, if any. */
@state() private errorMessage = '';
/** Unsubscribe from LibraryAdded, while this element is connected. */
private cancelLibraryAdded?: () => void;
override async connectedCallback(): Promise<void> {
super.connectedCallback();
// Subscribed before the read below, so a library arriving while
// that call is in flight is not answered with a stale empty list.
this.cancelLibraryAdded = EventsOn(Events.LibraryAdded, () => {
this.dismiss();
});
try {
const existing = await GetAllLibrariesWithTrackCounts();
@@ -72,8 +54,6 @@ export class FirstRunWizard extends LitElement {
return;
}
if (this.finished) return;
this.active = true;
await this.updateComplete;
@@ -81,13 +61,6 @@ export class FirstRunWizard extends LitElement {
if (this.dialog) this.dialog.open = true;
}
override disconnectedCallback(): void {
this.cancelLibraryAdded?.();
this.cancelLibraryAdded = undefined;
super.disconnectedCallback();
}
static override styles = css`
wa-dialog {
--width: 480px;
@@ -266,20 +239,6 @@ export class FirstRunWizard extends LitElement {
if (!this.finished) e.preventDefault();
};
/**
* Close, and stay closed: a library exists, so setup is over.
*
* `finished` is set first, or `preventClose` cancels the hide this
* asks for.
*/
private dismiss(): void {
this.finished = true;
if (this.dialog) this.dialog.open = false;
this.active = false;
}
private handleChoose = async (): Promise<void> => {
this.errorMessage = '';
@@ -305,7 +264,11 @@ export class FirstRunWizard extends LitElement {
try {
await AddLibrary(this.selectedDirectory);
this.dismiss();
this.finished = true;
if (this.dialog) this.dialog.open = false;
this.active = false;
} catch (err) {
this.errorMessage = explainError(
err,
@@ -15,7 +15,6 @@ import { describeError } from '@utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
@customElement('genre-details')
@@ -38,7 +37,7 @@ export class GenreDetails extends LitElement {
private scanCompleteCleanup: (() => void) | null =
null;
static override styles = [designTokens, backButton, css`
static override styles = [designTokens, css`
:host {
display: flex;
flex-direction: column;
@@ -78,6 +77,31 @@ export class GenreDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -166,7 +166,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
* gated on the device having hover rather than on width. A
* touch long-press synthesises a hover state in the WebView,
* so on a phone it flashed into view during the 500ms hold
* that utils/touch-gestures.ts is measuring for a long press
* that utils/long-press.ts is measuring for a context menu
* a control appearing because you were reaching for a
* different one. A phone user taps the album and plays from
* the detail view, so there is nothing to replace it with.
@@ -23,14 +23,8 @@ export class LibraryFilter extends LitElement {
align-items: center;
}
/* 120x32 on the reference device (#186). This control has two
placements since #57 -- the desktop top bar and Settings ->
Libraries -- and it is the only route to setSelectedLibrary
in either, so it is one of the controls #148 argued must not
simply be taken away. It is one component, so it reaches the
floor in one place. */
select {
min-height: 44px;
height: 32px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid
@@ -65,7 +65,6 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { sheetScrollFade } from '../../styles/sheet-scroll.css';
import { PHONE_QUERY } from '@utils/breakpoints';
import { nameDialogsIn } from '@utils/name-dialog';
@@ -156,26 +155,10 @@ export class MenuSurface extends LitElement {
bottom was at y=452 on a 439px screen -- the one row a
destructive action is most likely to be. The cap has to stay
(a sheet covering the whole screen is a page, not a sheet),
so the body is what gives.
**And a body that scrolls says so** (#207). Scrolling was the
whole of the fix above, which left the last item reachable
and nothing on screen admitting it was there -- measured at
424x439, eight items ending at y=470 with the fold at 439,
and worse when the cut lands on a row boundary, where the
sheet ends in a clean edge that reads as the end of the list.
The two layers that say it live in styles/sheet-scroll.css
(#210), because the phone has a second sheet -- bottom-nav's
"More" -- which overflows for the same reason and must not
arrive at its own answer for what a fold looks like. What is
local to this sheet is the colour the cover is painted in:
the menus' elevated grey, handed over as --yj-sheet-surface
on the same box. */
so the body is what gives. */
wa-dialog::part(body) {
padding: 0;
--yj-sheet-surface: var(--yj-bg-elevated, #343a40);
${sheetScrollFade}
overflow-y: auto;
}
/* A sheet is dragged at with a thumb, so it says where its top
@@ -105,19 +105,6 @@ export class NowPlayingView extends LitElement {
color: var(--yj-text-secondary, #adb5bd);
}
/* The art and the names are one block, so that a short
screen can lay them out side by side without either of them
knowing about the other's box. Vertically it is exactly what
the host used to do -- same gap, art flexible, names fixed --
so the tall layout is unchanged. */
.stack {
display: flex;
flex-direction: column;
gap: 0.75em;
flex: 1 1 auto;
min-height: 0;
}
.art {
flex: 1 1 auto;
display: flex;
@@ -126,89 +113,38 @@ export class NowPlayingView extends LitElement {
min-height: 0;
}
.art img {
/* Square, and never larger than the room left over --
where "square" is a property of what is painted and not
just of what was asked for.
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone.
The previous rule asked for a square and did not get
one. width: min(100%, 60vh) makes the width definite,
aspect-ratio: 1 derives the height from it, and
max-height: 100% then clamps that height **without
re-deriving the width** -- which is how the
aspect-ratio property is specified to behave, unlike
an intrinsic ratio. So whenever the room left over was
shorter than the box was wide, the art was drawn as a
letterbox strip and object-fit: cover cropped the
cover to it. Measured on the reference device at
424x439: **264x53**, a 5:1 band of a square image.
**max-height is what actually keeps that promise**, and
it was missing. With a definite width and
a 1:1 aspect-ratio the height is *derived from the width*
and is bounded by nothing: at the reference device's
424x439 that is a 263px square (60vh) in a box with far
less than 263px left, so the art overflowed its own
centred flex item and drew over the header above and the
title below it. The comment claimed this was handled;
60vh is a bound on the *viewport*, not on the room left
over, and those differ by however much chrome is above
and below.
That is not only the phone. The leftover exceeds the
width only above ~843px of viewport, so every height
from ~500 to ~843 -- most phones, and any small window
-- drew a cropped strip too.
Both maxes with auto sizes is the fix, and it is the
replaced-element path rather than the aspect-ratio
one: the used size preserves the ratio under *both*
bounds (CSS2.1 10.4), so the art is square at every
height. Checked against Chrome 113 itself -- the
device's engine -- at column heights of 288, 300, 451,
600 and 800: square at all five, where the old rule
cropped at four.
A corollary worth knowing: auto will not upscale past
the image's natural size, and the largest tier
saveCoverArt keeps is 400px. Drawing it larger was
upscaling, so nothing is lost. */
max-width: 100%;
Pre-existing -- screenshotted on main -- and made acute
by #56, which gives the transport 95px more than it had.
Found by reading a screenshot, which is the only tier
that can see it: nothing fails, nothing overflows the
*shell*, and every control is still hittable. */
width: min(100%, 60vh);
max-height: 100%;
width: auto;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
/* The placeholder is not a replaced element, so it cannot use
the rule above: with no intrinsic size, auto/auto collapses
it to its icon -- measured at 13x58 in Chrome 113, which is
neither square nor the art's size.
So it is sized from the height, and then bounded by the
width in the one way a box like this can be. A non-replaced
element cannot express "the largest square that fits" in a
single rule: aspect-ratio derives the second axis from the
first, and whichever max clamps it does not re-derive the
other, which is the same trap the image rule above is about.
Driving it from the height alone is right until the column
is taller than it is wide -- ~843px of viewport, which is a
tall phone and #51's other named device -- and there it went
380x484.
max-height in viewport units is what closes it, and it is
sound here for the reason 60vh was not: this view is a
phone-width detail view, so its content box really is the
viewport less the host's own 1rem gutters. It is a *max*, so
the failure mode if that ever stopped being true is a square
bounded slightly early rather than a crop. rem and not em --
this box sets font-size: 3rem for the icon, so 2em here
would be 96px. */
.art .placeholder {
height: 100%;
width: auto;
max-width: 100%;
max-height: calc(100vw - 2rem);
/* A flex item's automatic minimum is its content, so
without this the icon's own width becomes a floor and
the box goes wider than it is tall the moment the row is
shorter than the icon -- which is exactly the state a
job band puts this screen in. */
min-width: 0;
aspect-ratio: 1;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
display: flex;
align-items: center;
justify-content: center;
@@ -296,60 +232,6 @@ export class NowPlayingView extends LitElement {
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
/* Below 500px of viewport the art and the names sit side by
side, and that is the whole of this screen's answer to a
short phone (#51).
The stacked layout cannot be rescued by sizing alone. Its
budget is fixed -- 48px of header, 143px of transport since
#64, 78px of names, 68px of padding and gaps -- so the art
gets height - 386, which on the reference device's 424x439
is **53px**. #172 measured 39px before #64 and named the
two options: give the art a floor and let the block scroll,
or reflow. A floor scrolls the transport off the bottom,
and "controls never scroll off" is #51's own Direction and
plan 018's promise -- so it is the reflow.
Sideways the art is bounded by the row's height rather than
by the column's leftover, which is the whole gain: the same
439px screen goes from a 53px sliver to **143px**, measured
on the device, with nothing scrolling and the transport
untouched.
500 is where the two layouts cross rather than a round
number. In a row the art is height - 296 and the names get
what is left of 392px, so the names hold 176px at exactly
500 and less above it; stacked, the art is height - 386,
which passes 176px at 562. Below 500 the row is the bigger
art *and* the readable one -- above it the column is, which
is why a tall phone (a Pixel 7's ~869) keeps the layout it
has. Unverified on that device: none was attached.
It is keyed on height alone, not on the phone's width,
because it is an answer to vertical room -- a 900x450 window
has the same problem and the same fix. */
@media (max-height: 500px) {
.stack {
flex-direction: row;
align-items: center;
}
/* A square of the row's height. The box has to carry the
ratio here rather than the image, because in a row the
art's width is what the ratio has to produce -- and the
image's own rule then fits it to a box that is already
square. */
.art {
flex: 0 1 auto;
height: 100%;
aspect-ratio: 1;
}
.meta {
flex: 1 1 auto;
}
}
`];
private back() {
@@ -401,67 +283,55 @@ export class NowPlayingView extends LitElement {
return html`
${this.renderHeader()}
<div class="stack">
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${creditLink(
creditStore.credits(track.recordingMbid),
track.artist,
track.artistMbid,
)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<!-- keepOnPhone: this screen is the phone's,
and it has no context menu to carry the
destination the way a row does (#67).
Suppressing these takes the artist and the
album away rather than moving them, and
they are two lines of their own here
rather than a few characters inside a
row. -->
<p class="artist">
${creditLink(
creditStore.credits(track.recordingMbid),
track.artist,
track.artistMbid,
{ keepOnPhone: true },
)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
{ keepOnPhone: true },
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
</div>
<div class="transport">
@@ -298,47 +298,6 @@ export class PageHeader extends LitElement {
flex-shrink: 0;
}
/* Every control in this header meets the app's 44px touch
floor -- the number #56 set for the transport and the
queue header already keeps (#186).
It is min-size rather than padding with a negative
margin, which is what the seek bar needed (#187), and
the difference is worth stating because it decides
whether targets can collide. There the painted track had
to stay thin, so the target was grown past its own box
and had to be checked against its neighbours. Here the
control *is* the target: the boxes are flex items, so
the gap keeps them apart and no two can overlap by
construction.
There is no phone branch. With the target being the box,
a 44px control on a desktop is merely large, and a
second declaration of what a phone shows is a second
thing to keep in step -- which is the reason this
component has never had one. It also avoids a media
query that no tier here renders, which is exactly how
the seek bar's phone rule came to be dead for months.
**The height is the box and the width is not**, and that
asymmetry is the whole of what the overflow fit below
cares about. That pass measures inline size, so a taller
control costs it nothing and a wider one costs it
directly. Growing the two square controls to 44px wide
added 22px, which fits at every width Chromium was
checked at and clipped the overflow trigger at 320px in
**WebKit** -- the engine closest to what actually ships,
and the one no machine here can run. So the horizontal
half is padding with the margin cancelling it, which is
what the issue asked for in the first place: the target
grows and the layout does not.
The cost is that a horizontal target can now overlap a
neighbour, which the box version could not. The arrow's
is deliberately lopsided for the seek bar's reason
(#187): the select is 6px to its left and there is open
space to its right, so it takes the side with nothing to
steal from. */
.sort select {
font: inherit;
color: inherit;
@@ -347,7 +306,6 @@ export class PageHeader extends LitElement {
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
min-block-size: 44px;
}
.sort-dir {
@@ -360,18 +318,6 @@ export class PageHeader extends LitElement {
color: inherit;
cursor: pointer;
padding: 3px 5px;
/* 28x21 before this, the smallest control in the
header and the only one that failed the floor in
both directions.
Vertically the box grows, because the header has the
room and nothing measures it. Horizontally the box
must not: 28 + 2 + 14 is a 44px target over a 28px
layout box, weighted right because the select is 6px
to the left. */
min-block-size: 44px;
padding-inline: 5px 21px;
margin-inline: 0 -16px;
}
.sort-dir:hover {
@@ -431,20 +377,10 @@ export class PageHeader extends LitElement {
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
justify-content: center;
min-block-size: 44px;
}
.more-button {
padding: 6px 10px;
/* 38x27, and it is the route to every collapsed
action, so it is the last control that should be
hard to hit -- and the one WebKit clipped at 320px
when this was 6px wider as a box. 38 + 3 + 3 is a
44px target over a 38px layout box; the actions row
has an 8px gap, so this one can be symmetric. */
padding-inline: 13px;
margin-inline: -3px;
}
/* The display: flex above outranks the UA stylesheet's
@@ -38,10 +38,6 @@ import {
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
@@ -74,20 +70,12 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import type { GoToTarget } from '@utils/go-to-menu';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
import {
ICON_PLAY,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REMOVE,
ICON_SHUFFLE,
} from '@utils/icon-language';
import { playAll } from '@utils/play-all';
/** One playlist row: the track and its position in the *playlist*,
* which is not its position in the filtered view. */
@@ -357,32 +345,14 @@ export class PlaylistDetails
// Track interactions
// =================================================================
private playableFilePaths(): string[] {
return this.tracks
private handlePlayAll() {
const filePaths = this.tracks
.filter((t) => !t.Phantom)
.map((t) => t.FilePath);
}
private handlePlayAll() {
// Start at the first row, not at a random one: the old `true`
// was `shuffleStart`, which only picks a random first track
// when shuffle mode is already on — so "Play All" quietly did
// "play from the top" while leaving the mode as it was. The
// mode semantics now live in `playAll`, shared with the other
// track lists.
playAll(
this.playableFilePaths(),
{ type: 'playlist', id: this.playlistId, label: this.playlistName },
false,
);
}
if (filePaths.length === 0) return;
private handleShuffleAll() {
playAll(
this.playableFilePaths(),
{ type: 'playlist', id: this.playlistId, label: this.playlistName },
true,
);
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
}
private handleTrackClick(
@@ -451,127 +421,6 @@ export class PlaylistDetails
queueStore.setQueue(filePaths, trackIndex, false, { type: 'playlist', id: this.playlistId, label: this.playlistName });
}
// =================================================================
// A finger on a playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/**
* A tap plays the playlist from that row.
*
* The same thing a double-click does, which is the rule the whole
* app follows: activating one row plays the list the row is in,
* from that row, rather than a queue of one that stops when the
* song ends.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap is left
// unclaimed and falls through to the click that selects it --
// which is what a mouse does here and the only useful thing a
// phantom row can answer.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
/**
* Swipe a row right to queue it.
*
* `track-list`'s rule, one list over: one row is a position and
* several rows are an explicit choice, and a swipe never changes
* the selection it reads.
*/
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
// A phantom has no file to queue, so there is nothing for
// the reveal to promise.
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${PlaylistDetails.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private handleTrackContextMenu(
e: MouseEvent,
trackIndex: number,
@@ -611,28 +460,6 @@ export class PlaylistDetails
.map((i) => this.tracks[i]!.FilePath);
}
/**
* The row "Go to Artist" / "Go to Album" navigate from one row
* or none, and only below the phone breakpoint, where the row's
* own names stopped being links (#67).
*/
private get goToTarget(): GoToTarget | undefined {
const indices = this.selection.getSelectedIndices();
if (indices.length !== 1) return undefined;
const track = this.tracks[indices[0]!];
if (!track) return undefined;
return {
artistName: track.Artist,
artistMBID: track.ArtistMBID,
albumName: track.Album,
albumMBID: track.ReleaseGroupMBID,
};
}
// =================================================================
// Context menu actions
// =================================================================
@@ -1127,11 +954,8 @@ export class PlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -1157,6 +981,31 @@ export class PlaylistDetails
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
@@ -1310,9 +1159,6 @@ export class PlaylistDetails
.track-item {
width: 100%;
box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
}
.track-header {
@@ -1377,14 +1223,8 @@ export class PlaylistDetails
user-select: none;
}
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item.selected {
@@ -1404,13 +1244,11 @@ export class PlaylistDetails
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.track-item.phantom:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
.track-item.phantom:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
.track-item.phantom.selected {
@@ -1420,19 +1258,6 @@ export class PlaylistDetails
);
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
.phantom-row {
grid-column: 1 / -1;
display: flex;
@@ -1619,16 +1444,9 @@ export class PlaylistDetails
class="play-all-button"
@click=${() => this.handlePlayAll()}
>
<wa-icon name=${ICON_PLAY}></wa-icon>
<wa-icon name="play"></wa-icon>
Play All
</button>
<button
class="play-all-button"
@click=${() => this.handleShuffleAll()}
>
<wa-icon name=${ICON_SHUFFLE}></wa-icon>
Shuffle All
</button>
</div>
<div class="track-header">
<div class="header-cell col-number">#</div>
@@ -1638,9 +1456,6 @@ export class PlaylistDetails
<div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div>
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer
class="track-scroller"
role="listbox"
@@ -1650,13 +1465,7 @@ export class PlaylistDetails
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
.layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer>
${this.renderSelectionBar()}
`;
}
@@ -1678,7 +1487,6 @@ export class PlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1689,7 +1497,6 @@ export class PlaylistDetails
role="option"
aria-selected=${selected}
data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
@@ -1736,7 +1543,6 @@ export class PlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div
class="phantom-row"
@@ -1972,14 +1778,6 @@ export class PlaylistDetails
Track
Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -13,7 +13,6 @@ import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { QueueController } from '@store/controllers/queue-controller';
import { PHONE_QUERY } from '@utils/breakpoints';
import { creditStore } from '@store/credit-store';
import {
describeQueueSource,
@@ -62,18 +61,11 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import type { GoToTarget } from '@utils/go-to-menu';
import {
ICON_NEW,
ICON_PLAY,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REMOVE,
} from '@utils/icon-language';
import type { GestureEvent } from '@utils/touch-gestures';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
/** Above this many tracks, clearing the queue asks first. */
const CLEAR_CONFIRM_THRESHOLD = 20;
@@ -128,25 +120,6 @@ export class QueuePanel
@property({ type: Boolean, reflect: true })
overlay = false;
/**
* Phone width, from `matchMedia` rather than from a media query,
* because it decides whether the scrim *exists* (#171)
* `job-band`'s rule, and a stylesheet cannot express it: a
* `display: none` scrim is still an element with a click handler.
*
* Below 600px the panel spans the whole content area, so the scrim
* has no uncovered pixels: measured at 424x439, host, panel and
* scrim are all 424x318 with the scrim entirely underneath. It dims
* nothing and dismisses nothing there, and the queue is a *screen*
* at that width anyway (#55) back and a 44px close button are its
* ways out. Between 600 and 899 the panel is a 320px column of a
* wider content area, the scrim is reachable, and #24's
* tap-outside-to-close is real; that band is untouched.
*/
@state() private phone = false;
private phoneQuery?: MediaQueryList;
@state()
private isDragging = false;
@@ -418,8 +391,6 @@ export class QueuePanel
display: none;
}
/* Overlay only, and above 600px only -- see the phone field,
which is where that half is decided (#171). */
.scrim {
position: absolute;
inset: 0;
@@ -443,10 +414,10 @@ export class QueuePanel
/* A screen's way out has to be hittable with a thumb.
Measured at 424x439 before #55: these were **25x21px**,
and with the panel spanning the whole width there is no
scrim here at all (#171) -- so this is the only pointer
route out of a full-screen surface. Back answers it now
as well, which is the other half.
and with the panel spanning the whole width the scrim
underneath has no uncovered pixels at all -- so it was
the only pointer route out of a full-screen surface.
Back answers it now as well, which is the other half.
Sized only in overlay mode: inline these sit in a 320px
column beside the content, where a mouse is what reaches
@@ -581,14 +552,8 @@ export class QueuePanel
contain: strict;
}
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item.selected {
@@ -603,19 +568,6 @@ export class QueuePanel
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
.track-position {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #888);
@@ -687,42 +639,23 @@ export class QueuePanel
text-overflow: ellipsis;
}
/*
* The per-row remove is a hover affordance, and on a device
* without hover it is redundant rather than missing: the row's
* context menu is a bottom sheet since #60 and carries "Remove
* from Queue", so the action is one long-press away. An
* always-visible X would instead spend part of a 424px row on
* something already reachable. #68's treatment, for #68's reason.
*
* display:none outside the query rather than visibility:hidden:
* a hidden button still occupies its hit area and is still in
* the accessibility tree, so a phone would keep a target for a
* control it can never see.
*/
.remove-button {
display: none;
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
visibility: hidden;
}
@media (hover: hover) and (pointer: fine) {
.remove-button {
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
visibility: hidden;
}
.track-item:hover .remove-button {
visibility: visible;
}
.track-item:hover .remove-button {
visibility: visible;
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
}
.list-area.drag-over {
@@ -870,8 +803,6 @@ export class QueuePanel
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('dragend', this.onTrackDragEnd);
virtEl.addEventListener('keydown', this.onDelegatedKeydown);
virtEl.addEventListener('yj-tap', this.onRowTap);
virtEl.addEventListener('yj-long-press', this.onRowLongPress);
this.delegationAttached = true;
}
@@ -897,12 +828,6 @@ export class QueuePanel
// desktop width rather than the minimum.
this.updateOverlayMode();
// Read here rather than in a field initialiser, so a test can
// install its own matchMedia before the element is created.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.phone = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.onPhoneMedia);
if (this.parentElement) {
this.spaceObserver = new ResizeObserver(() =>
this.updateOverlayMode(),
@@ -945,8 +870,6 @@ export class QueuePanel
this.creditsUnsub = undefined;
this.spaceObserver?.disconnect();
this.spaceObserver = undefined;
this.phoneQuery?.removeEventListener('change', this.onPhoneMedia);
this.phoneQuery = undefined;
document.removeEventListener('keydown', this.onOverlayKeydown);
document.removeEventListener(
'mousemove',
@@ -990,8 +913,6 @@ export class QueuePanel
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
virtEl.removeEventListener('keydown', this.onDelegatedKeydown);
virtEl.removeEventListener('yj-tap', this.onRowTap);
virtEl.removeEventListener('yj-long-press', this.onRowLongPress);
}
this.delegationAttached = false;
}
@@ -1015,10 +936,6 @@ export class QueuePanel
this.overlay = available - this.panelWidth < MAIN_PANEL_FLOOR;
};
private onPhoneMedia = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
/**
* Escape closes a scrimmed overlay, which is the one keyboard rule
* every dialog in this app already follows.
@@ -1277,92 +1194,6 @@ export class QueuePanel
this.queue.playAtIndex(index);
}
// =================================================================
// A finger on a queue row (plan 019 phase 3, #63)
// =================================================================
/**
* A tap plays this position in the queue.
*
* `track-list`'s tap sets the queue to the list it was made in;
* copying that here would rebuild the queue from the queue, which
* is not the no-op it looks like -- it would discard the queue's
* source, its shuffle order and everything a user had inserted by
* hand. `playAtIndex` is what a double-click already does, and it
* is what a tap means.
*/
private onRowTap = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
// A control inside the row owns its own tap -- the same rule
// the shortcut service has for a focused control that owns a
// key. The remove button is the one here.
if ((e.target as HTMLElement).closest('.remove-button')) return;
e.preventDefault();
// The roving tab stop follows the finger, or Tab returns to
// wherever the arrows last were rather than to the row that was
// just touched.
this.focusedIndex = idx;
if (this.selection.selectionMode) {
this.selection.toggleInMode(String(idx), idx);
this.virtualizer?.requestUpdate();
return;
}
this.selection.clear();
this.queue.playAtIndex(idx);
};
private onRowLongPress = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
e.preventDefault();
this.focusedIndex = idx;
this.selection.enterSelectionMode(String(idx), idx);
this.virtualizer?.requestUpdate();
};
/**
* The two worth a thumb, and "More" for the rest.
*
* Remove is here rather than left to the overflow because it is
* what a selection in a *queue* is most often made for, and it is
* the action the row's own × offers one row at a time.
*/
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${QueuePanel.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private handleTrackContextMenu(
e: MouseEvent,
index: number,
@@ -1645,29 +1476,6 @@ export class QueuePanel
.map((i) => tracks[i]!.filePath);
}
/**
* The row "Go to Artist" / "Go to Album" navigate from, which is
* one row or none the rule the Play item already follows. Both
* items are drawn only below the phone breakpoint, where the row's
* own names stopped being links (#67).
*/
private get goToTarget(): GoToTarget | undefined {
const indices = this.selection.getSelectedIndices();
if (indices.length !== 1) return undefined;
const track = this.queue.tracks[indices[0]!];
if (!track) return undefined;
return {
artistName: track.artist,
artistMBID: track.artistMbid,
albumName: track.album,
albumMBID: track.releaseGroupMbid,
};
}
// =================================================================
// Drop target (tracks dropped into queue)
// =================================================================
@@ -2164,7 +1972,7 @@ export class QueuePanel
const tracks = this.queue.tracks;
return html`
${this.overlay && !this.phone
${this.overlay
? html`<div
class="scrim"
part="scrim"
@@ -2201,25 +2009,11 @@ export class QueuePanel
`
: nothing}
</div>
<!-- **Every action here is named by aria-label**, like
the close button #24 added beside them (#170). A
title alone *is* a name, which is why a sweep for
empty names reports these clean and why an
assertion by role and name is green either way --
but it is the weakest one: title is the last
fallback in the accname order, so any content put
inside the button later silently outranks it, and
a phone has no hover to show it as a tooltip.
The titles stay. On a desktop they are the tooltip
for an icon-only control, which is a different job
from naming it, and aria-label does not do it. -->
<div class="header-actions">
<button
class="header-action-button"
@click=${() => void this.handleClearQueue()}
?disabled=${tracks.length === 0}
aria-label="Clear queue"
title="Clear queue"
>
<wa-icon
@@ -2230,7 +2024,6 @@ export class QueuePanel
class="header-action-button add-to-playlist-button"
@click=${this.handleAddToPlaylist}
?disabled=${tracks.length === 0}
aria-label="Add queue to playlist"
title="Add queue to playlist"
>
<wa-icon
@@ -2311,7 +2104,6 @@ export class QueuePanel
></lit-virtualizer>
`}
</div>
${this.renderSelectionBar()}
</div>
<menu-surface
@@ -2400,14 +2192,6 @@ export class QueuePanel
Track
Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -61,32 +61,11 @@ export class SearchTrigger extends LitElement {
display: inline-flex;
align-items: center;
justify-content: center;
/* The app's touch floor, from #56 -- and this is the
control that should least have to argue for it: #57
created it as the phone's replacement for the header
search box, so it exists *only* where there is a
thumb.
It shipped at 40px under a comment calling that "the
smallest a touch target should be", which was the
floor being restated four pixels short rather than a
second opinion about it (#186). The rest of that
comment said the header's own action buttons are
smaller because they carry a label; they are 44px
now too, so that no longer distinguishes anything.
The extra width is a target rather than a box, for
page-header's reason: this button sits in that
header, whose overflow fit (#69) measures inline
size, and four pixels there is four pixels the
trigger for every collapsed action does not get at
320px. Height is free -- nothing measures it. */
min-width: 44px;
min-height: 44px;
/* Border-box, so the 44 above is the whole target and
the margin is what hands the four extra pixels back
to the row. */
margin-inline: -2px;
/* The smallest a touch target should be. The header's
own action buttons are smaller because they carry a
label; this one is a glyph. */
min-width: 40px;
min-height: 40px;
padding: 0;
background: none;
border: 1px solid var(--yj-border-subtle, #555);
@@ -1,218 +0,0 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { ICON_MORE_ACTIONS } from '@utils/icon-language';
/**
* What a selection can have done to it, while a finger is holding one.
*
* This is the context menu (plan 019, #63). Not a second surface
* beside it the same actions, contextualised to whatever is
* selected, in the shape Android puts them in.
*
* #63 asked for a double tap to open the menu instead. That mapping
* costs the app's primary interaction 250ms on every play, because the
* first tap of a double tap is indistinguishable from a single tap
* until the interval expires, and playing a track is ~100ms end to end
* on the reference device. So there is no double tap: a long press
* selects, this says what can be done, and the sheet behind "More" is
* the same `menu-surface` every other menu in the app opens.
*
* Four things about it are load-bearing.
*
* **It is presentational.** It takes a count and a list of actions and
* emits `selection-action` / `selection-exit`; it holds no selection
* and calls no store. The host already owns a `SelectionController`
* and an action handler, and a bar that reached for either would be a
* second definition of what "play the selection" means the fault
* `utils/library-status.ts` exists to have fixed one feature over.
*
* **The bar is only what fits, and "More" is the rest.** A context
* menu can be nine items because it is a sheet; a bar is one row on a
* 424px screen. So the host passes the two or three worth a thumb and
* the overflow opens the menu it already renders, which is what keeps
* every action reachable at every size plan 018's promise, and the
* reason this cannot simply drop the long tail.
*
* **Its controls are 44px** (#56, #186), and the count is a live
* region: the number changes under the user's finger as they tap rows,
* and nothing else on screen announces it.
*
* **Escape leaves the mode, from here rather than from each host.**
* This element exists only while the mode does, so it is the one place
* a dismissal can be attached and detached with the thing it
* dismisses. It is the same exception the overlaid queue's Escape is.
*
* **It renders nothing at zero.** The mode ends when the last row is
* deselected `SelectionController.toggleInMode` is where that is
* decided so a bar with a count of none is a state this should never
* be asked to draw, and drawing it anyway would hide the fact that it
* has been.
*/
export interface SelectionAction {
id: string;
label: string;
icon: string;
danger?: boolean;
}
@customElement('selection-bar')
export class SelectionBar extends LitElement {
static override styles = [
designTokens,
css`
:host {
display: block;
}
.bar {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.25em 0.5em;
background: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border-subtle, #333);
}
.count {
flex: 1;
min-width: 0;
font-size: var(--yj-text-md);
font-weight: 600;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 44px, from #56 and #186 -- this is a bar a thumb uses. */
button {
display: flex;
align-items: center;
justify-content: center;
min-inline-size: 44px;
min-block-size: 44px;
padding: 0 0.5em;
border: none;
border-radius: 4px;
background: none;
color: var(--yj-text-primary, #fff);
font-family: inherit;
font-size: var(--yj-text-md);
cursor: pointer;
}
button:hover {
background: var(--yj-bg-overlay, #495057);
}
button.danger {
color: var(--yj-error-text, #ff8787);
}
`,
];
/** How many items are selected. Zero renders nothing. */
@property({ type: Number }) count = 0;
/** The actions worth a thumb. The rest live behind "More". */
@property({ attribute: false }) actions: SelectionAction[] = [];
private emit(name: string, detail?: unknown) {
this.dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true }),
);
}
/**
* Escape leaves the mode.
*
* A mode changes what a tap means, so it has to have an exit that
* is not "find the ×" -- and this is the documented exception to
* the app's one-keyboard-authority rule, on exactly the grounds
* the overlaid queue's Escape is: **it is a dismissal, not a
* shortcut**, so it is not a panel-scoped binding and it is
* attached only while there is something to dismiss. Putting it
* here rather than in each host is what gives all four surfaces
* the same answer, since this element exists only while the mode
* does.
*
* The platform's own back gesture is the other half of that and is
* deliberately *not* here: the shell owns the history stack
* (#6/#55), and a component reaching for `history` itself is how
* two stacks come to disagree about what one press means -- the
* fault that deleted `navStack`. See #200.
*/
private onKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || this.count <= 0) return;
e.preventDefault();
e.stopPropagation();
this.emit('selection-exit');
};
override connectedCallback() {
super.connectedCallback();
document.addEventListener('keydown', this.onKeydown, true);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this.onKeydown, true);
}
override render() {
if (this.count <= 0) return nothing;
const noun = this.count === 1 ? 'track' : 'tracks';
return html`
<div class="bar" role="toolbar" aria-label="Selection actions">
<button
aria-label="Leave selection"
@click=${() => this.emit('selection-exit')}
>
<wa-icon name="xmark"></wa-icon>
</button>
<span class="count" role="status" aria-live="polite">
${this.count.toLocaleString()} ${noun} selected
</span>
${this.actions.map(
(action) => html`
<button
class=${action.danger ? 'danger' : ''}
aria-label=${action.label}
title=${action.label}
@click=${() =>
this.emit('selection-action', { id: action.id })}
>
<wa-icon name=${action.icon}></wa-icon>
</button>
`,
)}
<button
aria-label="More actions"
@click=${(e: MouseEvent) => {
const box = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
this.emit('selection-more', {
x: box.left,
y: box.top,
});
}}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'selection-bar': SelectionBar;
}
}
+7 -73
View File
@@ -42,26 +42,6 @@ export class AppSidebar extends LitElement {
scrollbar-width: thin;
}
/* A host that has made room owns the box, not just the labels
(#71). The bottom-nav sheet is the width of the screen and
provides the one scroll container it needs; left to itself
the sidebar is a 200px column with a second scroller inside
it, which is what a nested scroll region feels like under a
thumb -- part of the surface moves and part of it does not. */
:host([expanded]) {
max-width: none;
height: auto;
overflow: visible;
}
/* And the width is not draggable there. It is a mouse
affordance (mousedown, col-resize) sitting on the right edge
of a touch surface, where the compatibility mouse events a
tap synthesises can start a resize nobody asked for. */
:host([expanded]) .resize-handle {
display: none;
}
.resize-handle {
position: absolute;
top: 0;
@@ -115,15 +95,8 @@ export class AppSidebar extends LitElement {
text-align: center;
}
/* A hover tint is for a device that hovers (#54), and this
component is on a phone too: below 600px it is what
bottom-nav's "More" sheet mounts, where a hold
synthesises a hover and leaves a destination looking picked
after the finger has gone. */
@media (hover: hover) and (pointer: fine) {
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
}
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
}
li button:focus-visible {
@@ -135,14 +108,6 @@ export class AppSidebar extends LitElement {
background-color: var(--yj-bg-overlay, #495057);
}
/* The press state (#54), after the .active rule and at the same
specificity, so pressing the destination you are already on
still says something. It is what a tap gets now that
index.css has taken the web view's own highlight box away. */
li button:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
li button p {
margin: 0;
white-space: nowrap;
@@ -180,25 +145,6 @@ export class AppSidebar extends LitElement {
:host(.collapsed) li button wa-icon {
font-size: var(--yj-icon-md);
}
/* Below 600px the only place this renders is the bottom-nav
sheet -- the shell's own copy is display: none there -- so
the rows are sized for the thumb that opened it: 48px, which
is #186's floor and the height every row in #60's context
sheet already has. A media query inside a shadow root is
answered by the viewport, so the component states this
itself rather than the sheet reaching in. */
@media (max-width: 599px) {
ul {
padding: 4px 8px 8px;
}
li button {
min-height: 48px;
padding: 12px 10px;
gap: 14px;
}
}
`];
/** Delay in ms before a drag-hover triggers navigation. */
@@ -227,14 +173,11 @@ export class AppSidebar extends LitElement {
/**
* Keep the labels regardless of the viewport, for a host that has
* made room for them -- `bottom-nav`'s sheet, which is the whole
* made room for them -- `bottom-nav`'s drawer, which is the whole
* screen wide on the phone where this would otherwise auto-collapse
* to icons. The auto-collapse is a *width* response to a narrow
* shell, and inside a sheet the shell is not what the sidebar is
* shell, and inside a drawer the shell is not what the sidebar is
* sharing space with.
*
* It says the host owns the *box*, not only the labels: the width,
* the scrolling and the resize handle all follow it (#71).
*/
@property({ type: Boolean, reflect: true })
expanded = false;
@@ -407,18 +350,9 @@ export class AppSidebar extends LitElement {
* be a media query in the stylesheet.
*/
private applyViewportWidth() {
// A host that made room decides how much: `bottom-nav`'s sheet
// is the whole screen wide, and the inline width below -- which
// beats any rule the host could write -- would draw the old
// 200px side drawer inside it.
if (this.expanded) {
this.style.width = '100%';
this.collapsed = false;
return;
}
const narrow = this.narrowViewport?.matches ?? false;
const narrow =
!this.expanded &&
(this.narrowViewport?.matches ?? false);
const width = narrow
? MIN_WIDTH
: this.userWidth;
@@ -15,7 +15,6 @@ import {
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { queueStore } from '@store/queue-store';
import { playAll } from '@utils/play-all';
import { creditStore } from '@store/credit-store';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
@@ -29,10 +28,6 @@ import {
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
setDragPayload,
@@ -65,16 +60,10 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import type { GoToTarget } from '@utils/go-to-menu';
import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { list } from '@utils/binding';
import {
ICON_PLAY,
ICON_PLAY_NEXT,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_SMART_PLAYLIST,
@@ -253,11 +242,8 @@ export class SmartPlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -283,6 +269,31 @@ export class SmartPlaylistDetails
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
@@ -456,9 +467,6 @@ export class SmartPlaylistDetails
.track-item {
width: 100%;
box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
}
.track-header {
@@ -517,14 +525,8 @@ export class SmartPlaylistDetails
user-select: none;
}
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item.selected {
@@ -540,19 +542,6 @@ export class SmartPlaylistDetails
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
/* Phantom rows span the full grid */
.track-item.phantom {
display: grid;
@@ -772,29 +761,24 @@ export class SmartPlaylistDetails
// Actions
// =================================================================
private playableFilePaths(): string[] {
return this.tracks
private handlePlay() {
const filePaths = this.tracks
.filter((t) => !t.Phantom)
.map((t) => t.FilePath);
}
private handlePlay() {
playAll(
this.playableFilePaths(),
{ type: 'smartPlaylist', id: this.playlistId, label: this.playlistName },
false,
);
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, false, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
}
private handleShuffle() {
// This used to be a no-op when shuffle mode was off: it passed
// `shuffleStart` without turning the mode on, so the queue
// started at track 1 in order. `playAll` sets the mode first.
playAll(
this.playableFilePaths(),
{ type: 'smartPlaylist', id: this.playlistId, label: this.playlistName },
true,
);
const filePaths = this.tracks
.filter((t) => !t.Phantom)
.map((t) => t.FilePath);
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
}
private async handleRefresh() {
@@ -955,136 +939,10 @@ export class SmartPlaylistDetails
.map((i) => this.tracks[i]!.FilePath);
}
/**
* The row "Go to Artist" / "Go to Album" navigate from one row
* or none, and only below the phone breakpoint, where the row's
* own names stopped being links (#67).
*/
private get goToTarget(): GoToTarget | undefined {
const indices = this.selection.getSelectedIndices();
if (indices.length !== 1) return undefined;
const track = this.tracks[indices[0]!];
if (!track) return undefined;
return {
artistName: track.Artist,
artistMBID: track.ArtistMBID,
albumName: track.Album,
albumMBID: track.ReleaseGroupMBID,
};
}
// =================================================================
// Context menu actions
// =================================================================
// =================================================================
// A finger on a smart playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/** A tap plays the playlist from that row -- `playlist-details`'
* rule, and the app's: activating a row plays the list it is in. */
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap falls through
// to the click that selects it.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'play-next', label: 'Play next', icon: ICON_PLAY_NEXT },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${SmartPlaylistDetails.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private onContextMenuAction(action: string) {
const filePaths = this.getSelectedFilePaths();
@@ -1499,9 +1357,6 @@ export class SmartPlaylistDetails
<div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div>
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer
role="listbox"
aria-label="Smart playlist tracks"
@@ -1510,13 +1365,7 @@ export class SmartPlaylistDetails
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
.layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer>
${this.renderSelectionBar()}
`;
}
@@ -1538,7 +1387,6 @@ export class SmartPlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1549,7 +1397,6 @@ export class SmartPlaylistDetails
role="option"
aria-selected=${selected}
data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
@@ -1584,7 +1431,6 @@ export class SmartPlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div class="phantom-row">
<wa-icon
@@ -1732,14 +1578,6 @@ export class SmartPlaylistDetails
></wa-icon>
Track Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -371,11 +371,8 @@ export class TopResultsRow extends LitElement {
<span class="card-name">${r.name}</span>
${artistPart || metaPart
? html`<span class="card-subtitle"
><!-- keepOnPhone: this card has no
context menu, so the credit is
the only route to the artist of
a top result (#67). -->${artistPart
? creditLink(creditStore.credits(r.mbid), artistPart, r.artistMbid ?? '', { keepOnPhone: true })
>${artistPart
? creditLink(creditStore.credits(r.mbid), artistPart, r.artistMbid ?? '')
: nothing}${artistPart && metaPart
? ' · '
: ''}${metaPart}</span
@@ -529,44 +529,6 @@ export class TrackDetails extends LitElement {
background: var(--yj-error, #e03131);
}
/*
* Both are the *only* route to changing or removing a track's
* cover art, so where the device has no hover they are always
* visible rather than hidden the inverse of #68's rule, which
* applies where the hover control is redundant. Revealed by
* opacity, so what is on screen is what the desktop reveal shows
* and nothing about the layout moves.
*/
@media not all and (hover: hover) {
/* The × is genuinely the only route to removing the art, so
on a device that cannot hover it is simply always there.
The pen is not: .cover-art-edit carries the click that
opens the file picker, so tapping the artwork already
worked while the overlay was invisible. It is a discovery
hint and paying for discovery by covering the artwork
being edited in 50% black, permanently, on every touch
device, is heavier than the hint is worth. It becomes a
corner chip in the remove button's own visual language
instead: same size, same disc, same alpha. */
.cover-art-remove {
opacity: 1;
}
.cover-art-overlay {
opacity: 1;
inset: auto 4px 4px auto;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
}
.cover-art-overlay wa-icon {
font-size: 14px;
}
}
/* Error message */
.error-message {
flex: 1;
+7 -29
View File
@@ -32,18 +32,6 @@ export interface ColumnDef {
id: string;
/** Human-readable header label. */
label: string;
/**
* Whether Settings may offer this column. Defaults to true.
*
* A definition is not the same thing as a *choice*. `titleArtist`
* is the phone's stacked column, picked by width in
* `PHONE_COLUMN_IDS`, and `tracklist.AllColumnIDs` in Go does not
* list it so a tick in the configurator sends a column set the
* backend rejects with `unknown track-list column ID`, the tick
* reverts on the next render, and the only trace is a
* `console.error` (#197).
*/
configurable?: boolean;
/** Extracts the display value from a track. */
accessor: (track: library.Track) => string;
/** Default CSS width (used when no saved width exists). */
@@ -110,16 +98,11 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
},
titleArtist: {
id: 'titleArtist',
// Named for what it sorts by. That label is drawn nowhere
// today: the phone has no column headers, and the page header's
// sort list is built from the *configured* columns, which this
// one can never be — see `configurable` below.
// Named for what it sorts by, since that is the only place the
// label is user-visible: the phone has no column headers, and
// the page header's sort list is built from the *configured*
// columns rather than the drawn ones.
label: 'Track Name',
// Chosen by width, never by the user, and rejected by the
// backend if it ever were. #197: Settings listed it anyway, so
// there were two rows called "Track Name" and the second one
// could not be selected.
configurable: false,
accessor: (t) => t.TrackName,
defaultWidth: '1fr',
comparator: (a, b) => compareStr(a.TrackName, b.TrackName),
@@ -283,15 +266,10 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
};
/**
* The column IDs Settings may offer, in default display order.
*
* Not every definition is one: a column the user cannot choose has no
* row in the configurator, because a checkbox that cannot change
* anything is worse than an absent one see `ColumnDef.configurable`.
* All column IDs in default display order.
* Used by the settings UI to list available columns.
*/
export const CONFIGURABLE_COLUMN_IDS: string[] = Object.keys(
COLUMN_DEFS,
).filter((id) => COLUMN_DEFS[id]?.configurable !== false);
export const ALL_COLUMN_IDS: string[] = Object.keys(COLUMN_DEFS);
/**
* Column IDs that are always searched regardless of visibility.

Some files were not shown because too many files have changed in this diff Show More