Files
yellowjacket/frontend/src/components/genre-details/genre-details.ts
T
logan 4f628b1f52
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m36s
fix(ui): raise the last controls below the touch floor
The rest of #186's second table, and one thing it could not have said.

    .section-toggle          187x15  autotag
    .folders-menu-trigger     32x18  autotag
    .back-button              32x32  artist-details
    Requests / Downloads tabs 85x34, 96x34
    .search-mode-tab          89x26, 79x26  explore
    explore search input     325x18  in a 36px box

**back-button was six controls, not one.** The issue names it in
artist-details because that is the view the sweep opened; the same
declaration is byte-identical in artist-details, genre-details,
playlist-details, smart-playlist-details, explore-artist-details and
explore-album-details, 32px in all six. So it is styles/back-button.
css.ts now, adopted by each, and a source sweep fails on a seventh
copy -- because the failure this invites is not a size changing, it is
somebody adding a detail view and writing `.back-button` out again,
which no device sweep would catch for the same reason this one did
not. That is icon-language.test.ts's shape, and the argument for it
here is the inverse of the column arrows': one declaration covering
36 controls is cheap to fix, and six declarations of one control are
six chances to miss five.

It is a real 44px box rather than padding with the width handed back:
a detail header runs no fit pass, and this button has a visible
background, so a hit area larger than the circle would be a control
bigger than it looks. The size is #55's, reached there for the same
reason -- "the way out is 44px on a phone".

**The explore search box was two faults.** The row was 36px *and* the
input inside it was 18, so eight pixels at each edge were not a target
at all: a tap near the top of the box landed on the container and did
nothing. The container is 44 and the input stretches to it.

**The Downloads tabs take padding rather than a min-size**, because
the mark for the selected tab is its bottom border -- a min-size
centres the label and leaves the underline 10px beneath it.

page-action-check-now (113x29) is in that table and is not here: it is
a PageAction, so #195 raised it with the rest of the header's actions
and touch-targets.test.ts already covers it.

**The Downloads tabs needed a min-size as well as the padding, and CI
is what said so.** Padding alone made them 44px on this machine and
**43px in the container**: the total is 13 + 13 + 2 + whatever line box
the font gives 13px text, and ubuntu:24.04's is a pixel shorter than
Arch's. A height computed from a font's line box is not a height you
control -- which is #195's "stated as a property on the strength of one
engine" one layer down, in the same PR that recorded it. The padding
stays, because it is what keeps the underline against the label; the
min-size is the floor.

Caught by the new test rather than by a person, which is the half of
this that worked.

Verified on the device, sweeping each view the way the issue was
filed: explore, downloads, autotag and artist-details now report
**one** control under the floor apiece, and it is the skip link, which
#186 already ruled out as keyboard-only. .search-mode-tab 89x44 and
79x44, the search input 325x44, the Downloads tabs 85x44 and 96x44,
.section-toggle 174x44, .folders-menu-trigger 44x44, .back-button
44x44.

All 12 new tests fail on main, the source sweep naming all six copies.
make ui-test 1041 pass; make e2e 236 pass on chromium, which is half
an answer -- CI had the other half, and used it.

Closes #186
2026-08-21 23:08:25 -04:00

291 lines
8.3 KiB
TypeScript

import { LitElement, html, css } from 'lit';
import {
customElement,
property,
state,
} from 'lit/decorators.js';
import * as library from '@go/library/models.js';
import {
GetTracksByGenre,
} from '@go/library/library.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { libraryStore } from '@store/library-store';
import { describeError } from '@utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
@customElement('genre-details')
export class GenreDetails extends LitElement {
@property({ type: String, attribute: 'genre-name' })
genreName = '';
@state()
private tracks: library.Track[] = [];
@state()
private loading = true;
/** A failed genre query used to be handed to `<track-list>` as an
* empty array, so it was indistinguishable from a slow one
* (errors.M2). */
@state()
private loadError = '';
private scanCompleteCleanup: (() => void) | null =
null;
static override styles = [designTokens, backButton, css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.load-error {
color: var(--yj-text-secondary, #b3b3b3);
padding: 1em;
}
.load-error button {
background: none;
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
color: inherit;
cursor: pointer;
font: inherit;
padding: 4px 10px;
}
/* ====================================
* Header
* ==================================== */
.genre-header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
flex-shrink: 0;
border-bottom: 1px solid
var(
--yj-border-subtle,
rgba(255, 255, 255, 0.06)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
.genre-avatar {
width: 80px;
height: 80px;
border-radius: 8px;
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;
flex-shrink: 0;
}
.genre-avatar .initial {
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 32px; /* large decorative initial */
font-weight: 600;
text-transform: uppercase;
user-select: none;
line-height: 1;
}
.genre-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.genre-title {
font-size: 24px; /* page title — outside type scale */
font-weight: 700;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
line-height: 1.2;
}
.track-count {
font-size: var(--yj-text-md);
color: var(
--yj-text-secondary,
#b3b3b3
);
}
/* ====================================
* Content
* ==================================== */
.content {
flex: 1;
overflow: hidden;
}
track-list {
width: 100%;
height: 100%;
}
`];
override connectedCallback() {
super.connectedCallback();
this.loadTracks();
this.scanCompleteCleanup = EventsOn(
Events.LibraryScanComplete,
() => this.loadTracks(),
);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.scanCompleteCleanup) {
this.scanCompleteCleanup();
this.scanCompleteCleanup = null;
}
}
/* ================================================================
* Data loading
* ================================================================ */
private async loadTracks() {
if (!this.genreName) return;
this.loadError = '';
try {
const libId =
libraryStore.getSelectedLibraryId();
this.tracks = await list(
GetTracksByGenre(this.genreName, libId ?? 0),
);
} catch (error) {
console.error('Error loading genre tracks:', error);
this.tracks = [];
this.loadError = describeError(
error,
`The tracks for “${this.genreName}” could not be loaded.`,
);
} finally {
this.loading = false;
}
}
/* ================================================================
* Navigation
* ================================================================ */
private navigateBack() {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'genres' },
}),
);
}
/* ================================================================
* Helpers
* ================================================================ */
private getInitial(name: string): string {
if (!name) return '?';
return name.charAt(0).toUpperCase();
}
/* ================================================================
* Rendering
* ================================================================ */
override render() {
const trackCount = this.tracks.length;
const trackLabel =
trackCount === 1 ? 'track' : 'tracks';
return html`
<div class="genre-header">
<button
class="back-button"
@click=${this.navigateBack}
title="Back to genres"
aria-label="Back to genres"
>
<wa-icon
name="arrow-left"
></wa-icon>
</button>
<div class="genre-avatar">
<span class="initial">
${this.getInitial(
this.genreName,
)}
</span>
</div>
<div class="genre-info">
<h1
class="genre-title"
title="${this.genreName}"
>
${this.genreName}
</h1>
${!this.loading
? html`
<span
class="track-count"
>
${trackCount}
${trackLabel}
</span>
`
: ''}
</div>
</div>
<div class="content">
${this.loadError
? html`<div class="load-error" data-testid="genre-error">
<p>${this.loadError}</p>
<button
type="button"
@click=${() => void this.loadTracks()}
>
Try again
</button>
</div>`
: html`<track-list
.externalTracks=${this.tracks}
.queueSource=${{ type: 'genre', id: 0, label: this.genreName }}
></track-list>`}
</div>
`;
}
}