From b46c080d190aff066393972d13ff1ce6debec3a8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 00:17:19 -0400 Subject: [PATCH 1/2] fix(web): all accent text and icons use accent-fg (#5318) The raw accent fails AA as text on every dark surface, not only on its own tint: 3.04:1 on the page, 2.70 on iron, 2.21 on slate, against 4.5. accent-fg (the house formula, 45% toward parchment) measures 5.62 at worst across both modes. The operator chose the readable colour over the signature teal for text, on 2026-10-08. - 36 sites swap. They are 35 Tailwind uses: links, "Now playing", the ingest progress line, active shuffle/repeat, the liked heart, the app download icon and its hover. The last is the alphabet rail's pending spinner in CSS. Icons follow the text: as graphics they need only 3:1, and the raw accent misses even that on iron. - check-tint-contrast adds accent to TEXT_NEVER_RAW, so a new raw text-accent or color: var(--fs-accent) fails the web lane. Run against the files before the swap, it finds all 36. Borders, rings and fills keep the raw accent. Co-Authored-By: Claude Opus 5.5 --- web/scripts/check-tint-contrast.js | 11 +++++++---- web/scripts/check-tint-contrast.test.js | 7 +++++++ web/src/lib/components/AlphabeticalGrid.svelte | 2 +- web/src/lib/components/LikeButton.svelte | 2 +- web/src/lib/components/MobileAppDownload.svelte | 4 ++-- web/src/lib/components/QueueTrackRow.svelte | 2 +- web/src/routes/admin/+page.svelte | 4 ++-- web/src/routes/admin/integrations/+page.svelte | 2 +- web/src/routes/admin/requests/+page.svelte | 2 +- web/src/routes/admin/tuning/+page.svelte | 4 ++-- web/src/routes/albums/[id]/+page.svelte | 4 ++-- web/src/routes/artists/[id]/+page.svelte | 2 +- web/src/routes/forgot-password/+page.svelte | 2 +- web/src/routes/library/albums/+page.svelte | 2 +- web/src/routes/library/artists/+page.svelte | 2 +- web/src/routes/library/genres/+page.svelte | 2 +- web/src/routes/library/liked/+page.svelte | 6 +++--- web/src/routes/library/years/+page.svelte | 2 +- web/src/routes/login/+page.svelte | 4 ++-- web/src/routes/now-playing/+page.svelte | 4 ++-- web/src/routes/register/+page.svelte | 2 +- web/src/routes/requests/+page.svelte | 4 ++-- web/src/routes/reset-password/[token]/+page.svelte | 2 +- web/src/routes/search/+page.svelte | 6 +++--- web/src/routes/settings/+page.svelte | 6 +++--- web/tailwind.config.js | 6 ++++-- 26 files changed, 54 insertions(+), 42 deletions(-) diff --git a/web/scripts/check-tint-contrast.js b/web/scripts/check-tint-contrast.js index a86abafc..5d6af080 100644 --- a/web/scripts/check-tint-contrast.js +++ b/web/scripts/check-tint-contrast.js @@ -65,10 +65,13 @@ export function findCss(source) { } // Hues that fail AA as text even on a plain surface, so the raw hue is never -// text at all: error measures 2.64:1 on slate and 3.23 on iron in dark mode, -// against 4.5; error-fg measures 5.30 at worst. A border or outline in the -// hue is a graphic with a 3:1 floor and stays allowed. -export const TEXT_NEVER_RAW = ['error']; +// text at all. Dark mode, against 4.5: +// error 2.64 on slate, 3.23 on iron → error-fg 5.30 at worst +// accent 2.21 on slate, 3.04 on the page → accent-fg 5.62 at worst (#5318) +// Accent icons (the liked heart, active shuffle/repeat) follow too: as +// graphics they only need 3:1, and the raw accent misses even that on iron. +// A border or outline in the hue is a graphic and stays allowed. +export const TEXT_NEVER_RAW = ['error', 'accent']; /** Raw text-X classes and color: var(--fs-X) for the hues above. */ export function findRawText(source) { diff --git a/web/scripts/check-tint-contrast.test.js b/web/scripts/check-tint-contrast.test.js index 8d1e02f2..f2cf3db9 100644 --- a/web/scripts/check-tint-contrast.test.js +++ b/web/scripts/check-tint-contrast.test.js @@ -42,6 +42,13 @@ describe('hue-on-own-tint check (#3150)', () => { expect(findRawText(`.msg { color: var(--fs-error); }`)[0].fix).toBe('var(--fs-error-fg)'); }); + test('raw accent text is caught too, including hover: variants (#5318)', () => { + expect(findRawText(``)[0].fix).toBe('text-accent-fg'); + expect(findRawText(``)).toHaveLength(1); + expect(findRawText(`.pending { color: var(--fs-accent); }`)[0].fix).toBe('var(--fs-accent-fg)'); + expect(findRawText(``)).toEqual([]); + }); + test('passes error-fg, and error as a border or outline', () => { expect(findRawText(`

x

`)).toEqual([]); expect(findRawText(`
`)).toEqual([]); diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte index 39781dc5..22c5bb53 100644 --- a/web/src/lib/components/AlphabeticalGrid.svelte +++ b/web/src/lib/components/AlphabeticalGrid.svelte @@ -201,7 +201,7 @@ cursor: default; } .rail-btn.pending { - color: var(--fs-accent); + color: var(--fs-accent-fg); cursor: wait; } :global(.rail-btn .spin) { diff --git a/web/src/lib/components/LikeButton.svelte b/web/src/lib/components/LikeButton.svelte index 1f9dcc30..7ce07dfa 100644 --- a/web/src/lib/components/LikeButton.svelte +++ b/web/src/lib/components/LikeButton.svelte @@ -44,5 +44,5 @@ aria-label={liked ? 'Unlike' : 'Like'} aria-pressed={liked} onclick={onClick} - class="rounded p-1 {sizeClass} {liked ? 'text-accent' : 'text-text-secondary hover:text-text-primary'}" + class="rounded p-1 {sizeClass} {liked ? 'text-accent-fg' : 'text-text-secondary hover:text-text-primary'}" >{liked ? '♥' : '♡'} diff --git a/web/src/lib/components/MobileAppDownload.svelte b/web/src/lib/components/MobileAppDownload.svelte index 6766e96d..0e16b0a9 100644 --- a/web/src/lib/components/MobileAppDownload.svelte +++ b/web/src/lib/components/MobileAppDownload.svelte @@ -48,9 +48,9 @@ - + Get the Android app diff --git a/web/src/lib/components/QueueTrackRow.svelte b/web/src/lib/components/QueueTrackRow.svelte index e14a245c..dc82e978 100644 --- a/web/src/lib/components/QueueTrackRow.svelte +++ b/web/src/lib/components/QueueTrackRow.svelte @@ -101,7 +101,7 @@ >
{#if isCurrent} - Now playing + Now playing {/if} {track.title}
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 5cc0c1a2..8c7ec2d3 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -290,7 +290,7 @@

Pending requests

{#if pendingCount > PREVIEW_LIMIT} -
View all {pendingCount} → + View all {pendingCount} → {/if}
@@ -523,7 +523,7 @@

Quarantine

{#if quarantineCount > PREVIEW_LIMIT} - View all {quarantineCount} → + View all {quarantineCount} → {/if}
diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index f98096b1..34c9cf82 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -424,7 +424,7 @@

Lidarr

- Search Lidarr from /discover and + Search Lidarr from /discover and route approved requests to it.

diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte index 99949afe..e84ead20 100644 --- a/web/src/routes/admin/requests/+page.svelte +++ b/web/src/routes/admin/requests/+page.svelte @@ -225,7 +225,7 @@ {rowMeta(r)}
{#if r.imported_album_count > 0 || r.imported_track_count > 0} -
+
{#if r.kind === 'artist'} {r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'} · {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte index 48a6d73e..09b88a4e 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -495,7 +495,7 @@ {/each} {#if s.points.length > 1} {:else if s.points.length === 1}

Album not found.

- Back to Library + Back to Library
{:else if query.isError} @@ -131,7 +131,7 @@ in that genre. --> {#if album.year}

- + {album.year}

diff --git a/web/src/routes/artists/[id]/+page.svelte b/web/src/routes/artists/[id]/+page.svelte index 8da5a2e9..9e6ef1ba 100644 --- a/web/src/routes/artists/[id]/+page.svelte +++ b/web/src/routes/artists/[id]/+page.svelte @@ -73,7 +73,7 @@ {#if notFound} {:else if query.isError} diff --git a/web/src/routes/forgot-password/+page.svelte b/web/src/routes/forgot-password/+page.svelte index 4aa90b5c..00d0a610 100644 --- a/web/src/routes/forgot-password/+page.svelte +++ b/web/src/routes/forgot-password/+page.svelte @@ -71,7 +71,7 @@ {/if}

- Remembered? Log in + Remembered? Log in

diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte index 9c0eb881..b2e6134d 100644 --- a/web/src/routes/library/albums/+page.svelte +++ b/web/src/routes/library/albums/+page.svelte @@ -65,7 +65,7 @@

No albums in your loaded library match '{filter.trim()}'. - + Search the full library →

diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte index edaf6287..ef627be9 100644 --- a/web/src/routes/library/artists/+page.svelte +++ b/web/src/routes/library/artists/+page.svelte @@ -63,7 +63,7 @@

No artists in your loaded library match '{filter.trim()}'. - + Search the full library →

diff --git a/web/src/routes/library/genres/+page.svelte b/web/src/routes/library/genres/+page.svelte index 70f2423d..f5a81511 100644 --- a/web/src/routes/library/genres/+page.svelte +++ b/web/src/routes/library/genres/+page.svelte @@ -112,7 +112,7 @@
Browse artists → + Browse artists → {/snippet} {:else} @@ -121,7 +121,7 @@ title="No liked albums yet." > {#snippet actions()} - Browse albums → + Browse albums → {/snippet} {:else} @@ -153,7 +153,7 @@ title="No liked tracks yet." > {#snippet actions()} - Find something new → + Find something new → {/snippet} {:else} diff --git a/web/src/routes/library/years/+page.svelte b/web/src/routes/library/years/+page.svelte index 2f6f4478..ce6f19f3 100644 --- a/web/src/routes/library/years/+page.svelte +++ b/web/src/routes/library/years/+page.svelte @@ -97,7 +97,7 @@

- Forgot password? + Forgot password?

Don't have an account? - Register + Register

diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte index 6569aa4f..07c470eb 100644 --- a/web/src/routes/now-playing/+page.svelte +++ b/web/src/routes/now-playing/+page.svelte @@ -163,7 +163,7 @@ aria-label="Shuffle" aria-pressed={player.shuffle} class="flex items-center justify-center min-h-[44px] min-w-[44px] rounded - {player.shuffle ? 'text-accent' : 'text-text-secondary hover:text-text-primary'} + {player.shuffle ? 'text-accent-fg' : 'text-text-secondary hover:text-text-primary'} focus-visible:ring-2 focus-visible:ring-accent" > @@ -216,7 +216,7 @@ aria-label={repeatLabel} aria-pressed={player.repeat !== 'off'} class="flex items-center justify-center min-h-[44px] min-w-[44px] rounded - {player.repeat !== 'off' ? 'text-accent' : 'text-text-secondary hover:text-text-primary'} + {player.repeat !== 'off' ? 'text-accent-fg' : 'text-text-secondary hover:text-text-primary'} focus-visible:ring-2 focus-visible:ring-accent" > {#if player.repeat === 'one'} diff --git a/web/src/routes/register/+page.svelte b/web/src/routes/register/+page.svelte index dd37293a..68fed9cc 100644 --- a/web/src/routes/register/+page.svelte +++ b/web/src/routes/register/+page.svelte @@ -170,7 +170,7 @@

Already have an account? - Sign in + Sign in

diff --git a/web/src/routes/requests/+page.svelte b/web/src/routes/requests/+page.svelte index 43d19a64..5a52f569 100644 --- a/web/src/routes/requests/+page.svelte +++ b/web/src/routes/requests/+page.svelte @@ -107,7 +107,7 @@ {rowMeta(r)} {#if r.imported_album_count > 0 || r.imported_track_count > 0} -
+
{#if r.kind === 'artist'} {r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'} · {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested @@ -139,7 +139,7 @@ Listen diff --git a/web/src/routes/reset-password/[token]/+page.svelte b/web/src/routes/reset-password/[token]/+page.svelte index 887ca8d7..292d6e81 100644 --- a/web/src/routes/reset-password/[token]/+page.svelte +++ b/web/src/routes/reset-password/[token]/+page.svelte @@ -89,7 +89,7 @@

- Back to login + Back to login

diff --git a/web/src/routes/search/+page.svelte b/web/src/routes/search/+page.svelte index 389ef32a..a32b2b76 100644 --- a/web/src/routes/search/+page.svelte +++ b/web/src/routes/search/+page.svelte @@ -56,7 +56,7 @@ {#if artists.total > artists.items.length} See all {artists.total} → @@ -77,7 +77,7 @@ {#if albums.total > albums.items.length} See all {albums.total} → @@ -98,7 +98,7 @@ {#if tracks.total > tracks.items.length} See all {tracks.total} → diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 2f58dcfd..64bd2b79 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -363,7 +363,7 @@
@@ -402,7 +402,7 @@ {/if}

- Get a token at listenbrainz.org/profile. + Get a token at listenbrainz.org/profile. Tokens are stored unencrypted in this server's database — treat as sensitive.

@@ -682,7 +682,7 @@

Library

diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 75a9b272..3d1babe0 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -26,8 +26,10 @@ export default { accent: { DEFAULT: 'var(--fs-accent)', tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)', - // Accent TEXT on an accent tint (#3150). The raw accent on its own - // tint measures 1.97:1 at worst; this measures 5.03. + // All accent TEXT and icons use this, on a tint or not (#3150, + // #5318). The raw accent measures 1.97:1 on its own tint and 2.21 on + // slate at worst; this measures 5.03 and 5.62. The raw accent stays + // for borders, rings and fills, where it is a graphic, not text. fg: 'var(--fs-accent-fg)' }, warning: { DEFAULT: 'var(--fs-warning)', fg: 'var(--fs-warning-fg)' }, -- 2.54.0 From 43c369f082248f5c4f8c72e3b8443c3d69153413 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 00:18:43 -0400 Subject: [PATCH 2/2] test(android): queue drag math is a pure function, held to web's cases (#2436) The drag-offset-to-row arithmetic lived inline in queueReorderDrag's onDragEnd lambda, which no JVM test can reach. Web's copy, offsetToDelta, has been extracted and tested since the start, and the Android comment says it mirrors web, but only one side could be held to that. - QueueDragMath.kt adds queueDragDelta (web's offsetToDelta) and queueDragTarget (delta plus the clamp to the queue). Kotlin's roundToInt breaks ties toward positive infinity, the same as JS Math.round, so the web cases carry over exactly, including half a row up staying put. - queueDragTarget returns the start index for an empty queue instead of letting coerceIn(0, -1) throw. - QueueDragMathTest mirrors queue-row-math.test.ts one case at a time, plus clamping past either end, a sub-half-row drag, an unmeasured row, and the empty queue. No behaviour change for a non-empty queue. Co-Authored-By: Claude Opus 5.5 --- .../minstrel/player/ui/QueueDragMath.kt | 32 ++++++++ .../minstrel/player/ui/QueueRow.kt | 4 +- .../minstrel/player/ui/QueueDragMathTest.kt | 78 +++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt new file mode 100644 index 00000000..7d0d8ad8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt @@ -0,0 +1,32 @@ +package com.fabledsword.minstrel.player.ui + +import kotlin.math.roundToInt + +/** + * Drag offset to row delta, rounded to the nearest row boundary. The Android + * half of web's `offsetToDelta` (web/src/lib/components/queue-row-math.ts): + * the two clients are meant to agree, and QueueDragMathTest mirrors web's + * cases so both are held to it (#2436). + * + * Ties round toward positive infinity, as JS `Math.round` does: half a row + * down moves one row, half a row up stays put. A row not yet measured + * (height 0) moves nothing. + */ +internal fun queueDragDelta(offsetPx: Float, rowHeightPx: Int): Int = + if (rowHeightPx > 0) (offsetPx / rowHeightPx).roundToInt() else 0 + +/** + * The queue index a row dragged from [index] lands on, clamped to the queue. + * A drag past either end stops at the first or last row. + */ +internal fun queueDragTarget( + index: Int, + offsetPx: Float, + rowHeightPx: Int, + queueSize: Int, +): Int { + // coerceIn throws on an empty range; a row can't be dragged in an empty + // queue, but the composable's keys can briefly outlive the list. + if (queueSize <= 0) return index + return (index + queueDragDelta(offsetPx, rowHeightPx)).coerceIn(0, queueSize - 1) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt index d9b55b0a..6dee5a84 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt @@ -48,7 +48,6 @@ import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.theme.LocalActionColors -import kotlin.math.roundToInt /* * A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435) @@ -278,8 +277,7 @@ private fun Modifier.queueReorderDrag( onOffsetChange(offset) }, onDragEnd = { - val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0 - val target = (index + delta).coerceIn(0, queueSize - 1) + val target = queueDragTarget(index, offset, rowHeightPx, queueSize) if (target != index) onMove(index, target) offset = 0f onOffsetChange(0f) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt new file mode 100644 index 00000000..db08bd35 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt @@ -0,0 +1,78 @@ +package com.fabledsword.minstrel.player.ui + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * The queue drag math (#2436). The delta cases mirror web's + * queue-row-math.test.ts one for one, so a change to either client's + * rounding shows up as a disagreement here rather than as a drop that lands + * one row off on one platform only. + */ +class QueueDragMathTest { + + @Test + fun `zero offset moves nothing`() { + assertEquals(0, queueDragDelta(0f, 64)) + } + + @Test + fun `a full row down moves one`() { + assertEquals(1, queueDragDelta(64f, 64)) + } + + @Test + fun `just under half a row rounds down`() { + assertEquals(0, queueDragDelta(31f, 64)) + } + + @Test + fun `half a row or more rounds up`() { + assertEquals(1, queueDragDelta(32f, 64)) + } + + @Test + fun `drag up is symmetric, with ties toward positive infinity as in JS`() { + assertEquals(-1, queueDragDelta(-64f, 64)) + assertEquals(0, queueDragDelta(-32f, 64)) + } + + @Test + fun `several rows`() { + assertEquals(3, queueDragDelta(192f, 64)) + assertEquals(-2, queueDragDelta(-128f, 64)) + } + + @Test + fun `a row not yet measured moves nothing`() { + assertEquals(0, queueDragDelta(100f, 0)) + assertEquals(0, queueDragDelta(100f, -1)) + } + + @Test + fun `the measured row height is what counts`() { + assertEquals(1, queueDragDelta(56f, 56)) + assertEquals(1, queueDragDelta(28f, 56)) + } + + @Test + fun `a drag past either end stops at the first or last row`() { + assertEquals(0, queueDragTarget(index = 1, offsetPx = -640f, rowHeightPx = 64, queueSize = 5)) + assertEquals(4, queueDragTarget(index = 3, offsetPx = 640f, rowHeightPx = 64, queueSize = 5)) + } + + @Test + fun `a sub-half-row drag lands where it started`() { + assertEquals(2, queueDragTarget(index = 2, offsetPx = 20f, rowHeightPx = 64, queueSize = 5)) + } + + @Test + fun `before the row is measured a drag lands where it started`() { + assertEquals(2, queueDragTarget(index = 2, offsetPx = 300f, rowHeightPx = 0, queueSize = 5)) + } + + @Test + fun `an empty queue does not throw`() { + assertEquals(0, queueDragTarget(index = 0, offsetPx = 64f, rowHeightPx = 64, queueSize = 0)) + } +} -- 2.54.0