Compare commits

...
Author SHA1 Message Date
logan 905654cc84 feat(explore): demote the album page's version selector to a disclosure
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 5m59s
Choosing which pressing you are looking at is an advanced,
metadata-repair task, and it sat directly above the tracklist with a
heading, a `<select>` and a paragraph explaining how our clustering
picks a "standard version" by weighing release count, status and date.
That is a sentence about our own heuristic in the most valuable space
on the page.

It is now "Other versions of this album (N)" below the tracklist: a
real `<button aria-expanded aria-controls>` inside the heading that
names the section, with the body rendered unconditionally and toggled
with `hidden`, because `aria-controls` has to name an element that is
in the DOM. Both rules are `config-section`'s rather than new ones.
It is demoted, not removed — matching the wrong release is a real
problem and this is how it gets fixed.

**Two more blocks shared that slot and neither was guarded.** The
selector at least had `distinctTracklistCount() <= 1`; the
`Versions / Loading releases…` spinner and the `Versions / <error>`
block did not, so both took the primary position on every album
regardless of whether there was ever going to be a choice. The spinner
said what `renderTracklist` was already saying about the same fetch, so
it is gone. The error was the one `catalog-scope-notice` shows at the
top of the page with a retry — every path that sets `errorReleases`
also sets `catalogFailed`, the only route to `unavailable`.

That error is what made this a rewrite rather than a move.
`renderTracklist` returned `nothing` on `errorReleases` and leaned on
the selector's own block to have said it, and a control inside a
collapsed disclosure cannot be a page's error surface. The failure
belongs to the list that is missing because of it, so that is where it
is drawn.

**What must not be lost is which version is on screen.** The default is
what the header already describes, so saying it on every album would be
this issue's own complaint one size smaller. `defaultVersionKey` is the
test: a line appears above the tracklist only once someone has chosen
another, naming it and offering the way back. The ★ and the words "in
your library" survive unchanged inside the panel, and the panel does
not close when the selection changes — a panel that shuts on use cannot
be used twice.

The `<select>` also loses an `aria-label` of "Select release version"
that outranked its own visible `<label>Version</label>`, which is a
label not in the name.

Verified against the running app as well as the suite: the collapsed
page, the open panel, a chosen version and 390px width all read
correctly, and the shell still measures 390 in a 390 viewport.

Closes #17
2026-08-19 01:24:49 -04:00
logan 219fa3c615 Merge pull request 'Make it obvious everywhere when you are looking at things you do not own' (#117) from feat/38-ownership-visibility into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 6m3s
Owned is plain; unowned is dimmed, named and requestable; a partly-held
album says how partly. Ownership is a file (`localId`), never the
`in_library` ratchet.

Closes #38
2026-08-19 05:11:43 +00:00
2 changed files with 539 additions and 48 deletions
@@ -198,6 +198,28 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@state() private versionEntries: VersionEntry[] = [];
/** Currently-selected dropdown entry (by VersionEntry.key). */
@state() private selectedVersionKey: string = '';
/**
* The key `buildClusters` defaulted to, kept so the page can tell
* "this is what we picked for you" from "you went and chose this".
*
* Only the second needs saying out loud. With the selector demoted
* to a disclosure below the tracklist, a chosen version is the one
* case where the list on screen is not the one the header
* describes, and nothing else on the page would say so.
*/
@state() private defaultVersionKey: string = '';
/**
* Whether the "Other versions" disclosure is open.
*
* Collapsed by default — choosing which pressing you are looking at
* is a metadata-repair task and does not belong above the
* tracklist. It is deliberately *not* closed when the selection
* changes: the user opened it to change something, and a panel that
* shuts on use cannot be used twice.
*/
@state() private versionsOpen = false;
@state() private coverArtURL = '';
/**
@@ -523,7 +545,93 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
flex-shrink: 0;
}
/* ── Version selector ── */
/* ── Other versions (a disclosure, below the tracklist) ── */
.versions {
margin-top: 24px;
border-top: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.08));
padding-top: 8px;
}
/* The heading exists so the section is reachable by heading
* navigation; the button inside it is the control. Its own
* type scale is the section header's, reduced — this is a
* footnote to the page, not a peer of the tracklist. */
.versions-heading {
margin: 0;
font-size: var(--yj-text-sm);
font-weight: 500;
}
.versions-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 2px;
background: none;
border: none;
color: var(--yj-text-secondary, #b3b3b3);
font: inherit;
text-align: left;
cursor: pointer;
}
.versions-toggle:hover {
color: var(--yj-text-primary, #fff);
}
.versions-toggle:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: 2px;
border-radius: 4px;
}
.versions-toggle wa-icon {
font-size: var(--yj-icon-xs, 11px);
transition: transform 0.2s ease;
}
.versions-toggle[aria-expanded='false'] wa-icon {
transform: rotate(-90deg);
}
.versions-intro {
margin: 0 0 10px;
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #888);
line-height: 1.4;
}
/* A line above the tracklist, and only after a deliberate
* choice — see renderChosenVersion. */
.chosen-version {
margin: 0 0 10px;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.chosen-version strong {
color: var(--yj-text-primary, #fff);
font-weight: 600;
}
.chosen-version-reset {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--yj-accent-text, #ffd43b);
text-decoration: underline;
cursor: pointer;
}
.chosen-version-reset:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: 2px;
border-radius: 2px;
}
.version-selector {
display: flex;
flex-direction: column;
@@ -948,6 +1056,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.releases = [];
this.versionEntries = [];
this.selectedVersionKey = '';
this.defaultVersionKey = '';
this.versionsOpen = false;
this.showFullTracklist = null;
this.localTracks = [];
this.filePaths = new Map();
@@ -1639,6 +1749,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|| standardEntry?.key
|| this.versionEntries[0]?.key
|| '';
// Recorded here rather than derived later: this is the one
// place that knows what "the version we picked" means, and
// recomputing the preference order at the render site would be
// a second copy of it. `handleTracklistScopeChange` rebuilds
// through here too, so the switch does not read as a choice of
// version.
this.defaultVersionKey = this.selectedVersionKey;
}
/**
@@ -2297,9 +2415,10 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
entity-type="album"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderVersionSelector()}
${this.renderChosenVersion()}
${this.renderTracklistScope()}
${this.renderTracklist()}
${this.renderVersionSelector()}
</div>
<track-details></track-details>
`;
@@ -2950,26 +3069,81 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Version Selector (R025, R026, R027) ── */
/**
* Which pressing is on screen — said only when the user chose it.
*
* The selector is a disclosure below the tracklist now, so nothing
* above the list names the version it came from. That is right for
* the default, which is what the header already describes; it is
* wrong the moment someone picks a different one, because then the
* tracklist and the page disagree and the control that explains it
* is off the bottom of the screen.
*
* `defaultVersionKey` is the whole test. A quiet line that appears
* on every album would be the thing this issue removed, one size
* smaller.
*/
private renderChosenVersion() {
if (this.loadingReleases || this.errorReleases) return nothing;
if (!this.selectedVersionKey) return nothing;
if (this.selectedVersionKey === this.defaultVersionKey) return nothing;
const current = this.currentVersion();
if (!current) return nothing;
return html`
<p class="chosen-version">
Showing <strong>${current.label}</strong> —
${current.sublabel}.
<button
type="button"
class="chosen-version-reset"
@click=${this.resetVersion}
>
Use the default version
</button>
</p>
`;
}
/** Back to what `buildClusters` picked, without opening the panel. */
private resetVersion = () => {
if (!this.defaultVersionKey) return;
this.selectedVersionKey = this.defaultVersionKey;
};
/**
* "Other versions" — a disclosure, below the tracklist.
*
* Choosing which pressing you are looking at is an advanced,
* metadata-repair task, and it used to sit directly above the
* tracklist with a heading and a paragraph of prose explaining our
* clustering heuristic. It is not removed — matching the wrong
* release is a real problem and this is how it gets fixed — it is
* demoted (#17).
*
* Two things about the shape are load-bearing, and both are
* `config-section`'s rules rather than new ones. The header is a
* real `<button aria-expanded aria-controls>` inside the heading
* that names the section, so it is reachable by Tab and by heading
* navigation alike. And the body **renders unconditionally and is
* toggled with `hidden`**, because `aria-controls` has to name an
* element that is in the DOM.
*
* The loading and error states this used to own are gone rather
* than moved. Both were unguarded, so they took the primary slot on
* every album regardless of whether there was ever going to be a
* choice: the spinner said the same thing `renderTracklist` was
* already saying about the same fetch, and the error is the one
* `catalog-scope-notice` shows at the top of the page with a retry
* — every path that sets `errorReleases` also sets `catalogFailed`,
* which is the only route to `unavailable`. What the tracklist does
* with a failure is now the tracklist's own business.
*/
private renderVersionSelector() {
if (this.loadingReleases) {
return html`
<section>
<h3 class="section-header">Versions</h3>
<div class="section-loading">Loading releases\u2026</div>
</section>
`;
}
if (this.errorReleases) {
return html`
<section>
<h3 class="section-header">Versions</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.errorReleases}
</div>
</section>
`;
}
if (this.loadingReleases || this.errorReleases) return nothing;
// A dropdown is only a choice if the choices differ. Counting
// *entries* is the wrong test: a release group routinely has
@@ -2980,7 +3154,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
//
// Distinct *tracklists* is the real question, and it is already
// computed: clusters are keyed by tracklist fingerprint.
if (this.distinctTracklistCount() <= 1) return nothing;
const choices = this.distinctTracklistCount();
if (choices <= 1) return nothing;
const aggregateEntries = this.versionEntries.filter(
(e) => e.group === 'aggregate',
@@ -2990,35 +3166,59 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
);
return html`
<div class="version-selector">
<div class="version-selector-row">
<label for="version-select">Version</label>
<select
id="version-select"
@change=${this.handleVersionChange}
aria-label="Select release version"
<section class="versions">
<h3 class="versions-heading">
<button
type="button"
class="versions-toggle"
aria-expanded=${this.versionsOpen ? 'true' : 'false'}
aria-controls="versions-body"
@click=${this.toggleVersions}
>
${aggregateEntries.length > 0
? html`
<optgroup label="Aggregate">
${aggregateEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
`
: nothing}
<optgroup label="Versions">
${clusterEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
</select>
<wa-icon name="chevron-down" aria-hidden="true"></wa-icon>
Other versions of this album (${choices})
</button>
</h3>
<div id="versions-body" ?hidden=${!this.versionsOpen}>
<p class="versions-intro">
A release group can have several pressings with
different tracklists. Pick another if the one
above does not match your copy.
</p>
<div class="version-selector">
<div class="version-selector-row">
<label for="version-select">Version</label>
<select
id="version-select"
@change=${this.handleVersionChange}
>
${aggregateEntries.length > 0
? html`
<optgroup label="Aggregate">
${aggregateEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
`
: nothing}
<optgroup label="Versions">
${clusterEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
</select>
</div>
${this.renderVersionMeta()}
</div>
</div>
${this.renderVersionMeta()}
</div>
</section>
`;
}
private toggleVersions = () => {
this.versionsOpen = !this.versionsOpen;
};
/**
* One option in the version list.
*
@@ -3197,8 +3397,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
`;
}
if (this.errorReleases) {
// Error already shown in version selector section
return nothing;
// The failure belongs to the list that is missing because
// of it. This used to return `nothing` and lean on the
// version selector's own error block to have said it, which
// is precisely the coupling that made demoting the selector
// a rewrite rather than a move: a control in a collapsed
// disclosure cannot be the page's error surface.
return html`
<section>
<h3 class="sr-only">Tracklist</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.errorReleases}
</div>
</section>
`;
}
const current = this.currentVersion();
if (!current) {
@@ -0,0 +1,278 @@
/**
* Choosing a pressing is a repair job, not the album page's headline.
*
* The version selector sat directly above the tracklist with a heading,
* a `<select>` and a paragraph explaining how our clustering picks a
* "standard version" — the most valuable space on the page spent on a
* control a normal user never touches (#17). Two more blocks shared
* that slot and were not even guarded by "is there a choice": a
* `Versions / Loading releases…` spinner about the same fetch
* `renderTracklist` was already reporting, and a `Versions / <error>`
* block duplicating what `catalog-scope-notice` shows at the top of the
* page with a retry.
*
* What is pinned here is the demotion and the three things that must
* survive it: the control is still reachable, the page still says which
* version you are looking at once you have chosen one, and a failed
* fetch still says so somewhere a collapsed panel is not.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import { page } from 'vitest/browser';
import '@components/explore-album-details/explore-album-details';
import { stub, stubFailure, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
const MBID = 'rg-0001';
function track(n: number) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: false,
};
}
function release(mbid: string, date: string, trackCount: number) {
return {
mbid,
title: 'Glass Harbour',
date,
status: 'Official',
tracks: Array.from({ length: trackCount }, (_, i) => track(i + 1)),
};
}
const UNKNOWN = { owned: 0, expected: 0, known: false, complete: false };
/** Two releases whose tracklists genuinely differ, so there is a choice. */
const TWO = [release('rel-1', '2019-04-01', 10), release('rel-2', '2020-09-01', 14)];
async function album(releases: unknown[] = TWO): Promise<LitElement> {
stub('explore.Service.BrowseReleases', releases);
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub('library.Library.GetAlbumTracks', []);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
/** Positions of two selectors within the shadow root, in document order. */
function order(el: Element, first: string, second: string): [number, number] {
const all = [...(el.shadowRoot?.querySelectorAll('*') ?? [])];
const a = all.findIndex((n) => n.matches(first));
const b = all.findIndex((n) => n.matches(second));
return [a, b];
}
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
describe('the version selector is no longer the headline', () => {
it('renders after the tracklist, not before it', async () => {
const el = await album();
const [tracklist, versions] = order(el, '.tracklist', '.versions');
expect(tracklist).toBeGreaterThan(-1);
expect(versions).toBeGreaterThan(tracklist);
});
it('starts collapsed', async () => {
const el = await album();
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(true);
});
/**
* `aria-controls` has to name an element that is in the DOM, so the
* body renders unconditionally and is toggled with `hidden` — the
* rule `config-section` states and the reason a conditional body
* would be wrong here too.
*/
it('keeps the panel in the DOM while it is shut', async () => {
const el = await album();
expect(shadow(el, '#versions-body')).not.toBeNull();
expect(
shadow(el, '.versions-toggle')?.getAttribute('aria-controls'),
).toBe('versions-body');
});
/**
* The browser's own answer: a disclosure that cannot be tabbed to is
* the fault `config-section` shipped for every setting in the app,
* and a shadow-root query cannot tell you a control has a name.
*/
it('is a named, expandable button', async () => {
await album();
await expect
.element(
page.getByRole('button', { name: /Other versions of this album \(2\)/ }),
)
.toBeInTheDocument();
});
it('opens when the button is pressed', async () => {
const el = await album();
const toggle = shadow<HTMLButtonElement>(el, '.versions-toggle');
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
toggle?.click();
await el.updateComplete;
expect(toggle?.getAttribute('aria-expanded')).toBe('true');
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(false);
});
it('says nothing at all when there is only one tracklist', async () => {
const el = await album([release('rel-1', '2019-04-01', 10)]);
expect(shadow(el, '.versions')).toBeNull();
});
});
describe('the two blocks that shared that slot', () => {
/**
* The spinner was unguarded, so `Versions / Loading releases…` took
* the primary position on *every* album load — including the ones
* that would never offer a choice — beside `renderTracklist`'s own
* "Loading tracks…" about the same fetch.
*/
it('no longer reports the same fetch twice while loading', async () => {
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub('library.Library.GetAlbumTracks', []);
stub('explore.Service.BrowseReleases', () => new Promise(() => {}));
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
albumName: 'Glass Harbour',
});
const loading = shadowAll(el, '.section-loading');
expect(loading).toHaveLength(1);
expect(loading[0]?.textContent).toContain('Loading tracks');
});
/**
* A failed browse must still be visible, and it cannot be visible
* from inside a collapsed disclosure. It belongs to the list that is
* missing because of it — `renderTracklist` used to return `nothing`
* here and lean on the selector's own error block, which is exactly
* the coupling that made this a rewrite rather than a move.
*/
it('reports a failed fetch in the tracklist, once', async () => {
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub('library.Library.GetAlbumTracks', []);
stubFailure('explore.Service.BrowseReleases', 'the catalog said no');
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
const errors = shadowAll(el, '.section-error');
expect(errors).toHaveLength(1);
expect(errors[0]?.textContent).toContain('versions');
expect(shadow(el, '.versions')).toBeNull();
});
});
describe('which version is on screen', () => {
/**
* The default is what the header already describes, so a line saying
* so on every album would be the thing this issue removed, one size
* smaller.
*/
it('is not stated while the page picked it', async () => {
const el = await album();
expect(shadow(el, '.chosen-version')).toBeNull();
});
/**
* The moment someone chooses another, the tracklist and the header
* disagree — and the control that explains it is now off the bottom
* of the page.
*/
it('is stated above the tracklist once the user chooses', async () => {
const el = await album();
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
const other = [...select.options].find((o) => o.value !== select.value)!;
select.value = other.value;
select.dispatchEvent(new Event('change'));
await el.updateComplete;
const line = shadow(el, '.chosen-version');
expect(line).not.toBeNull();
const [chosen, tracklist] = order(el, '.chosen-version', '.tracklist');
expect(chosen).toBeGreaterThan(-1);
expect(tracklist).toBeGreaterThan(chosen);
});
it('offers a way back, which clears the line', async () => {
const el = await album();
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
const first = select.value;
const other = [...select.options].find((o) => o.value !== first)!;
select.value = other.value;
select.dispatchEvent(new Event('change'));
await el.updateComplete;
shadow<HTMLButtonElement>(el, '.chosen-version-reset')?.click();
await el.updateComplete;
expect(shadow<HTMLSelectElement>(el, '#version-select')?.value).toBe(first);
expect(shadow(el, '.chosen-version')).toBeNull();
});
/** A panel that shuts on use cannot be used twice. */
it('leaves the disclosure open after a choice', async () => {
const el = await album();
shadow<HTMLButtonElement>(el, '.versions-toggle')?.click();
await el.updateComplete;
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
const other = [...select.options].find((o) => o.value !== select.value)!;
select.value = other.value;
select.dispatchEvent(new Event('change'));
await el.updateComplete;
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(false);
});
});