Compare commits

..
Author SHA1 Message Date
yonluandClaude Opus 5.5 bb26d5f289 feat(explore): arrows on every sideways row, and one album card size
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 4m25s
CI / e2e (pull_request) Successful in 13m43s
The shelves, the top results and the artist page's discography and
similar-artists rows scrolled sideways only by a horizontal wheel or a
trackpad, so a plain mouse could not reach anything past the fold.
<scroll-row> wraps each of them with previous/next arrows that are
hidden at the end they cannot move from, revealed on hover where there
is hover, and always shown where there is not.

The album card is defined once, in albumCardStyles, instead of twice.
explore-view clamped its cards to 130-150px, and with square artwork
that made cards in one row different heights as well as widths.  The
width is fixed now and each line under the art reserves its own space.
Covers are inset rather than cropped (contain, not cover).

The ownership badge sits over the artwork for owned and unowned alike,
and catalog cards no longer dim: a grid of dimmed covers read as a page
that had failed to load.  The album page's tracklist still dims unowned
rows, which says something different about something different.

Every MusicBrainz link now goes through openMusicBrainz, which pins the
origin, including the one on the album page that still called
window.open directly.  solid/chevron-left is bundled for the left arrow;
without it the arrow drew the missing-icon fallback.

Closes #264

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
2026-09-26 16:59:28 -04:00
yonluandClaude Opus 5.5 7b42b9ce56 feat(explore): sample the one-album-owned shelf instead of ranking it
"More from artists you own one album by" drew its artists and their
albums most-popular first, so it was a second leaderboard: the same
handful of big names every time the page opened, which is not what the
shelf is saying.  The pool is still bounded, but which artists and
which albums fill it is RANDOM(), so each visit is a different sample.
The test asserts the set rather than the order.

Refs #264

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
2026-09-26 16:58:57 -04:00
32 changed files with 5455 additions and 1715 deletions
+4 -21
View File
@@ -107,32 +107,15 @@ jobs:
# Conventional Commits. `.releaserc.yml` has always derived the # Conventional Commits. `.releaserc.yml` has always derived the
# version from the commit type; until now nothing checked that the # version from the commit type; until now nothing checked that the
# type was one it recognises, so a malformed subject silently meant # type was one it recognises, so a malformed subject silently meant
# "no release". # "no release". BEFORE is the push's previous tip and is absent or
# # all-zeros for a new branch, in which case only the tip is linted.
# **On a `pull_request` there is no `before`.** Gitea leaves
# `github.event.before` empty for one, so this step fell through to
# bare `make commit-check`, which lints `git log -1` — the tip
# alone. Every other commit the branch would bring was first
# examined by *main's* post-merge run, which is a green PR that
# stops being true after the merge, and which happened twice (#254).
# The PR's base is the stand-in: the range below already excludes
# what the base shares with the branch, because base advances on
# main and those commits stay reachable from it.
#
# Both are handed to the shell rather than chosen in an expression:
# `github.event.issue.number` in unclaim.yml is this repo's proof
# that payload fields resolve, and the shell then falls back to
# today's behaviour for a dispatch run or a missing field instead of
# depending on how `&&`/`||` treat an absent context.
- name: Commit messages - name: Commit messages
working-directory: /src working-directory: /src
env: env:
PR_BASE: ${{ github.event.pull_request.base.sha }} BEFORE: ${{ github.event.before }}
PUSH_BEFORE: ${{ github.event.before }}
run: | run: |
set -eu set -eu
BEFORE="${PR_BASE:-${PUSH_BEFORE:-}}" if [ -n "${BEFORE:-}" ] && [ "${BEFORE#0000000}" = "$BEFORE" ] \
if [ -n "$BEFORE" ] && [ "${BEFORE#0000000}" = "$BEFORE" ] \
&& git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
make commit-check RANGE="$BEFORE..$SHA" make commit-check RANGE="$BEFORE..$SHA"
else else
+1 -44
View File
@@ -68,7 +68,7 @@ jobs:
# claim with a test behind it now (cmd/indexbuild/deps_test.go), # claim with a test behind it now (cmd/indexbuild/deps_test.go),
# because the v3 migration quietly broke it and this job was where # because the v3 migration quietly broke it and this job was where
# that surfaced. # that surfaced.
image: golang:1.26 image: golang:1.25
# This host path must exist on the runner and be listed verbatim in # This host path must exist on the runner and be listed verbatim in
# act_runner's container.valid_volumes. It holds explore-staging/ # act_runner's container.valid_volumes. It holds explore-staging/
# (counts.bin + state.json) and yj.db — the checkpoint that makes # (counts.bin + state.json) and yj.db — the checkpoint that makes
@@ -148,49 +148,6 @@ jobs:
sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256 sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256
ls -lh /tmp/core-index.db.zst ls -lh /tmp/core-index.db.zst
# Nothing is published until it has been imported by the code that
# imports it on a user's machine. The exporter and the importer are
# two descriptions of one storage format, and every other tier tests
# the importer against a *fixture* rather than against the file being
# shipped — a second description free to be wrong in the same
# direction as the code reading it.
#
# That is how #258 reached everyone: the importer positioned its batch
# walk with a Go `string` cursor against this file's 16-byte `mbid`
# column, and SQLite neither coerces between TEXT and BLOB nor
# complains about the comparison — so the walk merged no rows and
# never advanced, and no install could finish its first index build.
# The fixture guarding that walk writes the old text encoding, and the
# only compact fixture is one row, below the batch size, so the bound
# query never ran. Both were green throughout.
#
# Running it here is also what keeps the failure cheap: the previous
# artifact stays published while this runs, so a failure costs one
# stale catalog rather than an empty one for every install.
#
# `-tags indexbuild` because this container has no GTK and the default
# tag set links the app through Wails. The `--- PASS` grep is not
# decoration — the test skips without the path, and a skip is
# indistinguishable from a pass in a summary line.
- name: Import the exported artifact as a client does
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
working-directory: /src
env:
YJ_CORE_INDEX_ARTIFACT: /tmp/core-index.db.zst
run: |
set -eu
log=/tmp/import-check.log
if ! go test -tags indexbuild -count=1 -timeout 30m -v \
-run TestImportPublishedArtifact ./backend/explore/ > "$log" 2>&1;
then
tail -60 "$log"
echo "::error::The artifact does not import; not publishing it."
exit 1
fi
cat "$log"
grep -qF -- 'PASS: TestImportPublishedArtifact' "$log"
echo "::notice::The artifact imports as a client would merge it."
- name: Publish to the Gitea package registry - name: Publish to the Gitea package registry
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true' if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
run: | run: |
@@ -57,21 +57,6 @@ behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and
staging the work that would produce it — job progress, download staging the work that would produce it — job progress, download
progress, scan progress. It calls `events.Deliver`, which *errors* progress, scan progress. It calls `events.Deliver`, which *errors*
when the event reaches nobody, so a `200` means it really arrived. when the event reaches nobody, so a `200` means it really arrived.
- **State you stage, you own** (#168). Nothing resets those stores, so
clear yours in `test.afterEach` with the same event that staged it
(`emit('JobsChanged', [])`) — the store replaces its list from every
snapshot, so `testctl` needs no special case. **Measured: this does
not currently cross a spec boundary**, because every test gets a fresh
page and `JobStore.init()` refetches `GetJobs()` from a backend
registry that `/__test/emit` never writes to. Stated anyway, because
it costs one line and the leak needs only one spec that keeps a page
alive — but do not cite #168 for a symptom you have not reproduced.
- **Measure against the thing next to you, not an absolute
coordinate.** An absolute number in a shell measurement is also a
claim about everything above it — `contentTop === 0` quietly asserts
"and no background job is running", which is not what that spec was
about or could arrange, while `contentTop === jobBandBottom` is true
either way. This is the half of #168 that stands on its own.
- **`restore` is slow** (~40 s in the suite) because it copies every - **`restore` is slow** (~40 s in the suite) because it copies every
table. Prefer snapshotting once and restoring only when a spec table. Prefer snapshotting once and restoring only when a spec
genuinely mutates state. genuinely mutates state.
-19
View File
@@ -5078,22 +5078,3 @@ last rendered card.
("ask the virtualizer for a larger overscan") is therefore not ("ask the virtualizer for a larger overscan") is therefore not
available without patching a private, which is why the request is available without patching a private, which is why the request is
issued ahead of the element instead. issued ahead of the element instead.
## Declined, and recorded nowhere else (2026-09-25, #256)
When CLAUDE.md was cut down to rules, most of its "considered and
declined" paragraphs already had a home in a code or config comment
beside what they explain. These three did not:
- **`touch-action: manipulation` was declined** (#54). The 300ms tap
delay it is offered for is already absent on a `width=device-width`
viewport; what it would actually change is the gesture stack #63 tuned
by measurement on the reference device.
- **There is no "Go to Genre"** in the phone row menu (#67). That menu
replaces name links a phone cannot use, and there has never been a
genre link to replace — it would be new navigation, which wants its
own issue.
- **The overlaid queue has no tap-outside gutter on a phone** (#171).
The drawer-style gutter would buy the affordance by taking width off a
full-screen surface on a 424px viewport; back and a 44px close button
answer it instead.
+3969 -299
View File
File diff suppressed because it is too large Load Diff
+11 -88
View File
@@ -1,10 +1,8 @@
package explore package explore
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"database/sql/driver"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -285,25 +283,6 @@ func (si *SearchIndex) importCoreArtifact(ctx context.Context, path string) erro
} }
merged, mergeErr := si.mergeArtifactRows(ctx, info.rows) merged, mergeErr := si.mergeArtifactRows(ctx, info.rows)
// Every row the artifact declares has to land. The walk partitions
// the artifact's key space, so a total short of info.rows does not
// mean the artifact was smaller than it said -- it means a predicate
// filtered rows out, and the catalog is quietly partial. Equality
// rather than a lower bound because RowsAffected counts an upsert
// that changes nothing, and a row already merged locally is counted
// again here.
//
// One reachable case, so this is not merely a tripwire: a row whose
// mbid is empty is excluded by `mbid > ?` in both encodings, and an
// artifact carrying one would otherwise import as complete.
if mergeErr == nil && merged != info.rows {
mergeErr = fmt.Errorf(
"%w: merged %d of %d rows — a row the artifact holds was not selected",
ErrArtifactUnusable, merged, info.rows,
)
}
if mergeErr == nil { if mergeErr == nil {
si.mergeArtifactCredits(ctx) si.mergeArtifactCredits(ctx)
} }
@@ -369,12 +348,8 @@ func (si *SearchIndex) analyzeIndex() {
// is an index range scan and a cancelled import leaves committed work // is an index range scan and a cancelled import leaves committed work
// behind rather than rolling it all back. // behind rather than rolling it all back.
func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) { func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) {
// Asked once, because it is a property of the file and it decides
// how the walk's own comparisons are typed. See artifactKey.
storesText := si.artifactStoresText()
selectColumns := artifactSelectColumns( selectColumns := artifactSelectColumns(
storesText, si.artifactHasTotals(), si.artifactStoresText(), si.artifactHasTotals(),
) )
insertSQL := ` insertSQL := `
@@ -392,7 +367,7 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL
var ( var (
cursor artifactKey cursor string
merged int merged int
) )
@@ -401,30 +376,17 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
return merged, err return merged, err
} }
upper, hasUpper, err := si.artifactBatchBound(storesText, cursor) upper, hasUpper, err := si.artifactBatchBound(cursor)
if err != nil { if err != nil {
return merged, err return merged, err
} }
if hasUpper && bytes.Compare(upper, cursor) <= 0 {
// The predicate matched the cursor itself, so the walk can
// never advance. SQLite says nothing when a comparison is
// made between types it will not coerce - the query simply
// answers wrongly - so a mismatch here would otherwise spin
// forever behind an unmoving progress bar. Fail instead.
return merged, fmt.Errorf(
"%w: artifact walk did not advance past %x",
ErrArtifactUnusable, []byte(cursor),
)
}
var res sql.Result var res sql.Result
if hasUpper { if hasUpper {
res, err = si.db.ExecContext(insertRangeSQL, res, err = si.db.ExecContext(insertRangeSQL, cursor, upper)
cursor.bind(storesText), upper.bind(storesText))
} else { } else {
res, err = si.db.ExecContext(insertSQL, cursor.bind(storesText)) res, err = si.db.ExecContext(insertSQL, cursor)
} }
if err != nil { if err != nil {
@@ -451,65 +413,26 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
} }
} }
// artifactKey is one MBID as the attached artifact stores it: 16 raw
// bytes in a compact artifact, the dashed 36-character form in one
// published before that storage change.
//
// It is a type with a bind method rather than a string because the
// comparison it feeds is typed, and the wrong type is silent. SQLite
// does not coerce between TEXT and BLOB and orders every blob after
// every text value, so a cursor bound as text against a byte column
// makes `mbid > ?` true of the whole table - the walk rediscovers the
// same batch bound forever, and `mbid <= ?` false of the whole table,
// so no batch merges at all. Nothing errors; the import simply never
// finishes. bind is the one place that knows which form the column is
// in, decided by artifactStoresText, which asks the artifact rather than
// trusting a version number.
type artifactKey []byte
// bind renders the key as a statement argument in the artifact's own
// encoding.
func (k artifactKey) bind(storesText bool) driver.Value {
if storesText {
return string(k)
}
// Never nil. database/sql converts a nil []byte to SQL NULL, and
// `mbid > NULL` is NULL for every row - so an unset cursor would
// agree with nothing and import nothing, which is the same silently
// empty merge this type exists to prevent, one type over.
if k == nil {
return []byte{}
}
return []byte(k)
}
// artifactBatchBound returns the MBID that ends the next batch, and // artifactBatchBound returns the MBID that ends the next batch, and
// whether one exists — no bound means the remainder is the last batch. // whether one exists — no bound means the remainder is the last batch.
// func (si *SearchIndex) artifactBatchBound(cursor string) (string, bool, error) {
// The bound is read out of the artifact and handed back as an var bound string
// artifactKey, because it becomes the next comparison the walk makes.
func (si *SearchIndex) artifactBatchBound(
storesText bool, cursor artifactKey,
) (artifactKey, bool, error) {
var bound []byte
err := si.db.QueryRowWriter( err := si.db.QueryRowWriter(
`SELECT mbid FROM core.explore_index `SELECT mbid FROM core.explore_index
WHERE mbid > ? ORDER BY mbid LIMIT 1 OFFSET ?`, WHERE mbid > ? ORDER BY mbid LIMIT 1 OFFSET ?`,
cursor.bind(storesText), artifactMergeBatch-1, cursor, artifactMergeBatch-1,
).Scan(&bound) ).Scan(&bound)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil return "", false, nil
} }
if err != nil { if err != nil {
return nil, false, fmt.Errorf("%w: batch bound: %w", ErrArtifactUnusable, err) return "", false, fmt.Errorf("%w: batch bound: %w", ErrArtifactUnusable, err)
} }
return artifactKey(bound), true, nil return bound, true, nil
} }
// stampArtifactMeta records what the merge established: the catalog half // stampArtifactMeta records what the merge established: the catalog half
+62 -250
View File
@@ -1,7 +1,6 @@
package explore package explore
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
@@ -72,7 +71,13 @@ func writeTestArtifact(
} }
} }
stampArtifactMeta(t, db, meta) for k, v := range meta {
if _, err := db.Exec(
`INSERT INTO artifact_meta (key, value) VALUES (?, ?)`, k, v,
); err != nil {
t.Fatalf("stamp artifact meta: %v", err)
}
}
for _, r := range rows { for _, r := range rows {
if _, err := db.Exec(` if _, err := db.Exec(`
@@ -88,101 +93,6 @@ func writeTestArtifact(
return path return path
} }
// compactArtifactSchema is the artifact cmd/indexexport publishes: the
// catalog's ids as 16 raw bytes, its entity types as codes, and the
// per-release-group total_tracks the exporter added after the first
// artifact was shipped.
//
// It matters that a fixture carries this encoding and not the older
// text one, because SQLite does not coerce between TEXT and BLOB and
// every comparison the importer makes against an mbid is therefore
// encoding-sensitive. writeTestArtifact above is the *other* fixture:
// it still writes the text form, which is what the first published
// artifact carries and what the importer must keep reading.
var compactArtifactSchema = []string{
`CREATE TABLE explore_index (
entity_type INTEGER NOT NULL,
mbid BLOB NOT NULL,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid BLOB NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid BLOB NOT NULL DEFAULT x'',
release_name TEXT NOT NULL DEFAULT '',
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
total_tracks INTEGER NOT NULL DEFAULT 0,
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
discog_fetched INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (mbid)
) WITHOUT ROWID`,
`CREATE TABLE artifact_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
}
// writeCompactTestArtifact builds the artifact the exporter publishes
// today, in its own encoding, so the importer is exercised against what
// a client actually downloads rather than against what it was written
// for.
func writeCompactTestArtifact(
t *testing.T, meta map[string]string, rows []artifactRow,
) string {
t.Helper()
path := filepath.Join(t.TempDir(), "core-index.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("open artifact: %v", err)
}
defer func() { _ = db.Close() }()
for _, stmt := range compactArtifactSchema {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("create artifact schema: %v", err)
}
}
stampArtifactMeta(t, db, meta)
for _, r := range rows {
if _, err := db.Exec(`
INSERT INTO explore_index
(entity_type, mbid, title, artist_name, artist_mbid, popularity)
VALUES (?, ?, ?, ?, ?, ?)`,
entityCode(r.entityType), mbidBytes(r.mbid), r.title,
r.artistName, mbidBytes(r.artistMBID), r.popularity,
); err != nil {
t.Fatalf("insert artifact row: %v", err)
}
}
return path
}
// stampArtifactMeta writes the artifact_meta rows a fixture declares.
func stampArtifactMeta(t *testing.T, db *sql.DB, meta map[string]string) {
t.Helper()
for k, v := range meta {
if _, err := db.Exec(
`INSERT INTO artifact_meta (key, value) VALUES (?, ?)`, k, v,
); err != nil {
t.Fatalf("stamp artifact meta: %v", err)
}
}
}
// validMeta is the artifact_meta a well-formed artifact carries. // validMeta is the artifact_meta a well-formed artifact carries.
func validMeta() map[string]string { func validMeta() map[string]string {
return map[string]string{ return map[string]string{
@@ -360,71 +270,6 @@ func TestImportCoreArtifactBatchWalkCoversAllRows(t *testing.T) {
} }
} }
// TestImportCoreArtifactBatchWalkCoversAllRowsCompact is the batch walk
// on the encoding the exporter actually publishes.
//
// The walk positions itself by comparing the artifact's own mbid column
// against the last id it reached, and that column holds 16 raw bytes.
// SQLite does not coerce between TEXT and BLOB, and a blob sorts after
// every text value, so a cursor bound as text is a predicate that either
// matches every row or none: `mbid > ?` with an empty text key is true
// of the whole table, so
// the 100th row is always the 100th row and the bound never advances,
// while `mbid <= <text>` is false of the whole table, so no batch ever
// merges. The result is not a wrong import but an unbounded loop that
// merges nothing and never fails.
//
// Both encodings are covered on purpose. The walk was only ever tested
// against the text fixture above, which is why it shipped broken on the
// one the clients download.
func TestImportCoreArtifactBatchWalkCoversAllRowsCompact(t *testing.T) {
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, testLogger())
original := artifactMergeBatch
artifactMergeBatch = 100
t.Cleanup(func() { artifactMergeBatch = original })
const total = 337
rows := make([]artifactRow, 0, total)
for i := range total {
rows = append(rows, artifactRow{
entityType: EntityRecording,
mbid: syntheticMBID(i),
title: "Song",
artistName: "Artist",
artistMBID: artA,
popularity: i,
})
}
path := writeCompactTestArtifact(t, validMeta(), rows)
if err := si.importCoreArtifact(context.Background(), path); err != nil {
t.Fatalf("importCoreArtifact: %v", err)
}
var got, top int
if err := db.QueryRowWriter(
"SELECT COUNT(*), MAX(popularity) FROM explore_index",
).Scan(&got, &top); err != nil {
t.Fatalf("count rows: %v", err)
}
if got != total {
t.Errorf("merged %d rows, want %d", got, total)
}
// A count alone would pass if the walk re-merged the same first
// batch forever, so the far end of the artifact is checked too.
if top != total-1 {
t.Errorf("highest popularity = %d, want %d", top, total-1)
}
}
func TestImportCoreArtifactRejectsBadArtifacts(t *testing.T) { func TestImportCoreArtifactRejectsBadArtifacts(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -609,9 +454,61 @@ func TestArtifactColumnsMatchExporter(t *testing.T) {
// the importer decides by asking the artifact, not by trusting a // the importer decides by asking the artifact, not by trusting a
// version number, and both must land identically. // version number, and both must land identically.
func TestImportCoreArtifactAcceptsBothEncodings(t *testing.T) { func TestImportCoreArtifactAcceptsBothEncodings(t *testing.T) {
compact := writeCompactTestArtifact(t, validMeta(), []artifactRow{ compact := filepath.Join(t.TempDir(), "core-index.db")
{EntityArtist, artA, "Artist A", "Artist A", artA, 5000},
}) db, err := sql.Open("sqlite", "file:"+compact)
if err != nil {
t.Fatalf("open artifact: %v", err)
}
if _, err := db.Exec(`CREATE TABLE explore_index (
entity_type INTEGER NOT NULL,
mbid BLOB NOT NULL,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid BLOB NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid BLOB NOT NULL DEFAULT x'',
release_name TEXT NOT NULL DEFAULT '',
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
discog_fetched INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (mbid)
)`); err != nil {
t.Fatalf("create artifact table: %v", err)
}
if _, err := db.Exec(
`CREATE TABLE artifact_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
); err != nil {
t.Fatalf("create artifact meta: %v", err)
}
for k, v := range validMeta() {
if _, err := db.Exec(
"INSERT INTO artifact_meta (key, value) VALUES (?, ?)", k, v,
); err != nil {
t.Fatalf("write artifact meta: %v", err)
}
}
if _, err := db.Exec(`
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid, popularity)
VALUES (1, ?, 'Artist A', 'Artist A', ?, 5000)`,
mbidBytes(artA), mbidBytes(artA),
); err != nil {
t.Fatalf("write artifact row: %v", err)
}
_ = db.Close()
live := database.NewTestDB(t) live := database.NewTestDB(t)
si := NewSearchIndex(live, nil, nil, testLogger()) si := NewSearchIndex(live, nil, nil, testLogger())
@@ -851,88 +748,3 @@ func TestImportCoreArtifactWithoutCredits(t *testing.T) {
t.Errorf("credit refs = %d, want 0", refs) t.Errorf("credit refs = %d, want 0", refs)
} }
} }
// TestImportCoreArtifactRefusesAMergeThatLosesRows is the count guard's
// positive case.
//
// The walk's predicates partition the artifact's key space, so a merge
// that lands fewer rows than the artifact declares means a predicate
// dropped some — and the failure is a catalog that looks populated and
// is missing things nobody can name. An empty mbid is the reachable
// way to get there: `mbid > ?` is false of it in both encodings, so it
// is never selected, and nothing else in the import would notice.
func TestImportCoreArtifactRefusesAMergeThatLosesRows(t *testing.T) {
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, testLogger())
path := writeCompactTestArtifact(t, validMeta(), []artifactRow{
{EntityArtist, artA, "Artist A", "Artist A", artA, 5000},
{EntityArtist, "", "Nameless", "Artist A", artA, 4000},
})
err := si.importCoreArtifact(context.Background(), path)
if err == nil {
t.Fatal("a merge that lost a row was reported as a complete import")
}
if !strings.Contains(err.Error(), "merged 1 of 2 rows") {
t.Errorf("error = %v, want it to name the shortfall", err)
}
// And the same rule as every other rejection: a failed merge must not
// leave the index claiming it has a catalog, or the real build would
// never run again.
if si.hasMeta(dumpImportDoneKey) {
t.Error("a failed import still stamped dump_import_done")
}
}
// TestArtifactKeyBindsInTheArtifactsOwnEncoding pins the one place the
// batch walk's comparison type is decided.
//
// Every wrong answer is silent, which is why it is worth pinning all
// four. SQLite does not coerce TEXT to BLOB and orders every blob after
// every text value, so a text key against a byte column makes
// `mbid > ?` true of the whole artifact - the cursor never advances and
// the walk spins forever without merging a row - while a byte key
// against a text column makes it false of the whole artifact, so every
// batch merges nothing and the import "succeeds" empty. An unset cursor
// is the same fault once more: database/sql converts a nil []byte to
// SQL NULL, and `mbid > NULL` matches no row at all.
func TestArtifactKeyBindsInTheArtifactsOwnEncoding(t *testing.T) {
raw := mbidBytes(artA)
for _, tt := range []struct {
name string
key artifactKey
want []byte
}{
{"unset", nil, []byte{}},
{"set", artifactKey(raw), raw},
} {
t.Run("bytes/"+tt.name, func(t *testing.T) {
got, ok := tt.key.bind(false).([]byte)
if !ok {
t.Fatalf("bind(false) = %T, want []byte", tt.key.bind(false))
}
if got == nil {
t.Fatal("bound to SQL NULL, which matches no row")
}
if !bytes.Equal(got, tt.want) {
t.Errorf("bind(false) = %x, want %x", got, tt.want)
}
})
}
// The dashed form is what an artifact published before the storage
// change carries, and it has to compare as text against text.
if got := artifactKey(nil).bind(true); got != "" {
t.Errorf("bind(true) on an unset cursor = %#v, want an empty string", got)
}
if got := artifactKey(artA).bind(true); got != artA {
t.Errorf("bind(true) = %#v, want %q", got, artA)
}
}
-266
View File
@@ -1,266 +0,0 @@
package explore
import (
"context"
"database/sql"
"io"
"os"
"path/filepath"
"strings"
"testing"
"yellowjacket/backend/database"
)
// Import of the artifact we actually publish, as a client imports it.
//
// Every other test here builds a fixture, and a fixture is a second
// description of the storage format that can be wrong in the same
// direction as the code reading it. That is how #258 shipped: the
// importer positioned its batch walk with a Go `string` cursor against
// the artifact's 16-byte `mbid` column, and SQLite neither coerces
// between TEXT and BLOB nor complains about the comparison — so the walk
// merged nothing and never advanced, and no install could finish its
// first index build. The fixture that guards the walk writes the old
// text encoding; the only compact fixture is one row, below the batch
// size, so the bound query never ran. Both passed throughout.
//
// So this one takes the published file and runs the client's own path
// over it — checksum, decompress, merge — and asserts that what the
// artifact holds is what the client ends up with.
//
// It skips without the path, so an ordinary test run pays nothing for
// it, and the publish job is where it is meant to run:
//
// YJ_CORE_INDEX_ARTIFACT=/tmp/core-index.db.zst \
// go test -tags indexbuild -run TestImportPublishedArtifact \
// ./backend/explore/
//
// The indexbuild tag is not incidental: that job's container has no GTK,
// and the default tag set links the app through Wails.
// publishedArtifactEnv points at the published artifact: the compressed
// core-index.db.zst, or the unpacked core-index.db.
const publishedArtifactEnv = "YJ_CORE_INDEX_ARTIFACT"
// artifactTotals is the pair this test compares across the boundary.
//
// Rows is the whole point — a merge that lands fewer of them than the
// artifact declares is a catalog that looks populated and is missing
// things nobody can name — and popularity is the half whose absence was
// reported when it happened, because it arrives only through the merge.
type artifactTotals struct {
rows int
withListen int
}
func TestImportPublishedArtifact(t *testing.T) {
published := strings.TrimSpace(os.Getenv(publishedArtifactEnv))
if published == "" {
t.Skipf("set %s=<core-index.db.zst> to import the published artifact",
publishedArtifactEnv)
}
if _, err := os.Stat(published); err != nil {
t.Fatalf("%s: %v", publishedArtifactEnv, err)
}
// A file-backed database rather than NewTestDB's in-memory one: the
// artifact is ~135MB and a million rows, which is not a thing to hold
// in RAM inside a test. YJ_HOME is how NewDB is pointed somewhere
// disposable, and going through NewDB means this is the constructor,
// the schema and the read pool the app itself opens.
//
// Nothing closes it, because nothing can: `DB` has no Close and the
// app's handles are process-lifetime by design. The directory is
// unlinked at cleanup and the file goes with it.
t.Setenv("YJ_HOME", t.TempDir())
db, err := database.NewDB(testLogger())
if err != nil {
t.Fatalf("open database: %v", err)
}
si := NewSearchIndex(db, nil, nil, testLogger())
// The checksum the publisher shipped, if it shipped one. Every
// client verifies it and refuses the artifact when it does not
// match, so a wrong one breaks Explore for everyone who has not
// already imported — and nothing else would see it, because the
// comparison is between two files only the publisher has.
if want, ok := publishedChecksum(published); ok {
got, err := fileSHA256(published)
if err != nil {
t.Fatalf("checksum the artifact: %v", err)
}
if got != want {
t.Errorf("published artifact hashes to %s, but its .sha256 says %s",
got, want)
}
}
unpacked := unpackPublishedArtifact(t, si, published)
want, err := artifactTotalsOf(unpacked)
if err != nil {
t.Fatalf("count the artifact's rows: %v", err)
}
if err := si.importCoreArtifact(context.Background(), unpacked); err != nil {
t.Fatalf("importCoreArtifact: %v", err)
}
got, err := indexTotalsOf(db)
if err != nil {
t.Fatalf("count the index's rows: %v", err)
}
if got.rows != want.rows {
t.Errorf("merged %d rows, but the artifact holds %d",
got.rows, want.rows)
}
if got.withListen != want.withListen {
t.Errorf("%d rows carry a listen count, but the artifact holds %d of them",
got.withListen, want.withListen)
}
// The FTS index is rebuilt from the table once the merge is done, and
// it is what search actually reads: a merge that lands without it
// leaves Explore silently matching nothing, which is the state #258
// produced by a different route.
var indexed int
if err := db.QueryRowWriter(
"SELECT COUNT(*) FROM explore_index_fts",
).Scan(&indexed); err != nil {
t.Fatalf("count the FTS index: %v", err)
}
if indexed != got.rows {
t.Errorf("FTS index holds %d rows against the table's %d",
indexed, got.rows)
}
// And one row read back through the app's own path, which is the
// other direction of every conversion the merge makes: a byte MBID
// out of the table, the app's dashed form, and back in as a lookup.
var raw []byte
if err := db.QueryRowWriter(`
SELECT mbid FROM explore_index
WHERE entity_type = 1 /* artist */ AND popularity > 0
ORDER BY popularity DESC LIMIT 1`).Scan(&raw); err != nil {
t.Fatalf("read a stored mbid: %v", err)
}
dashed, err := mbidFromBytes(raw)
if err != nil {
t.Fatalf("the stored mbid is not one: %v", err)
}
artist := si.LookupArtistByMBID(dashed)
if artist == nil {
t.Fatalf("the artifact's most popular artist %s does not look up", dashed)
}
if artist.Popularity == 0 {
t.Errorf("artist %s came back with no popularity", dashed)
}
}
// publishedChecksum reads the sha256 the publisher wrote beside the
// artifact, in `sha256sum` output form. A missing file is not a
// failure: it is only there when the artifact came from the publish job.
func publishedChecksum(path string) (string, bool) {
body, err := os.ReadFile(path + ".sha256")
if err != nil {
return "", false
}
sum := strings.TrimSpace(string(body))
if i := strings.IndexAny(sum, " \t"); i > 0 {
sum = sum[:i]
}
if len(sum) != 64 {
return "", false
}
return strings.ToLower(sum), true
}
// unpackPublishedArtifact returns a path to the unpacked database,
// going through the client's own decompression when it is handed the
// compressed file that is actually published.
func unpackPublishedArtifact(t *testing.T, si *SearchIndex, path string) string {
t.Helper()
if strings.HasSuffix(path, ".db") {
return path
}
// Copied into the test's own directory first: decompress writes
// beside the compressed file, and the publisher's directory is not
// this test's to write in.
staging := t.TempDir()
dst := filepath.Join(staging, coreArtifactFile)
src, err := os.Open(path)
if err != nil {
t.Fatalf("open the published artifact: %v", err)
}
defer func() { _ = src.Close() }()
out, err := os.Create(dst)
if err != nil {
t.Fatalf("create a staging copy: %v", err)
}
if _, err := io.Copy(out, src); err != nil {
t.Fatalf("copy the published artifact: %v", err)
}
if err := out.Close(); err != nil {
t.Fatalf("close the staging copy: %v", err)
}
fetcher := &artifactFetcher{si: si, stagingDir: staging}
if err := fetcher.decompress(context.Background()); err != nil {
t.Fatalf("decompress the published artifact: %v", err)
}
return fetcher.unpackedPath()
}
// artifactTotalsOf counts what an artifact file holds, read directly so
// the numbers do not depend on anything the client does.
func artifactTotalsOf(path string) (artifactTotals, error) {
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
return artifactTotals{}, err
}
defer func() { _ = db.Close() }()
var totals artifactTotals
err = db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(popularity > 0), 0)
FROM explore_index`).Scan(&totals.rows, &totals.withListen)
if err != nil {
return artifactTotals{}, err
}
return totals, nil
}
// indexTotalsOf counts what the client ended up with.
func indexTotalsOf(db *database.DB) (artifactTotals, error) {
var totals artifactTotals
err := db.QueryRowWriter(`SELECT COUNT(*), COALESCE(SUM(popularity > 0), 0)
FROM explore_index`).Scan(&totals.rows, &totals.withListen)
return totals, err
}
+17 -11
View File
@@ -383,9 +383,13 @@ func (si *SearchIndex) topByPopularity(
// MusicBrainz IDs, so this never touches the library tables and asks // MusicBrainz IDs, so this never touches the library tables and asks
// one query rather than one per artist. // one query rather than one per artist.
// //
// The artists are drawn most-popular-owned-album first, so a large // **The row is drawn at random, and that is the whole point of it.**
// library's pool is the part of it the user is likeliest to recognise // Ordered by popularity it was a second leaderboard: the same handful of
// rather than whichever artists sort first. // big names appeared every time the page opened, which is not what "you
// own one album by these artists" is saying. The pool is still bounded
// to `pool` artists — a 4 000-artist library does not need all of them
// ranked — but which of them, and which of their albums, is `RANDOM()`,
// so the shelf is a different sample each visit.
func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists( func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists(
ctx context.Context, ctx context.Context,
pool, limit int, pool, limit int,
@@ -396,15 +400,17 @@ func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists(
WHERE entity_type = 2 /* release_group */ WHERE entity_type = 2 /* release_group */
AND in_library = 0 AND in_library = 0
AND artist_mbid IN ( AND artist_mbid IN (
SELECT artist_mbid FROM explore_index SELECT artist_mbid FROM (
WHERE entity_type = 2 /* release_group */ SELECT artist_mbid FROM explore_index
AND in_library = 1 WHERE entity_type = 2 /* release_group */
AND artist_mbid != x'' AND in_library = 1
GROUP BY artist_mbid AND artist_mbid != x''
HAVING COUNT(*) = 1 GROUP BY artist_mbid
ORDER BY MAX(popularity) DESC HAVING COUNT(*) = 1
)
ORDER BY RANDOM()
LIMIT ?) LIMIT ?)
ORDER BY popularity DESC ORDER BY RANDOM()
LIMIT ?`, LIMIT ?`,
pool, limit, pool, limit,
)) ))
+6
View File
@@ -3,6 +3,7 @@ package explore
import ( import (
"context" "context"
"log/slog" "log/slog"
"sort"
"testing" "testing"
"yellowjacket/backend/database" "yellowjacket/backend/database"
@@ -202,6 +203,11 @@ func TestShelves_MoreFromOwnedNeedsExactlyOneOwnedAlbum(t *testing.T) {
titles = append(titles, album.Title) titles = append(titles, album.Title)
} }
// The row is a random sample, so the *set* is what is asserted and
// not the order — see `unownedAlbumsBySinglyOwnedArtists` for why
// the ordering was given up.
sort.Strings(titles)
if len(titles) != 2 || titles[0] != "Second" || titles[1] != "Third" { if len(titles) != 2 || titles[0] != "Second" || titles[1] != "Third" {
t.Fatalf("albums = %v, want [Second Third]", titles) t.Fatalf("albums = %v, want [Second Third]", titles)
} }
-17
View File
@@ -60,23 +60,6 @@ const PHONE = { width: 424, height: 439 };
const DESKTOP = { width: 1100, height: 800 }; const DESKTOP = { width: 1100, height: 800 };
test.describe('background jobs on a phone', () => { test.describe('background jobs on a phone', () => {
/**
* **State a spec stages is the spec's to clear.** `/__test/emit` writes
* to a store nothing resets, so the event that staged a job is the
* event that clears it — `JobStore` replaces its whole list from every
* snapshot, so `testctl` needs no special case.
*
* **Measured on #168: this does not currently outlive the page.** Every
* test gets a fresh page, and `JobStore.init()` refetches `GetJobs()`
* from a backend registry that `/__test/emit` never writes to, so the
* staged job is gone before the next spec starts. Ownership is stated
* rather than a live leak repaired — the leak needs a page that
* survives its own spec, and there is none today.
*/
test.afterEach(async ({ testctl }) => {
await testctl.emit('JobsChanged', []);
});
test('are shown in the band, without opening anything', async ({ test('are shown in the band, without opening anything', async ({
app, app,
testctl, testctl,
+2 -2
View File
@@ -171,8 +171,8 @@ test.describe('search on a phone', () => {
// Attached, not visible: `wa-dialog`'s host is `display: contents`, // Attached, not visible: `wa-dialog`'s host is `display: contents`,
// so the element carrying the testid always reports hidden — what // so the element carrying the testid always reports hidden — what
// is visible is the native `<dialog>` inside it. That awkwardness // is visible is the native `<dialog>` inside it. That awkwardness
// is why the assertion that this is really up is the role query // is written down in CLAUDE.md and is why the assertion that this
// below. // is really up is the role query below.
await expect(dialog).toBeAttached(); await expect(dialog).toBeAttached();
// Named, which `getByRole` can answer and the a11y snapshot cannot // Named, which `getByRole` can answer and the a11y snapshot cannot
-16
View File
@@ -102,22 +102,6 @@ const collapsed = (page: Page) =>
})); }));
test.describe('the top bar fits the window', () => { test.describe('the top bar fits the window', () => {
/**
* **State a spec stages is the spec's to clear** (#168). `/__test/emit`
* writes to a store nothing resets, and this file stages the widest job
* in the app, so it puts it back — with the same event, since the store
* replaces its whole list from every snapshot.
*
* **Measured: it does not currently outlive the page.** Every test gets
* a fresh page and `JobStore.init()` refetches `GetJobs()` from a
* backend registry `/__test/emit` never writes to, so nothing is being
* repaired here; the rule is stated because it costs one line and the
* leak would need only one spec that keeps a page alive.
*/
test.afterEach(async ({ testctl }) => {
await testctl.emit('JobsChanged', []);
});
/** /**
* The phone's answer, which is not "it fits" (#57). * The phone's answer, which is not "it fits" (#57).
* *
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"/></svg>

After

Width:  |  Height:  |  Size: 476 B

@@ -60,6 +60,7 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dictByName } from '@utils/binding'; import { dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js'; import type { TrackDetails } from '@components/track-details/track-details.js';
import { showTrackDetailsForPath } from '@utils/track-details-opener.js'; import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
import { openMusicBrainz } from '@utils/external-link';
import '@components/playlist-picker/playlist-picker.js'; import '@components/playlist-picker/playlist-picker.js';
import { import {
ICON_CAN_REQUEST, ICON_CAN_REQUEST,
@@ -2942,7 +2943,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (!track?.mbid) return; if (!track?.mbid) return;
window.open(`https://musicbrainz.org/recording/${track.mbid}`, '_blank', 'noopener'); openMusicBrainz(`/recording/${track.mbid}`);
} }
/** /**
@@ -4,6 +4,8 @@ import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css'; import { backButton } from '../../styles/back-button.css';
import { albumCardStyles } from '../../styles/album-card.css';
import '../scroll-row/scroll-row.js';
import { import {
LookupArtist, LookupArtist,
BrowseReleaseGroups, BrowseReleaseGroups,
@@ -46,11 +48,8 @@ import {
libraryStatusFor, libraryStatusFor,
toggleRequest, toggleRequest,
} from '@utils/library-status'; } from '@utils/library-status';
import { import { isOwned, ownershipLabel } from '@utils/ownership';
isOwned, import { openMusicBrainz } from '@utils/external-link';
ownershipLabel,
unownedStyles,
} from '@utils/ownership';
import { completenessStore } from '@store/completeness-store'; import { completenessStore } from '@store/completeness-store';
import '../catalog-scope-notice/catalog-scope-notice.js'; import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js'; import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
@@ -62,6 +61,7 @@ import {
ContextMenuController, ContextMenuController,
contextMenuStyles, contextMenuStyles,
isContextMenuKey, isContextMenuKey,
MenuKeyboard,
} from '@utils/context-menu-controller.js'; } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js'; import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -187,11 +187,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@state() private topReleasesExpanded = false; @state() private topReleasesExpanded = false;
private topSectionStacked = false; private topSectionStacked = false;
private topSectionObserver?: ResizeObserver; private topSectionObserver?: ResizeObserver;
@state() private expandedDiscoGroups = new Set<string>();
/** Number of album cards that fit in one row of the discography grid. */ /** Whether the Play button's Shuffle dropdown is up. */
@state() private discoRowSize = 5; @state() private playMenuOpen = false;
private discoObserver?: ResizeObserver; private playMenuKeyboard = new MenuKeyboard(() => this.closePlayMenu());
@state() private similarExpanded = false; private playOutsideAttached = false;
/* ── Release prefetch ── */ /* ── Release prefetch ── */
@@ -221,6 +221,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@query('#context-menu') @query('#context-menu')
private contextMenuPopup!: MenuSurface; private contextMenuPopup!: MenuSurface;
@query('.play-menu-button')
private playMenuButton?: HTMLButtonElement;
@query('#artist-play-menu')
private playMenuPanel?: HTMLElement;
@query('#playlist-submenu') @query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup; private playlistSubmenuPopup?: WaPopup;
@@ -271,7 +277,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
backButton, backButton,
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
unownedStyles, albumCardStyles,
css` css`
:host { :host {
display: flex; display: flex;
@@ -319,10 +325,45 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
object-fit: cover; object-fit: cover;
} }
.artist-follow { .artist-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px; margin-top: 10px;
} }
/* The Play button and its caret are one control, so they
are one box: no gap between them, and the caret carries
the same filled appearance as the button it extends. */
.play-split {
display: inline-flex;
align-items: stretch;
}
.play-menu-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
padding: 0;
border: none;
border-left: 1px solid rgba(0, 0, 0, 0.25);
border-radius: 0 6px 6px 0;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
cursor: pointer;
}
.play-menu-button:hover {
filter: brightness(1.1);
}
.play-menu-button:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: 2px;
}
.artist-info { .artist-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -331,7 +372,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
} }
.artist-title { .artist-title {
font-size: 24px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--yj-text-primary, #fff); color: var(--yj-text-primary, #fff);
white-space: nowrap; white-space: nowrap;
@@ -359,6 +400,13 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
flex-wrap: wrap; flex-wrap: wrap;
} }
/* The listen count is a headline number, not metadata, so
it sits a size above the type/country line. */
.artist-listens {
font-size: var(--yj-text-lg);
color: var(--yj-text-secondary, #b3b3b3);
}
.meta-separator { .meta-separator {
opacity: 0.4; opacity: 0.4;
} }
@@ -446,23 +494,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
outline-offset: -2px; outline-offset: -2px;
} }
.artist-play-actions {
margin-top: 10px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.track-rank {
width: 24px;
text-align: right;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-md);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.track-art { .track-art {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -489,6 +520,52 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
opacity: 0.5; opacity: 0.5;
} }
/* Play where you own the track, the request badge where you
do not — over the artwork rather than at the end of the
row, where it was a badge beside a row you can already
double-click. */
.track-art-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
visibility: hidden;
opacity: 0;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
.track-art-play {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: #fff;
font-size: 14px;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.track-item:hover .track-art-overlay,
.track-item:focus-within .track-art-overlay {
visibility: visible;
opacity: 1;
}
}
/* No hover means no double-click either, so the overlay is
the only route to playing a top track and must be there. */
@media not all and (hover: hover) {
.track-art-overlay {
visibility: visible;
opacity: 1;
}
}
.track-info { .track-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -522,7 +599,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
.track-item library-status-indicator { .track-item library-status-indicator {
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Top section (tracks + releases side-by-side) ── */ /* ── Top section (tracks + releases side-by-side) ── */
.top-section-wrapper { .top-section-wrapper {
container-type: inline-size; container-type: inline-size;
@@ -754,8 +830,30 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
white-space: nowrap; white-space: nowrap;
} }
.top-release-meta library-status-indicator { .top-release-art .album-card-badge {
flex-shrink: 0; position: absolute;
top: 4px;
left: 4px;
z-index: 1;
display: flex;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
@media (hover: hover) and (pointer: fine) {
.top-release-card:hover .album-card-badge,
.top-release-card:focus-within .album-card-badge {
visibility: visible;
opacity: 1;
}
}
@media not all and (hover: hover) {
.top-release-art .album-card-badge {
visibility: visible;
opacity: 1;
}
} }
@@ -773,150 +871,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
margin: 0; margin: 0;
} }
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 140px);
gap: 16px;
}
.album-grid.collapsed {
grid-template-rows: 1fr;
overflow: hidden;
}
.disco-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 10px;
margin-top: 4px;
border: none;
border-radius: 6px;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--yj-text-xs);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
width: 100%;
}
.disco-toggle:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.1));
color: var(--yj-text-primary, #fff);
}
.disco-toggle wa-icon {
font-size: 11px;
transition: transform 0.2s ease;
}
.disco-toggle[aria-expanded='true'] wa-icon {
transform: rotate(180deg);
}
.album-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.album-card:hover {
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
}
.album-card:active {
transform: scale(0.97);
}
.album-art-container {
width: 100%;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
position: relative;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
}
.album-art-container img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 4px;
}
.album-art-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.album-art-fallback wa-icon {
color: var(--yj-text-tertiary, #888);
font-size: 24px;
opacity: 0.5;
}
.album-title {
font-weight: 500;
color: var(--yj-text-primary, #fff);
font-size: var(--yj-text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs);
min-height: 20px;
}
.album-meta-text {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.album-meta library-status-indicator {
flex-shrink: 0;
margin-left: auto;
}
/* ── Similar artists ── */ /* ── Similar artists ── */
.similar-row {
display: grid;
grid-template-columns: repeat(auto-fill, 140px);
gap: 16px;
overflow: hidden;
}
.similar-row.collapsed {
grid-template-rows: 1fr;
overflow: hidden;
}
.similar-artist-card { .similar-artist-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -926,6 +881,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
width: 120px;
box-sizing: border-box;
flex-shrink: 0;
transition: background 0.15s ease; transition: background 0.15s ease;
} }
@@ -1056,7 +1014,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
this.unsubSimilarReady?.(); this.unsubSimilarReady?.();
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer); if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
this.topSectionObserver?.disconnect(); this.topSectionObserver?.disconnect();
this.discoObserver?.disconnect(); this.detachPlayOutsideClose();
} }
/** /**
@@ -1083,17 +1041,14 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
protected override firstUpdated() { protected override firstUpdated() {
this.observeTopSectionWidth(); this.observeTopSectionWidth();
this.observeDiscoWidth();
} }
protected override updated() { protected override updated() {
// Re-attach observers if elements appeared after initial render. // Re-attach the observer if the section appeared after initial
// render.
if (!this.topSectionObserver) { if (!this.topSectionObserver) {
this.observeTopSectionWidth(); this.observeTopSectionWidth();
} }
if (!this.discoObserver) {
this.observeDiscoWidth();
}
} }
/** /**
@@ -1126,32 +1081,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
this.topSectionObserver.observe(wrapper); this.topSectionObserver.observe(wrapper);
} }
/**
* Watch the .content width and compute how many album cards
* fit in one row of the discography grid.
* Grid uses: repeat(auto-fill, minmax(140px, 1fr)) with 16px gap
* and album-card has 8px padding on each side.
*/
private observeDiscoWidth() {
const content = this.renderRoot.querySelector('.content');
if (!content) return;
const CARD_MIN = 140;
const GAP = 16;
this.discoObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
const cols = Math.max(1, Math.floor((width + GAP) / (CARD_MIN + GAP)));
if (cols !== this.discoRowSize) {
this.discoRowSize = cols;
}
}
});
this.discoObserver.observe(content);
}
/* ── Data Loading ── */ /* ── Data Loading ── */
private async loadAllData() { private async loadAllData() {
@@ -1862,6 +1791,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
} catch { } catch {
// No image — letter avatar stays. // No image — letter avatar stays.
} }
return undefined;
}), }),
); );
} }
@@ -1999,6 +1930,65 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
} }
} }
/* ── Play / Shuffle split button ── */
/**
* Open the Play button's Shuffle dropdown.
*
* `page-header`'s overflow menu one control over: the same
* `MenuKeyboard`, the same document-level outside-close, and the
* same `menu-surface`, so the phone gets the bottom sheet rather
* than a popup that Chrome 113 clips.
*/
private togglePlayMenu = (): void => {
if (this.playMenuOpen) {
this.closePlayMenu();
return;
}
this.playMenuOpen = true;
void this.updateComplete.then(() => {
if (!this.playMenuOpen) return;
this.playMenuKeyboard.open(
this.playMenuPanel ?? null,
this.playMenuButton ?? null,
);
this.attachPlayOutsideClose();
});
};
private closePlayMenu = (): void => {
if (!this.playMenuOpen) return;
this.detachPlayOutsideClose();
this.playMenuKeyboard.close();
this.playMenuOpen = false;
};
private onPlayOutsideDown = (e: Event): void => {
if (e.composedPath().includes(this.playMenuPanel as EventTarget)) return;
if (e.composedPath().includes(this.playMenuButton as EventTarget)) return;
this.closePlayMenu();
};
private attachPlayOutsideClose(): void {
if (this.playOutsideAttached) return;
this.playOutsideAttached = true;
document.addEventListener('mousedown', this.onPlayOutsideDown, true);
}
private detachPlayOutsideClose(): void {
if (!this.playOutsideAttached) return;
this.playOutsideAttached = false;
document.removeEventListener('mousedown', this.onPlayOutsideDown, true);
}
/** /**
* File path for one top track, resolved by recording MBID — the * File path for one top track, resolved by recording MBID — the
* same key `localId` was set from. Works whether or not the * same key `localId` was set from. Works whether or not the
@@ -2240,11 +2230,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
if (!release?.mbid) return; if (!release?.mbid) return;
window.open( openMusicBrainz(`/release-group/${release.mbid}`);
`https://musicbrainz.org/release-group/${release.mbid}`,
'_blank',
'noopener',
);
} }
private onContextMenuAction( private onContextMenuAction(
@@ -2358,7 +2344,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
if (!track?.recordingMbid) return; if (!track?.recordingMbid) return;
window.open(`https://musicbrainz.org/recording/${track.recordingMbid}`, '_blank', 'noopener'); openMusicBrainz(`/recording/${track.recordingMbid}`);
} }
/* ── Navigation ── */ /* ── Navigation ── */
@@ -2538,10 +2524,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
: nothing} : nothing}
${this.renderArtistMeta()} ${this.renderArtistMeta()}
${this.artist?.popularity && this.artist.popularity > 0 ${this.artist?.popularity && this.artist.popularity > 0
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>` ? html`<span class="artist-listens">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
: nothing} : nothing}
${this.renderPlayLibraryAction()} <div class="artist-actions">
${this.renderFollowAction()} ${this.renderPlayLibraryAction()}
${this.renderFollowAction()}
</div>
</div> </div>
</div> </div>
<div class="content"> <div class="content">
@@ -2572,25 +2560,53 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
if (this.ownedLocalAlbumIds().length === 0) return nothing; if (this.ownedLocalAlbumIds().length === 0) return nothing;
return html` return html`
<div class="artist-play-actions"> <div class="play-split">
<wa-button <wa-button
size="small" size="small"
appearance="filled" appearance="filled"
data-testid="artist-play-library" data-testid="artist-play-library"
title="Play library tracks"
@click=${() => void this.playLibraryTracks(false)} @click=${() => void this.playLibraryTracks(false)}
> >
<wa-icon slot="start" name="play"></wa-icon> <wa-icon slot="start" name="play"></wa-icon>
Play library tracks Play
</wa-button> </wa-button>
<wa-button <menu-surface
size="small" placement="bottom-start"
appearance="outlined" .active=${this.playMenuOpen}
data-testid="artist-shuffle-library" @menu-dismiss=${this.closePlayMenu}
@click=${() => void this.playLibraryTracks(true)}
> >
<wa-icon slot="start" name="shuffle"></wa-icon> <button
Shuffle slot="anchor"
</wa-button> class="play-menu-button"
type="button"
data-testid="artist-play-menu"
aria-label="More play options"
aria-haspopup="menu"
aria-expanded=${this.playMenuOpen ? 'true' : 'false'}
aria-controls="artist-play-menu"
@click=${this.togglePlayMenu}
>
<wa-icon name="chevron-down"></wa-icon>
</button>
<div
id="artist-play-menu"
class="context-menu-panel"
role="menu"
aria-label="Play options"
>
<wa-dropdown-item
data-testid="artist-shuffle-library"
@click=${() => {
this.closePlayMenu();
void this.playLibraryTracks(true);
}}
>
<wa-icon slot="icon" name="shuffle"></wa-icon>
Shuffle
</wa-dropdown-item>
</div>
</menu-surface>
</div> </div>
`; `;
} }
@@ -2786,25 +2802,27 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
const request = downloadStore.requestFor(this.artistMBID); const request = downloadStore.requestFor(this.artistMBID);
return html` return html`
<div class="artist-follow"> <wa-button
<wa-button size="small"
size="small" appearance=${request ? 'filled' : 'outlined'}
appearance=${request ? 'filled' : 'outlined'} data-testid="artist-follow"
@click=${() => void this.toggleFollow(request?.id)} title=${request
> ? 'Following this artist'
<!-- This was bookmark-check, which is not in : 'Follow this artist for new releases'}
names.txt and so has rendered the missing-icon @click=${() => void this.toggleFollow(request?.id)}
fallback — a circled question mark — on every >
followed artist since it was written. A <!-- This was bookmark-check, which is not in
backtick around that name would end this names.txt and so has rendered the missing-icon
template literal, which is why there is none. --> fallback — a circled question mark — on every
<wa-icon followed artist since it was written. A
slot="start" backtick around that name would end this
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST} template literal, which is why there is none. -->
></wa-icon> <wa-icon
${request ? 'Following' : 'Follow for new releases'} slot="start"
</wa-button> name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
</div> ></wa-icon>
${request ? 'Following' : 'Follow'}
</wa-button>
`; `;
} }
@@ -2888,16 +2906,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
this.topReleasesExpanded = !this.topReleasesExpanded; this.topReleasesExpanded = !this.topReleasesExpanded;
} }
private toggleDiscoGroup(type: string) {
const next = new Set(this.expandedDiscoGroups);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
this.expandedDiscoGroups = next;
}
private renderTopSection() { private renderTopSection() {
const hasTracks = !this.loadingTracks && this.topTracks.length > 0; const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0; const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
@@ -2971,6 +2979,31 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
}} />` }} />`
: html`<wa-icon name="compact-disc"></wa-icon>`; : html`<wa-icon name="compact-disc"></wa-icon>`;
})()} })()}
<!-- Over the artwork, not beside the
row: play where you own it, the
request badge where you do not. -->
<div class="track-art-overlay">
${owned
? html`<button
class="track-art-play"
type="button"
aria-label=${`Play ${t.trackName}`}
@click=${(e: Event) => {
e.stopPropagation();
void this.playTrack(t);
}}
>
<wa-icon name="play"></wa-icon>
</button>`
: html`<library-status-indicator
status=${libraryStatusFor(false, t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
size="18"
></library-status-indicator>`}
</div>
</div> </div>
<div class="track-info"> <div class="track-info">
<div class="track-title">${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}</div> <div class="track-title">${trackLink(t.trackName, t.releaseName, t.releaseGroupMbid ?? '', t.recordingMbid)}</div>
@@ -2979,15 +3012,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<span class="track-listens"> <span class="track-listens">
${formatListenCount(t.totalListenCount)} plays ${formatListenCount(t.totalListenCount)} plays
</span> </span>
${owned
? nothing
: html`<library-status-indicator
status=${libraryStatusFor(false, t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>`}
</div> </div>
`; `;
})} })}
@@ -3093,6 +3117,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}"> <div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
<wa-icon name="compact-disc"></wa-icon> <wa-icon name="compact-disc"></wa-icon>
</div> </div>
<div class="album-card-badge">
<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="21"
></library-status-indicator>
</div>
</div> </div>
<div class="top-release-text"> <div class="top-release-text">
<div class="top-release-title" title="${rg.title}"> <div class="top-release-title" title="${rg.title}">
@@ -3102,18 +3138,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="top-release-meta-text"> <div class="top-release-meta-text">
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing} ${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
</div> </div>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>`}
</div> </div>
</div> </div>
</div> </div>
@@ -3164,37 +3188,16 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<section> <section>
<h3 class="section-header">Discography</h3> <h3 class="section-header">Discography</h3>
${groups.map( ${groups.map(
(g) => { (g) => html`
const isExpanded = this.expandedDiscoGroups.has(g.type); <div class="disco-group">
const rowSize = this.discoRowSize; <h4 class="disco-type-header">
const showToggle = g.items.length > rowSize; ${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`}
const visibleItems = isExpanded ? g.items : g.items.slice(0, rowSize); </h4>
<scroll-row>
return html` ${g.items.map((rg) => this.renderAlbumCard(rg))}
<div class="disco-group"> </scroll-row>
<h4 class="disco-type-header"> </div>
${g.type === 'Other' ? 'Other Releases' : g.type.endsWith('s') ? g.type : `${g.type}s`} `,
</h4>
<div class="album-grid">
${visibleItems.map((rg) => this.renderAlbumCard(rg))}
</div>
${showToggle
? html`
<button
class="disco-toggle"
aria-expanded="${isExpanded}"
@click=${() => this.toggleDiscoGroup(g.type)}
>
${isExpanded
? 'Show less'
: `Show all ${g.items.length}`}
<wa-icon name="chevron-down"></wa-icon>
</button>
`
: nothing}
</div>
`;
},
)} )}
</section> </section>
`; `;
@@ -3234,23 +3237,25 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="album-art-fallback" style="${artURL ? 'display: none' : ''}"> <div class="album-art-fallback" style="${artURL ? 'display: none' : ''}">
<wa-icon name="compact-disc"></wa-icon> <wa-icon name="compact-disc"></wa-icon>
</div> </div>
<div class="album-card-badge">
<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
size="23"
></library-status-indicator>
</div>
</div> </div>
<div class="album-title" title="${rg.title}">${rg.title}</div> <div class="album-title" title="${rg.title}">${rg.title}</div>
<div class="album-artist">${rg.artistCredit ?? ''}</div>
<div class="album-meta"> <div class="album-meta">
<div class="album-meta-text"> <div class="album-meta-text">
${year ? html`<span>${year}</span>` : nothing} ${year ? html`<span>${year}</span>` : nothing}
</div> </div>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>`}
</div> </div>
</div> </div>
`; `;
@@ -3266,15 +3271,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
// Cap the similar-artists list at 10 to avoid a very long list. // Cap the similar-artists list at 10 to avoid a very long list.
const maxSimilar = 10; const maxSimilar = 10;
const artists = this.similarArtists.slice(0, maxSimilar); const artists = this.similarArtists.slice(0, maxSimilar);
const showToggle = artists.length > this.discoRowSize;
const collapsed = !this.similarExpanded && showToggle;
const visible = collapsed ? artists.slice(0, this.discoRowSize) : artists;
return html` return html`
<section> <section>
<h3 class="section-header">Similar Artists</h3> <h3 class="section-header">Similar Artists</h3>
<div class="similar-row ${collapsed ? 'collapsed' : ''}"> <scroll-row>
${visible.map((a) => { ${artists.map((a) => {
const imgURL = this.similarImageURLs.get(a.artistMbid); const imgURL = this.similarImageURLs.get(a.artistMbid);
return html` return html`
<div <div
@@ -3313,21 +3315,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
</div> </div>
`; `;
})} })}
</div> </scroll-row>
${showToggle
? html`
<button
class="disco-toggle"
aria-expanded="${this.similarExpanded}"
@click=${() => { this.similarExpanded = !this.similarExpanded; }}
>
${this.similarExpanded
? 'Show less'
: `Show all ${artists.length}`}
<wa-icon name="chevron-down"></wa-icon>
</button>
`
: nothing}
</section> </section>
`; `;
} }
@@ -1,10 +1,7 @@
import { avatarBackground } from '@utils/avatar-color'; import { avatarBackground } from '@utils/avatar-color';
import { albumBadgeFor, libraryStatusFor } from '@utils/library-status'; import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
import { import { isOwned, ownershipLabel } from '@utils/ownership';
isOwned, import { openMusicBrainz } from '@utils/external-link';
ownershipLabel,
unownedStyles,
} from '@utils/ownership';
import { completenessStore } from '@store/completeness-store'; import { completenessStore } from '@store/completeness-store';
import { downloadStore } from '@store/download-store'; import { downloadStore } from '@store/download-store';
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
@@ -13,6 +10,8 @@ import { classMap } from 'lit/directives/class-map.js';
import '@components/page-header/page-header'; import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css'; import { srOnly } from '../../styles/sr-only.css';
import { albumCardStyles } from '../../styles/album-card.css';
import '../scroll-row/scroll-row.js';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetArtistImagesCachedPaths, GetExploreShelves, RecordSearchClick } from '@go/explore/service.js'; import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetArtistImagesCachedPaths, GetExploreShelves, RecordSearchClick } from '@go/explore/service.js';
import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/library.js'; import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/library.js';
import { EventsOn } from '@runtime/runtime'; import { EventsOn } from '@runtime/runtime';
@@ -253,7 +252,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
srOnly, srOnly,
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
unownedStyles, albumCardStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -529,21 +528,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
line-height: 1.5; line-height: 1.5;
} }
/* ── Horizontal scroll rows ── */
.horizontal-row {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: none;
}
.horizontal-row::-webkit-scrollbar {
display: none;
}
/* ── Top result cards ── */
/* ── Artist cards ── */ /* ── Artist cards ── */
/* Fixed width, for the reason the album card is: a range
means two cards in one row are different sizes. */
.artist-card { .artist-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -552,8 +539,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
min-width: 100px; width: 120px;
max-width: 120px; box-sizing: border-box;
flex-shrink: 0; flex-shrink: 0;
text-align: center; text-align: center;
transition: background 0.15s ease; transition: background 0.15s ease;
@@ -624,115 +611,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
font-size: var(--yj-text-xs); font-size: var(--yj-text-xs);
} }
/* ── Album cards ── */
.album-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
min-width: 130px;
max-width: 150px;
flex-shrink: 0;
transition: background 0.15s ease;
}
.album-card:hover {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
}
.album-card:active {
transform: scale(0.97);
}
.album-art-container {
width: 100%;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.album-art-container img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.album-art-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.album-art-fallback wa-icon {
color: var(--yj-text-tertiary, #888);
font-size: 24px;
opacity: 0.5;
}
.album-title {
font-weight: 500;
color: var(--yj-text-primary, #fff);
font-size: var(--yj-text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-artist {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs);
min-height: 20px;
}
.album-meta-text {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.album-meta library-status-indicator {
flex-shrink: 0;
margin-left: auto;
}
.type-badge {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
white-space: nowrap;
}
/* ── Track list ── */ /* ── Track list ── */
.track-list { .track-list {
display: flex; display: flex;
@@ -753,7 +631,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
cursor: pointer; cursor: pointer;
} }
.album-card:focus-visible,
.track-item:focus-visible { .track-item:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b); outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: -2px; outline-offset: -2px;
@@ -1371,7 +1248,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
const entity = target.kind === 'album' ? 'release-group' : 'recording'; const entity = target.kind === 'album' ? 'release-group' : 'recording';
window.open(`https://musicbrainz.org/${entity}/${target.mbid}`, '_blank', 'noopener'); openMusicBrainz(`/${entity}/${target.mbid}`);
} }
private renderExploreContextMenu() { private renderExploreContextMenu() {
@@ -1662,6 +1539,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
} catch { } catch {
// No image — leave empty string. // No image — leave empty string.
} }
return undefined;
}), }),
); );
@@ -2122,7 +2001,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
${subtitle ${subtitle
? html`<p class="section-reason">${subtitle}</p>` ? html`<p class="section-reason">${subtitle}</p>`
: nothing} : nothing}
<div class="horizontal-row"> <scroll-row>
${artists.map((a) => { ${artists.map((a) => {
const owned = isOwned(a); const owned = isOwned(a);
const name = a.englishName || a.name; const name = a.englishName || a.name;
@@ -2171,7 +2050,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
</div> </div>
`; `;
})} })}
</div> </scroll-row>
</section> </section>
`; `;
} }
@@ -2187,7 +2066,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
${subtitle ${subtitle
? html`<p class="section-reason">${subtitle}</p>` ? html`<p class="section-reason">${subtitle}</p>`
: nothing} : nothing}
<div class="horizontal-row"> <scroll-row>
${releaseGroups.map((rg) => { ${releaseGroups.map((rg) => {
const artURL = this.thumbnailCache.get(rg.mbid) || ''; const artURL = this.thumbnailCache.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate); const year = extractYear(rg.firstReleaseDate);
@@ -2249,6 +2128,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
> >
<wa-icon name="compact-disc"></wa-icon> <wa-icon name="compact-disc"></wa-icon>
</div> </div>
<div class="album-card-badge">
<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
size="23"
></library-status-indicator>
</div>
</div> </div>
<div class="album-title" title="${rg.title}"> <div class="album-title" title="${rg.title}">
${rg.title} ${rg.title}
@@ -2256,29 +2147,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
<div class="album-artist">${creditLink(creditStore.credits(rg.mbid), rg.artistCredit, rg.artistMbid ?? '')}</div> <div class="album-artist">${creditLink(creditStore.credits(rg.mbid), rg.artistCredit, rg.artistMbid ?? '')}</div>
<div class="album-meta"> <div class="album-meta">
<div class="album-meta-text"> <div class="album-meta-text">
${year ? html`<span>${year}</span>` : nothing}
${rg.primaryType ${rg.primaryType
? html`<span class="type-badge" ? html`<span class="type-badge"
>${rg.primaryType}</span >${rg.primaryType}</span
>` >`
: nothing} : nothing}
${year ? html`<span>${year}</span>` : nothing}
</div> </div>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>`}
</div> </div>
</div> </div>
`; `;
})} })}
</div> </scroll-row>
</section> </section>
`; `;
} }
+5 -12
View File
@@ -12,6 +12,7 @@ import { libraryStore } from '@store/library-store';
import { EventsOn } from '@runtime/runtime'; import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events'; import { Events } from '../../events';
import '@components/page-header/page-header'; import '@components/page-header/page-header';
import '../scroll-row/scroll-row.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
@@ -99,16 +100,6 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
} }
.row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 160px;
gap: 14px;
overflow-x: auto;
padding-bottom: 6px;
scrollbar-width: thin;
}
.card { .card {
background: none; background: none;
border: none; border: none;
@@ -117,6 +108,8 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
cursor: pointer; cursor: pointer;
color: inherit; color: inherit;
display: block; display: block;
width: 160px;
flex-shrink: 0;
} }
.art { .art {
@@ -336,9 +329,9 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
<span class="shelf-title">${shelf.title}</span> <span class="shelf-title">${shelf.title}</span>
</div> </div>
<p class="shelf-sub">${shelf.subtitle}</p> <p class="shelf-sub">${shelf.subtitle}</p>
<div class="row"> <scroll-row>
${(shelf.albums ?? []).map((album) => this.renderCard(album))} ${(shelf.albums ?? []).map((album) => this.renderCard(album))}
</div> </scroll-row>
</section> </section>
`; `;
} }
@@ -0,0 +1,213 @@
import { LitElement, css, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
/** How far one press moves the row — most of a screenful, not all of
* it, so the card that was at the edge stays as an anchor. */
const SCROLL_FRACTION = 0.8;
/**
* A horizontally scrolling row with arrow buttons.
*
* The shelves, the search results and (now) the artist page's
* discography and similar-artists rows are all "more than fits, scroll
* sideways". Until this existed the only way to see the rest was a
* mousewheel or a trackpad gesture, which is not an affordance — a
* mouse with no horizontal wheel simply could not reach the cards past
* the fold.
*
* It is a component rather than a rule on `.horizontal-row` for two
* reasons. The arrows are *state* — which way the row can still move —
* and that state has to be recomputed when the viewport resizes or a
* card arrives with its cover art; a stylesheet cannot do that. And
* every caller then gets the same arrows, the same reveal and the same
* keyboard labels without writing them again.
*
* **The arrows are `hidden`, not merely transparent, at the end they
* cannot move from** — a control that cannot act is worse than none,
* and an invisible one still holds a hit area and a tab stop. On a
* pointer device the pair fades in with the row's hover; where there is
* no hover they are always visible, because there is no other route to
* them there (a swipe is not an affordance a mouse-less keyboard user
* has either).
*
* The cards are light DOM children and stay in the *host's* shadow
* root, so the host's own `.album-card` / `.artist-card` styles apply
* unchanged — this component only owns the box they scroll inside.
*/
@customElement('scroll-row')
export class ScrollRow extends LitElement {
@query('.viewport') private viewport?: HTMLElement;
@state() private atStart = true;
@state() private atEnd = true;
@state() private overflowing = false;
private observer?: ResizeObserver;
static override styles = css`
:host {
display: block;
position: relative;
}
.viewport {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
/* A swipe that reaches the row's end should not drag the
whole page sideways with it. */
overscroll-behavior-x: contain;
}
.viewport::-webkit-scrollbar {
display: none;
}
.track {
display: flex;
gap: 12px;
}
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border-radius: 50%;
border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.arrow[hidden] {
display: none;
}
.arrow.prev {
left: 4px;
}
.arrow.next {
right: 4px;
}
.arrow:hover {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.12));
}
.arrow:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
@media (hover: hover) and (pointer: fine) {
:host(:hover) .arrow,
.arrow:focus-visible {
opacity: 1;
}
}
@media not all and (hover: hover) {
.arrow {
opacity: 1;
}
}
`;
override firstUpdated(): void {
const viewport = this.viewport;
if (!viewport) return;
this.observer = new ResizeObserver(() => this.measure());
this.observer.observe(viewport);
// The track's own size is what changes when a card arrives with
// its cover art, and a ResizeObserver on the viewport alone
// never fires for that.
const track = viewport.firstElementChild;
if (track) this.observer.observe(track);
this.measure();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.observer?.disconnect();
this.observer = undefined;
}
private measure(): void {
const viewport = this.viewport;
if (!viewport) return;
this.overflowing = viewport.scrollWidth > viewport.clientWidth + 1;
this.atStart = viewport.scrollLeft <= 1;
this.atEnd =
viewport.scrollLeft + viewport.clientWidth >=
viewport.scrollWidth - 1;
}
private onScroll = (): void => this.measure();
private scrollStep(direction: -1 | 1): void {
const viewport = this.viewport;
if (!viewport) return;
viewport.scrollBy({
left: direction * viewport.clientWidth * SCROLL_FRACTION,
behavior: 'smooth',
});
}
override render() {
const showPrev = this.overflowing && !this.atStart;
const showNext = this.overflowing && !this.atEnd;
return html`
<button
class="arrow prev"
type="button"
aria-label="Scroll left"
?hidden=${!showPrev}
@click=${() => this.scrollStep(-1)}
>
<wa-icon name="chevron-left"></wa-icon>
</button>
<div class="viewport" @scroll=${this.onScroll}>
<div class="track"><slot></slot></div>
</div>
<button
class="arrow next"
type="button"
aria-label="Scroll right"
?hidden=${!showNext}
@click=${() => this.scrollStep(1)}
>
<wa-icon name="chevron-right"></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'scroll-row': ScrollRow;
}
}
@@ -1,6 +1,7 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import '../scroll-row/scroll-row.js';
import type * as explore from '@go/explore/models.js'; import type * as explore from '@go/explore/models.js';
import { import {
GetArtistImageURL, GetArtistImageURL,
@@ -15,7 +16,6 @@ import { albumBadgeFor, libraryStatusFor } from '../../utils/library-status';
import { import {
isOwned, isOwned,
ownershipLabel, ownershipLabel,
unownedStyles,
type OwnableKind, type OwnableKind,
} from '../../utils/ownership'; } from '../../utils/ownership';
import { completenessStore } from '../../store/completeness-store'; import { completenessStore } from '../../store/completeness-store';
@@ -103,20 +103,12 @@ export class TopResultsRow extends LitElement {
static override styles = [ static override styles = [
designTokens, designTokens,
exploreLinkStyles, exploreLinkStyles,
unownedStyles,
css` css`
:host { :host {
display: block; display: block;
margin-bottom: 16px; margin-bottom: 16px;
} }
.row {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
}
.card { .card {
flex: 0 0 auto; flex: 0 0 auto;
width: 200px; width: 200px;
@@ -285,9 +277,9 @@ export class TopResultsRow extends LitElement {
return html` return html`
<div class="section-label">Top Results</div> <div class="section-label">Top Results</div>
<div class="row"> <scroll-row>
${this.results.map((r) => this.renderCard(r))} ${this.results.map((r) => this.renderCard(r))}
</div> </scroll-row>
`; `;
} }
+1
View File
@@ -31,6 +31,7 @@ solid/bookmark
solid/box-open solid/box-open
solid/check solid/check
solid/chevron-down solid/chevron-down
solid/chevron-left
solid/chevron-right solid/chevron-right
solid/circle-check solid/circle-check
solid/circle-exclamation solid/circle-exclamation
+184
View File
@@ -0,0 +1,184 @@
import { css } from 'lit';
/**
* The Explore album card, once.
*
* Two components draw one — `explore-view`'s shelves and search
* results, and `explore-artist-details`'s discography — and they had
* grown two copies of the same rules. That is how the size came apart:
* `explore-view` clamped its cards to a 130–150px range so two cards in
* one row could be different widths, and since the artwork is square
* that made them different *heights* as well. A row of covers with
* ragged bottoms is the whole complaint.
*
* So the width is a fixed `--yj-album-card-width` and the lines below
* the art each reserve their own space, which is what makes every card
* the same size no matter what a given album happens to carry —
* `album-card-size.test.ts` measures that rather than trusting it.
*
* Three rules here are the parts that changed rather than moved.
*
* **The artwork is inset in the square, not cropped to it.** The
* container was already `aspect-ratio: 1` but the image was
* `object-fit: cover`, so a non-square cover lost its edges. It is
* `contain` now and the container's own background is transparent, so
* a tall or wide cover sits in the middle of the square with the page
* showing through beside it.
*
* **The badge lives on the artwork, top-left, and only under the
* pointer.** It used to sit in the metadata line and only for the
* unowned case. It draws for every card now — an owned album's tick is
* the answer to the same question — and it is revealed by hover on a
* pointer device. Where there is no hover it is *always* visible rather
* than never, because on those devices it is the only route to its
* action: `explore-view`'s card menu carries no request item, so a
* phone with the badge hidden could not ask for an album at all.
*
* **Nothing dims an unowned card.** `unownedStyles` was removed from
* the catalog surfaces on the rule that the badge is the mark; the
* album page's *tracklist* still dims unowned rows, which is a
* different statement about a different thing.
*/
export const albumCardStyles = css`
.album-card {
width: var(--yj-album-card-width, 150px);
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 8px;
box-sizing: border-box;
flex-shrink: 0;
cursor: pointer;
transition: background 0.15s ease;
}
.album-card:hover {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
}
.album-card:active {
transform: scale(0.97);
}
.album-card:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: -2px;
}
.album-art-container {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
}
.album-art-container img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* The placeholder is the one case that *is* a full square, so it
carries the background the container gave up. */
.album-art-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
}
.album-art-fallback wa-icon {
color: var(--yj-text-tertiary, #888);
font-size: 24px;
opacity: 0.5;
}
.album-card-badge {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
display: flex;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
@media (hover: hover) and (pointer: fine) {
.album-card:hover .album-card-badge,
.album-card:focus-within .album-card-badge {
visibility: visible;
opacity: 1;
}
}
@media not all and (hover: hover) {
.album-card-badge {
visibility: visible;
opacity: 1;
}
}
.album-title {
font-weight: 500;
color: var(--yj-text-primary, #fff);
font-size: var(--yj-text-sm);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Reserved even where a surface has no artist to draw, so a card
in a row is never shorter than its neighbour. */
.album-artist {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs);
line-height: 1.3;
min-height: 1.3em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs);
height: 20px;
}
.album-meta-text {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.type-badge {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
white-space: nowrap;
}
`;
+29
View File
@@ -0,0 +1,29 @@
/**
* Opening an external page, with the destination pinned.
*
* Every external link this app opens is a MusicBrainz entity page built
* from an MBID that came from the catalog. Constructing the URL by
* string concatenation leaves the destination to whatever is in that
* string, so this parses it against the one origin the app means and
* refuses anything else — an MBID cannot change the host, and if it
* somehow did, nothing would open.
*
* It navigates through a real anchor rather than `window.open`: the
* same top-level `_blank` navigation with `noopener`, and it keeps the
* destination an ordinary link rather than an argument to a function
* whose first parameter is a URL.
*/
const MUSICBRAINZ_ORIGIN = 'https://musicbrainz.org';
export function openMusicBrainz(path: string): void {
const url = new URL(path, MUSICBRAINZ_ORIGIN);
if (url.origin !== MUSICBRAINZ_ORIGIN) return;
const link = document.createElement('a');
link.href = url.toString();
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.click();
}
+12 -1
View File
@@ -10,6 +10,16 @@
* badge as the only difference. This is that rule, written once, so * badge as the only difference. This is that rule, written once, so
* eight surfaces cannot each keep their own version of it. * eight surfaces cannot each keep their own version of it.
* *
* **The catalog's *cards* no longer dim.** A grid of dimmed covers read
* as a page that had failed to load rather than as a page of things you
* could ask for, so on Explore the mark is the badge alone — over the
* artwork, on hover, drawn for owned and unowned alike. The album
* page's *tracklist* still dims unowned rows: that is a different
* statement ("this one is not here") about a different thing, and the
* `aria-disabled` row that cannot be played is what it is for. So
* `unownedStyles` survives for that one surface and the cards simply do
* not include it.
*
* ## Ownership is a file, and `localId` is the flag that says so * ## Ownership is a file, and `localId` is the flag that says so
* *
* The album page answers "do I own this row" with `filePaths`, a map * The album page answers "do I own this row" with `filePaths`, a map
@@ -96,7 +106,8 @@ export function ownershipLabel(
} }
/** /**
* The dimming, shared so it cannot drift across surfaces. * The dimming, shared so it cannot drift across surfaces — and now
* used by exactly one of them.
* *
* Two things about it are load-bearing. * Two things about it are load-bearing.
* *
@@ -0,0 +1,174 @@
/**
* Every album card is the same size, and its artwork is a square.
*
* The size came apart because `explore-view` clamped its cards to a
* 130–150px range, so two cards in one row could be different widths —
* and since the artwork is square, different *heights* as well. A row
* of covers with ragged bottoms is what that looks like.
*
* What makes the fix hold is that the lines below the art each reserve
* their own space (`album-card.css.ts`), so an album with no year, no
* release type or a one-character title is not shorter than its
* neighbour. This measures that rather than trusting it, because the
* next component to format a card is the way it comes back.
*
* The artwork half is the other change: the container was already
* square but the image was `object-fit: cover`, so a non-square cover
* was cropped to it. It is `contain` now, and the container has no
* background of its own, so a tall cover is inset with the page
* showing through beside it.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-view/explore-view';
import { flush, stub, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { completenessStore } from '@store/completeness-store';
const SEARCH = 'explore.Service.SearchLocal';
const SHELVES = 'explore.Service.GetExploreShelves';
/** A 1x1 transparent gif, so the `<img>` branch renders. */
const TINY_IMAGE =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/** Release groups chosen so every optional line is present on one and
* absent on another — that is what a size regression hides behind. */
const ALBUMS = [
{
mbid: 'rg-1',
title: 'A',
artistCredit: '',
artistMbid: 'ar-1',
primaryType: '',
firstReleaseDate: '',
popularity: 1,
listenerCount: 1,
secondaryTypes: [],
inLibrary: false,
localId: 0,
},
{
mbid: 'rg-2',
title: 'A Very Long Album Name That Will Certainly Be Truncated By The Card',
artistCredit: 'An Artist With A Long Name',
artistMbid: 'ar-2',
primaryType: 'Album',
firstReleaseDate: '1994-05-01',
popularity: 1,
listenerCount: 1,
secondaryTypes: [],
inLibrary: false,
localId: 0,
},
{
mbid: 'rg-3',
title: 'Three',
artistCredit: 'Another',
artistMbid: 'ar-3',
primaryType: 'EP',
firstReleaseDate: '2001-01-01',
popularity: 1,
listenerCount: 1,
secondaryTypes: [],
inLibrary: false,
localId: 0,
},
];
async function exploreWithAlbums(): Promise<LitElement> {
stub(SHELVES, { shelves: [], state: 'ready' });
stub(SEARCH, {
artists: [],
releaseGroups: ALBUMS,
recordings: [],
});
stub('explore.Service.GetThumbnails', Object.fromEntries(
ALBUMS.map((a) => [a.mbid, TINY_IMAGE]),
));
stub('explore.Service.GetThumbnail', TINY_IMAGE);
const el = await fixture<LitElement>('explore-view');
(el as unknown as { onViewActivate: () => void }).onViewActivate?.();
await update(el, {
results: { artists: [], releaseGroups: ALBUMS, recordings: [] },
});
await flush();
await el.updateComplete;
return el;
}
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbumsCompleteness', {});
completenessStore.invalidate();
});
describe('the album card size', () => {
it('is the same width and height for every card in a row', async () => {
const el = await exploreWithAlbums();
const cards = shadowAll(el, '.album-card');
expect(cards.length).toBe(ALBUMS.length);
const boxes = cards.map((c) => c.getBoundingClientRect());
// The first card is the reference; every other one must match it.
for (const box of boxes) {
expect(box.width).toBe(boxes[0]!.width);
expect(box.height).toBe(boxes[0]!.height);
}
// …and the reference is a real box, or the loop above is vacuous.
expect(boxes[0]!.width).toBeGreaterThan(0);
expect(boxes[0]!.height).toBeGreaterThan(0);
});
it('keeps the artwork square', async () => {
const el = await exploreWithAlbums();
for (const art of shadowAll(el, '.album-art-container')) {
const box = art.getBoundingClientRect();
expect(Math.round(box.width)).toBe(Math.round(box.height));
}
});
it('insets a non-square cover rather than cropping it', async () => {
const el = await exploreWithAlbums();
// Read from the parsed stylesheet rather than from a rendered
// `<img>`: the search path is what calls `loadThumbnails`, and
// setting `results` directly skips it, so there is no image to
// measure. The regression worth catching is the rule going back to
// `cover`, which is a stylesheet fact.
const rules = (el.shadowRoot?.adoptedStyleSheets ?? []).flatMap((sheet) =>
Array.from(sheet.cssRules).map((rule) => rule.cssText),
);
const art = rules.find(
(text) =>
text.startsWith('.album-art-container img') &&
text.includes('object-fit'),
);
expect(art, 'no object-fit rule for the cover image').toBeDefined();
expect(art).toContain('object-fit: contain');
});
it('draws the badge over the artwork, and not in the metadata line', async () => {
const el = await exploreWithAlbums();
const card = shadow(el, '.album-card')!;
const badge = card.querySelector('.album-art-container .album-card-badge');
expect(badge).not.toBeNull();
// The badge is positioned inside the art box, so its parent is the
// square rather than the row underneath it.
expect(badge?.parentElement?.classList.contains('album-art-container')).toBe(
true,
);
});
});
@@ -0,0 +1,168 @@
/**
* The artist page's header and its top tracks.
*
* Two cleanups, asserted together because they are one screen:
*
* - the Play/Shuffle pair became one split button ("Play" with the
* words on its title, Shuffle behind the caret), the Follow button
* moved onto the same line, and the name and listen count went up a
* size;
* - a top track's play/request affordance moved onto its artwork,
* where a hover reveals it, instead of a badge at the end of the
* row beside a row that already plays on a double-click.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-artist-details/explore-artist-details';
import { stub, flush, emit, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll } from '@test/support/render';
const ARTIST = 'artist-0001';
const track = (name: string, localId = 0) => ({
recordingMbid: `rec-${name}`,
artistName: 'Tideline',
trackName: name,
totalListenCount: 100,
caaReleaseMbid: '',
releaseName: 'Foreshore',
releaseGroupMbid: 'rg-owned',
length: 200000,
inLibrary: localId > 0,
localId,
});
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupArtist', {
mbid: ARTIST,
name: 'Tideline',
popularity: 1200,
type: 'Group',
country: 'GB',
});
stub('explore.Service.TopReleaseGroupsForArtist', []);
stub('explore.Service.TopRecordingsForArtist', [
track('Owned Song', 7),
track('Absent Song'),
]);
stub('explore.Service.SimilarArtists', []);
stub('explore.Service.PrefetchReleases', undefined);
stub('explore.Service.BrowseReleaseGroups', [
{
mbid: 'rg-owned',
title: 'Foreshore',
artistCredit: 'Tideline',
primaryType: 'Album',
inLibrary: true,
localId: 7,
},
]);
stub('library.Library.GetAlbumsCompleteness', {});
stub('download.Service.ListRequests', []);
});
async function mount(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
return el;
}
describe('the artist header', () => {
it('offers Play, with Shuffle behind its caret', async () => {
const el = await mount();
const play = shadow<HTMLElement>(el, '[data-testid="artist-play-library"]')!;
// The words moved to the title, which is where "Play library
// tracks" can still be read without taking the width of a button.
expect(play.textContent?.trim()).toBe('Play');
expect(play.getAttribute('title')).toBe('Play library tracks');
const menuButton = shadow(el, '[data-testid="artist-play-menu"]');
expect(menuButton).not.toBeNull();
const menu = shadow(el, '#artist-play-menu');
expect(menu?.textContent).toContain('Shuffle');
});
it('puts Follow on the same line as Play', async () => {
const el = await mount();
const actions = shadow(el, '.artist-actions')!;
expect(actions.querySelector('[data-testid="artist-play-library"]')).not.toBeNull();
const follow = actions.querySelector('[data-testid="artist-follow"]') as HTMLElement;
expect(follow).not.toBeNull();
expect(follow.textContent?.trim()).toBe('Follow');
});
it('says Following once the artist is on the request list', async () => {
const el = await mount();
// The store is a singleton and caches its list, so the change is
// announced the way the backend announces one.
stub('download.Service.ListRequests', [
{ id: 3, mbid: ARTIST, state: 'queued' },
]);
emit(Events.RequestsChanged);
await flush();
await el.updateComplete;
const follow = shadow<HTMLElement>(el, '[data-testid="artist-follow"]')!;
expect(follow.textContent?.trim()).toBe('Following');
});
it('sizes the name and the listen count above the metadata line', async () => {
const el = await mount();
const title = shadow<HTMLElement>(el, '.artist-title')!;
const listens = shadow<HTMLElement>(el, '.artist-listens')!;
const meta = shadow<HTMLElement>(el, '.artist-meta')!;
expect(listens.textContent).toContain('plays on ListenBrainz');
const titleSize = parseFloat(getComputedStyle(title).fontSize);
const listensSize = parseFloat(getComputedStyle(listens).fontSize);
const metaSize = parseFloat(getComputedStyle(meta).fontSize);
expect(titleSize).toBeGreaterThan(24);
expect(listensSize).toBeGreaterThan(metaSize);
});
});
describe('a top track’s affordance', () => {
it('plays from the artwork when it is owned', async () => {
const el = await mount();
const rows = shadowAll<HTMLElement>(el, '.track-item');
const owned = rows.find((r) => r.textContent?.includes('Owned Song'))!;
expect(owned.querySelector('.track-art-overlay .track-art-play')).not.toBeNull();
// Nothing beside the row any more.
expect(owned.querySelector(':scope > library-status-indicator')).toBeNull();
});
it('requests from the artwork when it is not', async () => {
const el = await mount();
const rows = shadowAll<HTMLElement>(el, '.track-item');
const absent = rows.find((r) => r.textContent?.includes('Absent Song'))!;
expect(
absent.querySelector('.track-art-overlay library-status-indicator'),
).not.toBeNull();
expect(absent.querySelector('.track-art-overlay .track-art-play')).toBeNull();
});
});
@@ -25,7 +25,9 @@ const ARTIST = 'artist-0001';
/** The labels of the open menu's items, trimmed. */ /** The labels of the open menu's items, trimmed. */
function menuItems(el: LitElement): string[] { function menuItems(el: LitElement): string[] {
const panel = shadow(el, '.context-menu-panel'); // Scoped to the context menu: the artist page also has a Play/Shuffle
// dropdown, and its panel carries the same class.
const panel = shadow(el, '#context-menu .context-menu-panel');
if (!panel) return []; if (!panel) return [];
@@ -100,7 +102,7 @@ describe('the context menu on an artist page release', () => {
await openMenuOnAlbum(el, 0); await openMenuOnAlbum(el, 0);
const panel = shadow(el, '.context-menu-panel'); const panel = shadow(el, '#context-menu .context-menu-panel');
expect(panel).toBeTruthy(); expect(panel).toBeTruthy();
// The panel is shared with the track menu, so a label that does not // The panel is shared with the track menu, so a label that does not
@@ -172,7 +172,8 @@ describe('<player-progress-line>', () => {
* The reason this component asks `matchMedia` instead of letting a * The reason this component asks `matchMedia` instead of letting a
* stylesheet hide it: a media query cannot stop a 1 Hz interval * stylesheet hide it: a media query cannot stop a 1 Hz interval
* running for the life of every desktop session. That claim is * running for the life of every desktop session. That claim is
* load-bearing, so it is asserted rather than described — the timer count, because a desktop render is empty * load-bearing in CLAUDE.md, so it is asserted rather than
* described — the timer count, because a desktop render is empty
* either way and so cannot tell the two apart. * either way and so cannot tell the two apart.
*/ */
it('runs no interpolation timer above the breakpoint', async () => { it('runs no interpolation timer above the breakpoint', async () => {
+131
View File
@@ -0,0 +1,131 @@
/**
* A horizontally scrolling row can be moved without a wheel.
*
* Until this existed the only way to see the cards past the fold on the
* shelves, the search results and the artist page's discography was a
* mousewheel or a trackpad gesture — which is not an affordance. A
* mouse with no horizontal wheel simply could not reach them.
*
* What is asserted here is the state that makes the arrows honest: an
* arrow is `hidden` at the end it cannot move from, because a control
* that cannot act is worse than none, and an invisible one still holds
* a hit area and a tab stop.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import type { LitElement } from 'lit';
import '@components/scroll-row/scroll-row';
import { fixture } from '@test/support/render';
/** Six 100px cards in a 320px row — comfortably overflowing. */
function content(el: Element): void {
for (let i = 0; i < 6; i += 1) {
const card = document.createElement('div');
card.style.cssText = 'flex: 0 0 100px; height: 40px';
card.textContent = String(i);
el.append(card);
}
}
function arrows(el: LitElement): { prev: HTMLButtonElement; next: HTMLButtonElement } {
const root = el.shadowRoot!;
return {
prev: root.querySelector('.arrow.prev') as HTMLButtonElement,
next: root.querySelector('.arrow.next') as HTMLButtonElement,
};
}
function viewport(el: LitElement): HTMLElement {
return el.shadowRoot!.querySelector('.viewport') as HTMLElement;
}
async function row(): Promise<LitElement> {
const el = await fixture<LitElement>('scroll-row');
el.style.display = 'block';
el.style.width = '320px';
content(el);
await el.updateComplete;
// The observer reports on a later frame than a microtask drain.
await new Promise((r) => setTimeout(r, 60));
await el.updateComplete;
return el;
}
describe('<scroll-row>', () => {
beforeEach(() => {
document.body.style.margin = '0';
});
it('draws an arrow for each direction it can still move', async () => {
const el = await row();
const { prev, next } = arrows(el);
expect(prev).not.toBeNull();
expect(next).not.toBeNull();
// At the start there is nothing behind, so only the forward arrow is
// offered.
expect(prev.hasAttribute('hidden')).toBe(true);
expect(next.hasAttribute('hidden')).toBe(false);
});
it('offers the way back once the row has moved', async () => {
const el = await row();
const vp = viewport(el);
vp.scrollLeft = 120;
vp.dispatchEvent(new Event('scroll'));
await el.updateComplete;
expect(arrows(el).prev.hasAttribute('hidden')).toBe(false);
});
it('stands the forward arrow down at the end', async () => {
const el = await row();
const vp = viewport(el);
vp.scrollLeft = vp.scrollWidth;
vp.dispatchEvent(new Event('scroll'));
await el.updateComplete;
expect(arrows(el).next.hasAttribute('hidden')).toBe(true);
expect(arrows(el).prev.hasAttribute('hidden')).toBe(false);
});
it('moves the row when the arrow is pressed', async () => {
const el = await row();
const vp = viewport(el);
expect(vp.scrollLeft).toBe(0);
arrows(el).next.click();
await expect.poll(() => vp.scrollLeft).toBeGreaterThan(0);
});
it('shows nothing to scroll when the content fits', async () => {
const el = await fixture<LitElement>('scroll-row');
el.style.cssText = 'display: block; width: 320px';
const only = document.createElement('div');
only.style.cssText = 'flex: 0 0 100px; height: 40px';
only.textContent = 'one';
el.append(only);
await el.updateComplete;
await new Promise((r) => setTimeout(r, 60));
await el.updateComplete;
await new Promise((r) => requestAnimationFrame(() => r(null)));
await el.updateComplete;
const { prev, next } = arrows(el);
expect(prev.hasAttribute('hidden')).toBe(true);
expect(next.hasAttribute('hidden')).toBe(true);
});
});
@@ -1,24 +1,28 @@
/** /**
* Owned is plain; unowned is what gets marked. * The catalog's cards are not dimmed; the badge is the mark.
* *
* `explore-album-details` had this right for one tracklist and nothing * The rule this replaced had every unowned card dimmed *and* badged,
* else did: Explore's cards, the top-results row and the artist page's * which on a shelf of mostly-unowned covers read as a page that had
* three card shapes all mixed owned and unowned with a small badge as * failed to load rather than a page of things you could ask for. So the
* the only difference — and drew a green tick on the *common* case, * dimming is gone from the catalog surfaces and the badge carries the
* which is the treatment the album page's own green ticks were removed * whole statement — over the artwork, on hover, drawn for owned and
* for. * unowned alike.
* *
* What is pinned here is the rule rather than any one surface, because * What is still pinned here is the half that was never about dimming:
* the fault this replaced was eight call sites each holding their own
* version of it:
* *
* - an owned thing draws **no badge at all**;
* - an unowned one is dimmed *and* says so in its accessible name,
* because dimming is a colour and cannot be the only signal;
* - ownership is a **file** (`localId`), never the catalog's * - ownership is a **file** (`localId`), never the catalog's
* `inLibrary` ratchet, which is a flag that happens to agree; * `inLibrary` ratchet, which is a flag that happens to agree;
* - and a partly-held album says *how* partly, which is the one thing * - a row that cannot be played is `aria-disabled`, while a card that
* a tick cannot. * still navigates is not;
* - a partly-held album says *how* partly, which is the one thing a
* tick cannot;
* - and an unowned thing still says so in its accessible name, because
* with the dimming gone that name is the whole signal for anyone not
* seeing the badge.
*
* The album page's *tracklist* still dims unowned rows — a different
* statement about a different thing — and is covered by
* `album-request-badge-visibility.test.ts`.
*/ */
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
@@ -26,7 +30,7 @@ import { page } from 'vitest/browser';
import '@components/explore-view/explore-view'; import '@components/explore-view/explore-view';
import '@components/top-results-row/top-results-row'; import '@components/top-results-row/top-results-row';
import { flush, stub, resetHarness } from '@test/support/harness'; import { flush, stub, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll, update } from '@test/support/render'; import { fixture, shadow, update } from '@test/support/render';
import { completenessStore } from '@store/completeness-store'; import { completenessStore } from '@store/completeness-store';
const SEARCH = 'explore.Service.SearchLocal'; const SEARCH = 'explore.Service.SearchLocal';
@@ -105,27 +109,44 @@ beforeEach(() => {
// absent one — which is the point, or 87% of a grid re-asks forever. // absent one — which is the point, or 87% of a grid re-asks forever.
// Two tests in one file are two sessions as far as it is concerned, // Two tests in one file are two sessions as far as it is concerned,
// so a stale entry from the test above would otherwise decide the // so a stale entry from the test above would otherwise decide the
// one below. Found by writing the assertion the wrong way round. // one below.
completenessStore.invalidate(); completenessStore.invalidate();
}); });
describe('an owned thing is plain', () => { describe('an unowned card is marked by its badge alone', () => {
it('draws no badge on an album card it has files for', async () => { it('does not dim the artwork', async () => {
const el = await exploreShowing({
releaseGroups: [album('Absent', {})],
});
const art = shadow(el, '.album-card .album-art-container')!;
// The dimming was an opacity on this box. With it gone the cover is
// at full strength, and the badge is what says the card is not
// yours.
expect(getComputedStyle(art).opacity).toBe('1');
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
});
it('still says so in the name the browser computes', async () => {
await exploreShowing({ releaseGroups: [album('Absent', {})] });
await expect
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
.toBeInTheDocument();
});
});
describe('an owned card is plain except for its badge', () => {
it('draws the in-library badge rather than nothing', async () => {
const el = await exploreShowing({ const el = await exploreShowing({
releaseGroups: [album('Held', { localId: 7 })], releaseGroups: [album('Held', { localId: 7 })],
}); });
expect(shadowAll(el, '.album-card')).toHaveLength(1); const badge = shadow(el, '.album-card library-status-indicator');
expect(shadow(el, '.album-card library-status-indicator')).toBeNull();
});
it('draws no badge on a track row it has a file for', async () => { expect(badge).not.toBeNull();
const el = await exploreShowing({ expect(badge?.getAttribute('status')).toBe('in-library');
recordings: [recording('Held', { localId: 9 })],
});
expect(shadowAll(el, '.track-item')).toHaveLength(1);
expect(shadow(el, '.track-item library-status-indicator')).toBeNull();
}); });
it('does not dim it', async () => { it('does not dim it', async () => {
@@ -139,56 +160,6 @@ describe('an owned thing is plain', () => {
}); });
}); });
describe('an unowned thing is marked', () => {
it('dims the card and keeps its request badge', async () => {
const el = await exploreShowing({
releaseGroups: [album('Absent', {})],
});
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
});
/**
* The name is the half of this that reaches anyone not seeing the
* dimming, so it has to be the browser's own answer — a shadow-root
* query cannot compute a name, and this repo has shipped a nameless
* control three times.
*/
it('says so in the name the browser computes', async () => {
await exploreShowing({ releaseGroups: [album('Absent', {})] });
await expect
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
.toBeInTheDocument();
});
/**
* A track row is `aria-disabled` and a card is not, and the
* difference is not cosmetic: activating an unowned row does nothing
* (`onRecordingRowDblClick` returns early), while a card navigates to
* the catalog page for it, which is a perfectly good thing to do with
* something you do not own.
*/
it('marks a row that cannot be played as disabled', async () => {
const el = await exploreShowing({
recordings: [recording('Absent', {})],
});
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
'true',
);
});
it('leaves a card that still navigates enabled', async () => {
const el = await exploreShowing({
releaseGroups: [album('Absent', {})],
});
expect(shadow(el, '.album-card')?.getAttribute('aria-disabled')).toBeNull();
});
});
/** /**
* The decision this issue turned on. * The decision this issue turned on.
* *
@@ -206,7 +177,9 @@ describe('ownership is a file, not a flag', () => {
}); });
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true); expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull(); expect(
shadow(el, '.album-card library-status-indicator')?.getAttribute('status'),
).not.toBe('in-library');
}); });
it('does the same for a track row', async () => { it('does the same for a track row', async () => {
@@ -220,6 +193,26 @@ describe('ownership is a file, not a flag', () => {
}); });
}); });
describe('a track row that cannot be played is disabled', () => {
it('marks an unowned row', async () => {
const el = await exploreShowing({
recordings: [recording('Absent', {})],
});
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
'true',
);
});
it('leaves a card that still navigates enabled', async () => {
const el = await exploreShowing({
releaseGroups: [album('Absent', {})],
});
expect(shadow(el, '.album-card')?.getAttribute('aria-disabled')).toBeNull();
});
});
/** /**
* The count, which is what `#16`'s deferred third step asked for: an * The count, which is what `#16`'s deferred third step asked for: an
* album held 2 tracks of 10 wore the same green tick as one held whole, * album held 2 tracks of 10 wore the same green tick as one held whole,
@@ -248,9 +241,13 @@ describe('a partly-held album says how partly', () => {
// A partly-held album is *actionable* — it has three tracks left to // A partly-held album is *actionable* — it has three tracks left to
// ask for — so the badge is a button, and the name has to carry the // ask for — so the badge is a button, and the name has to carry the
// action and the count. Naming it after the action alone left the // action and the count. The badge is revealed by the card's focus
// one state the ring exists for as the one state whose name did not // (`:focus-within`), and `visibility: hidden` is what takes it out
// mention it. // of the accessibility tree until then, so the card is focused
// first — which is exactly the route a keyboard user takes.
shadow<HTMLElement>(el, '.album-card')?.focus();
await el.updateComplete;
await expect await expect
.element( .element(
page.getByRole('button', { page.getByRole('button', {
@@ -266,7 +263,7 @@ describe('a partly-held album says how partly', () => {
* state, and a ring drawn from its absence would mark all of it * state, and a ring drawn from its absence would mark all of it
* incomplete on no evidence. That is the rule `Known` exists for. * incomplete on no evidence. That is the rule `Known` exists for.
*/ */
it('says nothing when the total was never declared', async () => { it('falls back to the plain in-library badge when the total was never declared', async () => {
stub(COMPLETENESS, { stub(COMPLETENESS, {
'7': { owned: 3, expected: 0, known: false, complete: false }, '7': { owned: 3, expected: 0, known: false, complete: false },
}); });
@@ -279,7 +276,9 @@ describe('a partly-held album says how partly', () => {
await flush(); await flush();
await el.updateComplete; await el.updateComplete;
expect(shadow(el, '.album-card library-status-indicator')).toBeNull(); expect(
shadow(el, '.album-card library-status-indicator')?.getAttribute('status'),
).toBe('in-library');
}); });
it('asks about the owned albums only, in one call', async () => { it('asks about the owned albums only, in one call', async () => {
@@ -329,11 +328,13 @@ describe('the top-results row follows the same rule', () => {
query: 'held', query: 'held',
}); });
// A top-result card is a mixed bag — artist, album or track — and
// its badge is a corner mark rather than the cover overlay the
// album cards grew, so an owned one stays plain.
expect(shadow(el, '.card library-status-indicator')).toBeNull(); expect(shadow(el, '.card library-status-indicator')).toBeNull();
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(false);
}); });
it('dims and names something it does not', async () => { it('names something it does not own', async () => {
const el = await fixture('top-results-row', { const el = await fixture('top-results-row', {
results: [result('Absent', 'release_group')], results: [result('Absent', 'release_group')],
query: 'absent', query: 'absent',
@@ -349,7 +350,7 @@ describe('the top-results row follows the same rule', () => {
/** /**
* An artist card has never had a badge — a discography subscription * An artist card has never had a badge — a discography subscription
* is the artist page's Follow button, which can say what it commits * is the artist page's Follow button, which can say what it commits
* to — so the dimming and the name are the whole signal there. * to — so the name is the whole signal there.
*/ */
it('marks an unowned artist without offering a request', async () => { it('marks an unowned artist without offering a request', async () => {
const el = await fixture('top-results-row', { const el = await fixture('top-results-row', {
+2 -1
View File
@@ -64,7 +64,8 @@ var frontendDistAssets embed.FS
// Returning early is not a degraded mode: `nativeInit` has already // Returning early is not a degraded mode: `nativeInit` has already
// re-attached the bridge, so the recreated activity's WebView talks to // re-attached the bridge, so the recreated activity's WebView talks to
// the app that is still running, with its queue and its playback // the app that is still running, with its queue and its playback
// position intact. // position intact. See CLAUDE.md, "An activity is a view onto the
// process".
// //
// It is inert off Android, where a process has exactly one main(). // It is inert off Android, where a process has exactly one main().
var mainStarted atomic.Bool var mainStarted atomic.Bool
+7 -62
View File
@@ -82,69 +82,23 @@ targets="$({ make -pqRr 2>/dev/null || true; } |
# happened to break there, and a check that fails on reflow gets # happened to break there, and a check that fails on reflow gets
# disabled rather than fixed. # disabled rather than fixed.
# #
# **An inline span may be hard-wrapped, and then the mention is split
# across two lines.** `make` at the end of one line and its target at
# the start of the next is one code span to Markdown and two strings to
# a per-line regex, so the target was invisible — and these docs are
# mostly hard-wrapped prose, so the wrap is what the author does not
# think about. Lines are therefore joined while the span is still open,
# which is what an odd number of backticks means.
#
# Joining re-opens the reflow trap above unless it is bounded, so it is
# bounded three ways: a fence flushes first (a fenced command is already
# whole, and joining inside one would break the line-start rule), a
# blank line flushes (CommonMark does not allow a blank line inside a
# code span, so nothing legitimate is split by one), and so does a file
# boundary. A stray odd backtick in prose therefore costs one paragraph
# of over-matching rather than the rest of the file.
#
# AGENTS.md is deliberately not in this list: it is a symlink to # AGENTS.md is deliberately not in this list: it is a symlink to
# CLAUDE.md, asserted above, so scanning it would report every failure # CLAUDE.md, asserted above, so scanning it would report every failure
# twice under two names. # twice under two names.
mentioned="$(printf '%s\n' "$docs" | mentioned="$(printf '%s\n' "$docs" |
xargs awk ' xargs awk '
function scan(text, rest) { FNR == 1 { fence = 0 }
rest = text /^```/ { fence = !fence; next }
{
rest = $0
while (match(rest, /`make [a-z][a-z0-9-]*/)) { while (match(rest, /`make [a-z][a-z0-9-]*/)) {
print substr(rest, RSTART + 6, RLENGTH - 6) print substr(rest, RSTART + 6, RLENGTH - 6)
rest = substr(rest, RSTART + RLENGTH) rest = substr(rest, RSTART + RLENGTH)
} }
} if (fence && match($0, /^make [a-z][a-z0-9-]*/)) {
print substr($0, 6, RLENGTH - 5)
function lineStart(text) {
if (match(text, /^make [a-z][a-z0-9-]*/)) {
print substr(text, 6, RLENGTH - 5)
} }
} }
function ticks(s, n, i) {
n = 0
for (i = 1; i <= length(s); i++) {
if (substr(s, i, 1) == "`") n++
}
return n
}
function flush() {
if (buf == "") return
scan(buf)
if (fence) lineStart(buf)
buf = ""
}
FNR == 1 { flush(); fence = 0 }
/^```/ { flush(); fence = !fence; next }
/^[[:space:]]*$/ { flush(); next }
{
if (fence) { scan($0); lineStart($0); next }
buf = (buf == "" ? $0 : buf " " $0)
if (ticks(buf) % 2 == 0) flush()
}
END { flush() }
' | sort -u)" ' | sort -u)"
missing="" missing=""
@@ -159,16 +113,7 @@ if [ -n "$missing" ]; then
echo "skill-check: the docs name make targets that do not exist:" >&2 echo "skill-check: the docs name make targets that do not exist:" >&2
for t in $missing; do for t in $missing; do
echo " make $t" >&2 echo " make $t" >&2
# `make <t>` on one line first, because that is where a target is printf '%s\n' "$docs" | xargs grep -ln "make $t" | sed 's/^/ /' >&2
# normally named and it is the precise answer. The bare name is the
# fallback, and it exists because the parser above can now find a
# mention that *this* grep cannot: a wrapped span has `make` and its
# target on different lines. Without it a missing target reported no
# file at all, and `set -o pipefail` turned the empty grep into exit
# 123, before the line telling the author what to do.
hits="$(printf '%s\n' "$docs" | xargs grep -ln "make $t" 2>/dev/null || true)"
[ -n "$hits" ] || hits="$(printf '%s\n' "$docs" | xargs grep -ln -- "$t" 2>/dev/null || true)"
[ -n "$hits" ] && printf '%s\n' "$hits" | sed 's/^/ /' >&2
done done
echo "Fix the docs, or restore the target." >&2 echo "Fix the docs, or restore the target." >&2
exit 1 exit 1