From 32e6b8b31b191c191ee13eee3703cbc312db5f51 Mon Sep 17 00:00:00 2001 From: Yahya Zekry Date: Tue, 18 Aug 2026 12:25:01 +0300 Subject: [PATCH 1/5] fix(theme): derive button text colour from accent luminance 46 rules in static/style.css (plus 5 inline styles in admin.js, document.js, settings.js and compare/scoreboard.js) pair a `background: var(--red)` with a hardcoded `color: #fff`. Because --red is theme-configurable, several built-in themes render unreadable text on their own buttons -- terminal is 1.37:1, ume 1.97, paper 2.24, forest 2.35, ocean 2.42, cute 2.68, all below the 3:1 large-text floor. Adds an `--on-accent` token, recomputed in applyColors() from --red's WCAG relative luminance: dark text when white would fall under 3:1, white otherwise. Every hardcoded site now reads the token. Contrast against --red, before -> after: terminal 1.37 -> 13.13 ume 1.97 -> 9.11 paper 2.24 -> 8.00 forest 2.35 -> 7.64 ocean 2.42 -> 7.40 cute 2.68 -> 6.69 Themes already at or above 3:1 are untouched, so the visual change is limited to the six that were failing. HSL lightness would not work here: #e06c75 and #f2c14e have nearly identical HSL-L but 0.27 vs 0.56 relative luminance, because the WCAG formula weights green (0.7152) far above red (0.2126) and blue (0.0722). models.js's IMG badge is deliberately left alone -- its background is a fixed purple fallback, not --red. --- static/js/admin.js | 2 +- static/js/compare/scoreboard.js | 2 +- static/js/document.js | 2 +- static/js/settings.js | 4 +- static/js/theme.js | 16 ++++++ static/style.css | 98 +++++++++++++++++---------------- 6 files changed, 73 insertions(+), 51 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 6162708fdc..ac0d05123e 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -2035,7 +2035,7 @@ async function loadMcpServers() { ${hasTools ? `Click to manage tools` : ''}
- ${s.needs_oauth ? `Authorize` : ''} + ${s.needs_oauth ? `Authorize` : ''} diff --git a/static/js/compare/scoreboard.js b/static/js/compare/scoreboard.js index eb051461e6..89349546ea 100644 --- a/static/js/compare/scoreboard.js +++ b/static/js/compare/scoreboard.js @@ -191,7 +191,7 @@ export function showScoreboard() { confirmLabel.textContent = 'Clear all vote history?'; const yesBtn = document.createElement('button'); yesBtn.textContent = 'Clear'; - yesBtn.style.cssText = 'padding:4px 12px;background:var(--red);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;'; + yesBtn.style.cssText = 'padding:4px 12px;background:var(--red);color:var(--on-accent);border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;'; yesBtn.addEventListener('click', () => { Storage.setJSON(VOTES_STORAGE_KEY, []); overlay.remove(); diff --git a/static/js/document.js b/static/js/document.js index b51aa56a02..53d8928614 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -3838,7 +3838,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
`; diff --git a/static/js/settings.js b/static/js/settings.js index b4bf69ab3d..0a609202cb 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2827,7 +2827,7 @@ async function initEmailAccountsSettings() {
- @@ -4439,7 +4439,7 @@ async function initUnifiedIntegrations() {
${esc(n.title)}
${esc(n.body)}
- + ${esc(n.linkLabel || 'Generate App Password')} diff --git a/static/js/theme.js b/static/js/theme.js index 09b13f2b76..75fba5355e 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -129,6 +129,18 @@ function _syncCustomThemesToServer(ct) { } // --- Syntax color derivation from theme base colors --- +// WCAG relative luminance (0..1). Used to choose readable text for content +// painted on a --red background: --red is theme-configurable, and a light +// accent needs dark text where a darker one reads fine with white. +// HSL lightness is not a substitute -- #e06c75 and #f2c14e sit at nearly +// identical HSL-L but 0.27 vs 0.56 luminance, since the WCAG formula +// weights green (0.7152) far above red (0.2126) and blue (0.0722). +function _relativeLuminance(hex) { + const { r, g, b } = hexToRgb(hex) || { r: 0, g: 0, b: 0 }; + const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + function hexToHSL(hex) { const rgb = hexToRgb(hex) || { r: 0, g: 0, b: 0 }; const r = rgb.r / 255; @@ -261,6 +273,10 @@ export function applyColors(colors) { s.setProperty('--panel', colors.panel); s.setProperty('--border', colors.border); if (colors.red) s.setProperty('--red', colors.red); + // Contrast of white against --red. Below ~3:1 (a light or strongly + // saturated accent) dark text is far more legible than white. + const _whiteOnRed = 1.05 / (_relativeLuminance(colors.red || '#e06c75') + 0.05); + s.setProperty('--on-accent', _whiteOnRed < 3 ? '#171717' : '#fff'); // Keep the mobile browser toolbar / status bar matched to the theme bg // (same as the early head-script does on first paint). diff --git a/static/style.css b/static/style.css index e38b82c0ac..908234d9c1 100644 --- a/static/style.css +++ b/static/style.css @@ -22,6 +22,12 @@ --panel: #111; --border: #355a66; --red: #e06c75; + /* Text colour for content sitting directly on a --red background + (buttons, pills, badges). Recomputed per theme in theme.js from + --red's actual WCAG relative luminance, because a light accent + needs dark text where a darker one is fine with white. This + default only covers first paint, before theme.js runs. */ + --on-accent: #fff; /* Were `var(--green)` / `var(--warn)` — self-referential, so they resolved to invalid and every site fell back to its own literal (or, for sites with no fallback, painted as transparent/inherit). @@ -1057,7 +1063,7 @@ body.bg-pattern-sparkles { width: 88px; height: 88px; border-radius: 50%; background: var(--accent, var(--red, #e53935)); - color: #fff; + color: var(--on-accent); display: flex; align-items: center; justify-content: center; pointer-events: none; opacity: 0; @@ -1134,7 +1140,7 @@ body.bg-pattern-sparkles { height: 16px; padding: 0 4px; background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); font-size: 9px; font-weight: 700; line-height: 16px; @@ -1151,7 +1157,7 @@ body.bg-pattern-sparkles { height: 16px; padding: 0 6px; background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); font-size: 9px; font-weight: 700; line-height: 16px; @@ -2824,7 +2830,7 @@ body.bg-pattern-sparkles { against ChatGPT-style themes where the stored override leaked to white and rendered the send button invisible. */ background: var(--send-btn-bg, var(--red)); - color: #fff; + color: var(--on-accent); border: none; border-radius: 8px; min-width: 32px; @@ -2900,7 +2906,7 @@ body.bg-pattern-sparkles { } .send-btn:hover { background: var(--send-btn-hover, color-mix(in srgb, var(--red) 80%, white)); - color: #fff; + color: var(--on-accent); } .send-btn.mic-mode, .send-btn.newchat-mode { @@ -2915,7 +2921,7 @@ body.bg-pattern-sparkles { .send-btn.mic-mode:hover, .send-btn.newchat-mode:hover { background: color-mix(in srgb, var(--send-btn-hover, var(--send-btn-bg, var(--red))) 85%, var(--panel, #2a2a2a)); - color: #fff; + color: var(--on-accent); } /* Hover: just spin the + 90° (no size change). The "New chat" affordance is the tooltip (title="New chat") plus the icon rotation. @@ -2954,7 +2960,7 @@ body.bg-pattern-sparkles { } .send-btn.recording { background: var(--red) !important; - color: #fff !important; + color: var(--on-accent) !important; border: none !important; animation: pulse-recording 1.5s infinite; } @@ -4310,7 +4316,7 @@ body.bg-pattern-sparkles { padding: 2px 8px; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; background: var(--accent-primary, var(--red)); - color: #fff; + color: var(--on-accent); border-radius: 0 0 4px 0; z-index: 2; pointer-events: none; @@ -5915,7 +5921,7 @@ body.bg-pattern-sparkles { } .confirm-btn-secondary { background:var(--bg); color:var(--fg); } .confirm-btn-secondary:hover { background:var(--border); } - .confirm-btn-primary { background:var(--accent-primary, var(--red, #4a9eff)); color:#fff; border-color:transparent; } + .confirm-btn-primary { background:var(--accent-primary, var(--red, #4a9eff)); color: var(--on-accent); border-color:transparent; } .confirm-btn-primary:hover { filter:brightness(1.15); } .confirm-btn-danger { background:var(--color-danger); color:#fff; border-color:transparent; } .confirm-btn-danger:hover { background:var(--color-error); } @@ -7132,7 +7138,7 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; } border: none; border-radius: 50%; background: var(--accent, var(--red, #d92534)); - color: #fff; + color: var(--on-accent); cursor: pointer; z-index: 2; display: flex; @@ -9054,7 +9060,7 @@ button.hamburger { border: 2px solid var(--bg); border-radius: 50%; background: var(--accent-primary, var(--red)); - color: #fff; + color: var(--on-accent); display: flex; align-items: center; justify-content: center; @@ -9160,7 +9166,7 @@ button.hamburger { } .attach-crop-primary { background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); border-color: var(--accent, var(--red)); } @media (max-width: 768px) { @@ -9177,7 +9183,7 @@ button.hamburger { border: 2px solid var(--bg); border-radius: 50%; background: var(--accent-primary, var(--red)); - color: #fff; + color: var(--on-accent); display: flex; align-items: center; justify-content: center; @@ -9871,7 +9877,7 @@ body.fullwidth-chat .chat-history { border-radius: 6px; border: none; background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); cursor: pointer; flex-shrink: 0; } @@ -14318,7 +14324,7 @@ mark.doc-find-mark.current { } .doc-suggestion-accept { background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); } .doc-suggestion-accept:hover { filter: brightness(1.15); @@ -15539,7 +15545,7 @@ span[id$="-logo"] svg:not([width]) { border: none; border-radius: 6px; background: var(--red); - color: #fff; + color: var(--on-accent); cursor: pointer; font-weight: 600; font-size: 11px; @@ -15770,7 +15776,7 @@ span[id$="-logo"] svg:not([width]) { border: none; border-radius: 6px; background: var(--red); - color: #fff; + color: var(--on-accent); cursor: pointer; font-size: 12px; white-space: nowrap; @@ -16133,7 +16139,7 @@ span[id$="-logo"] svg:not([width]) { border: none; border-radius: 999px; background: var(--accent-primary, var(--red)); - color: #fff; + color: var(--on-accent); font-family: inherit; font-size: 15px; font-weight: 600; @@ -16713,7 +16719,7 @@ body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane { left: 50%; transform: translate(-50%, -50%); background: var(--accent, #2563eb); - color: #fff; + color: var(--on-accent); padding: 8px 16px; border-radius: 6px; font-size: 12px; @@ -18718,7 +18724,7 @@ body.gallery-selecting .gallery-dl-btn, .vision-editor-btn:hover { border-color: color-mix(in srgb, var(--fg) 40%, var(--border)); } .vision-editor-btn-primary { background: var(--accent-primary, var(--red)); - color: #fff; border-color: transparent; + color: var(--on-accent); border-color: transparent; } .vision-editor-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } @@ -18829,7 +18835,7 @@ body.gallery-selecting .gallery-dl-btn, border: 1px solid var(--red); border-radius: 4px; cursor: pointer; font-size: 11px; font-family: inherit; } -.gallery-bulk-delete:hover { background: var(--red); color: #fff; } +.gallery-bulk-delete:hover { background: var(--red); color: var(--on-accent); } .gallery-bulk-cancel { padding: 3px 10px; background: transparent; color: var(--fg); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; @@ -19902,7 +19908,7 @@ body.gallery-selecting .gallery-dl-btn, .serve-select-cb:hover { background: color-mix(in srgb, var(--red) 50%, transparent); } #hwfit-cache-select.active { background: var(--red); - color: #fff; + color: var(--on-accent); border-color: var(--red); } .cookbook-serve-dirs { @@ -20477,7 +20483,7 @@ body.gallery-selecting .gallery-dl-btn, } .cookbook-slot-saved { background: color-mix(in srgb, var(--accent) 10%, transparent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); color: var(--accent); } .cookbook-slot-saved:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); } -.cookbook-slot-btn.active { opacity: 1; background: var(--accent); color: #fff; border-color: var(--accent); +.cookbook-slot-btn.active { opacity: 1; background: var(--accent); color: var(--on-accent); border-color: var(--accent); } .cookbook-slot-wrap { position: relative; @@ -20649,7 +20655,7 @@ body.gallery-selecting .gallery-dl-btn, } .cookbook-dep-install { background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); border: none; cursor: pointer; font-family: inherit; @@ -21288,7 +21294,7 @@ body.gallery-selecting .gallery-dl-btn, } .hwfit-serve-launch { background: var(--accent-primary, var(--red)); - color: #fff; + color: var(--on-accent); border: 1px solid var(--accent-primary, var(--red)); border-radius: 4px; font-weight: 700; @@ -22485,7 +22491,7 @@ body.gallery-selecting .gallery-dl-btn, } .cookbook-dl-btn { background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); border: none; border-radius: 4px; padding: 0 14px; @@ -22539,7 +22545,7 @@ body.gallery-selecting .gallery-dl-btn, transition: all 0.15s; } .hwfit-panel-hf-link:hover { - color: #fff; + color: var(--on-accent); background: var(--red); border-color: var(--red); } @@ -22870,7 +22876,7 @@ body.gallery-selecting .gallery-dl-btn, .cookbook-server-rm:hover { opacity: 1; background: var(--red); - color: #fff; + color: var(--on-accent); border-color: var(--red); } /* Labelled "Delete server" variant (lives in the Model Directory row) — override @@ -22990,7 +22996,7 @@ body.gallery-selecting .gallery-dl-btn, } .hwfit-gpu-btn.active { background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); border-color: var(--accent, var(--red)); } /* Pool selector for heterogeneous GPU boxes — sits left of the RAM/GPU buttons */ @@ -24766,7 +24772,7 @@ a.chat-link[href^="#research-"] { .task-btn-primary { background: var(--red); - color: #fff; + color: var(--on-accent); border-color: transparent; opacity: 1; } @@ -26773,7 +26779,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type { align-items: center; justify-content: center; border-radius: 50%; - color: #fff; + color: var(--on-accent); background: var(--red); cursor: pointer; opacity: 0.95; @@ -26922,7 +26928,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type { position: relative; top: -2px; background: var(--send-btn-bg, var(--red)); - color: #fff; + color: var(--on-accent); border: none; border-radius: 8px; min-width: 32px; @@ -29097,7 +29103,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type { .ge-btn-primary { background: var(--red); - color: #fff; + color: var(--on-accent); border-color: var(--red); font-weight: 600; } @@ -29170,7 +29176,7 @@ button .spinner-whirlpool { z-index: 10000; padding: 5px 10px; background: var(--red); - color: #fff; + color: var(--on-accent); font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; @@ -29543,7 +29549,7 @@ button .spinner-whirlpool { .ge-crop-apply-btn { padding: 4px 10px; background: var(--red); - color: #fff; + color: var(--on-accent); border: none; border-radius: 4px; font-size: 11px; @@ -30262,7 +30268,7 @@ button .spinner-whirlpool { } #group-model-picker .btn-primary { background: var(--accent, var(--red)); - color: #fff; + color: var(--on-accent); border: none; border-radius: 6px; cursor: pointer; @@ -30370,7 +30376,7 @@ button .spinner-whirlpool { margin-right: 4px; } .email-done-check:hover { border-color: var(--accent, #4a9eff); } -.email-done-check.active { background: var(--accent, #4a9eff); border-color: var(--accent, #4a9eff); color: #fff; } +.email-done-check.active { background: var(--accent, #4a9eff); border-color: var(--accent, #4a9eff); color: var(--on-accent); } .email-done-check.active svg { display: block; } .email-done-check svg { display: none; } @@ -31984,7 +31990,7 @@ button .spinner-whirlpool { } .schedule-send-confirm { background: var(--accent-primary, var(--red)) !important; - color: #fff !important; + color: var(--on-accent) !important; border-color: var(--accent-primary, var(--red)) !important; } .schedule-send-confirm:hover { @@ -32141,7 +32147,7 @@ body.doc-find-active mark.doc-find-mark.current { .email-avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--accent, #4a9eff); - color: #fff; display: flex; align-items: center; justify-content: center; + color: var(--on-accent); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0; margin-top: 1px; } .email-item-content { flex: 1; min-width: 0; overflow: hidden; } @@ -33218,7 +33224,7 @@ body.doc-find-active mark.doc-find-mark.current { border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border)); background: var(--accent-primary, var(--red)); - color: #fff; + color: var(--on-accent); font: inherit; font-size: 11px; cursor: pointer; @@ -34993,7 +34999,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active { .note-card-selected .note-card-select { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); opacity: 1; } @@ -35460,7 +35466,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active { position: relative; top: -2px; } -.note-reminder-tag-x:hover { background: var(--red); color: #fff; } +.note-reminder-tag-x:hover { background: var(--red); color: var(--on-accent); } /* Reminder tag on card */ .note-card-reminder { @@ -35626,7 +35632,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active { .note-reminder-day-chip.active { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } .note-form-type-btn, @@ -37401,7 +37407,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active { button.cal-nav { background:color-mix(in srgb, var(--fg) 6%, transparent); border:1px solid var(--border); color:var(--fg); border-radius:5px; padding:0 8px; height:24px; line-height:22px; cursor:pointer; font-size:11px; font-family:inherit; box-sizing:border-box; vertical-align:middle; } button.cal-nav:hover { background:color-mix(in srgb, var(--fg) 12%, transparent); } button.cal-today-btn { font-size:10px; opacity:0.5; } -button.cal-add-btn { background:var(--accent); color:#fff; border:none; border-radius:50%; width:24px; height:24px; line-height:22px; font-size:18px; cursor:pointer; flex-shrink:0; padding:0; box-sizing:border-box; font-family:inherit; vertical-align:middle; text-align:center; } +button.cal-add-btn { background:var(--accent); color: var(--on-accent); border:none; border-radius:50%; width:24px; height:24px; line-height:22px; font-size:18px; cursor:pointer; flex-shrink:0; padding:0; box-sizing:border-box; font-family:inherit; vertical-align:middle; text-align:center; } button.cal-add-btn.cal-add-btn-text { width:auto; min-width:0; background: color-mix(in srgb, var(--fg) 8%, transparent); @@ -38614,7 +38620,7 @@ button.cal-view-btn { .cal-year-wd { font-size:7px; text-align:center; opacity:0.3; padding:1px 0; } .cal-year-cell { font-size:8px; text-align:center; padding:2px 0; min-height:14px; border-radius:2px; transition:transform 0.12s, background 0.12s; } .cal-year-day { cursor:pointer; opacity:0.5; } -.cal-year-day:hover { background:var(--accent, var(--red)); color:#fff; opacity:1; transform:scale(1.15); font-weight:700; z-index:2; position:relative; } +.cal-year-day:hover { background:var(--accent, var(--red)); color: var(--on-accent); opacity:1; transform:scale(1.15); font-weight:700; z-index:2; position:relative; } .cal-year-today { color:var(--accent, var(--red)); font-weight:700; opacity:1; } .cal-year-has { opacity:1; background:color-mix(in srgb, var(--accent) 15%, transparent); } .cal-year-has.cal-year-today { background:color-mix(in srgb, var(--accent, var(--red)) 25%, transparent); } @@ -39326,7 +39332,7 @@ body.research-panel-view #research-divider { display:none; } margin-left:auto; display:flex; align-items:center; gap:5px; padding:6px 16px; border:none; border-radius:6px; - background:var(--accent-primary, var(--red)); color:#fff; + background:var(--accent-primary, var(--red)); color: var(--on-accent); font-size:12px; font-weight:600; cursor:pointer; transition:opacity 0.15s; } From 00147e9fb797191654aa477ffbd1fada07da0eb6 Mon Sep 17 00:00:00 2001 From: Yahya Zekry Date: Wed, 19 Aug 2026 13:38:42 +0300 Subject: [PATCH 2/5] fix(theme): pair each accent surface with its own foreground Review feedback: --on-accent was derived from --red alone, but the send button's background is independently configurable via --send-btn-bg / --send-btn-hover, and those overrides are applied after the token is computed. A dark --red with a light custom send button therefore kept white text on a light surface -- e.g. --red #e06c75 with --send-btn-bg #f2c14e left #fff at 1.68:1. Each independently configurable accent surface now gets a foreground derived from the background it actually renders, resolved after the advanced overrides are applied: --on-accent <- --red (unchanged consumers) --on-send-btn <- resolved --send-btn-bg --on-send-btn-hover <- resolved --send-btn-hover --on-send-btn-newchat-hover <- that hover colour mixed 85% into --panel The new-chat state is a third surface because its background is itself a color-mix(); _mixSrgb() reproduces the CSS mix so it can be measured rather than approximated. Repointed .send-btn, .send-btn:hover, .send-btn.newchat-mode:hover and .ge-ai-command-run at the matching token; no rule now pairs --on-accent with a send-button background. Adds tests/test_theme_accent_foreground_js.py, which runs the derivation helpers under Node with a dark accent plus a light send-button override and asserts the two foregrounds differ, that reusing the accent's foreground would drop below 3:1, and that every surface clears 3:1 against its own background. A second test scans style.css so the mismatch cannot reappear. --- static/js/theme.js | 44 +++++++- static/style.css | 13 ++- tests/test_theme_accent_foreground_js.py | 123 +++++++++++++++++++++++ 3 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 tests/test_theme_accent_foreground_js.py diff --git a/static/js/theme.js b/static/js/theme.js index 75fba5355e..8491a8375b 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -141,6 +141,26 @@ function _relativeLuminance(hex) { return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); } +// Pick a readable foreground for an arbitrary background: white unless it +// would fall below ~3:1, in which case near-black. Shared by every +// accent-painted surface so each one is judged against the background it +// actually renders, not a global accent. +function _readableOn(bgHex) { + const contrastWithWhite = 1.05 / (_relativeLuminance(bgHex) + 0.05); + return contrastWithWhite < 3 ? '#171717' : '#fff'; +} + +// sRGB mix matching CSS color-mix(in srgb, a pct%, b). Needed where a +// surface's background is itself a mix, e.g. the new-chat send button. +function _mixSrgb(aHex, bHex, aPct) { + const a = hexToRgb(aHex) || { r: 0, g: 0, b: 0 }; + const b = hexToRgb(bHex) || { r: 0, g: 0, b: 0 }; + const w = Math.max(0, Math.min(1, aPct)); + const ch = (x, y) => Math.round(x * w + y * (1 - w)); + const hex = (n) => n.toString(16).padStart(2, '0'); + return `#${hex(ch(a.r, b.r))}${hex(ch(a.g, b.g))}${hex(ch(a.b, b.b))}`; +} + function hexToHSL(hex) { const rgb = hexToRgb(hex) || { r: 0, g: 0, b: 0 }; const r = rgb.r / 255; @@ -273,10 +293,10 @@ export function applyColors(colors) { s.setProperty('--panel', colors.panel); s.setProperty('--border', colors.border); if (colors.red) s.setProperty('--red', colors.red); - // Contrast of white against --red. Below ~3:1 (a light or strongly - // saturated accent) dark text is far more legible than white. - const _whiteOnRed = 1.05 / (_relativeLuminance(colors.red || '#e06c75') + 0.05); - s.setProperty('--on-accent', _whiteOnRed < 3 ? '#171717' : '#fff'); + // Foreground for surfaces painted with --red itself. Surfaces with an + // independently configurable background get their own token below, + // once the advanced overrides have been resolved. + s.setProperty('--on-accent', _readableOn(colors.red || '#e06c75')); // Keep the mobile browser toolbar / status bar matched to the theme bg // (same as the early head-script does on first paint). @@ -303,6 +323,22 @@ export function applyColors(colors) { s.setProperty(css, adv[key] || defaults[key]); } + // The send button's background is independently configurable + // (--send-btn-bg / --send-btn-hover), so a foreground derived from + // --red can be wrong for it: a dark accent with a light custom send + // button would keep white text on a light surface. Pair each of its + // rendered backgrounds with its own foreground instead. + const _sendBg = adv.sendBtnBg || defaults.sendBtnBg; + const _sendHoverBg = adv.sendBtnHover || defaults.sendBtnHover; + s.setProperty('--on-send-btn', _readableOn(_sendBg)); + s.setProperty('--on-send-btn-hover', _readableOn(_sendHoverBg)); + // The new-chat state blends the hover colour 85% into --panel, so it + // is a third distinct surface. + s.setProperty( + '--on-send-btn-newchat-hover', + _readableOn(_mixSrgb(_sendHoverBg, colors.panel || '#111111', 0.85)), + ); + // Update favicon to match theme accent color _updateFavicon(colors.red || '#e06c75'); } diff --git a/static/style.css b/static/style.css index 908234d9c1..d36699594b 100644 --- a/static/style.css +++ b/static/style.css @@ -28,6 +28,11 @@ needs dark text where a darker one is fine with white. This default only covers first paint, before theme.js runs. */ --on-accent: #fff; + /* Surfaces whose background is independently configurable get their own + foreground, recomputed in theme.js once those overrides resolve. */ + --on-send-btn: #fff; + --on-send-btn-hover: #fff; + --on-send-btn-newchat-hover: #fff; /* Were `var(--green)` / `var(--warn)` — self-referential, so they resolved to invalid and every site fell back to its own literal (or, for sites with no fallback, painted as transparent/inherit). @@ -2830,7 +2835,7 @@ body.bg-pattern-sparkles { against ChatGPT-style themes where the stored override leaked to white and rendered the send button invisible. */ background: var(--send-btn-bg, var(--red)); - color: var(--on-accent); + color: var(--on-send-btn); border: none; border-radius: 8px; min-width: 32px; @@ -2906,7 +2911,7 @@ body.bg-pattern-sparkles { } .send-btn:hover { background: var(--send-btn-hover, color-mix(in srgb, var(--red) 80%, white)); - color: var(--on-accent); + color: var(--on-send-btn-hover); } .send-btn.mic-mode, .send-btn.newchat-mode { @@ -2921,7 +2926,7 @@ body.bg-pattern-sparkles { .send-btn.mic-mode:hover, .send-btn.newchat-mode:hover { background: color-mix(in srgb, var(--send-btn-hover, var(--send-btn-bg, var(--red))) 85%, var(--panel, #2a2a2a)); - color: var(--on-accent); + color: var(--on-send-btn-newchat-hover); } /* Hover: just spin the + 90° (no size change). The "New chat" affordance is the tooltip (title="New chat") plus the icon rotation. @@ -26928,7 +26933,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type { position: relative; top: -2px; background: var(--send-btn-bg, var(--red)); - color: var(--on-accent); + color: var(--on-send-btn); border: none; border-radius: 8px; min-width: 32px; diff --git a/tests/test_theme_accent_foreground_js.py b/tests/test_theme_accent_foreground_js.py new file mode 100644 index 0000000000..9dc4889b9f --- /dev/null +++ b/tests/test_theme_accent_foreground_js.py @@ -0,0 +1,123 @@ +"""Accent-painted surfaces must derive their foreground from their own background. + +Regression cover for a theme whose accent (`--red`) is dark while the send +button is independently overridden to a light colour: a single global +foreground derived from `--red` leaves white text on a light button. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parents[1] +_THEME_JS = _REPO / "static" / "js" / "theme.js" +_STYLE_CSS = _REPO / "static" / "style.css" + + +def _extract_fn(source: str, name: str) -> str: + """Pull one top-level `function name(...) { ... }` out of the module.""" + start = source.index(f"function {name}(") + depth = 0 + i = source.index("{", start) + while i < len(source): + if source[i] == "{": + depth += 1 + elif source[i] == "}": + depth -= 1 + if depth == 0: + return source[start : i + 1] + i += 1 + raise AssertionError(f"unbalanced braces extracting {name}") + + +def _run_node(script: str) -> dict: + proc = subprocess.run( + ["node", "--input-type=module"], + input=script, + capture_output=True, + text=True, + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_send_button_foreground_follows_its_own_background(): + if not shutil.which("node"): + pytest.skip("node is not installed") + + src = _THEME_JS.read_text(encoding="utf-8") + helpers = "\n".join( + _extract_fn(src, name) + for name in ("hexToRgb", "_relativeLuminance", "_readableOn", "_mixSrgb") + ) if "function hexToRgb(" in src else "\n".join( + [ + # hexToRgb is imported from ./color/hex.js in the module; inline an + # equivalent so the helpers can be exercised standalone. + "function hexToRgb(h){h=String(h).replace('#','');" + "if(h.length===3)h=h.split('').map(x=>x+x).join('');" + "return {r:parseInt(h.slice(0,2),16),g:parseInt(h.slice(2,4),16),b:parseInt(h.slice(4,6),16)};}", + _extract_fn(src, "_relativeLuminance"), + _extract_fn(src, "_readableOn"), + _extract_fn(src, "_mixSrgb"), + ] + ) + + script = f""" + {helpers} + const contrast = (a, b) => {{ + const l = [_relativeLuminance(a), _relativeLuminance(b)].sort((x, y) => y - x); + return (l[0] + 0.05) / (l[1] + 0.05); + }}; + // Dark accent, deliberately light custom send button. + const red = '#e06c75'; + const sendBg = '#f2c14e'; + const sendHover = '#f7d488'; + const panel = '#111111'; + + const onAccent = _readableOn(red); + const onSend = _readableOn(sendBg); + const onSendHover = _readableOn(sendHover); + const newchatBg = _mixSrgb(sendHover, panel, 0.85); + const onNewchat = _readableOn(newchatBg); + + console.log(JSON.stringify({{ + onAccent, onSend, onSendHover, onNewchat, newchatBg, + accentContrast: contrast(red, onAccent), + sendContrast: contrast(sendBg, onSend), + sendHoverContrast: contrast(sendHover, onSendHover), + newchatContrast: contrast(newchatBg, onNewchat), + naiveSendContrast: contrast(sendBg, onAccent), + }})); + """ + out = _run_node(script) + + # The accent and the send button disagree: that disagreement is the bug. + assert out["onAccent"] == "#fff" + assert out["onSend"] == "#171717" + assert out["onSendHover"] == "#171717" + + # Reusing the accent's foreground on the send button is what used to happen. + assert out["naiveSendContrast"] < 3, out + + # Every surface clears the 3:1 floor against the background it renders. + for key in ("accentContrast", "sendContrast", "sendHoverContrast", "newchatContrast"): + assert out[key] >= 3, (key, out[key]) + + +def test_no_rule_pairs_on_accent_with_a_send_button_background(): + """A send-button background must never be paired with --on-accent.""" + css = _STYLE_CSS.read_text(encoding="utf-8") + offenders = [] + for match in re.finditer(r"([^\n{}]+)\{([^{}]*)\}", css): + body = match.group(2) + if "var(--on-accent)" in body and ( + "--send-btn-bg" in body or "--send-btn-hover" in body + ): + offenders.append(match.group(1).strip()) + assert not offenders, offenders From 5200a923a2423c9ae0f6afa1b5b8df86f68f5cb2 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:43:44 +0000 Subject: [PATCH 3/5] fix(theme): match command button hover foreground --- static/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/static/style.css b/static/style.css index d36699594b..4a3a41e36b 100644 --- a/static/style.css +++ b/static/style.css @@ -26950,6 +26950,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type { } .ge-ai-command-run:hover { background: var(--send-btn-hover, color-mix(in srgb, var(--red) 80%, white)); + color: var(--on-send-btn-hover); } .ge-ai-command-close { width: 28px; From ca85485e6299c58418a4b28dd9ea448b4b02fa2d Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:56:19 +0000 Subject: [PATCH 4/5] fix(theme): pair primary accent foreground --- static/js/document.js | 2 +- static/js/theme.js | 6 ++++++ static/style.css | 21 +++++++++++---------- tests/test_theme_accent_foreground_js.py | 18 ++++++++++++------ 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/static/js/document.js b/static/js/document.js index 53d8928614..2d4ddd65f7 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -3838,7 +3838,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
`; diff --git a/static/js/theme.js b/static/js/theme.js index 8491a8375b..fa9d3fc9f2 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -323,6 +323,12 @@ export function applyColors(colors) { s.setProperty(css, adv[key] || defaults[key]); } + // Primary-accent surfaces can be independently overridden from --red, so + // derive their foreground from the background they actually use. + const _accentPrimary = adv.accentPrimary || colors.red || '#e06c75'; + s.setProperty('--accent-primary', _accentPrimary); + s.setProperty('--on-accent-primary', _readableOn(_accentPrimary)); + // The send button's background is independently configurable // (--send-btn-bg / --send-btn-hover), so a foreground derived from // --red can be wrong for it: a dark accent with a light custom send diff --git a/static/style.css b/static/style.css index 4a3a41e36b..586839af25 100644 --- a/static/style.css +++ b/static/style.css @@ -33,6 +33,7 @@ --on-send-btn: #fff; --on-send-btn-hover: #fff; --on-send-btn-newchat-hover: #fff; + --on-accent-primary: #fff; /* Were `var(--green)` / `var(--warn)` — self-referential, so they resolved to invalid and every site fell back to its own literal (or, for sites with no fallback, painted as transparent/inherit). @@ -4321,7 +4322,7 @@ body.bg-pattern-sparkles { padding: 2px 8px; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; background: var(--accent-primary, var(--red)); - color: var(--on-accent); + color: var(--on-accent-primary); border-radius: 0 0 4px 0; z-index: 2; pointer-events: none; @@ -5926,7 +5927,7 @@ body.bg-pattern-sparkles { } .confirm-btn-secondary { background:var(--bg); color:var(--fg); } .confirm-btn-secondary:hover { background:var(--border); } - .confirm-btn-primary { background:var(--accent-primary, var(--red, #4a9eff)); color: var(--on-accent); border-color:transparent; } + .confirm-btn-primary { background:var(--accent-primary, var(--red, #4a9eff)); color: var(--on-accent-primary); border-color:transparent; } .confirm-btn-primary:hover { filter:brightness(1.15); } .confirm-btn-danger { background:var(--color-danger); color:#fff; border-color:transparent; } .confirm-btn-danger:hover { background:var(--color-error); } @@ -9065,7 +9066,7 @@ button.hamburger { border: 2px solid var(--bg); border-radius: 50%; background: var(--accent-primary, var(--red)); - color: var(--on-accent); + color: var(--on-accent-primary); display: flex; align-items: center; justify-content: center; @@ -9188,7 +9189,7 @@ button.hamburger { border: 2px solid var(--bg); border-radius: 50%; background: var(--accent-primary, var(--red)); - color: var(--on-accent); + color: var(--on-accent-primary); display: flex; align-items: center; justify-content: center; @@ -16144,7 +16145,7 @@ span[id$="-logo"] svg:not([width]) { border: none; border-radius: 999px; background: var(--accent-primary, var(--red)); - color: var(--on-accent); + color: var(--on-accent-primary); font-family: inherit; font-size: 15px; font-weight: 600; @@ -18729,7 +18730,7 @@ body.gallery-selecting .gallery-dl-btn, .vision-editor-btn:hover { border-color: color-mix(in srgb, var(--fg) 40%, var(--border)); } .vision-editor-btn-primary { background: var(--accent-primary, var(--red)); - color: var(--on-accent); border-color: transparent; + color: var(--on-accent-primary); border-color: transparent; } .vision-editor-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } @@ -21299,7 +21300,7 @@ body.gallery-selecting .gallery-dl-btn, } .hwfit-serve-launch { background: var(--accent-primary, var(--red)); - color: var(--on-accent); + color: var(--on-accent-primary); border: 1px solid var(--accent-primary, var(--red)); border-radius: 4px; font-weight: 700; @@ -31996,7 +31997,7 @@ button .spinner-whirlpool { } .schedule-send-confirm { background: var(--accent-primary, var(--red)) !important; - color: var(--on-accent) !important; + color: var(--on-accent-primary) !important; border-color: var(--accent-primary, var(--red)) !important; } .schedule-send-confirm:hover { @@ -33230,7 +33231,7 @@ body.doc-find-active mark.doc-find-mark.current { border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border)); background: var(--accent-primary, var(--red)); - color: var(--on-accent); + color: var(--on-accent-primary); font: inherit; font-size: 11px; cursor: pointer; @@ -39338,7 +39339,7 @@ body.research-panel-view #research-divider { display:none; } margin-left:auto; display:flex; align-items:center; gap:5px; padding:6px 16px; border:none; border-radius:6px; - background:var(--accent-primary, var(--red)); color: var(--on-accent); + background:var(--accent-primary, var(--red)); color: var(--on-accent-primary); font-size:12px; font-weight:600; cursor:pointer; transition:opacity 0.15s; } diff --git a/tests/test_theme_accent_foreground_js.py b/tests/test_theme_accent_foreground_js.py index 9dc4889b9f..356d925128 100644 --- a/tests/test_theme_accent_foreground_js.py +++ b/tests/test_theme_accent_foreground_js.py @@ -47,7 +47,7 @@ def _run_node(script: str) -> dict: return json.loads(proc.stdout) -def test_send_button_foreground_follows_its_own_background(): +def test_accent_foregrounds_follow_their_own_backgrounds(): if not shutil.which("node"): pytest.skip("node is not installed") @@ -76,19 +76,22 @@ def test_send_button_foreground_follows_its_own_background(): }}; // Dark accent, deliberately light custom send button. const red = '#e06c75'; + const accentPrimary = '#f2c14e'; const sendBg = '#f2c14e'; const sendHover = '#f7d488'; const panel = '#111111'; const onAccent = _readableOn(red); + const onAccentPrimary = _readableOn(accentPrimary); const onSend = _readableOn(sendBg); const onSendHover = _readableOn(sendHover); const newchatBg = _mixSrgb(sendHover, panel, 0.85); const onNewchat = _readableOn(newchatBg); console.log(JSON.stringify({{ - onAccent, onSend, onSendHover, onNewchat, newchatBg, + onAccent, onAccentPrimary, onSend, onSendHover, onNewchat, newchatBg, accentContrast: contrast(red, onAccent), + accentPrimaryContrast: contrast(accentPrimary, onAccentPrimary), sendContrast: contrast(sendBg, onSend), sendHoverContrast: contrast(sendHover, onSendHover), newchatContrast: contrast(newchatBg, onNewchat), @@ -99,6 +102,7 @@ def test_send_button_foreground_follows_its_own_background(): # The accent and the send button disagree: that disagreement is the bug. assert out["onAccent"] == "#fff" + assert out["onAccentPrimary"] == "#171717" assert out["onSend"] == "#171717" assert out["onSendHover"] == "#171717" @@ -106,18 +110,20 @@ def test_send_button_foreground_follows_its_own_background(): assert out["naiveSendContrast"] < 3, out # Every surface clears the 3:1 floor against the background it renders. - for key in ("accentContrast", "sendContrast", "sendHoverContrast", "newchatContrast"): + for key in ("accentContrast", "accentPrimaryContrast", "sendContrast", "sendHoverContrast", "newchatContrast"): assert out[key] >= 3, (key, out[key]) -def test_no_rule_pairs_on_accent_with_a_send_button_background(): - """A send-button background must never be paired with --on-accent.""" +def test_no_rule_pairs_on_accent_with_an_independent_background(): + """An independently configurable background must not use --on-accent.""" css = _STYLE_CSS.read_text(encoding="utf-8") offenders = [] for match in re.finditer(r"([^\n{}]+)\{([^{}]*)\}", css): body = match.group(2) if "var(--on-accent)" in body and ( - "--send-btn-bg" in body or "--send-btn-hover" in body + "--send-btn-bg" in body + or "--send-btn-hover" in body + or "--accent-primary" in body ): offenders.append(match.group(1).strip()) assert not offenders, offenders From 028569fef373b61a275ab179b74b3ff89010f686 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:34:16 +0000 Subject: [PATCH 5/5] fix(theme): pair primary hover foreground --- static/js/theme.js | 3 +++ static/style.css | 12 ++++++---- tests/test_theme_accent_foreground_js.py | 28 ++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/static/js/theme.js b/static/js/theme.js index fa9d3fc9f2..d97358a814 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -328,6 +328,9 @@ export function applyColors(colors) { const _accentPrimary = adv.accentPrimary || colors.red || '#e06c75'; s.setProperty('--accent-primary', _accentPrimary); s.setProperty('--on-accent-primary', _readableOn(_accentPrimary)); + const _accentPrimaryHover = _mixSrgb(_accentPrimary, '#ffffff', 0.85); + s.setProperty('--accent-primary-hover', _accentPrimaryHover); + s.setProperty('--on-accent-primary-hover', _readableOn(_accentPrimaryHover)); // The send button's background is independently configurable // (--send-btn-bg / --send-btn-hover), so a foreground derived from diff --git a/static/style.css b/static/style.css index 586839af25..0f49b571c3 100644 --- a/static/style.css +++ b/static/style.css @@ -34,6 +34,8 @@ --on-send-btn-hover: #fff; --on-send-btn-newchat-hover: #fff; --on-accent-primary: #fff; + --accent-primary-hover: #e5828a; + --on-accent-primary-hover: #171717; /* Were `var(--green)` / `var(--warn)` — self-referential, so they resolved to invalid and every site fell back to its own literal (or, for sites with no fallback, painted as transparent/inherit). @@ -5928,7 +5930,7 @@ body.bg-pattern-sparkles { .confirm-btn-secondary { background:var(--bg); color:var(--fg); } .confirm-btn-secondary:hover { background:var(--border); } .confirm-btn-primary { background:var(--accent-primary, var(--red, #4a9eff)); color: var(--on-accent-primary); border-color:transparent; } - .confirm-btn-primary:hover { filter:brightness(1.15); } + .confirm-btn-primary:hover { background:var(--accent-primary-hover); color:var(--on-accent-primary-hover); } .confirm-btn-danger { background:var(--color-danger); color:#fff; border-color:transparent; } .confirm-btn-danger:hover { background:var(--color-error); } #cookbook-gguf-delete-overlay { @@ -9073,11 +9075,12 @@ button.hamburger { font-size: 15px; line-height: 1; z-index: 3; - transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease; + transition: transform 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; } .thumb.thumb-image button:hover { transform: scale(1.12); - filter: brightness(1.12); + background: var(--accent-primary-hover); + color: var(--on-accent-primary-hover); box-shadow: 0 2px 8px rgba(0,0,0,0.25); } .thumb.thumb-image button:active { @@ -32001,7 +32004,8 @@ button .spinner-whirlpool { border-color: var(--accent-primary, var(--red)) !important; } .schedule-send-confirm:hover { - background: color-mix(in srgb, var(--accent-primary, var(--red)) 85%, white) !important; + background: var(--accent-primary-hover) !important; + color: var(--on-accent-primary-hover) !important; } /* Senders embed inline colors in their HTML that clash with the app's diff --git a/tests/test_theme_accent_foreground_js.py b/tests/test_theme_accent_foreground_js.py index 356d925128..2cdbb04c3c 100644 --- a/tests/test_theme_accent_foreground_js.py +++ b/tests/test_theme_accent_foreground_js.py @@ -83,15 +83,19 @@ def test_accent_foregrounds_follow_their_own_backgrounds(): const onAccent = _readableOn(red); const onAccentPrimary = _readableOn(accentPrimary); + const accentPrimaryHover = _mixSrgb(accentPrimary, '#ffffff', 0.85); + const onAccentPrimaryHover = _readableOn(accentPrimaryHover); const onSend = _readableOn(sendBg); const onSendHover = _readableOn(sendHover); const newchatBg = _mixSrgb(sendHover, panel, 0.85); const onNewchat = _readableOn(newchatBg); console.log(JSON.stringify({{ - onAccent, onAccentPrimary, onSend, onSendHover, onNewchat, newchatBg, + onAccent, onAccentPrimary, accentPrimaryHover, onAccentPrimaryHover, + onSend, onSendHover, onNewchat, newchatBg, accentContrast: contrast(red, onAccent), accentPrimaryContrast: contrast(accentPrimary, onAccentPrimary), + accentPrimaryHoverContrast: contrast(accentPrimaryHover, onAccentPrimaryHover), sendContrast: contrast(sendBg, onSend), sendHoverContrast: contrast(sendHover, onSendHover), newchatContrast: contrast(newchatBg, onNewchat), @@ -103,6 +107,7 @@ def test_accent_foregrounds_follow_their_own_backgrounds(): # The accent and the send button disagree: that disagreement is the bug. assert out["onAccent"] == "#fff" assert out["onAccentPrimary"] == "#171717" + assert out["onAccentPrimaryHover"] == "#171717" assert out["onSend"] == "#171717" assert out["onSendHover"] == "#171717" @@ -110,7 +115,14 @@ def test_accent_foregrounds_follow_their_own_backgrounds(): assert out["naiveSendContrast"] < 3, out # Every surface clears the 3:1 floor against the background it renders. - for key in ("accentContrast", "accentPrimaryContrast", "sendContrast", "sendHoverContrast", "newchatContrast"): + for key in ( + "accentContrast", + "accentPrimaryContrast", + "accentPrimaryHoverContrast", + "sendContrast", + "sendHoverContrast", + "newchatContrast", + ): assert out[key] >= 3, (key, out[key]) @@ -127,3 +139,15 @@ def test_no_rule_pairs_on_accent_with_an_independent_background(): ): offenders.append(match.group(1).strip()) assert not offenders, offenders + + +def test_primary_hover_states_use_the_derived_hover_pair(): + """Hover fills must not rely on a filter while retaining base text.""" + css = _STYLE_CSS.read_text(encoding="utf-8") + assert ".confirm-btn-primary:hover { filter:brightness(1.15); }" not in css + thumb_hover = re.search(r"\.thumb\.thumb-image button:hover\s*\{([^}]*)\}", css) + assert thumb_hover, "thumbnail hover rule disappeared" + assert "filter" not in thumb_hover.group(1) + assert "background:var(--accent-primary-hover); color:var(--on-accent-primary-hover)" in css + assert "background: var(--accent-primary-hover);" in css + assert "color: var(--on-accent-primary-hover);" in css