Compare commits
3
Commits
fd32ce71d2
...
7410109884
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7410109884 | ||
|
|
0b7ffd5679 | ||
|
|
49b1194333 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -132,7 +132,7 @@ export class JobLogView extends LitElement {
|
||||
}
|
||||
|
||||
.entry.error .message {
|
||||
color: #ff8787;
|
||||
color: var(--yj-error-text, #ff8787);
|
||||
}
|
||||
|
||||
.detail {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user