From 8b79953cd4d5dfaae05d908ef81f85bccd02dc28 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 07:26:35 +0100 Subject: [PATCH 1/2] feat(webapp): editorial pass over the playing, albums and artist pages Now playing lays the track and its lyrics out in two columns on wide screens and keeps one centred column for a track without lyrics; the day's mixes get a labelled shelf with mosaic covers and a mix marker, apart from the albums; the artist's image actions move behind one menu button on the header, built on a popup menu shared with the track menu; and the inspector presents the measured bit depth as a measurement. Signed-off-by: InstaZDLL --- webapp/e2e/editorial-pages.spec.ts | 531 ++++++++++++++++++ webapp/e2e/studio-nocturne.spec.ts | 76 ++- .../src/components/artist-image-controls.tsx | 162 ++++-- webapp/src/components/mix-shelves.tsx | 44 +- webapp/src/components/popup-menu.tsx | 214 +++++++ webapp/src/components/quality-section.tsx | 6 + webapp/src/components/track-menu.tsx | 163 +----- webapp/src/pages/albums-page.tsx | 16 +- webapp/src/pages/playing-page.tsx | 116 +++- webapp/src/pages/playing-view.test.ts | 21 +- webapp/src/state/i18n.tsx | 24 +- webapp/src/styles/styles.css | 204 +++++-- 12 files changed, 1283 insertions(+), 294 deletions(-) create mode 100644 webapp/e2e/editorial-pages.spec.ts create mode 100644 webapp/src/components/popup-menu.tsx diff --git a/webapp/e2e/editorial-pages.spec.ts b/webapp/e2e/editorial-pages.spec.ts new file mode 100644 index 00000000..55997e23 --- /dev/null +++ b/webapp/e2e/editorial-pages.spec.ts @@ -0,0 +1,531 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, type Page, test } from "@playwright/test"; + +/** + * The editorial pass over four screens: the playing screen in two columns, + * the mixes set apart from the albums, the artist's image actions behind one + * menu, and the measured bit depth said as a measurement. + * + * Its own mock, shaped like the one `studio-nocturne.spec.ts` drives — the + * same session, the same `Library` shape, the same lyrics envelope — and kept + * to the routes these screens read. Anything else answers 404, as there. + */ + +const session = { + access_token: "e2e-access-token", + user: { id: "user-1", username: "listener", role: "admin" }, + device_id: "device-1", +}; + +const library = (role: "owner" | "manager" | "listener") => ({ + id: "library-1", + name: "Ma musique", + visibility: "private" as const, + role, + last_scan_started_at: null, + last_scan_completed_at: null, + accepts_uploads: false, + accepts_canvas: false, + follow_links: false, +}); + +const albums = [ + { + id: "album-1", + library_id: "library-1", + title: "Post", + artist: "Björk", + artist_id: "artist-1", + artwork_hash: null, + year: 1995, + starred_at: null, + user_rating: null, + }, + { + id: "album-2", + library_id: "library-1", + title: "Vespertine", + artist: "Björk", + artist_id: "artist-1", + artwork_hash: null, + year: 2001, + starred_at: null, + user_rating: null, + }, +]; + +const song = (index: number, title: string) => ({ + id: `song-${index}`, + library_id: "library-1", + album_id: "album-2", + title, + album: "Vespertine", + artist: "Björk", + artist_id: "artist-1", + artwork_hash: null, + duration_ms: 300_000, + track: index, + disc: 1, + starred_at: null, + user_rating: null, + codec: "FLAC", + suffix: "flac", + size: 27_983_872, + bitrate: 957, + sample_rate: 96_000, + channels: 2, + bit_depth: 24, +}); + +const songs = [song(1, "Hidden Place"), song(2, "Cocoon"), song(3, "Undo")]; + +type Sheet = { + displayArtist: string | null; + displayTitle: string; + lang: string; + synced: boolean; + line: Array<{ start?: number; value: string }>; +}; + +/** The words the mock holds per track; absent is a track without any. */ +let lyricSheets = new Map(); +/** The mixes `GET /api/v2/mixes` answers; none by default. */ +let mixes: Array<{ + id: string; + kind: string; + genre: string | null; + decade: number | null; + track_count: number; + artwork_hashes: string[]; +}> = []; +/** The account's role in the one library. */ +let role: "owner" | "manager" | "listener" = "owner"; + +/** A synced sheet of forty lines, one every five seconds. */ +const longSheet = (): Sheet[] => [ + { + displayArtist: "Björk", + displayTitle: "Hidden Place", + lang: "eng", + synced: true, + line: Array.from({ length: 40 }, (_, index) => ({ + start: index * 5_000, + value: `column line ${index}`, + })), + }, +]; + +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +async function expectNoWcagViolations(page: Page) { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter( + (animation) => + animation.effect?.getComputedTiming().endTime !== Infinity, + ) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); +} + +async function mockApi(page: Page) { + await page.context().addCookies([ + { + name: "waveflow-csrf", + value: "e2e-csrf", + url: test.info().project.use.baseURL ?? "http://127.0.0.1:4173", + }, + ]); + await page.route("**/api/v2/**", async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const answer = (json: unknown) => route.fulfill({ json }); + if (path === "/api/v2/web/auth/refresh") return answer(session); + if (path === "/api/v2/libraries") return answer([library(role)]); + if (path === "/api/v2/queue") { + return answer({ + current: "song-1", + position_ms: 0, + changed_by: null, + updated_at: 1, + songs, + }); + } + if (path.startsWith("/api/v2/artwork/")) { + return route.fulfill({ contentType: "image/png", body: png }); + } + if ( + path === "/api/v2/bookmarks" || + path === "/api/v2/now-playing" || + path === "/api/v2/playlists" || + path === "/api/v2/smart-playlists" || + path === "/api/v2/radio-stations" + ) { + return answer([]); + } + if (path === "/api/v2/podcasts") { + return answer({ enabled: false, can_manage: false, channels: [] }); + } + if (path === "/api/v2/albums") return answer(albums); + if (path === "/api/v2/albums/album-2") return answer({ ...albums[1], songs }); + if (path === "/api/v2/mixes") return answer(mixes); + if (path === "/api/v2/artists/artist-1/top-songs") return answer([]); + if (path === "/api/v2/artists/artist-1") { + return answer({ + id: "artist-1", + name: "Björk", + album_count: 2, + library_id: "library-1", + artwork_hash: "d".repeat(64), + banner_hash: "c".repeat(64), + image_choice: { + portrait: { source: "upload", set_at: 1 }, + banner: { source: "upload", set_at: 1 }, + }, + albums, + }); + } + const parts = path.split("/"); + if (parts[3] === "tracks" && parts.length === 6) { + const trackId = parts[4] as string; + if (parts[5] === "lyrics") { + return answer({ + trackId, + structuredLyrics: lyricSheets.get(trackId) ?? [], + }); + } + if (parts[5] === "quality") { + // A 24-bit file whose samples use all 24: the case most easily read + // as a certificate of genuine hi-res, which it is not. + return answer({ + verdict: "consistent", + cutoff_hz: null, + wall_db: 4.1, + nyquist_hz: 48_000, + effective_bits: 24, + analyzed_at: 1_700_000_000_000, + }); + } + } + if (parts[3] === "tracks" && parts.length === 5) { + const found = songs.find((entry) => entry.id === parts[4]); + if (found) { + return answer({ + ...found, + relative_path: `Björk/Vespertine/${found.track}.flac`, + full_hash: "a".repeat(64), + created_at: 1_700_000_000_000, + }); + } + } + return route.fulfill({ status: 404, json: { error: "not found" } }); + }); +} + +test.beforeEach(async ({ page }) => { + lyricSheets = new Map(); + mixes = []; + role = "owner"; + await mockApi(page); +}); + +/** Two boxes, read in one go so they describe the same layout. */ +async function boxes(page: Page, first: string, second: string) { + return page.evaluate( + ([a, b]) => + [a, b].map((selector) => { + const box = document.querySelector(selector)?.getBoundingClientRect(); + return box + ? { left: box.left, right: box.right, top: box.top, bottom: box.bottom } + : null; + }), + [first, second] as const, + ); +} + +/** + * Wide, the track and its words are two columns side by side; at a phone's + * width they stack, the track above. The cover grows in the wide layout: the + * point of giving the track a column was the room. + */ +test("lays the playing screen out in two columns, and stacks it on a phone", async ({ + page, +}) => { + lyricSheets.set("song-1", longSheet()); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/playing"); + const playing = page.locator(".playing"); + await expect(playing).toHaveClass(/playing-split/); + await expect(page.locator(".playing-lyrics .lyrics ol")).toBeVisible(); + + const [track, words] = await boxes(page, ".playing-track", ".playing-lyrics"); + if (!track || !words) throw new Error("a column is missing"); + // Beside, not under: the words start right of the track, level with it. + expect(words.left).toBeGreaterThanOrEqual(track.right); + expect(Math.abs(words.top - track.top)).toBeLessThanOrEqual(2); + // Larger than the 18rem the cover was capped at beside the names. + const cover = await page.locator(".playing-track .cover.large").boundingBox(); + expect(cover?.width ?? 0).toBeGreaterThan(18 * 16); + await expectNoWcagViolations(page); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect + .poll(async () => { + const [stackedTrack, stackedWords] = await boxes( + page, + ".playing-track", + ".playing-lyrics", + ); + return ( + stackedTrack !== null && + stackedWords !== null && + stackedWords.top >= stackedTrack.bottom + ); + }) + .toBe(true); + // And nothing wider than the phone. + expect( + await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ), + ).toBeLessThanOrEqual(1); +}); + +/** + * The tall column still reads along: the sung line is marked and held in + * view, the column scrolls itself and not the page, and a line pressed moves + * the head to it. + */ +test("follows the sung line and seeks from the lyrics column", async ({ + page, +}) => { + lyricSheets.set("song-1", longSheet()); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/playing"); + const column = page.locator(".playing-lyrics .lyrics"); + const list = column.locator("ol"); + await expect(list).toBeVisible(); + // Tall: more than the 26rem the strip under the names was given. + expect(await list.evaluate((node) => node.clientHeight)).toBeGreaterThan( + 26 * 16, + ); + await expect.poll(() => list.evaluate((node) => node.scrollTop)).toBe(0); + + for (let press = 0; press < 20; press += 1) { + await page.keyboard.press("ArrowRight"); + } + await expect( + column.getByRole("listitem").filter({ hasText: /^column line 20$/ }), + ).toHaveAttribute("aria-current", "true"); + await expect + .poll(() => list.evaluate((node) => node.scrollTop)) + .toBeGreaterThan(0); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + + await column + .getByRole("button", { name: "column line 5, jump to 0:25", exact: true }) + .click(); + await expect( + column.getByRole("listitem").filter({ hasText: /^column line 5$/ }), + ).toHaveAttribute("aria-current", "true"); + await expectNoWcagViolations(page); +}); + +/** + * A track without words keeps one column, in the middle, and says there is + * nothing to read — rather than holding half the screen open for words that + * are not there. + */ +test("keeps one centred column for a track without lyrics", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/playing"); + const playing = page.locator(".playing"); + await expect(page.getByText("No lyrics travel with this file.")).toBeVisible(); + await expect(playing).toHaveClass(/playing-solo/); + await expect(page.locator(".playing-lyrics")).toHaveCount(0); + + const [frame, track] = await boxes(page, ".playing", ".playing-track"); + if (!frame || !track) throw new Error("the layout is missing"); + const middle = (box: { left: number; right: number }) => + (box.left + box.right) / 2; + expect(Math.abs(middle(track) - middle(frame))).toBeLessThanOrEqual(2); + await expectNoWcagViolations(page); +}); + +/** + * The mixes are a shelf of their own, named as generated playlists, each card + * marked as a mix and the one with four covers drawn as a mosaic; the albums + * under it carry their own heading, and take no card from the shelf. + */ +test("sets the day's mixes apart from the albums", async ({ page }) => { + mixes = [ + { + id: "most-played", + kind: "most_played", + genre: null, + decade: null, + track_count: 2, + artwork_hashes: ["1", "2", "3", "4"].map((digit) => digit.repeat(64)), + }, + { + id: "genre:art pop", + kind: "genre", + genre: "Art Pop", + decade: null, + track_count: 2, + artwork_hashes: ["5".repeat(64)], + }, + ]; + await page.goto("/"); + const shelf = page.getByRole("region", { name: "Mixes for you" }); + await expect(shelf).toBeVisible(); + await expect( + shelf.getByText("Playlists drawn from your listening, new every day."), + ).toBeVisible(); + // Each card says it is a mix, in words. + const cards = shelf.getByRole("link"); + await expect(cards).toHaveCount(2); + for (const card of await cards.all()) { + await expect(card.locator(".mix-badge")).toHaveText("Mix"); + } + await expect(cards.first()).toHaveAccessibleName(/Mix.*Most played/); + // Four sleeves in a square for the mix that has four; one for the other. + await expect(cards.first().locator(".mix-mosaic img")).toHaveCount(4); + await expect(cards.nth(1).locator(".mix-mosaic")).toHaveCount(0); + + // The albums carry a heading of their own, under the shelf, and the shelf's + // cards are not among them. + const albumsHeading = page.getByRole("heading", { name: "In your library" }); + await expect(albumsHeading).toBeVisible(); + const order = await page.evaluate(() => { + const top = (selector: string) => + document.querySelector(selector)?.getBoundingClientRect().top ?? -1; + return [top(".mix-shelf"), top(".albums-grid-title"), top(".grid")]; + }); + expect(order[0]).toBeLessThan(order[1] as number); + expect(order[1]).toBeLessThan(order[2] as number); + await expect(page.locator(".grid li")).toHaveCount(2); + await expect(page.locator(".grid .mix-badge")).toHaveCount(0); + await expectNoWcagViolations(page); + + // Filtering the albums puts the shelf away, and the heading with it. + await page.getByLabel("Filter by title or artist").fill("Post"); + await expect(shelf).toHaveCount(0); + await expect(albumsHeading).toHaveCount(0); +}); + +test("names no album section when there is no shelf above it", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + await expect(page.locator(".grid li")).toHaveCount(2); + await expect(page.getByRole("heading", { name: "In your library" })).toHaveCount(0); +}); + +/** + * The image actions sit behind one button on the header, for whoever may + * take them: a menu button that says it opens a menu and whether it is open, + * whose menu answers the keyboard and hands focus back when it closes. + */ +test("offers an editor the artist's image actions behind one menu", async ({ + page, +}) => { + await page.goto("/artists/artist-1"); + const hero = page.locator(".artist-hero"); + await expect(hero.locator("img.artist-banner")).toBeVisible(); + const trigger = hero.getByRole("button", { name: "Edit images", exact: true }); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + // None of the actions is a button on the picture any more. + for (const name of [ + "Change portrait", + "Search Deezer", + "Automatic portrait", + "Change banner", + ]) { + await expect(hero.getByRole("button", { name, exact: true })).toHaveCount(0); + } + await expectNoWcagViolations(page); + + await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + const menu = page.getByRole("menu", { name: "Images of Björk" }); + // The portrait chosen by hand and the banner too: both can go back. + await expect(menu.getByRole("menuitem")).toHaveText([ + "Change portrait", + "Search Deezer", + "Automatic portrait", + "Change banner", + "No banner", + ]); + await expect(menu.getByRole("menuitem").first()).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect( + menu.getByRole("menuitem", { name: "Search Deezer" }), + ).toBeFocused(); + await expectNoWcagViolations(page); + + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(trigger).toBeFocused(); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + + // The button closes what it opened, rather than closing and reopening it. + await trigger.click(); + await expect(menu).toBeVisible(); + await trigger.click(); + await expect(menu).toHaveCount(0); +}); + +test("shows a reader the artist's header with no edit menu", async ({ + page, +}) => { + role = "listener"; + await page.goto("/artists/artist-1"); + const hero = page.locator(".artist-hero"); + await expect(hero.locator("img.artist-banner")).toBeVisible(); + await expect(hero.getByRole("heading", { name: "Björk" })).toBeVisible(); + await expect(hero.getByRole("button", { name: "Edit images" })).toHaveCount(0); + await expect(page.getByRole("menu")).toHaveCount(0); + await expectNoWcagViolations(page); +}); + +/** + * The measured bit depth is a measurement, and says so: a file that uses all + * 24 bits it declares was not padded, which does not make it a genuine + * hi-res recording. + */ +test("presents the measured bit depth as a measurement", async ({ page }) => { + await page.goto("/albums/album-2"); + await page + .locator(".songs tbody tr") + .first() + .locator("button.song-title") + .focus(); + await page.keyboard.press("Shift+F10"); + await page.getByRole("menuitem", { name: "Properties" }).click(); + const dialog = page.getByRole("dialog"); + await expect( + dialog.getByRole("heading", { name: "Real quality" }), + ).toBeVisible(); + const row = dialog + .locator(".property-row") + .filter({ hasText: "Bits in use, measured" }); + await expect(row.locator("dd")).toHaveText("24 of 24"); + await expect(dialog.locator(".quality-note")).toHaveText( + "A measurement of the samples, not proof of genuine hi-res: a file that uses every bit it declares was not padded, which does not mean it was recorded at that depth.", + ); + await expectNoWcagViolations(page); +}); diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 85d35eaf..1edabbfb 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -6959,6 +6959,20 @@ test("closes a folder's form when another folder is opened", async ({ page }) => expect(state.writes).toEqual([]); }); +/** + * Opens the artist header's image menu, where the portrait and banner actions + * live behind one button, and returns the menu. + */ +async function openImageMenu(page: Page) { + await page + .locator(".artist-hero") + .getByRole("button", { name: "Edit images", exact: true }) + .click(); + const menu = page.getByRole("menu", { name: "Images of Björk" }); + await expect(menu).toBeVisible(); + return menu; +} + /** * A stand-in for the artist routes of RFC-013 with state: the page re-reads * after every write, and what it shows must be what the write did. @@ -7029,9 +7043,13 @@ test("changes an artist's banner and portrait from the artist page", async ({ const { state, png } = await serveArtistImages(page); await page.goto("/artists/artist-1"); const hero = page.locator(".artist-hero"); - await expect(hero.getByRole("button", { name: "Change portrait" })).toBeVisible(); - await expect(hero.getByRole("button", { name: "Change banner" })).toBeVisible(); - await expect(hero.getByRole("button", { name: "No banner" })).toHaveCount(0); + // The actions sit behind one button in the header's corner. + let menu = await openImageMenu(page); + await expect(menu.getByRole("menuitem", { name: "Change portrait" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Change banner" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "No banner" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); // With no banner, the portrait is the backdrop, blurred. await expect(hero.locator(".artist-hero-ambient")).toBeAttached(); @@ -7064,10 +7082,12 @@ test("changes an artist's banner and portrait from the artist page", async ({ await expect(hero.getByRole("alert")).toHaveText("Choose a JPEG, PNG or WebP image."); // Back to automatic: no banner, and the fetched portrait again. - await hero.getByRole("button", { name: "No banner" }).click(); + menu = await openImageMenu(page); + await menu.getByRole("menuitem", { name: "No banner" }).click(); await expect(hero.getByRole("status")).toHaveText("Banner removed."); await expect(hero.locator("img.artist-banner")).toHaveCount(0); - await hero.getByRole("button", { name: "Automatic portrait" }).click(); + menu = await openImageMenu(page); + await menu.getByRole("menuitem", { name: "Automatic portrait" }).click(); await expect(hero.getByRole("status")).toHaveText("Back to the automatic portrait."); await expect(hero.getByText("Portrait chosen by hand")).toHaveCount(0); expect(state.writes.slice(-2)).toEqual([ @@ -7086,6 +7106,8 @@ test("shows a listener the artist's banner with nothing to change", async ({ await page.goto("/artists/artist-1"); const hero = page.locator(".artist-hero"); await expect(hero.locator("img.artist-banner")).toBeVisible(); + // Not even the button the actions sit behind. + await expect(hero.getByRole("button", { name: "Edit images" })).toHaveCount(0); await expect(hero.getByRole("button", { name: /Change/ })).toHaveCount(0); await expect(hero.getByRole("button", { name: "Search Deezer" })).toHaveCount(0); }); @@ -7198,7 +7220,9 @@ test("chooses an artist's portrait from a Deezer search", async ({ page }) => { await page.goto("/artists/artist-1"); const hero = page.locator(".artist-hero"); - await hero.getByRole("button", { name: "Search Deezer" }).click(); + await (await openImageMenu(page)) + .getByRole("menuitem", { name: "Search Deezer" }) + .click(); const picker = page.getByRole("dialog", { name: "Choose a portrait from Deezer" }); await expect(picker).toBeVisible(); const results = picker.getByRole("list", { name: "Deezer artists" }); @@ -7575,7 +7599,9 @@ test("keeps the artist hero and the Deezer picker readable in the light", async // Opened from the hero, the picker sits inside it in the tree, and took // the hero's light-on-dark ink: the fan count was white on white. - await hero.getByRole("button", { name: "Search Deezer" }).click(); + await (await openImageMenu(page)) + .getByRole("menuitem", { name: "Search Deezer" }) + .click(); const picker = page.getByRole("dialog", { name: "Choose a portrait from Deezer" }); await expect(picker.getByText("1500000 fans")).toBeVisible(); await expectNoWcagViolations(page); @@ -7765,20 +7791,38 @@ for (const { theme, banner, portrait } of [ ] as const) { expect(await lowestContrast(page, text), label).toBeGreaterThanOrEqual(4.5); } - // Every button is measured; these two are required, the plain one and the - // quiet one, so the loop below cannot pass over an empty list. - for (const required of ["Change portrait", "Automatic portrait"]) { - const button = hero.getByRole("button", { name: required, exact: true }); - await expect(button).toHaveCount(1); - await expect(button).toBeVisible(); - } - for (const button of await hero.locator(".artist-image-button").all()) { + // Every button over the picture is measured. The image actions sit + // behind one menu button now, which is required, so the loop below + // cannot pass over an empty list. + const trigger = hero.getByRole("button", { name: "Edit images", exact: true }); + await expect(trigger).toHaveCount(1); + await expect(trigger).toBeVisible(); + const overPicture = await hero.locator(".artist-image-button").all(); + expect(overPicture.length).toBeGreaterThan(0); + for (const button of overPicture) { if (!(await button.isVisible())) continue; expect( await lowestContrast(page, button, true), `the button “${await button.innerText()}”`, ).toBeGreaterThanOrEqual(4.5); } + // And what it opens: the plain entry and the quiet one, both required — + // a portrait chosen by hand is why "Automatic portrait" is offered. The + // menu is drawn on its own panel, outside the header, and is measured + // over whatever it covers all the same. + await trigger.click(); + const menu = page.getByRole("menu", { name: "Images of Björk" }); + for (const required of ["Change portrait", "Automatic portrait"]) { + const entry = menu.getByRole("menuitem", { name: required, exact: true }); + await expect(entry).toHaveCount(1); + await expect(entry).toBeVisible(); + } + for (const entry of await menu.getByRole("menuitem").all()) { + expect( + await lowestContrast(page, entry.locator("span")), + `the menu entry “${await entry.innerText()}”`, + ).toBeGreaterThanOrEqual(4.5); + } }); } @@ -8819,7 +8863,7 @@ test("offers the day's mixes above the albums, and plays one", async ({ await page.goto("/"); const shelf = page.locator(".mix-shelf"); await expect( - shelf.getByRole("heading", { name: "Made for you today" }), + shelf.getByRole("heading", { name: "Mixes for you" }), ).toBeVisible(); // Named by the client, in the listener's language, from what the server // sent: a kind and its parameter, never a title. diff --git a/webapp/src/components/artist-image-controls.tsx b/webapp/src/components/artist-image-controls.tsx index 0f2c4861..f1c9a154 100644 --- a/webapp/src/components/artist-image-controls.tsx +++ b/webapp/src/components/artist-image-controls.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { ApiError, @@ -12,6 +13,7 @@ import { artistQuery } from "../api/queries"; import { type TranslationKey, useI18n } from "../state/i18n"; import { DeezerPortraitPicker } from "./deezer-portrait-picker"; import { Icon } from "./icons"; +import { PopupMenu, type PopupMenuItem } from "./popup-menu"; /** What the server takes: recognised from the bytes, these three only. */ const ACCEPT = "image/jpeg,image/png,image/webp"; @@ -42,7 +44,8 @@ const LABELS: Record< /** * Change an artist's portrait or banner, or go back to the automatic one * (RFC-013). Drawn only for the library's owners and managers; the server - * refuses anyone else regardless. + * refuses anyone else regardless. The actions sit behind one menu button in + * the header's corner; what they report stays in the header, beside it. * * A file is sent as it is: no crop, no resize. The banner is framed by the * page at a fixed position, as the desktop frames it. The portrait may also @@ -57,6 +60,9 @@ export function ArtistImageControls({ artist }: { artist: ArtistDetail }) { }; const [busy, setBusy] = useState(false); const [searching, setSearching] = useState(false); + // Where the menu hangs, under the button's right edge; null while closed. + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + const trigger = useRef(null); const [notice, setNotice] = useState<{ error: boolean; text: string } | null>( null, ); @@ -108,60 +114,110 @@ export function ArtistImageControls({ artist }: { artist: ArtistDetail }) { } const kinds: ArtistImageKind[] = ["portrait", "banner"]; + // In the order a reader meets them: the portrait's three ways, then the + // banner's two. A reset is offered only where there is a choice to undo. + const items: PopupMenuItem[] = kinds.flatMap((kind) => [ + { + key: `change-${kind}`, + icon: "edit" as const, + label: t(LABELS[kind].change), + run: () => pickers[kind].current?.click(), + }, + ...(kind === "portrait" + ? [ + { + key: "deezer", + icon: "search" as const, + label: t("artistImage.searchDeezer"), + run: () => { + setNotice(null); + setSearching(true); + }, + }, + ] + : []), + ...(artist.image_choice?.[kind] + ? [ + { + key: `reset-${kind}`, + icon: "close" as const, + label: t(LABELS[kind].reset), + run: () => void reset(kind), + }, + ] + : []), + ]); + + function toggle() { + if (menu) { + setMenu(null); + return; + } + const box = trigger.current?.getBoundingClientRect(); + if (!box) return; + setMenu({ x: box.right, y: box.bottom + 6 }); + } + + function closeMenu() { + setMenu(null); + trigger.current?.focus(); + } + return (
-
- {kinds.map((kind) => ( - - - {kind === "portrait" ? ( - - ) : null} - {artist.image_choice?.[kind] ? ( - - ) : null} - { - const file = event.target.files?.[0]; - // Cleared, so the same file chosen again is a change too. - event.target.value = ""; - if (file) void choose(kind, file); - }} - /> - - ))} -
+ {/* One discreet button over the picture rather than a row of five: + the photograph is what the header is for, and the actions are for + the few who may take them. */} + + {kinds.map((kind) => ( + { + const file = event.target.files?.[0]; + // Cleared, so the same file chosen again is a change too. + event.target.value = ""; + if (file) void choose(kind, file); + }} + /> + ))} + {/* Portalled for the reason the track menu is: the header clips its + overflow, and the menu would be cut off at its lower edge. And + outside the header in the tree, so none of its light-on-picture ink + reaches the menu's own panel. */} + {menu + ? createPortal( + , + document.body, + ) + : null} {notice ? (

= 4 ? hashes.slice(0, 4) : null; + return ( + + {four ? ( + + {four.map((hash) => ( + + ))} + + ) : ( + + )} + + + {t("mixes.badge")} + + + ); +} + /** * The mixes the server drew for this account today (RFC-024 decision 7), as * one shelf above the albums. @@ -54,7 +86,12 @@ export function MixShelves() { if (!mixes?.length) return null; return (

-

{t("mixes.title")}

+ {/* Says what these are before anybody has to guess: generated + playlists, redrawn each day, and not more albums. */} +
+

{t("mixes.shelfTitle")}

+

{t("mixes.shelfDetail")}

+
{failed ? (

{t("mixes.failed", { title: failed })} @@ -70,10 +107,7 @@ export function MixShelves() { to="/mixes/$mixId" params={{ mixId: mix.id }} > - + {title} {t("common.tracks", { count: mix.track_count })} diff --git a/webapp/src/components/popup-menu.tsx b/webapp/src/components/popup-menu.tsx new file mode 100644 index 00000000..799461a8 --- /dev/null +++ b/webapp/src/components/popup-menu.tsx @@ -0,0 +1,214 @@ +import { + type KeyboardEvent, + type RefObject, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { Icon, type IconName } from "./icons"; + +/** One entry of a menu. */ +export type PopupMenuItem = { + key: string; + icon: IconName; + label: string; + run: () => void; + /** + * The entry replaces the menu with a dialog of its own, which takes focus. + * Such an entry closes the menu itself: closing it here would hand focus back + * to what opened the menu, from under the dialog that has just taken it. + */ + opensDialog?: boolean; +}; + +/** + * A menu fixed to the viewport at a point, as the right click on a track + * opens one (RFC-012 decision 4), and as the artist page opens its image + * actions. + * + * It is placed at `x`, `y` — its left edge there, or its right edge with + * `align: "end"`, for a menu hung under a button at the right of the screen — + * and moved back inside the window when it would hang off an edge. It answers + * the keys of the ARIA menu pattern, and anything outside it closes it. + * + * Fixed rather than absolute, so the caller portals it to the body: an + * ancestor that clips its overflow, or that carries a transform while it + * rises into place, would otherwise cut it off or place it inside itself. + */ +export function PopupMenu({ + label, + x, + y, + align = "start", + items, + onClose, + anchor, +}: { + label: string; + x: number; + y: number; + align?: "start" | "end"; + items: PopupMenuItem[]; + onClose: () => void; + /** + * The button that opened the menu, if one did. A press on it is not a press + * "outside": it toggles the menu, and closing here first would have the + * same press open it again. + */ + anchor?: RefObject; +}) { + const list = useRef(null); + const [place, setPlace] = useState({ left: x, top: y }); + + // Kept inside the window: a menu opened near the right or bottom edge + // would otherwise hang off it, half unreadable. + useLayoutEffect(() => { + const node = list.current; + if (!node) return; + const rect = node.getBoundingClientRect(); + const margin = 8; + const left = align === "end" ? x - rect.width : x; + setPlace({ + left: Math.max( + margin, + Math.min(left, window.innerWidth - rect.width - margin), + ), + top: Math.max( + margin, + Math.min(y, window.innerHeight - rect.height - margin), + ), + }); + node.querySelector('[role="menuitem"]')?.focus(); + }, [align, x, y]); + + // Anything outside the menu closes it: a click, the wheel or a finger + // moving the page, the window losing focus. + // + // **Not the `scroll` event.** Scrolls nobody asked for happen while a menu + // is open — focusing the row that raised it scrolls it into view on the next + // frame, and a page still rising into place moves the row under it — and + // closing on those shut the menu the instant it appeared. What closes it is + // the visitor setting out to scroll, outside the menu. + // + // Installed once per menu, with the latest `onClose` read through a ref: the + // parent hands a new one on every render, and re-installing on each would + // also reset `armed` below in the middle of a gesture. + const closeRef = useRef(onClose); + closeRef.current = onClose; + const anchorRef = useRef(anchor); + anchorRef.current = anchor; + useEffect(() => { + const away = (event: Event) => { + const target = event.target as Node; + if (list.current?.contains(target)) return; + if (anchorRef.current?.current?.contains(target)) return; + closeRef.current(); + }; + // The wheel closes it, over the menu too, unless the menu has rows to + // scroll: a track credited to many artists makes it taller than a short + // window, and it is then capped and scrolls on its own. A menu with nothing + // to scroll would hand the wheel to the page beneath and leave the menu + // where its row used to be, so that one still closes. + const wheel = (event: Event) => { + const menu = list.current; + const inside = menu?.contains(event.target as Node) ?? false; + if (inside && menu && menu.scrollHeight > menu.clientHeight) return; + closeRef.current(); + }; + const leave = () => closeRef.current(); + // A long press opens the menu with the finger still down, and that finger + // drifts as it lifts: its moves are the gesture that opened the menu, not + // one leaving it. Only a touch that starts after the menu is open counts. + let armed = false; + const arm = () => { + armed = true; + }; + const dragged = (event: Event) => { + if (armed) away(event); + }; + document.addEventListener("pointerdown", away, true); + document.addEventListener("wheel", wheel, { capture: true, passive: true }); + document.addEventListener("touchstart", arm, { + capture: true, + passive: true, + }); + document.addEventListener("touchmove", dragged, { + capture: true, + passive: true, + }); + window.addEventListener("blur", leave); + window.addEventListener("resize", leave); + return () => { + document.removeEventListener("pointerdown", away, true); + document.removeEventListener("wheel", wheel, true); + document.removeEventListener("touchstart", arm, true); + document.removeEventListener("touchmove", dragged, true); + window.removeEventListener("blur", leave); + window.removeEventListener("resize", leave); + }; + }, []); + + // The keys a menu is expected to answer, per the ARIA menu pattern. Tab + // leaves, and leaving closes: a menu is not a place focus can stay. + function onKeyDown(event: KeyboardEvent) { + const entries = Array.from( + list.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + const at = entries.indexOf(document.activeElement as HTMLElement); + const go = (index: number) => { + event.preventDefault(); + entries[(index + entries.length) % entries.length]?.focus(); + }; + switch (event.key) { + case "ArrowDown": + go(at + 1); + break; + case "ArrowUp": + go(at - 1); + break; + case "Home": + go(0); + break; + case "End": + go(entries.length - 1); + break; + case "Escape": + event.preventDefault(); + onClose(); + break; + case "Tab": + onClose(); + break; + } + } + + return ( +

+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/webapp/src/components/quality-section.tsx b/webapp/src/components/quality-section.tsx index bbee8da1..aff5678c 100644 --- a/webapp/src/components/quality-section.tsx +++ b/webapp/src/components/quality-section.tsx @@ -107,6 +107,12 @@ export function QualitySection({ song }: { song: Song }) {
))} + {/* The bit count is the figure most easily read as a certificate, so it + says what it is: a measurement, with the same caution the quality + page gives "no sign of a lossy source". Nothing computed changes. */} + {data.effective_bits === null ? null : ( +

{t("quality.bitsNote")}

+ )} {data.spectrum && data.nyquist_hz ? ( (null); - const [place, setPlace] = useState({ left: opened.x, top: opened.y }); const song = opened.songs[opened.position]; const mayCorrect = mayCorrectTracks( libraries.find((library) => library.id === song.library_id), ); - // Kept inside the window: a menu opened near the right or bottom edge - // would otherwise hang off it, half unreadable. - useLayoutEffect(() => { - const node = list.current; - if (!node) return; - const rect = node.getBoundingClientRect(); - const margin = 8; - setPlace({ - left: Math.max( - margin, - Math.min(opened.x, window.innerWidth - rect.width - margin), - ), - top: Math.max( - margin, - Math.min(opened.y, window.innerHeight - rect.height - margin), - ), - }); - node.querySelector('[role="menuitem"]')?.focus(); - }, [opened.x, opened.y]); - - // Anything outside the menu closes it: a click, the wheel or a finger - // moving the page, the window losing focus. - // - // **Not the `scroll` event.** Scrolls nobody asked for happen while a menu - // is open — focusing the row that raised it scrolls it into view on the next - // frame, and a page still rising into place moves the row under it — and - // closing on those shut the menu the instant it appeared. What closes it is - // the visitor setting out to scroll, outside the menu. - // - // Installed once per menu, with the latest `onClose` read through a ref: the - // parent hands a new one on every render, and re-installing on each would - // also reset `armed` below in the middle of a gesture. - const closeRef = useRef(onClose); - closeRef.current = onClose; - useEffect(() => { - const away = (event: Event) => { - if (!list.current?.contains(event.target as Node)) closeRef.current(); - }; - // The wheel closes it, over the menu too, unless the menu has rows to - // scroll: a track credited to many artists makes it taller than a short - // window, and it is then capped and scrolls on its own. A menu with nothing - // to scroll would hand the wheel to the page beneath and leave the menu - // where its row used to be, so that one still closes. - const wheel = (event: Event) => { - const menu = list.current; - const inside = menu?.contains(event.target as Node) ?? false; - if (inside && menu && menu.scrollHeight > menu.clientHeight) return; - closeRef.current(); - }; - const leave = () => closeRef.current(); - // A long press opens the menu with the finger still down, and that finger - // drifts as it lifts: its moves are the gesture that opened the menu, not - // one leaving it. Only a touch that starts after the menu is open counts. - let armed = false; - const arm = () => { - armed = true; - }; - const dragged = (event: Event) => { - if (armed) away(event); - }; - document.addEventListener("pointerdown", away, true); - document.addEventListener("wheel", wheel, { capture: true, passive: true }); - document.addEventListener("touchstart", arm, { - capture: true, - passive: true, - }); - document.addEventListener("touchmove", dragged, { - capture: true, - passive: true, - }); - window.addEventListener("blur", leave); - window.addEventListener("resize", leave); - return () => { - document.removeEventListener("pointerdown", away, true); - document.removeEventListener("wheel", wheel, true); - document.removeEventListener("touchstart", arm, true); - document.removeEventListener("touchmove", dragged, true); - window.removeEventListener("blur", leave); - window.removeEventListener("resize", leave); - }; - }, []); - - const items: Array<{ - key: string; - icon: Parameters[0]["name"]; - label: string; - run: () => void; - }> = [ + const items: PopupMenuItem[] = [ { key: "play", icon: "play", @@ -469,6 +380,7 @@ function TrackMenu({ icon: "info", label: t("trackMenu.properties"), run: () => onProperties(song), + opensDialog: true, }, ...(mayCorrect ? [ @@ -488,72 +400,19 @@ function TrackMenu({ icon: "folder" as const, label: t("trackMenu.move"), run: () => onMove(song), + opensDialog: true, }, ] : []), ]; - // The keys a menu is expected to answer, per the ARIA menu pattern. Tab - // leaves, and leaving closes: a menu is not a place focus can stay. - function onKeyDown(event: KeyboardEvent) { - const entries = Array.from( - list.current?.querySelectorAll('[role="menuitem"]') ?? [], - ); - const at = entries.indexOf(document.activeElement as HTMLElement); - const go = (index: number) => { - event.preventDefault(); - entries[(index + entries.length) % entries.length]?.focus(); - }; - switch (event.key) { - case "ArrowDown": - go(at + 1); - break; - case "ArrowUp": - go(at - 1); - break; - case "Home": - go(0); - break; - case "End": - go(entries.length - 1); - break; - case "Escape": - event.preventDefault(); - onClose(); - break; - case "Tab": - onClose(); - break; - } - } - return ( -
- {items.map((item) => ( - - ))} -
+ ); } diff --git a/webapp/src/pages/albums-page.tsx b/webapp/src/pages/albums-page.tsx index 7d7b9180..0e6d7ba3 100644 --- a/webapp/src/pages/albums-page.tsx +++ b/webapp/src/pages/albums-page.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import type { AlbumSort } from "../api"; -import { albumsQuery } from "../api/queries"; +import { albumsQuery, mixesQuery } from "../api/queries"; import { AlbumGrid } from "../components/album-grid"; import { FilterField } from "../components/filter-field"; import { MixShelves } from "../components/mix-shelves"; @@ -10,6 +10,7 @@ import { EmptyState, Loading, PageHeader } from "../components/page-shell"; import { matches, normalizeFilter } from "../lib/filter"; import { type TranslationKey, useI18n } from "../state/i18n"; import { useScopeId } from "../state/library-scope"; +import { useLocalDay } from "../state/local-day"; /** * The orders offered on the albums page, in menu order. Four of them narrow the @@ -38,7 +39,11 @@ export function AlbumsPage() { const [filter, setFilter] = useState(""); const scope = useScopeId(); const { data: value, error } = useQuery(albumsQuery(sort, scope)); + // The shelf's own question, answered from the same cache: the page needs to + // know whether the shelf is there to name the grid under it. + const { data: mixes } = useQuery(mixesQuery(scope, useLocalDay())); const needle = normalizeFilter(filter); + const shelf = !needle && Boolean(mixes?.length); const shown = useMemo( () => !value || !needle @@ -102,7 +107,14 @@ export function AlbumsPage() { {/* Above the albums and not inside their filter: the shelf is what the server suggests today, and narrowing the grid does not narrow it. */} - {needle ? null : } + {shelf ? : null} + {/* With the mixes above it, the grid is named too, so the line between + what was generated and what is in the library is drawn twice — once + by the shelf's heading and once by this one. Without them the page + title says it, and a second "albums" under it would only repeat. */} + {shelf ? ( +

{t("albums.gridTitle")}

+ ) : null} {needle && shown.length === 0 ? ( ) : ( diff --git a/webapp/src/pages/playing-page.tsx b/webapp/src/pages/playing-page.tsx index 7f1b4a72..109586b3 100644 --- a/webapp/src/pages/playing-page.tsx +++ b/webapp/src/pages/playing-page.tsx @@ -115,6 +115,14 @@ export function lyricSheetState(answer: { */ const BLANK_LINE = "\u00a0"; +/** A track's sheet as `useLyricSheet` reads it: the words, the sung line. */ +type LyricSheet = { + sheet: StructuredLyrics | undefined; + active: number; + keys: string[]; + state: "waiting" | "sheet" | "none"; +}; + /** * The sheet a track travels with, and where in it the playing head is. * @@ -122,7 +130,7 @@ const BLANK_LINE = "\u00a0"; * about which line is being sung — or about how a refrain is keyed, which is * the part that is easy to get subtly different twice. */ -function useLyricSheet(trackId: string) { +function useLyricSheet(trackId: string): LyricSheet { const progress = usePlayerProgress(); const { data: value, error } = useQuery(lyricsQuery(trackId)); const sheet: StructuredLyrics | undefined = value?.structuredLyrics[0]; @@ -270,15 +278,17 @@ function LyricLines({ } /** - * The words beside the cover, in the detail view. + * The words in the detail view: a column of their own beside the track on a + * wide screen, under its names on a narrow one. * * Smaller than the lyrics view and inside a box of its own, but reading the * same way: the sung line is held in view and, where the sheet is synced, a - * line seeks to itself. + * line seeks to itself. The sheet is read by `DetailView` and handed down, + * because that view lays itself out by whether there is one. */ -function LyricsStrip({ trackId }: { trackId: string }) { +function LyricsStrip({ lyrics }: { lyrics: LyricSheet }) { const { t } = useI18n(); - const { sheet, active, keys, state } = useLyricSheet(trackId); + const { sheet, active, keys, state } = lyrics; const column = useRef(null); useFollowSungLine(column, sheet, active); @@ -477,6 +487,78 @@ function ImmersiveView({ song }: { song: Song }) { ); } +/** + * Whether the detail view gives the words a column of their own, from what it + * gave them last and what the sheet now says. + * + * **Only once there are words to put in it.** A track without lyrics is most of + * a library, and a second column held open for them would be an empty half of + * the screen with "no lyrics" written in its corner; the track takes the + * middle instead. The queue was the other candidate for that column, and was + * not taken: it has a page and a panel of its own, it is empty as often as the + * sheet is, and a column that changes subject from one track to the next is + * harder to read than one that is either the words or absent. + * + * **A wait is not an answer.** While the sheet is out the layout keeps the + * shape it had, so moving from one track with words to the next does not fold + * the column away and open it again a moment later. + */ +export function detailSplit( + previous: boolean, + state: LyricSheet["state"], +): boolean { + return state === "waiting" ? previous : state === "sheet"; +} + +/** + * The track, and its words beside it. + * + * On a wide screen, two columns: the artwork, larger than it was, with the + * names and what the file is under it, and the words in a tall column of their + * own. Without words, one column in the middle. Narrower, the two stack, cover + * first, as they always have — the stylesheet decides that, not this. + */ +function DetailView({ song }: { song: Song }) { + const { t } = useI18n(); + const lyrics = useLyricSheet(song.id); + // Adjusted while rendering rather than in an effect, as the artwork does: an + // effect would paint one frame in the old shape around the new answer. + const [split, setSplit] = useState(false); + const next = detailSplit(split, lyrics.state); + if (next !== split) setSplit(next); + + return ( +
+
+ + + +
+ {song.album ?? t("common.album")} +

{song.title}

+

{song.artist ?? t("common.unknownArtist")}

+ {/* Under the names and above the words: what the file is belongs + with the track and not with the lyrics, and a listener who came + here to read along should not have to pass it twice. */} + + {/* Without a column, the strip says here that there is nothing to + read, or that it is still asking. */} + {next ? null : } +
+
+ {next ? ( +
+ +
+ ) : null} +
+ ); +} + /** The three ways to look at the track, as links, because the view is a place. */ function ViewSwitcher() { const { t } = useI18n(); @@ -588,29 +670,7 @@ export function PlayingPage() { {view === "lyrics" ? ( ) : ( -
- - - -
- - {current.album ?? t("common.album")} - -

{current.title}

-

- {current.artist ?? t("common.unknownArtist")} -

- {/* Under the names and above the words: what the file is belongs - with the track and not with the lyrics, and a listener who - came here to read along should not have to pass it twice. */} - - -
-
+ )} ) : ( diff --git a/webapp/src/pages/playing-view.test.ts b/webapp/src/pages/playing-view.test.ts index d4a67e8e..02d793a3 100644 --- a/webapp/src/pages/playing-view.test.ts +++ b/webapp/src/pages/playing-view.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { lyricSheetState, playingView } from "./playing-page"; +import { detailSplit, lyricSheetState, playingView } from "./playing-page"; /** * Which view a location asks for. @@ -102,3 +102,22 @@ describe("lyricSheetState", () => { ); }); }); + +/** + * Whether the detail view opens a column for the words. + * + * Only for words: a track without them keeps one centred column rather than + * half a screen with "no lyrics" in its corner. And a wait decides nothing, so + * the column does not fold and reopen between two tracks that both have words. + */ +describe("detailSplit", () => { + it("opens the column for a sheet and closes it for none", () => { + expect(detailSplit(false, "sheet")).toBe(true); + expect(detailSplit(true, "none")).toBe(false); + }); + + it("keeps the shape it had while the sheet is out", () => { + expect(detailSplit(true, "waiting")).toBe(true); + expect(detailSplit(false, "waiting")).toBe(false); + }); +}); diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 4011825d..11f1493b 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -274,6 +274,7 @@ const en = { one: "{count} record in your library", other: "{count} records in your library", }, + "albums.gridTitle": "In your library", "artists.detail": { one: "{count} voice or collaborator", other: "{count} voices and collaborators", @@ -580,8 +581,10 @@ const en = { "quality.verdictLabel": "Verdict", "quality.highsStop": "Highs stop at", "quality.wall": "Fall at that point", - "quality.bitsUsed": "Bits used", + "quality.bitsUsed": "Bits in use, measured", "quality.bitsOf": "{used} of {declared}", + "quality.bitsNote": + "A measurement of the samples, not proof of genuine hi-res: a file that uses every bit it declares was not padded, which does not mean it was recorded at that depth.", "quality.analysed": "Analysed", "quality.chart": "Averaged spectrum: the highs stop at {where}.", "quality.chartFullBand": "Averaged spectrum: the highs run on with no wall.", @@ -634,6 +637,9 @@ const en = { "artistImage.failed": "That did not work. Try again.", "artistImage.chosen": "Portrait chosen by hand", "artistImage.searchDeezer": "Search Deezer", + "artistImage.menu": "Edit images", + "artistImage.menuShort": "Edit", + "artistImage.menuLabel": "Images of {name}", "artistImage.deezerTitle": "Choose a portrait from Deezer", "artistImage.deezerQuery": "Artist name", "artistImage.deezerSearch": "Search", @@ -1025,6 +1031,10 @@ const en = { "mixes.genre": "{genre} mix", "mixes.decade": "The {decade}s", "mixes.failed": "“{title}” could not be opened. Try again.", + "mixes.shelfTitle": "Mixes for you", + "mixes.shelfDetail": + "Playlists drawn from your listening, new every day. Your albums are below.", + "mixes.badge": "Mix", // Smart playlists and podcasts (RFC-025), kept together at the end. "smart.title": "Smart playlists", "smart.detail": @@ -1416,6 +1426,7 @@ const fr: Record = { one: "{count} disque dans votre bibliothèque", other: "{count} disques dans votre bibliothèque", }, + "albums.gridTitle": "Dans votre bibliothèque", "artists.detail": { one: "{count} voix ou collaboration", other: "{count} voix et collaborations", @@ -1721,8 +1732,10 @@ const fr: Record = { "quality.verdictLabel": "Verdict", "quality.highsStop": "Les aigus s’arrêtent à", "quality.wall": "Chute à cet endroit", - "quality.bitsUsed": "Bits utilisés", + "quality.bitsUsed": "Bits utilisés, mesurés", "quality.bitsOf": "{used} sur {declared}", + "quality.bitsNote": + "Une mesure des échantillons, pas une preuve de vraie haute résolution : un fichier qui utilise tous les bits qu’il annonce n’a pas été gonflé, ce qui ne veut pas dire qu’il a été enregistré à cette profondeur.", "quality.analysed": "Analysé le", "quality.chart": "Spectre moyen : les aigus s’arrêtent à {where}.", "quality.chartFullBand": "Spectre moyen : les aigus continuent sans mur.", @@ -1777,6 +1790,9 @@ const fr: Record = { "artistImage.failed": "Cela n’a pas fonctionné. Réessayez.", "artistImage.chosen": "Portrait choisi à la main", "artistImage.searchDeezer": "Rechercher sur Deezer", + "artistImage.menu": "Modifier les images", + "artistImage.menuShort": "Modifier", + "artistImage.menuLabel": "Images de {name}", "artistImage.deezerTitle": "Choisir un portrait sur Deezer", "artistImage.deezerQuery": "Nom de l’artiste", "artistImage.deezerSearch": "Rechercher", @@ -2181,6 +2197,10 @@ const fr: Record = { "mixes.genre": "Mix {genre}", "mixes.decade": "Les années {decade}", "mixes.failed": "Impossible d’ouvrir « {title} ». Réessayez.", + "mixes.shelfTitle": "Mix pour vous", + "mixes.shelfDetail": + "Des listes tirées de vos écoutes, renouvelées chaque jour. Vos albums sont en dessous.", + "mixes.badge": "Mix", "smart.title": "Listes intelligentes", "smart.detail": "Définies par des règles et recalculées à chaque ouverture : elles suivent la bibliothèque.", diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index d158ba6b..9952d17e 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -2013,14 +2013,6 @@ button.danger { color: var(--accent); } -/* Cover on the left, words on the right, stacking under the fold. */ -.playing { - display: flex; - align-items: flex-start; - gap: clamp(var(--space-6), 4vw, var(--space-16)); - margin-bottom: var(--space-8); -} - .playing-detail { min-width: 0; flex: 1 1 auto; @@ -2071,6 +2063,90 @@ button.danger { font-weight: 600; } +/* ── Editorial pages: the playing screen in two columns ───────────────────── + The track and its words. In the middle range the cover sits beside the + names with the words under both, as this screen always read; on a wide + screen the track becomes a column of its own — a larger cover over the + names — and the words a tall one beside it (`.playing-split`). A track with + no words keeps one column, centred (`.playing-solo`), rather than half a + screen held open for nothing. Narrow, everything stacks, cover first. */ +.playing { + display: grid; + gap: clamp(var(--space-6), 4vw, var(--space-16)); + margin-bottom: var(--space-8); +} + +/* The same gutter between the cover and its names as between the columns: + inherited, so the fluid range is written once. */ +.playing-track { + display: flex; + align-items: flex-start; + gap: inherit; + min-width: 0; +} + +.playing-lyrics { + min-width: 0; +} + +.playing-lyrics .lyrics { + margin-top: 0; +} + +@media (width > 64rem) { + .playing-split { + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + align-items: start; + } + + .playing-split .playing-track, + .playing-solo .playing-track { + flex-direction: column; + gap: var(--space-6); + width: 100%; + max-width: 26rem; + } + + .playing-split .playing-track .canvas-stage, + .playing-solo .playing-track .canvas-stage, + .playing-split .cover.large, + .playing-solo .cover.large { + width: 100%; + } + + .playing-split .playing-detail, + .playing-solo .playing-detail { + width: 100%; + } + + /* Tall enough to read a verse without scrolling, and set larger than the + strip it was: the words are half the screen now, not a footnote. */ + .playing-split .lyrics ol { + max-height: min(70vh, 44rem); + scroll-padding: 40% 0; + } + + .playing-split .lyrics li { + font-size: var(--text-lg); + line-height: 1.5; + } + + .playing-solo { + justify-items: center; + } + + .playing-solo .playing-track { + align-items: center; + text-align: center; + } + + .playing-solo .quality-meter { + justify-content: center; + } +} + +/* ── End of the playing screen's columns ── */ + /* The three ways to look at the track, in the page header beside the bookmark button. Links and not buttons, because each one is a location. */ .view-switcher { @@ -2324,7 +2400,7 @@ button.danger { grid-template-columns: 1fr; } - .playing { + .playing-track { flex-direction: column; } } @@ -3523,18 +3599,6 @@ button.danger { max-width: 50%; } -.artist-image-buttons { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--space-2); -} - -.artist-image-pair { - display: inline-flex; - gap: var(--space-1); -} - .artist-image-button { display: inline-flex; align-items: center; @@ -3552,15 +3616,12 @@ button.danger { backdrop-filter: blur(6px); } -/* A bare outline is text straight on the picture — white on a pale sky or a - pale portrait, ink on a dark one — so the quiet button keeps a lighter - glass than the others instead of none. */ -.artist-hero.has-backdrop .artist-image-button.subtle { - background: color-mix(in srgb, var(--fe-on-wash-tone) 85%, transparent); -} - -.artist-hero.has-banner .artist-image-button.subtle { - background: rgb(0 0 0 / 42%); +/* Editorial pages: the image actions behind one button. It holds the corner + the row of five used to fill, and says it is a menu by its dots as much as + by its name. */ +.artist-image-menu { + gap: var(--space-1); + padding-inline: var(--space-3) var(--space-4); } .artist-image-notice { @@ -3649,10 +3710,6 @@ button.danger { max-width: none; justify-items: start; } - - .artist-image-buttons { - justify-content: flex-start; - } } /* The quality page (RFC-016). The verdicts read as the lists of "Needs @@ -3767,6 +3824,14 @@ button.danger { font-size: var(--text-sm); } +/* Editorial pages: the caution under the measured bit depth, set as the + explanation above the figures is. */ +.quality-note { + max-width: 62ch; + margin: var(--space-2) 0 var(--space-3); + font-size: var(--text-xs); +} + .quality-spectrum { margin: var(--space-3) 0; } @@ -3938,7 +4003,7 @@ button.danger { } .mix-shelf h3 { - margin: 0 0 var(--space-3); + margin: 0; font-size: var(--text-lg); } @@ -3982,6 +4047,75 @@ button.danger { opacity: 1; } +/* ── Editorial pages: the mixes set apart from the albums ─────────────────── + The shelf names itself and says what a mix is; each card sits on a tinted + ground of its own, its cover is a mosaic of four sleeves where the mix has + four, and a "Mix" marker rides on the cover — so nobody takes a generated + playlist for another record. The albums under it carry their own heading. */ +.mix-shelf-header { + display: grid; + gap: var(--space-1); + margin-bottom: var(--space-4); +} + +.mix-shelf-detail { + margin: 0; + font-size: var(--text-sm); +} + +.mix-shelf .mix-card { + padding: var(--space-2) var(--space-2) var(--space-3); + border: 1px solid var(--line); + border-radius: 1rem; + background: color-mix(in srgb, var(--accent-soft) 60%, var(--panel-soft)); +} + +.mix-cover { + position: relative; + display: block; +} + +.mix-mosaic { + display: grid; + overflow: hidden; + grid-template-columns: repeat(2, minmax(0, 1fr)); + aspect-ratio: 1; + border: 1px solid var(--line); + border-radius: 0.85rem; +} + +/* Square tiles meeting edge to edge; the mosaic rounds the four together. + Two classes deep, or the material's own `.cover` radius, loaded later, + wins the tie. */ +.mix-cover .mix-mosaic > * { + border: 0; + border-radius: 0; +} + +.mix-badge { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-radius: 999px; + background: var(--text); + color: var(--bg); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.albums-grid-title { + margin: 0 0 var(--space-4); + font-size: var(--text-lg); +} + +/* ── End of the mixes shelf ── */ + /* Statistics (RFC-024): five figures, a timeline of bars, four top lists. */ .stats-totals { display: grid; From 43c32922e1628d40faa0fc37ebce7da2aae85ade Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 07:52:04 +0100 Subject: [PATCH 2/2] fix(webapp): write the popup menu's callback refs after commit, not during render Signed-off-by: InstaZDLL --- webapp/src/components/popup-menu.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/popup-menu.tsx b/webapp/src/components/popup-menu.tsx index 799461a8..f227dbd9 100644 --- a/webapp/src/components/popup-menu.tsx +++ b/webapp/src/components/popup-menu.tsx @@ -94,11 +94,15 @@ export function PopupMenu({ // // Installed once per menu, with the latest `onClose` read through a ref: the // parent hands a new one on every render, and re-installing on each would - // also reset `armed` below in the middle of a gesture. + // also reset `armed` below in the middle of a gesture. The refs are written + // after the commit, not during the render: a render React throws away must + // not leave its callback behind. const closeRef = useRef(onClose); - closeRef.current = onClose; const anchorRef = useRef(anchor); - anchorRef.current = anchor; + useLayoutEffect(() => { + closeRef.current = onClose; + anchorRef.current = anchor; + }); useEffect(() => { const away = (event: Event) => { const target = event.target as Node;