Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusen 3077ed75fe Merge pull request 'Readable accent text app-wide; Android queue drag math under test' (#144) from dev into main
release / govulncheck (push) Successful in 18s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m2s
release / android (push) Successful in 4m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 06:29:48 -04:00
bvandeusenandClaude Opus 5.5 43c369f082 test(android): queue drag math is a pure function, held to web's cases (#2436)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Successful in 4m46s
release / Build signed APK (releases and dev) (push) Successful in 4m58s
release / integration (push) Successful in 15m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
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 <noreply@anthropic.com>
2026-10-08 00:18:43 -04:00
bvandeusenandClaude Opus 5.5 b46c080d19 fix(web): all accent text and icons use accent-fg (#5318)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m26s
release / integration (push) Successful in 4m18s
release / android (push) Successful in 4m44s
release / Build signed APK (releases and dev) (push) Successful in 4m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
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 <noreply@anthropic.com>
2026-10-08 00:17:19 -04:00
29 changed files with 165 additions and 45 deletions
@@ -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)
}
@@ -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)
@@ -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))
}
}
+7 -4
View File
@@ -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) {
+7
View File
@@ -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(`<a class="text-sm text-accent hover:underline">`)[0].fix).toBe('text-accent-fg');
expect(findRawText(`<a class="hover:text-accent">`)).toHaveLength(1);
expect(findRawText(`.pending { color: var(--fs-accent); }`)[0].fix).toBe('var(--fs-accent-fg)');
expect(findRawText(`<a class="text-accent-fg border-accent">`)).toEqual([]);
});
test('passes error-fg, and error as a border or outline', () => {
expect(findRawText(`<p class="text-error-fg">x</p>`)).toEqual([]);
expect(findRawText(`<div class="border-error" class:border-error={bad}>`)).toEqual([]);
@@ -201,7 +201,7 @@
cursor: default;
}
.rail-btn.pending {
color: var(--fs-accent);
color: var(--fs-accent-fg);
cursor: wait;
}
:global(.rail-btn .spin) {
+1 -1
View File
@@ -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 ? '♥' : '♡'}</button>
@@ -48,9 +48,9 @@
<a
href={info.apkUrl}
download="minstrel.apk"
class="mobile-app-download flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary no-underline transition-colors hover:bg-surface-hover hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
class="mobile-app-download flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary no-underline transition-colors hover:bg-surface-hover hover:text-accent-fg focus-visible:ring-2 focus-visible:ring-accent"
>
<Smartphone size={18} strokeWidth={1.5} class="shrink-0 text-accent" />
<Smartphone size={18} strokeWidth={1.5} class="shrink-0 text-accent-fg" />
<span class="flex flex-col leading-tight">
<span class="font-medium">Get the Android app</span>
<span class="text-xs text-text-secondary">
+1 -1
View File
@@ -101,7 +101,7 @@
>
<div class="flex items-center gap-2 min-w-0">
{#if isCurrent}
<span class="text-xs text-accent uppercase tracking-wide flex-shrink-0">Now playing</span>
<span class="text-xs text-accent-fg uppercase tracking-wide flex-shrink-0">Now playing</span>
{/if}
<span class="font-medium truncate text-text-primary">{track.title}</span>
</div>
+2 -2
View File
@@ -290,7 +290,7 @@
<header class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Pending requests</h3>
{#if pendingCount > PREVIEW_LIMIT}
<a href="/admin/requests" class="text-sm text-accent hover:underline">View all {pendingCount} →</a>
<a href="/admin/requests" class="text-sm text-accent-fg hover:underline">View all {pendingCount} →</a>
{/if}
</header>
@@ -523,7 +523,7 @@
<header class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Quarantine</h3>
{#if quarantineCount > PREVIEW_LIMIT}
<a href="/admin/quarantine" class="text-sm text-accent hover:underline">View all {quarantineCount} →</a>
<a href="/admin/quarantine" class="text-sm text-accent-fg hover:underline">View all {quarantineCount} →</a>
{/if}
</header>
@@ -424,7 +424,7 @@
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Lidarr</h3>
<p class="mt-1 text-sm text-text-secondary">
Search Lidarr from <span class="font-mono text-accent">/discover</span> and
Search Lidarr from <span class="font-mono text-accent-fg">/discover</span> and
route approved requests to it.
</p>
</div>
+1 -1
View File
@@ -225,7 +225,7 @@
{rowMeta(r)}
</div>
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
<div class="text-sm text-accent" data-testid="ingest-progress">
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
{#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
+2 -2
View File
@@ -495,7 +495,7 @@
{/each}
{#if s.points.length > 1}
<polyline
class="text-accent"
class="text-accent-fg"
points={sparkPoints(s)}
fill="none"
stroke="currentColor"
@@ -503,7 +503,7 @@
/>
{:else if s.points.length === 1}
<circle
class="text-accent"
class="text-accent-fg"
cx={xFor(s.points[0].week_start)}
cy={SPARK_H - s.points[0].skip_rate * SPARK_H}
r="2" fill="currentColor"
+2 -2
View File
@@ -95,7 +95,7 @@
{#if notFound}
<div role="alert" class="rounded border border-border bg-surface p-4">
<p>Album not found.</p>
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
<a href="/" class="mt-2 inline-block text-sm text-accent-fg">Back to Library</a>
</div>
{:else if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
@@ -131,7 +131,7 @@
in that genre. -->
{#if album.year}
<p class="text-sm">
<a href={`/library/years?y=${album.year}`} class="text-accent hover:underline">
<a href={`/library/years?y=${album.year}`} class="text-accent-fg hover:underline">
{album.year}
</a>
</p>
+1 -1
View File
@@ -73,7 +73,7 @@
{#if notFound}
<div role="alert" class="rounded border border-border bg-surface p-4">
<p>Artist not found.</p>
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
<a href="/" class="mt-2 inline-block text-sm text-accent-fg">Back to Library</a>
</div>
{:else if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
+1 -1
View File
@@ -71,7 +71,7 @@
{/if}
<p class="mt-4 text-center text-sm text-text-secondary">
Remembered? <a href="/login" class="text-accent hover:underline">Log in</a>
Remembered? <a href="/login" class="text-accent-fg hover:underline">Log in</a>
</p>
</div>
</main>
+1 -1
View File
@@ -65,7 +65,7 @@
<p class="text-text-secondary">
No albums in your loaded library match
<span class="font-medium">'{filter.trim()}'</span>.
<a href={`/search/albums?q=${encodeURIComponent(filter.trim())}`} class="text-accent hover:underline">
<a href={`/search/albums?q=${encodeURIComponent(filter.trim())}`} class="text-accent-fg hover:underline">
Search the full library →
</a>
</p>
+1 -1
View File
@@ -63,7 +63,7 @@
<p class="text-text-secondary">
No artists in your loaded library match
<span class="font-medium">'{filter.trim()}'</span>.
<a href={`/search/artists?q=${encodeURIComponent(filter.trim())}`} class="text-accent hover:underline">
<a href={`/search/artists?q=${encodeURIComponent(filter.trim())}`} class="text-accent-fg hover:underline">
Search the full library →
</a>
</p>
+1 -1
View File
@@ -112,7 +112,7 @@
<header class="space-y-2">
<a
href="/library/genres"
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
class="inline-flex items-center gap-1 text-sm text-accent-fg hover:underline"
>
<ChevronLeft size={14} aria-hidden="true" />
All genres
+3 -3
View File
@@ -89,7 +89,7 @@
title="No liked artists yet."
>
{#snippet actions()}
<a href="/library/artists" class="text-accent hover:underline">Browse artists →</a>
<a href="/library/artists" class="text-accent-fg hover:underline">Browse artists →</a>
{/snippet}
</EmptyState>
{:else}
@@ -121,7 +121,7 @@
title="No liked albums yet."
>
{#snippet actions()}
<a href="/library/albums" class="text-accent hover:underline">Browse albums →</a>
<a href="/library/albums" class="text-accent-fg hover:underline">Browse albums →</a>
{/snippet}
</EmptyState>
{:else}
@@ -153,7 +153,7 @@
title="No liked tracks yet."
>
{#snippet actions()}
<a href="/" class="text-accent hover:underline">Find something new →</a>
<a href="/" class="text-accent-fg hover:underline">Find something new →</a>
{/snippet}
</EmptyState>
{:else}
+1 -1
View File
@@ -97,7 +97,7 @@
<header class="space-y-2">
<a
href="/library/years"
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
class="inline-flex items-center gap-1 text-sm text-accent-fg hover:underline"
>
<ChevronLeft size={14} aria-hidden="true" />
All years
+2 -2
View File
@@ -85,11 +85,11 @@
</form>
<p class="mt-4 text-center text-sm text-text-secondary">
<a href="/forgot-password" class="text-accent hover:underline">Forgot password?</a>
<a href="/forgot-password" class="text-accent-fg hover:underline">Forgot password?</a>
</p>
<p class="mt-2 text-center text-sm text-text-secondary">
Don't have an account?
<a href="/register" class="text-accent hover:underline">Register</a>
<a href="/register" class="text-accent-fg hover:underline">Register</a>
</p>
</div>
</main>
+2 -2
View File
@@ -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"
>
<Shuffle size={20} strokeWidth={1.5} />
@@ -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'}
+1 -1
View File
@@ -170,7 +170,7 @@
<p class="mt-4 text-center text-sm text-text-secondary">
Already have an account?
<a href="/login" class="text-accent hover:underline">Sign in</a>
<a href="/login" class="text-accent-fg hover:underline">Sign in</a>
</p>
</div>
</main>
+2 -2
View File
@@ -107,7 +107,7 @@
{rowMeta(r)}
</div>
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
<div class="text-sm text-accent" data-testid="ingest-progress">
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
{#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 @@
<a
{href}
aria-label={`Listen to ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
class="inline-flex items-center gap-1 text-sm text-accent-fg hover:underline"
>
<Music2 size={16} strokeWidth={1} /> Listen
</a>
@@ -89,7 +89,7 @@
</button>
</form>
<p class="mt-4 text-center text-sm text-text-secondary">
<a href="/login" class="text-accent hover:underline">Back to login</a>
<a href="/login" class="text-accent-fg hover:underline">Back to login</a>
</p>
</div>
</main>
+3 -3
View File
@@ -56,7 +56,7 @@
{#if artists.total > artists.items.length}
<a
href={`/search/artists?q=${encodeURIComponent(q)}`}
class="text-sm text-accent hover:underline"
class="text-sm text-accent-fg hover:underline"
>
See all {artists.total} →
</a>
@@ -77,7 +77,7 @@
{#if albums.total > albums.items.length}
<a
href={`/search/albums?q=${encodeURIComponent(q)}`}
class="text-sm text-accent hover:underline"
class="text-sm text-accent-fg hover:underline"
>
See all {albums.total} →
</a>
@@ -98,7 +98,7 @@
{#if tracks.total > tracks.items.length}
<a
href={`/search/tracks?q=${encodeURIComponent(q)}`}
class="text-sm text-accent hover:underline"
class="text-sm text-accent-fg hover:underline"
>
See all {tracks.total} →
</a>
+3 -3
View File
@@ -363,7 +363,7 @@
<button
type="button"
onclick={clearToken}
class="text-sm text-accent hover:underline"
class="text-sm text-accent-fg hover:underline"
disabled={$tokenMutation.isPending}
>clear</button>
</div>
@@ -402,7 +402,7 @@
{/if}
<p class="text-xs text-text-secondary">
Get a token at <a href="https://listenbrainz.org/profile/" target="_blank" rel="noopener" class="text-accent hover:underline">listenbrainz.org/profile</a>.
Get a token at <a href="https://listenbrainz.org/profile/" target="_blank" rel="noopener" class="text-accent-fg hover:underline">listenbrainz.org/profile</a>.
Tokens are stored unencrypted in this server's database — treat as sensitive.
</p>
</div>
@@ -682,7 +682,7 @@
<h2 class="text-lg font-semibold">Library</h2>
<ul class="space-y-2 text-sm">
<li>
<a href="/library/hidden" class="text-accent hover:underline">Hidden tracks</a>
<a href="/library/hidden" class="text-accent-fg hover:underline">Hidden tracks</a>
<span class="text-text-secondary"> — tracks you've flagged as bad rips, wrong tags, or otherwise unplayable.</span>
</li>
</ul>
+4 -2
View File
@@ -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)' },