Compare commits

..
3 Commits
Author SHA1 Message Date
logan 7410109884 docs: record the semantic palette landing, and one claim that was false
Search index maintenance / maintain-index (push) Successful in 6s
Build & publish Arch package / arch-package (push) Successful in 2m9s
CI / check (push) Successful in 2m31s
CI / e2e (push) Successful in 5m22s
The two findings recorded as too big for the contrast pass are fixed, so
the plan says so. Also corrects a claim I made and did not check: the
chrome does not stay dark under the light ramp -- that screenshot was
taken before the theme propagated, which is the third time in two passes
a picture read at the wrong moment produced a confident wrong claim.
2026-08-13 01:08:17 -04:00
logan 0b7ffd5679 build: check that css template literals were not ended by a comment
A backtick inside a comment in a css`` literal ends the literal. It has
cost four sessions across three plans, it is written down in CLAUDE.md,
the skill and NOTES.md, and it was read twice in the session it then
cost a cycle in. Knowledge that has been ignored three times is not a
knowledge problem.

The expense is the report, not the mistake: the literal ends early, the
rest of the CSS parses as JavaScript, and tsc says 'Class static side
incorrectly extends base class static side' pointing at a line of prose
-- or, in a shared module, every test in the suite fails to import and
the output reads like a broken test runner. make dev-headless mean-
while keeps serving the last good bundle.

Detection is exact rather than heuristic: if a backtick in a comment
closed the literal early, the text the parser took as the literal
contains an unterminated /*. Nothing else produces that. Verified both
ways -- clean on the tree, and red on a deliberately broken comment.
2026-08-13 01:07:08 -04:00
logan 49b1194333 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.
2026-08-13 01:05:37 -04:00
38 changed files with 578 additions and 117 deletions
+10
View File
@@ -155,6 +155,16 @@ jobs:
working-directory: /src/frontend
run: npx tsc --noEmit
# A backtick inside a comment in a css`` literal ends the literal.
# tsc above does fail on it, with a message about CSSResult
# pointing at a line of prose; this one names the cause. It runs
# after tsc for exactly that reason — whichever fails, the log has
# the sentence in it.
- name: CSS template literals are intact
if: ${{ !cancelled() }}
working-directory: /src
run: make css-check
- name: Component and store suite
working-directory: /src
run: make ui-test
+8 -1
View File
@@ -97,6 +97,12 @@ reference, because you need them *before* the failure, not after.
old behaviour. `make dev-headless` prints the esbuild error; a
reload does not. One way to cause one is a stray backtick inside a
comment in a `css` tagged template literal, which ends the literal.
**That one is a check now** — `make css-check` (instant, a pre-commit
hook and a CI step) names the file, the line and the cause, because
what you otherwise get is `Property 'scroll' does not exist on type
'CSSResult'` pointing at a line of prose, or every test in the suite
failing to import. It went in after the trap cost a fourth session in
which its own warning had been read twice.
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
says it is not.** That endpoint 404s on this Gitea build. The REST
API answers, with the `GITEA_TOKEN` already in the environment:
@@ -162,7 +168,8 @@ Two rules about climbing:
do not exist.
Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
`make bindings-check` and — from `frontend/` — `npx tsc --noEmit`. The
`make bindings-check`, `make css-check` and — from `frontend/` —
`npx tsc --noEmit`. The
first four are lefthook hooks, so skipping them locally only defers the
failure; the typecheck is a hook too but only CI runs it over the test
tree, which is where it has actually broken.
+74
View File
@@ -1954,3 +1954,77 @@ Nine things worth keeping:
and every one was a *different* mechanism. A finding at 110 nodes
hides them; at 3 they are individually obvious. Cheap tail, only
reachable from the other side of the main fix.
## A role, not a value, decides whether a colour can be fixed
Plan 008 phase 2, third landing: the two findings the contrast pass had
recorded as too big to fix in it, fixed — plus the check for the trap
that has now cost four sessions.
The generalisation: **a token that is used for two different jobs will
be wrong at one of them, and no amount of choosing a better value
fixes it.** `--yj-error` was "the colour of error", which is two
questions. As the *background of a danger button* it wants to stay red
in every theme, and it does. As *the word `failed` on a surface* it
cannot be one value at all — a single colour cannot clear 4.5:1 against
both a near-black and a near-white background, which is why the fixed
set measured 2.31–4.28:1 on nearly every combination. Splitting the
token by the question it answers made both answerable; picking better
hexes never would have.
The same shape twice more in the same landing:
- **A fill's foreground cannot be written down**, because the accent is
a colour picker. `color: #000` is right for the current default
yellow and wrong for a navy one. `readableOn()` computes it — white
where white clears 4.5:1, black otherwise — which keeps a red danger
button conventional (4.51:1) and flips a green one (3.45:1).
- **`var(--yj-bg-base)` as a foreground is a token used for the wrong
meaning.** Two accent buttons did that. It reads as "the opposite of
the accent" and it is not: it inverts with the ramp, so the light
theme rendered white on yellow at 1.43:1. The bug is not the value,
it is that the *name* did not mean what the call site needed.
Six more things worth keeping:
- **The picture and the number disagreed, and the number was mine —
again.** I recorded "the header and player chrome stay dark while the
body goes light" as a finding, in the plan and in these notes. It is
false: `.top-bar` is `#e9ecef` and `.sidebar` `#f8f9fa` under the
light ramp, and a re-taken screenshot agrees with the DOM. The
original was captured before the theme had propagated. That is the
third time in two passes a screenshot read at the wrong *moment*
produced a confident wrong claim, after a spec reading the DOM before
a fetch and a sweep that had moved the app to another ramp. **A
screenshot has a timestamp and a state; check both before quoting
it.**
- **A `color:` regex matches `border-color:`.** Twice in one landing —
3 borders while rewriting semantic text, then 30 more while rewriting
accent text. Both caught by grepping the *result*, not by any test,
because a border in the wrong shade fails nothing and looks fine.
When a mechanical rewrite is the right tool, the review is a grep of
what it did, not a run of the suite.
- **A fix at the ubiquitous case makes the rare ones findable.** The
greyscale fix took the dark ramp to 0 and the light ramp from 50 to a
list short enough to read individually — at which point every
remaining item was a *different* mechanism, and two of them were the
findings above. A queue of 110 hides its own structure.
- **Knowledge that has been ignored three times is not a knowledge
problem.** The backtick-in-a-`css`-comment trap is documented in
`CLAUDE.md`, in the skill and here; I read it twice in the session it
then cost a cycle in. It is `make css-check` now — a pre-commit hook
and a CI step. The detection is exact rather than heuristic: if a
backtick in a comment closed the literal early, the text the parser
took as the literal contains an unterminated `/*`, and nothing else
produces that.
- **The value of that check is the sentence, not the failure.** `tsc`
already failed on it — with `Class static side 'typeof NowPlaying'
incorrectly extends base class static side` and `Property 'scroll'
does not exist on type 'CSSResult'`, pointing at a line of prose. The
check was verified by breaking a file on purpose and reading both
reports side by side, which is also the only way to know it fires.
- **A change can be invisible to the tier that looks the most like it
covers it.** `make ui-visual` passed unchanged through a whole
palette rewrite, twice, because the component tier has no `:root` and
renders the fallbacks. The tier that *did* catch things was a unit
test over the palette table and a probe against the running app.
+54 -19
View File
@@ -325,23 +325,60 @@ access to any value by not being able to. **Stays dropped.**
#### Two things the measurements found that are not in the audit
Both are bigger than what they were found under, and neither is fixed:
Both were bigger than what they were found under. Both are now fixed —
see the third landing below.
- **The semantic colours are fixed across ramps, and a fixed colour
- **The semantic colours were fixed across ramps, and a fixed colour
cannot serve a near-black and a near-white background.** `--yj-error`
is 3.42:1 on dark's surface and 2.55:1 on its elevated; `--yj-info`
is 3.10:1 and 2.31:1; success and warning fail on dark and light
both. As *backgrounds* under white text, success (3.45) and warning
(3.58) fail too. The fix is a per-ramp semantic palette, which is a
decision about the app's colour identity rather than a value.
- **The light ramp is not a supported theme.** With the greyscale fixed
it still has **50 failing nodes**: the accent yellow under white text
(1.43:1), the autotag diff's pale greens and reds on white
(1.36–2.59:1), and the header and player chrome staying dark while
the body goes light. Read in a screenshot — the "Low confidence pick"
banner is invisible and the primary button is white-on-yellow. This
is a design job, and the honest question it raises is whether the
light theme should ship at all in its current state.
measured 3.42:1 on dark's surface and 2.55:1 on its elevated;
`--yj-info` 3.10:1 and 2.31:1; success and warning failed on dark and
light both. As *backgrounds* under white text, success (3.45) and
warning (3.58) failed too.
- **The light ramp was not a usable theme.** With the greyscale fixed
it still had **50 failing nodes**: the accent yellow under white text
(1.43:1) and the autotag diff's pale greens and reds on white
(1.36–2.59:1).
### Phase 2, third landing — the ramp reaches the semantic colours
**2237 nodes across three ramps and twelve views, 0 failing.**
The split is by the question a colour answers. A **fill** is "what
colour is a danger button" — red in every theme, unchanged. A **text**
colour is "what colour is the word *failed* on this background" — per
ramp, because one value cannot clear 4.5:1 against both a near-black and
a near-white surface. `bgOverlay` keeps the exception it already had on
the dark ramp.
And every fill now carries a **computed foreground**, because the accent
is a colour picker and no fixed answer survives one: white if white
clears 4.5:1, else black. That keeps a red danger button white and
flips a green or amber one to black. Accent-as-text goes through
`accentTextOn()`, which mixes along the hue until it clears the ramp's
surface and stops — returning the accent *unchanged* on both dark
ramps, so the dark themes are visually untouched by that half.
`make ui-test` 608 → **649**.
#### Where this pass was wrong
- **"The chrome stays dark while the body goes light" was mine, and it
was false.** I read it off a screenshot; the DOM says `.top-bar` is
`#e9ecef` and `.sidebar` `#f8f9fa` under the light ramp, and a
re-taken screenshot agrees. The first one was captured before the
theme had propagated. Third time in two passes that a screenshot read
at the wrong moment produced a confident wrong claim — and the second
time this pass that **the picture and the number disagreed and the
number was mine**.
- **A `color:` regex matches `border-color:`.** Twice: once rewriting
semantic text colours (3 borders) and once rewriting accent text (30
more). A border is a fill, not text. Caught by grepping the result
rather than by any test, because nothing renders differently enough
to fail.
- **Two accent buttons took their foreground from `--yj-bg-base`**,
which inverts with the ramp — white on yellow at 1.43:1. That is not
a colour that was chosen badly; it is a token used for the wrong
meaning, and it only shows up in the theme nobody looks at.
---
@@ -358,10 +395,8 @@ Two of them are not one-liners and should be treated as such:
minimum at 800×600. Reflow at high zoom is the same question one
variable over. It may want its own landing.
- **The unnamed `<select>`s** from Phase 1, with `26`.
- **The semantic palette** and **the light ramp**, from Phase 2. Both
are larger than the rest of this tail put together and may not belong
in it at all — the light ramp in particular is a question about
whether that theme ships, not a contrast fix.
- ~~**The semantic palette** and **the light ramp**~~ — both landed in
Phase 2's third pass rather than waiting for this tail.
- **`22`** asks for a non-colour marker on the playing row, which is a
visual change to the densest list in the app and moves a baseline.
+17
View File
@@ -505,6 +505,23 @@ first track arrives) and `job-indicator`, whose label swings between
"Scanning Music", "3 background jobs" and "Finished". The notification
surface already had one from Phase 3.
**A colour's role decides whether it can be fixed across themes.** A
*fill* — `--yj-error`, `--yj-success` — is "what colour is a danger
button", which is red in every theme and stays fixed. A *text* colour
— `--yj-error-text` — is "what colour is the word *failed* on this
background", which cannot be: one value 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. And every fill carries a **computed**
foreground (`--yj-accent-fg`, `--yj-*-fg`) rather than a written-down
one, because the accent is a colour picker: white on the default
`#ffd43b` is 1.43:1. `readableOn()` keeps white where white clears and
flips to black where it does not, and `accentTextOn()` mixes the accent
along its own hue until it clears the ramp's surface — returning it
unchanged on both dark ramps. Two accent buttons used to take their
foreground from `--yj-bg-base`, which *inverts with the ramp*; that is a
token used for the wrong meaning, and it only shows in the theme nobody
looks at.
**Contrast is a property of the ramp, and the ramps are data.**
`theme-store`'s `SHADE_PALETTES` — not `tokens.css.ts`, which holds only
the type scale and icon sizes — is where the colours live, applied to
+7
View File
@@ -107,6 +107,13 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
bindings-check: ## Fail if frontend/wailsjs is stale against the Go bindings
@./scripts/bindings-check.sh
# A backtick inside a comment in a css`` literal ends the literal, and
# what you get back is a type error about CSSResult, or every test in
# the suite failing to import. Four sessions, three plans. Instant.
.PHONY: css-check
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
@cd frontend && node scripts/check-css-literals.mjs
# .pi/ documents commands, and a skill that documents a command wrongly
# is worse than no skill: an agent runs it confidently. Every command
# in there is a make target on purpose, so this is checkable.
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* A backtick inside a comment in a `css` tagged template literal ends
* the literal.
*
* This has cost four sessions across three plans. It is written down in
* CLAUDE.md, in the yellowjacket-dev skill and in NOTES.md, and it was
* read twice in the session it then cost a cycle in — so it is a check
* now rather than a fourth paragraph. Knowledge that has been ignored
* three times is not a knowledge problem.
*
* What makes it expensive is not the mistake but the *report*. The
* literal ends early, the rest of the CSS is parsed as JavaScript, and
* what comes back is `Expected "]" but found "inline"` pointing at a
* line of prose — or, when it happens in a shared module like
* `tokens.css.ts`, every test in the suite failing to import and an
* output that reads like a broken test runner. And `make dev-headless`
* leaves the dev server serving the last good bundle, so the page still
* works and still shows the old behaviour.
*
* The detection is exact rather than heuristic. If a backtick inside a
* comment closed the literal early, then the text the parser *did* take
* as the literal contains an unterminated `/*`. Nothing else produces
* that, and a legitimate literal cannot contain one.
*/
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
const TAGS = ['css', 'html', 'svg'];
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/** Strip `${...}` substitutions, which may legitimately contain anything. */
function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
}
return out;
}
function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
const problems = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
let match;
while ((match = tagPattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
const body = stripSubstitutions(src.slice(open + 1, close));
const opens = (body.match(/\/\*/g) ?? []).length;
const closes = (body.match(/\*\//g) ?? []).length;
if (opens > closes) {
problems.push({
file,
line: lineOf(src, open),
tag: match[2],
});
}
}
}
if (problems.length > 0) {
for (const p of problems) {
console.error(
`${p.file}:${p.line}: unterminated /* inside a ${p.tag}\`\` literal — ` +
'a backtick in a comment ends the literal early',
);
}
console.error(
`\ncss-literal-check: ${problems.length} problem(s). ` +
'Remove the backticks from the comment; markdown quoting does not ' +
'survive a tagged template.',
);
process.exit(1);
}
console.log(`css-literal-check: ${files.length} files, no broken literals`);
@@ -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.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<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));
},
+9
View File
@@ -47,6 +47,15 @@ pre-commit:
root: "frontend/"
run: ./node_modules/.bin/tsc --noEmit
# A backtick in a comment inside a css`` literal ends the literal.
# tsc does catch it, as "Class static side incorrectly extends base
# class static side" pointing at a line of prose; this says what
# actually happened. Instant.
css-literals:
glob: "frontend/**/*.ts"
root: "frontend/"
run: node scripts/check-css-literals.mjs
pre-push:
parallel: true
commands: