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)); },