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.
This commit is contained in:
2026-08-13 01:05:37 -04:00
parent fd32ce71d2
commit 49b1194333
30 changed files with 263 additions and 97 deletions
@@ -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 {
@@ -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 ── */
@@ -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;
@@ -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;
@@ -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);
}
`;
@@ -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;
}
`;
@@ -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);
}
@@ -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 {
@@ -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) {
@@ -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;
@@ -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;
}
+2 -2
View File
@@ -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 {
@@ -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;
+1 -1
View File
@@ -132,7 +132,7 @@ export class JobLogView extends LitElement {
}
.entry.error .message {
color: #ff8787;
color: var(--yj-error-text, #ff8787);
}
.detail {
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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;
@@ -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 {
@@ -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 {
@@ -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;
}
@@ -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 {
@@ -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);
}
@@ -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 {
@@ -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%,
@@ -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;
}
@@ -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 {
@@ -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;
}
@@ -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;
}
`];
@@ -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;
}
+123 -1
View File
@@ -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.314.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<BackgroundShade, ShadePalette> = {
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<BackgroundShade, ShadePalette> = {
// 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<BackgroundShade, ShadePalette> = {
// 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,
};
}
@@ -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));
},