fix(design): badge text clears AA — the ladder was painting a hue on a tint of itself (#3132)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 36s

Every status and priority badge used its raw hue as TEXT on a 12% tint of
that same hue. Measured on the dark palette, all six pairs failed the kit's
own AA floor: todo 1.60:1, in-progress 1.97:1, done 2.06:1, low 2.02:1,
high 2.92:1, medium 2.97:1, against 4.5. Four also failed in light mode.

The cause is structural, not a bad colour pick. A 12% tint sits near the
surface it composites over, so the hue as text on it has almost nowhere to
go. Strengthening the tint was measured and REJECTED: on a dark palette a
heavier tint moves the chip toward the light text and makes it worse. 12%
was already optimal.

So each pair gains a `-fg` sibling: the hue mixed toward --fs-text-primary
until it clears 4.5:1 worst-case over surface-raised AND surface-hover in
BOTH modes. Mixing toward that token rather than a literal is what makes one
declaration cover both — it inverts by mode, so the text follows.

Recorded in the DESIGN SYSTEM, not hand-written into theme.css: seven tokens
on design system 2, each carrying its measurement and its reasoning, then the
sheet regenerated. theme.css says not to hand-edit the --fs-* block and it is
right — a hand-edit would be silently reverted by the next regeneration.

The ladder keeps its shape. High priority still holds 52% saturation and
medium 31% — the rungs that need to shout still shout. Low, todo and done
wash toward neutral, which is what their own rationales ask for: status-todo
is derived from the border colour precisely so not-yet-started recedes.
Receding and illegible are different things and the old value was the second.

--fs-status-cancelled-fg was found by measuring, not by reasoning. Cancelled
derives from --fs-text-tertiary, which looks like the obviously-correct
"quiet" choice and is a HINT colour tuned for plain surfaces — 2.63:1 on a
badge tint in light mode.

StatusBadge additionally dropped a `color-mix(..., #000 15%)` that darkened
the hue: a light-mode instinct that made these worse on a near-black surface,
and a literal besides.

THE GUARD IS THE POINT. check_design_tokens.py now FAILS on any rule that
paints text with a token on a tint of that same token, and names the -fg
sibling as the fix. Verified by reintroducing the defect: exit 1 with it,
exit 0 without. Unlike a raw literal there is nothing to weigh up, so it
gates rather than reports.

Two `border-top-color` uses keep the raw hue, correctly — a border is a
non-text graphic and needs 3:1, which is what the hue is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:11:14 -04:00
co-authored by Claude Opus 5
parent a0b54ff6a3
commit 0c74dc8275
8 changed files with 87 additions and 26 deletions
+14
View File
@@ -12,6 +12,13 @@
file used to read, and it is deliberate: the light palette was never specified file used to read, and it is deliberate: the light palette was never specified
by any rule, so it is recorded as a departure rather than as the default. by any rule, so it is recorded as a departure rather than as the default.
The -fg tokens are a badge's TEXT colour, added because the ladder used its
raw hue as text on a 12% tint of the same hue — measured 1.60-2.97:1 on the
dark palette against the kit's AA floor of 4.5. Each is the hue mixed toward
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
surface-hover in BOTH modes. Mixing toward that token is what makes one
declaration cover both: it inverts, so the text follows the mode.
Only 12 tokens differ between modes. Everything else — spacing, type, motion, Only 12 tokens differ between modes. Everything else — spacing, type, motion,
radius, and every derived colour — is stated once, because a value built with radius, and every derived colour — is stated once, because a value built with
var() resolves where it is USED, not where it is written. var() resolves where it is USED, not where it is written.
@@ -78,10 +85,13 @@
/* priority */ /* priority */
--fs-priority-low: var(--fs-info); --fs-priority-low: var(--fs-info);
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent); --fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
--fs-priority-low-fg: color-mix(in srgb, var(--fs-priority-low) 45%, var(--fs-text-primary)); /* Badge TEXT for low priority — the readable partner of the -bg tint */
--fs-priority-medium: var(--fs-warning); --fs-priority-medium: var(--fs-warning);
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent); --fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
--fs-priority-medium-fg: color-mix(in srgb, var(--fs-priority-medium) 55%, var(--fs-text-primary)); /* Badge TEXT for medium priority */
--fs-priority-high: var(--fs-error); --fs-priority-high: var(--fs-error);
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent); --fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
--fs-priority-high-fg: color-mix(in srgb, var(--fs-priority-high) 55%, var(--fs-text-primary)); /* Badge TEXT for high priority */
/* radius */ /* radius */
--fs-radius-sm: 4px; /* pills, tags, code spans */ --fs-radius-sm: 4px; /* pills, tags, code spans */
@@ -116,12 +126,16 @@
/* status */ /* status */
--fs-status-todo: var(--fs-border-color); --fs-status-todo: var(--fs-border-color);
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent); --fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
--fs-status-todo-fg: color-mix(in srgb, var(--fs-status-todo) 40%, var(--fs-text-primary)); /* Badge TEXT for a not-started task */
--fs-status-in-progress: var(--fs-accent); --fs-status-in-progress: var(--fs-accent);
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent); --fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
--fs-status-in-progress-fg: color-mix(in srgb, var(--fs-status-in-progress) 45%, var(--fs-text-primary)); /* Badge TEXT for a task underway */
--fs-status-done: var(--fs-success); --fs-status-done: var(--fs-success);
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent); --fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
--fs-status-done-fg: color-mix(in srgb, var(--fs-status-done) 50%, var(--fs-text-primary)); /* Badge TEXT for a completed task */
--fs-overdue: var(--fs-error); --fs-overdue: var(--fs-error);
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */ --fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
--fs-status-cancelled-fg: color-mix(in srgb, var(--fs-status-cancelled) 60%, var(--fs-text-primary)); /* Badge TEXT for a cancelled task */
/* surface */ /* surface */
--fs-surface-page: #14171A; /* page bg, deepest surface */ --fs-surface-page: #14171A; /* page bg, deepest surface */
+3 -3
View File
@@ -34,14 +34,14 @@ const labels: Record<TaskPriority, string> = {
} }
.priority-low { .priority-low {
background: var(--fs-priority-low-bg); background: var(--fs-priority-low-bg);
color: var(--fs-priority-low); color: var(--fs-priority-low-fg);
} }
.priority-medium { .priority-medium {
background: var(--fs-priority-medium-bg); background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium); color: var(--fs-priority-medium-fg);
} }
.priority-high { .priority-high {
background: var(--fs-priority-high-bg); background: var(--fs-priority-high-bg);
color: var(--fs-priority-high); color: var(--fs-priority-high-fg);
} }
</style> </style>
+3 -3
View File
@@ -153,7 +153,7 @@ watch(() => [props.projectId, props.designSystemId], run);
} }
.pdt-clean { .pdt-clean {
color: var(--fs-status-done); color: var(--fs-status-done-fg);
} }
.pdt-summary { .pdt-summary {
@@ -206,12 +206,12 @@ watch(() => [props.projectId, props.designSystemId], run);
.pdt-tag.unknown { .pdt-tag.unknown {
background: var(--fs-priority-high-bg); background: var(--fs-priority-high-bg);
color: var(--fs-priority-high); color: var(--fs-priority-high-fg);
} }
.pdt-tag.local { .pdt-tag.local {
background: var(--fs-priority-medium-bg); background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium); color: var(--fs-priority-medium-fg);
} }
.pdt-tag.superseded { .pdt-tag.superseded {
+12 -8
View File
@@ -37,21 +37,25 @@ const labels: Record<TaskStatus, string> = {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.025em; letter-spacing: 0.025em;
} }
/* Text comes from the -fg tokens, which are the hue mixed toward
--fs-text-primary until they clear AA. The old spelling darkened the hue
with `#000 15%` — a light-mode instinct that made these WORSE on the dark
palette, where the surface is already near-black, and a literal besides. */
.status-todo { .status-todo {
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%); background: var(--fs-status-todo-bg);
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%); color: var(--fs-status-todo-fg);
} }
.status-in_progress { .status-in_progress {
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%); background: var(--fs-status-in-progress-bg);
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%); color: var(--fs-status-in-progress-fg);
} }
.status-done { .status-done {
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%); background: var(--fs-status-done-bg);
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%); color: var(--fs-status-done-fg);
} }
.status-cancelled { .status-cancelled {
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%); background: var(--fs-status-todo-bg);
color: var(--fs-text-tertiary); color: var(--fs-status-cancelled-fg);
} }
.clickable { .clickable {
cursor: pointer; cursor: pointer;
+2 -2
View File
@@ -554,8 +554,8 @@ async function confirmDelete() {
/* The two bases must never look alike — one is mechanical, the other is the /* The two bases must never look alike — one is mechanical, the other is the
reviewer's judgment, and that difference is the whole decision. */ reviewer's judgment, and that difference is the whole decision. */
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; } .area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); } .area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); } .area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
.area-offer { .area-offer {
display: flex; display: flex;
+2 -2
View File
@@ -1427,12 +1427,12 @@ textarea.input {
.spec-status.violated { .spec-status.violated {
background: var(--fs-priority-high-bg); background: var(--fs-priority-high-bg);
color: var(--fs-priority-high); color: var(--fs-priority-high-fg);
} }
.spec-status.missing { .spec-status.missing {
background: var(--fs-priority-medium-bg); background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium); color: var(--fs-priority-medium-fg);
} }
.sheet { .sheet {
+7 -7
View File
@@ -946,10 +946,10 @@ onUnmounted(() => {
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: 500;
} }
.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); } .status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); } .status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress-fg); }
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); } .status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); text-decoration: line-through; } .status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-cancelled-fg); text-decoration: line-through; }
.priority-badge { .priority-badge {
font-size: 0.7rem; font-size: 0.7rem;
@@ -957,9 +957,9 @@ onUnmounted(() => {
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: 500;
} }
.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low); } .priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low-fg); }
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); } .priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); } .priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high-fg); }
.task-due { .task-due {
font-size: 0.78rem; font-size: 0.78rem;
+44 -1
View File
@@ -90,6 +90,32 @@ def style_source(path: pathlib.Path) -> str:
return CSS_COMMENT.sub(" ", css) return CSS_COMMENT.sub(" ", css)
# A rule that paints text with a colour token AND its own -bg tint of the same
# token. The pair looks harmonious and is close to illegible: a 12% tint of a
# hue sits near the surface, so the hue as text on it lands around 2:1 against
# an AA floor of 4.5. Measured across the whole Scribe ladder in 2026-08:
# every one of the six pairs failed on the dark palette, worst 1.60:1.
#
# The fix is always the same and always available — the token's `-fg` sibling,
# which is the hue mixed toward --fs-text-primary far enough to clear AA. So
# this FAILS rather than reports: unlike a raw literal, there is nothing to
# weigh up.
SAME_TOKEN_PAIR = re.compile(
r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)" # color: var(--fs-X)
r"|background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)"
)
def same_hue_text_on_tint(css: str) -> list[str]:
"""Tokens used as TEXT on a tint of themselves, within one rule block."""
hits = []
for body in re.findall(r"\{([^{}]*)\}", css):
fg = set(re.findall(r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body))
bg = set(re.findall(r"background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)", body))
hits.extend(sorted(fg & bg))
return hits
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sheet", default="frontend/src/assets/theme.css") parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
@@ -117,6 +143,7 @@ def main() -> int:
) )
unresolved: list[tuple[pathlib.Path, str]] = [] unresolved: list[tuple[pathlib.Path, str]] = []
same_hue_hits: list[tuple[pathlib.Path, str]] = []
superseded_hits: list[tuple[pathlib.Path, str, str]] = [] superseded_hits: list[tuple[pathlib.Path, str, str]] = []
literal_count = 0 literal_count = 0
@@ -140,6 +167,9 @@ def main() -> int:
literal_count += len(HEX_LITERAL.findall(css)) literal_count += len(HEX_LITERAL.findall(css))
for tok in same_hue_text_on_tint(css):
same_hue_hits.append((path, tok))
if unresolved: if unresolved:
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).") print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
print(" These render as the fallback if given one, or as nothing at all.") print(" These render as the fallback if given one, or as nothing at all.")
@@ -162,12 +192,25 @@ def main() -> int:
print(f" {path}: {literal} -> {token}") print(f" {path}: {literal} -> {token}")
print() print()
if same_hue_hits:
print(f"FAIL — {len(same_hue_hits)} rule(s) paint text with a token on a "
f"tint of that same token.")
print(" A 12% tint sits near the surface, so the hue as text on it lands "
"around 2:1 against AA's 4.5.")
print(" Use the token's -fg sibling, which is mixed toward "
"--fs-text-primary until it clears the floor.\n")
for path, tok in same_hue_hits:
print(f" {path}: color: var({tok}) on var({tok}-bg) -> var({tok}-fg)")
print()
else:
print("OK — no text painted with a token on a tint of itself.\n")
if args.report_literals: if args.report_literals:
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.") print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
print(" Advisory: a literal is a value stated outside the system, so it " print(" Advisory: a literal is a value stated outside the system, so it "
"cannot follow a palette change.\n") "cannot follow a palette change.\n")
return 1 if unresolved else 0 return 1 if (unresolved or same_hue_hits) else 0
if __name__ == "__main__": if __name__ == "__main__":