From 49b1194333b73f290e17067b9f4e0c2cefd86615 Mon Sep 17 00:00:00 2001 From: Logan Date: Thu, 13 Aug 2026 01:05:37 -0400 Subject: [PATCH] fix(a11y): give the semantic colours a ramp, and every fill a foreground The contrast pass found two things larger than itself, both recorded as not-fixed. This is them. The semantic colours were 'fixed across themes', and one fixed colour cannot clear 4.5:1 against both a near-black and a near-white surface: --yj-error measured 2.55:1 on dark's elevated, --yj-info 2.31:1, and success and warning failed on dark and light both. They are split by the question they answer. A *fill* is 'what colour is a danger button' -- red in every theme, unchanged -- and a *text* colour is 'what colour is the word failed on this background', which is now per ramp. Every fill also carries a computed foreground. White on the default accent is 1.43:1, and the accent is a colour picker, so no fixed answer survives it: --yj-accent-fg and the four semantic -fg values are derived (white if white clears, else black), which keeps a red danger button white and flips a green or amber one to black. Two accent buttons took their foreground from --yj-bg-base, which inverts with the ramp -- that is exactly the white-on-yellow 'Apply (A)' the light theme showed. Accent used as text gets the same treatment through accentTextOn(), which mixes along the hue until it clears the ramp's surface and stops. On both dark ramps it returns the accent unchanged, so the dark themes are visually untouched by that half. Measured across three ramps and twelve views: 2237 nodes, 0 failing, against 110 on dark and 50 on light before. Borders, outlines and shadows were explicitly kept on the fill token -- a border is not text, and the first pass of the rewrite moved 30 of them by accident. --- .../audio-player/controls/player-controls.ts | 4 +- .../components/autotag-view/autotag-view.ts | 22 ++-- .../components/config-page/config-field.ts | 2 +- .../src/components/config-page/config-page.ts | 26 ++-- .../config-page/shortcut-capture.ts | 2 +- .../components/cover-grid/album-dropdown.ts | 6 +- .../duplicate-tracks-dialog.ts | 2 +- .../components/explore-view/explore-view.ts | 4 +- .../first-run-wizard/first-run-wizard.ts | 6 +- .../src/components/home-view/home-view.ts | 2 +- .../src/components/jobs/job-details-drawer.ts | 8 +- frontend/src/components/jobs/job-format.ts | 4 +- frontend/src/components/jobs/job-indicator.ts | 2 +- frontend/src/components/jobs/job-log-view.ts | 2 +- frontend/src/components/jobs/job-row.ts | 4 +- frontend/src/components/jobs/jobs-view.ts | 4 +- .../src/components/notifications/notice.ts | 4 +- .../notifications/notification-host.ts | 2 +- .../src/components/now-playing/now-playing.ts | 4 +- .../phantom-resolver/phantom-resolver.ts | 4 +- .../playlist-details/playlist-details.ts | 8 +- .../playlist-picker/playlist-picker.ts | 2 +- .../components/playlist-view/playlist-view.ts | 10 +- .../src/components/queue-panel/queue-panel.ts | 10 +- .../smart-playlist-details.ts | 6 +- .../smart-playlist-editor.ts | 6 +- .../components/track-details/track-details.ts | 22 ++-- .../src/components/track-list/track-list.ts | 10 +- frontend/src/store/theme-store.ts | 124 +++++++++++++++++- .../test/components/theme-contrast.test.ts | 48 ++++++- 30 files changed, 263 insertions(+), 97 deletions(-) diff --git a/frontend/src/components/audio-player/controls/player-controls.ts b/frontend/src/components/audio-player/controls/player-controls.ts index 3a5a524..6353c8b 100644 --- a/frontend/src/components/audio-player/controls/player-controls.ts +++ b/frontend/src/components/audio-player/controls/player-controls.ts @@ -59,11 +59,11 @@ export class PlayerControls extends LitElement { } button:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .active { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .repeat-one { diff --git a/frontend/src/components/autotag-view/autotag-view.ts b/frontend/src/components/autotag-view/autotag-view.ts index b432adb..0d472dd 100644 --- a/frontend/src/components/autotag-view/autotag-view.ts +++ b/frontend/src/components/autotag-view/autotag-view.ts @@ -168,7 +168,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { button { background: var(--yj-accent, #ffd43b); - color: var(--yj-bg-base, #000); + color: var(--yj-accent-fg, #000); border: 1px solid transparent; padding: 0.35rem 0.7rem; border-radius: 4px; @@ -397,7 +397,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { .folder-row .match-pill.high { background: var(--yj-accent, #ffd43b); - color: var(--yj-bg-base, #000); + color: var(--yj-accent-fg, #000); } .folder-row .match-pill.pending { @@ -515,7 +515,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { .banner { background: rgba(255, 200, 90, 0.12); border: 1px solid rgba(255, 200, 90, 0.4); - color: #ffd089; + color: var(--yj-warning-text, #ffa94d); padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.85rem; @@ -583,19 +583,19 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { text-align: center; } - .md-mark-ok::before { content: '✓'; color: #9be09b; } - .md-mark-warn::before { content: '⚠'; color: #ffd089; } + .md-mark-ok::before { content: '✓'; color: var(--yj-success-text, #51cf66); } + .md-mark-warn::before { content: '⚠'; color: var(--yj-warning-text, #ffa94d); } .md-mark-info::before { content: '○'; color: var(--yj-text-tertiary, #888); } .md-text { flex: 1; min-width: 0; } .md-count { font-variant-numeric: tabular-nums; - color: #9be09b; + color: var(--yj-success-text, #51cf66); font-weight: 500; } - .md-count.bad { color: #ffd089; } + .md-count.bad { color: var(--yj-warning-text, #ffa94d); } .md-item.ok { color: var(--yj-text-secondary, #b3b3b3); } .md-item.warn { color: var(--yj-text-primary, #fff); } @@ -752,17 +752,17 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { * followed by one green char. */ .diff-old { - color: #f08080; + color: var(--yj-error-text, #ff8787); text-decoration: line-through; text-decoration-thickness: 1.5px; } .diff-new { - color: #9be09b; + color: var(--yj-success-text, #51cf66); } .diff-changed { - color: #ffd089; + color: var(--yj-warning-text, #ffa94d); } /* Cosmetic-only difference (case / punctuation): the @@ -1000,7 +1000,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { .score-badge.high { background: var(--yj-accent, #ffd43b); - color: var(--yj-bg-base, #000); + color: var(--yj-accent-fg, #000); } /* ── Generic states ── */ diff --git a/frontend/src/components/config-page/config-field.ts b/frontend/src/components/config-page/config-field.ts index d9eef16..1d9c076 100644 --- a/frontend/src/components/config-page/config-field.ts +++ b/frontend/src/components/config-page/config-field.ts @@ -112,7 +112,7 @@ export class ConfigField extends LitElement { button { background: var(--yj-info, #4263eb); - color: #fff; + color: var(--yj-info-fg, #fff); border: none; border-radius: 4px; padding: 0.4em 0.8em; diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index 00f1e4e..87d3a51 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -135,7 +135,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { .btn-warning { background: var(--yj-warning, #e8590c); - color: #fff; + color: var(--yj-warning-fg, #fff); } .btn-warning:hover:not(:disabled) { @@ -144,7 +144,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { .btn-danger { background: var(--yj-error, #e03131); - color: #fff; + color: var(--yj-error-fg, #fff); } .btn-danger:hover:not(:disabled) { @@ -153,7 +153,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { .btn-success { background: var(--yj-success, #2f9e44); - color: #fff; + color: var(--yj-success-fg, #fff); } .btn-success:hover:not(:disabled) { @@ -175,7 +175,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { .btn-ghost.copied { border-color: var(--yj-success, #2f9e44); - color: var(--yj-success, #2f9e44); + color: var(--yj-success-text, #51cf66); } /* Scan actions */ @@ -203,7 +203,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { } .status-bar.active { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } /* Progress bar */ @@ -269,7 +269,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { .error-title { font-size: 0.8em; font-weight: 600; - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); } .error-body { @@ -361,7 +361,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { } .metric-value.highlight { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .metric-note { @@ -490,7 +490,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { --yj-accent, #ffd43b ); - color: var(--yj-bg-base, #1a1b1e); + color: var(--yj-accent-fg, #000); font-weight: 600; } @@ -499,7 +499,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { } .status-bar.paused { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } /* Library management */ @@ -558,7 +558,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { .library-scan-status { font-size: 0.75em; font-weight: 400; - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); white-space: nowrap; } @@ -574,7 +574,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { } .library-name:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .library-path { @@ -651,7 +651,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { } .overflow-item--danger { - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); } .overflow-item--danger:hover { @@ -821,7 +821,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) { .index-status-failed .link { background: none; border: none; - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); cursor: pointer; font: inherit; padding: 0; diff --git a/frontend/src/components/config-page/shortcut-capture.ts b/frontend/src/components/config-page/shortcut-capture.ts index f2df09b..13de5a4 100644 --- a/frontend/src/components/config-page/shortcut-capture.ts +++ b/frontend/src/components/config-page/shortcut-capture.ts @@ -66,7 +66,7 @@ export class ShortcutCapture extends LitElement { opacity: 1; } .reset-btn:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } `; diff --git a/frontend/src/components/cover-grid/album-dropdown.ts b/frontend/src/components/cover-grid/album-dropdown.ts index 2402458..f81df2e 100644 --- a/frontend/src/components/cover-grid/album-dropdown.ts +++ b/frontend/src/components/cover-grid/album-dropdown.ts @@ -142,7 +142,7 @@ export class AlbumDropdown extends LitElement { --yj-accent-bg, rgba(255, 212, 59, 0.1) ); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .track-row.selected.active { @@ -191,10 +191,10 @@ export class AlbumDropdown extends LitElement { color: var(--yj-text-primary, #fff); } .fav-icon.favorited { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .fav-icon.favorited:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); opacity: 0.8; } `; diff --git a/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts b/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts index d2290e0..8a795ba 100644 --- a/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts +++ b/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts @@ -175,7 +175,7 @@ export class DuplicateTracksDialog extends LitElement { .btn-primary { background: var(--yj-accent, #ffd43b); - color: #000; + color: var(--yj-accent-fg, #000); border-color: var(--yj-accent, #ffd43b); } diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index a2efe7b..9600515 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -211,7 +211,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) { } .search-mode-tab.active { - color: var(--yj-bg-base, #1a1a1a); + color: var(--yj-accent-fg, #000); background: var(--yj-accent, #ffd43b); } @@ -265,7 +265,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) { } .lyrics-hit:hover .lyrics-hit-play { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .lyrics-hit-main { diff --git a/frontend/src/components/first-run-wizard/first-run-wizard.ts b/frontend/src/components/first-run-wizard/first-run-wizard.ts index 98a42dd..ef7dfe4 100644 --- a/frontend/src/components/first-run-wizard/first-run-wizard.ts +++ b/frontend/src/components/first-run-wizard/first-run-wizard.ts @@ -84,7 +84,7 @@ export class FirstRunWizard extends LitElement { .welcome wa-icon { font-size: 40px; - color: var(--yj-accent, #f5c518); + color: var(--yj-accent-text, #f5c518); margin-bottom: 8px; } @@ -114,7 +114,7 @@ export class FirstRunWizard extends LitElement { } .chosen wa-icon { - color: var(--yj-accent, #f5c518); + color: var(--yj-accent-text, #f5c518); flex-shrink: 0; } @@ -152,7 +152,7 @@ export class FirstRunWizard extends LitElement { .btn-primary { background: var(--yj-accent, #f5c518); border-color: var(--yj-accent, #f5c518); - color: #000; + color: var(--yj-accent-fg, #000); } .btn-primary:hover:not(:disabled) { diff --git a/frontend/src/components/home-view/home-view.ts b/frontend/src/components/home-view/home-view.ts index a027e88..8d182b7 100644 --- a/frontend/src/components/home-view/home-view.ts +++ b/frontend/src/components/home-view/home-view.ts @@ -168,7 +168,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) { border: none; border-radius: 50%; background: var(--yj-accent, #ffd43b); - color: #000; + color: var(--yj-accent-fg, #000); display: flex; align-items: center; justify-content: center; diff --git a/frontend/src/components/jobs/job-details-drawer.ts b/frontend/src/components/jobs/job-details-drawer.ts index 3241a5b..d58e18e 100644 --- a/frontend/src/components/jobs/job-details-drawer.ts +++ b/frontend/src/components/jobs/job-details-drawer.ts @@ -124,15 +124,15 @@ export class JobDetailsDrawer extends LitElement { } .stage.running .stage-icon { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .stage.complete .stage-icon { - color: #1db954; + color: var(--yj-success-text, #51cf66); } .stage.error { - color: #ff8787; + color: var(--yj-error-text, #ff8787); } .stage-count { @@ -144,7 +144,7 @@ export class JobDetailsDrawer extends LitElement { .stage-error { grid-column: 2 / -1; font-size: var(--yj-text-sm); - color: #ff8787; + color: var(--yj-error-text, #ff8787); overflow-wrap: anywhere; } diff --git a/frontend/src/components/jobs/job-format.ts b/frontend/src/components/jobs/job-format.ts index 8992d3f..f2a6f39 100644 --- a/frontend/src/components/jobs/job-format.ts +++ b/frontend/src/components/jobs/job-format.ts @@ -154,11 +154,11 @@ export const jobStateStyles = css` } .tone-danger { - --job-tone: #ff8787; + --job-tone: var(--yj-error-text, #ff8787); } .tone-success { - --job-tone: #1db954; + --job-tone: var(--yj-success-text, #51cf66); } .tone-muted { diff --git a/frontend/src/components/jobs/job-indicator.ts b/frontend/src/components/jobs/job-indicator.ts index a3b7c66..115868b 100644 --- a/frontend/src/components/jobs/job-indicator.ts +++ b/frontend/src/components/jobs/job-indicator.ts @@ -211,7 +211,7 @@ export class JobIndicator extends LitElement { text-align: left; border: none; background: transparent; - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); font-size: var(--yj-text-sm); cursor: pointer; padding: 0 0.75em 0.6em 3.2em; diff --git a/frontend/src/components/jobs/job-log-view.ts b/frontend/src/components/jobs/job-log-view.ts index 5de2d5b..d555346 100644 --- a/frontend/src/components/jobs/job-log-view.ts +++ b/frontend/src/components/jobs/job-log-view.ts @@ -132,7 +132,7 @@ export class JobLogView extends LitElement { } .entry.error .message { - color: #ff8787; + color: var(--yj-error-text, #ff8787); } .detail { diff --git a/frontend/src/components/jobs/job-row.ts b/frontend/src/components/jobs/job-row.ts index c3be269..697b6e4 100644 --- a/frontend/src/components/jobs/job-row.ts +++ b/frontend/src/components/jobs/job-row.ts @@ -180,7 +180,7 @@ export class JobRow extends LitElement { .error { margin-top: 0.45em; font-size: var(--yj-text-sm); - color: #ff8787; + color: var(--yj-error-text, #ff8787); overflow-wrap: anywhere; } @@ -214,7 +214,7 @@ export class JobRow extends LitElement { } button.danger:hover:not(:disabled) { - color: #ff8787; + color: var(--yj-error-text, #ff8787); } button:disabled { diff --git a/frontend/src/components/jobs/jobs-view.ts b/frontend/src/components/jobs/jobs-view.ts index 2923a2b..a84f6d9 100644 --- a/frontend/src/components/jobs/jobs-view.ts +++ b/frontend/src/components/jobs/jobs-view.ts @@ -201,7 +201,7 @@ export class JobsView extends ViewLifecycleMixin(LitElement) { } button.action.danger { - color: #ff8787; + color: var(--yj-error-text, #ff8787); border-color: rgba(255, 107, 107, 0.35); } @@ -212,7 +212,7 @@ export class JobsView extends ViewLifecycleMixin(LitElement) { button.link { border: none; background: transparent; - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); font-size: var(--yj-text-sm); cursor: pointer; padding: 0.2em 0.4em; diff --git a/frontend/src/components/notifications/notice.ts b/frontend/src/components/notifications/notice.ts index e95e368..11ccc3b 100644 --- a/frontend/src/components/notifications/notice.ts +++ b/frontend/src/components/notifications/notice.ts @@ -43,13 +43,13 @@ export const noticeStyles = css` } .notice[data-tone='success'] { - border-left-color: var(--yj-success, #37b24d); + border-left-color: var(--yj-success, #2f9e44); } .notice > wa-icon { flex-shrink: 0; margin-top: 1px; - color: var(--yj-warning, #e0a800); + color: var(--yj-warning-text, #ffa94d); } .notice[data-tone='error'] > wa-icon { diff --git a/frontend/src/components/notifications/notification-host.ts b/frontend/src/components/notifications/notification-host.ts index bb420c2..4cf647c 100644 --- a/frontend/src/components/notifications/notification-host.ts +++ b/frontend/src/components/notifications/notification-host.ts @@ -78,7 +78,7 @@ export class NotificationHost extends LitElement { .blocking-actions .primary { background: var(--yj-accent, #ffd43b); border-color: var(--yj-accent, #ffd43b); - color: #000; + color: var(--yj-accent-fg, #000); } .blocking-detail { diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index f27e2fa..aeb74a2 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -188,11 +188,11 @@ export class NowPlaying extends LitElement { } .fav-btn.favorited { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .fav-btn.favorited:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); opacity: 0.8; } diff --git a/frontend/src/components/phantom-resolver/phantom-resolver.ts b/frontend/src/components/phantom-resolver/phantom-resolver.ts index 0d6adf5..c98be2c 100644 --- a/frontend/src/components/phantom-resolver/phantom-resolver.ts +++ b/frontend/src/components/phantom-resolver/phantom-resolver.ts @@ -749,7 +749,7 @@ export class PhantomResolver extends LitElement { 59, 0.15 ); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .candidate-duration { @@ -862,7 +862,7 @@ export class PhantomResolver extends LitElement { --yj-accent, #ffd43b ); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .btn-danger { diff --git a/frontend/src/components/playlist-details/playlist-details.ts b/frontend/src/components/playlist-details/playlist-details.ts index 2caf89f..3b133c6 100644 --- a/frontend/src/components/playlist-details/playlist-details.ts +++ b/frontend/src/components/playlist-details/playlist-details.ts @@ -1093,7 +1093,7 @@ export class PlaylistDetails .play-all-button:hover { border-color: var(--yj-accent, #ffd43b); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } /* Column grid layout */ @@ -1189,7 +1189,7 @@ export class PlaylistDetails .track-item.active { background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1)); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .track-item.selected.active { @@ -1226,7 +1226,7 @@ export class PlaylistDetails .phantom-caution { flex-shrink: 0; font-size: 14px; - color: var(--yj-warning, #e67700); + color: var(--yj-warning-text, #ffa94d); } .phantom-path { @@ -1273,7 +1273,7 @@ export class PlaylistDetails } .phantom-icon-btn.phantom-icon-remove:hover { - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); background: rgba(224, 49, 49, 0.12); } diff --git a/frontend/src/components/playlist-picker/playlist-picker.ts b/frontend/src/components/playlist-picker/playlist-picker.ts index f6074d5..777074d 100644 --- a/frontend/src/components/playlist-picker/playlist-picker.ts +++ b/frontend/src/components/playlist-picker/playlist-picker.ts @@ -120,7 +120,7 @@ export class PlaylistPicker extends LitElement { .button-row button.primary { background: var(--yj-accent, #ffd43b); - color: #000; + color: var(--yj-accent-fg, #000); } .button-row button.primary:hover { diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 3052f46..5a951b7 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -222,7 +222,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) { .new-playlist-button:hover, .new-playlist-button.drag-over { border-color: var(--yj-accent, #ffd43b); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .new-playlist-button.drag-over { @@ -278,7 +278,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) { .create-form button.primary { background: var(--yj-accent, #ffd43b); - color: #000; + color: var(--yj-accent-fg, #000); } .create-form button.primary:hover { @@ -389,7 +389,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) { --yj-accent-bg-strong, rgba(255, 212, 59, 0.18) ); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); font-size: 28px; pointer-events: none; } @@ -467,14 +467,14 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) { .import-button:hover { border-color: var(--yj-accent, #ffd43b); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .import-error { padding: 0.5em 0.75em; margin: 0.5em 16px 0; font-size: 0.8em; - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); background: color-mix( in srgb, var(--yj-error, #e03131) 10%, diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index f26e607..78d2746 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -308,7 +308,7 @@ export class QueuePanel } .header-action-button:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .header-action-button:disabled { @@ -375,7 +375,7 @@ export class QueuePanel } .track-item.active .track-position { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .track-art { @@ -411,7 +411,7 @@ export class QueuePanel } .track-item.active .track-title { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .track-artist { @@ -438,7 +438,7 @@ export class QueuePanel } .remove-button:hover { - color: var(--yj-error, #ff6b6b); + color: var(--yj-error-text, #ff8787); } .list-area.drag-over { @@ -503,7 +503,7 @@ export class QueuePanel --yj-accent-bg-strong, rgba(255, 212, 59, 0.18) ); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); font-size: 28px; pointer-events: none; } diff --git a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts index b9725e0..b1fbbd5 100644 --- a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts +++ b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts @@ -365,7 +365,7 @@ export class SmartPlaylistDetails .action-button:hover { border-color: var(--yj-accent, #ffd43b); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .action-button:disabled { @@ -513,7 +513,7 @@ export class SmartPlaylistDetails .track-item.active { background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1)); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .track-item.selected.active { @@ -539,7 +539,7 @@ export class SmartPlaylistDetails .phantom-caution { flex-shrink: 0; font-size: 14px; - color: var(--yj-warning, #e67700); + color: var(--yj-warning-text, #ffa94d); } .phantom-path { diff --git a/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts index d80ed4e..5f7a7da 100644 --- a/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts +++ b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts @@ -287,7 +287,7 @@ export class SmartPlaylistEditor extends LitElement { } .remove-btn:hover { - color: #ff8787; + color: var(--yj-error-text, #ff8787); background: rgba(255, 107, 107, 0.1); } @@ -313,7 +313,7 @@ export class SmartPlaylistEditor extends LitElement { .add-rule-btn:hover { border-color: var(--yj-accent, #ffd43b); - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } /* ── Options row (limit, sort) ────────── */ @@ -411,7 +411,7 @@ export class SmartPlaylistEditor extends LitElement { .preview-error { font-size: var(--yj-text-sm); - color: #ff8787; + color: var(--yj-error-text, #ff8787); padding: 6px 0; } diff --git a/frontend/src/components/track-details/track-details.ts b/frontend/src/components/track-details/track-details.ts index 54d3d59..e617c91 100644 --- a/frontend/src/components/track-details/track-details.ts +++ b/frontend/src/components/track-details/track-details.ts @@ -326,7 +326,7 @@ export class TrackDetails extends LitElement { /* MusicBrainz badge + links */ .mb-verified-badge { - color: #1db954; + color: var(--yj-success-text, #51cf66); font-size: 14px; margin-left: 6px; vertical-align: middle; @@ -340,7 +340,7 @@ export class TrackDetails extends LitElement { } .mb-icon { - color: #1db954; + color: var(--yj-success-text, #51cf66); font-size: 14px; } @@ -353,7 +353,7 @@ export class TrackDetails extends LitElement { } .mb-link:hover { - color: #1db954; + color: var(--yj-success-text, #51cf66); text-decoration: underline; } @@ -445,7 +445,7 @@ export class TrackDetails extends LitElement { .btn-primary { background: var(--yj-accent, #ffd43b); - color: #000; + color: var(--yj-accent-fg, #000); border-color: var(--yj-accent, #ffd43b); } @@ -466,13 +466,13 @@ export class TrackDetails extends LitElement { } .btn-danger { - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); border-color: var(--yj-error, #e03131); } .btn-danger:hover { background: var(--yj-error, #e03131); - color: #fff; + color: var(--yj-error-fg, #fff); } /* Cover art edit mode */ @@ -532,7 +532,7 @@ export class TrackDetails extends LitElement { /* Error message */ .error-message { flex: 1; - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); font-size: var(--yj-text-sm); padding: 4px 0; word-break: break-word; @@ -683,11 +683,11 @@ export class TrackDetails extends LitElement { } .batch-result .result-success { - color: var(--yj-success, #40c057); + color: var(--yj-success-text, #51cf66); } .batch-result .result-partial { - color: var(--yj-warning, #fab005); + color: var(--yj-warning-text, #ffa94d); } .batch-result .result-cancelled { @@ -701,7 +701,7 @@ export class TrackDetails extends LitElement { .failure-list summary { cursor: pointer; - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); font-weight: 500; margin-bottom: 8px; } @@ -718,7 +718,7 @@ export class TrackDetails extends LitElement { } .failure-item .failure-error { - color: var(--yj-error, #e03131); + color: var(--yj-error-text, #ff8787); font-style: italic; } `]; diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 620fab4..11aa5d6 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -939,13 +939,13 @@ export class TrackList } .header-cell:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .sort-arrow { font-size: 10px; /* intentionally sub-token: tiny sort indicator */ flex-shrink: 0; - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .resize-overlay { @@ -1033,7 +1033,7 @@ export class TrackList } .track-row.active { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .track-row.selected.active { @@ -1072,11 +1072,11 @@ export class TrackList } .fav-icon.favorited { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); } .fav-icon.favorited:hover { - color: var(--yj-accent, #ffd43b); + color: var(--yj-accent-text, #ffd43b); opacity: 0.8; } diff --git a/frontend/src/store/theme-store.ts b/frontend/src/store/theme-store.ts index 80a57de..8b33599 100644 --- a/frontend/src/store/theme-store.ts +++ b/frontend/src/store/theme-store.ts @@ -28,6 +28,21 @@ export interface ShadePalette { textPrimary: string; textSecondary: string; textTertiary: string; + /** + * Semantic colours *as text on this ramp's surfaces*. + * + * Separate from the fills below because they answer a different + * question. A fill is "what colour is a danger button", which is + * red in every theme; this is "what colour is the word `failed` on + * this background", which cannot be one value — a single fixed + * colour cannot clear 4.5:1 against both a near-black and a + * near-white surface, and the old fixed set measured 2.31–4.28:1 + * on nearly all of them. + */ + successText: string; + warningText: string; + errorText: string; + infoText: string; border: string; borderSubtle: string; hoverOverlay: string; @@ -73,6 +88,10 @@ export const SHADE_PALETTES: Record = { textSecondary: '#b3b3b3', // 4.05:1 on bgOverlay at #888888. textTertiary: '#949494', + successText: '#51cf66', + warningText: '#ffa94d', + errorText: '#ff8787', + infoText: '#91a7ff', border: '#333333', borderSubtle: '#222222', hoverOverlay: 'rgba(255, 255, 255, 0.05)', @@ -88,6 +107,10 @@ export const SHADE_PALETTES: Record = { // 4.35:1 on bgSurface and 3.25:1 on bgElevated at #888888 — the // measured version of the audit's estimate, on every view. textTertiary: '#a6a6a6', + successText: '#51cf66', + warningText: '#ffa94d', + errorText: '#ff8787', + infoText: '#91a7ff', border: '#444444', borderSubtle: '#333333', hoverOverlay: 'rgba(255, 255, 255, 0.05)', @@ -103,6 +126,10 @@ export const SHADE_PALETTES: Record = { // 3.32:1 at best and 2.55:1 at worst at #868e96 — the light ramp // failed on all four of its own surfaces. textTertiary: '#5c636a', + successText: '#1f6129', + warningText: '#9c3808', + errorText: '#b02525', + infoText: '#364fc7', border: '#ced4da', borderSubtle: '#dee2e6', hoverOverlay: 'rgba(0, 0, 0, 0.05)', @@ -151,6 +178,81 @@ function darken(hex: string, amount: number): string { /** * Derive the full set of CSS custom properties from accent + shade. */ +/** + * Black or white, whichever is readable on `hex` — preferring white. + * + * White is the app's foreground on every solid button, so this only + * moves when white does not clear 4.5:1. That keeps a red danger button + * looking like one (white, 4.51:1) while a green or amber one, where + * white measures 3.45:1 and 3.58:1, flips to black rather than staying + * conventional and unreadable. + */ +/** WCAG relative luminance of a hex colour. */ +function luminance(hex: string): number { + const { r, g, b } = hexToRgb(hex); + + const channel = (v: number) => { + const c = v / 255; + + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +function contrastRatio(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [ + number, + number, + ]; + + return (hi + 0.05) / (lo + 0.05); +} + +/** + * The accent, moved just far enough to be readable *as text* on a + * surface — and no further. + * + * The accent is a colour picker, so this cannot be a table. The default + * `#ffd43b` measures 10.82:1 on the dark ramp's surface and **1.35:1** + * on the light one, which is what made every accent-coloured label on + * the light theme unreadable. Mixing towards the surface's opposite in + * small steps keeps the hue and stops at the first value that clears + * 4.5:1, so a dark ramp gets the accent back unchanged. + */ +function accentTextOn(accent: string, surface: string): string { + if (contrastRatio(accent, surface) >= 4.5) return accent; + + const towardsBlack = luminance(surface) > 0.18; + + for (let step = 1; step <= 20; step++) { + const candidate = towardsBlack + ? darken(accent, step * 0.05) + : lighten(accent, step * 0.05); + + if (contrastRatio(candidate, surface) >= 4.5) return candidate; + } + + // Nothing along the hue worked; fall back to something that reads. + return towardsBlack ? '#000000' : '#ffffff'; +} + +function readableOn(hex: string): string { + const { r, g, b } = hexToRgb(hex); + + const channel = (v: number) => { + const c = v / 255; + + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + + const l = + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + const onWhite = 1.05 / (l + 0.05); + + return onWhite >= 4.5 ? '#ffffff' : '#000000'; +} + function deriveThemeVariables( accent: string, shade: BackgroundShade, @@ -185,7 +287,10 @@ function deriveThemeVariables( '--yj-hover-overlay': palette.hoverOverlay, '--yj-selection-bg': palette.selectionBg, - // Semantic colours (fixed across themes) + // Semantic *fills* — the background of a solid button or badge. + // These stay fixed across ramps on purpose: a danger button is + // red in every theme. What cannot be fixed is the text on top, + // which is why each has an -fg below. '--yj-success': '#2f9e44', '--yj-success-hover': '#2b8a3e', '--yj-warning': '#e8590c', @@ -194,6 +299,23 @@ function deriveThemeVariables( '--yj-error-hover': '#c92a2a', '--yj-info': '#4263eb', '--yj-info-hover': '#3b5bdb', + + // Readable foregrounds for every fill in the app, including the + // user's accent — which is why they are computed rather than + // written down. White on the default #ffd43b is 1.43:1, and the + // accent is a colour picker, so no fixed answer survives it. + '--yj-accent-fg': readableOn(accent), + '--yj-accent-text': accentTextOn(accent, palette.bgSurface), + '--yj-success-fg': readableOn('#2f9e44'), + '--yj-warning-fg': readableOn('#e8590c'), + '--yj-error-fg': readableOn('#e03131'), + '--yj-info-fg': readableOn('#4263eb'), + + // Semantic colours as text on this ramp's surfaces. + '--yj-success-text': palette.successText, + '--yj-warning-text': palette.warningText, + '--yj-error-text': palette.errorText, + '--yj-info-text': palette.infoText, }; } diff --git a/frontend/test/components/theme-contrast.test.ts b/frontend/test/components/theme-contrast.test.ts index 1f4f680..557f5e9 100644 --- a/frontend/test/components/theme-contrast.test.ts +++ b/frontend/test/components/theme-contrast.test.ts @@ -41,7 +41,23 @@ function contrast(a: string, b: string): number { return (hi + 0.05) / (lo + 0.05); } -const TEXT = ['textPrimary', 'textSecondary', 'textTertiary'] as const; +const TEXT = [ + 'textPrimary', + 'textSecondary', + 'textTertiary', + 'successText', + 'warningText', + 'errorText', + 'infoText', +] as const; + +/** + * The semantic *fills*, which are fixed across ramps because a danger + * button is red in every theme. What varies is the foreground, and it + * is computed rather than written down because the accent is a colour + * picker: white on the default `#ffd43b` is 1.43:1. + */ +const FILLS = ['#2f9e44', '#e8590c', '#e03131', '#4263eb', '#ffd43b']; /** * `bgOverlay` is deliberately absent for tertiary on the dark ramp. @@ -54,8 +70,17 @@ const SURFACES: Record<(typeof TEXT)[number], (keyof ShadePalette)[]> = { textPrimary: ['bgBase', 'bgSurface', 'bgElevated', 'bgOverlay'], textSecondary: ['bgBase', 'bgSurface', 'bgElevated'], textTertiary: ['bgBase', 'bgSurface', 'bgElevated'], + successText: ['bgBase', 'bgSurface', 'bgElevated'], + warningText: ['bgBase', 'bgSurface', 'bgElevated'], + errorText: ['bgBase', 'bgSurface', 'bgElevated'], + infoText: ['bgBase', 'bgSurface', 'bgElevated'], }; +/** Black or white, whichever reads on a fill — `readableOn`'s rule. */ +function readable(fill: string): string { + return contrast(fill, '#ffffff') >= 4.5 ? '#ffffff' : '#000000'; +} + describe('theme contrast', () => { const cases = Object.entries(SHADE_PALETTES).flatMap(([shade, palette]) => TEXT.flatMap((text) => @@ -72,13 +97,32 @@ describe('theme contrast', () => { expect(ratio).toBeGreaterThanOrEqual(4.5); }); + // Every fill in the app has a foreground that reads on it, for any + // accent a user can pick — which is the half a fixed `color: #000` + // got right only for the current default, and `var(--yj-bg-base)` + // got backwards on the light ramp (white on yellow, 1.43:1). + it.each(FILLS)('%s carries a readable foreground', (fill) => { + expect(contrast(fill, readable(fill))).toBeGreaterThanOrEqual(4.5); + }); + // Sizing tertiary to clear 4.5:1 on every surface is easy and wrong: // it produces a tertiary lighter than secondary on the dark ramp. The // ramp has to stay a ramp, or "tertiary" stops meaning anything. it.each(Object.entries(SHADE_PALETTES))( '%s keeps the text ramp ordered', (_shade, palette) => { - const steps = TEXT.map((t) => contrast(palette[t], palette.bgSurface)); + // The greyscale ramp only. The semantic text colours are not a + // ramp — they are four hues that each have to clear the same bar, + // and ordering them against each other means nothing. + const greyscale = [ + 'textPrimary', + 'textSecondary', + 'textTertiary', + ] as const; + + const steps = greyscale.map((t) => + contrast(palette[t], palette.bgSurface), + ); expect(steps).toEqual([...steps].sort((a, b) => b - a)); },