From 0c74dc82755fee8f815ccebe4e9db8d2835fa33b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 27 Aug 2026 19:11:14 -0400 Subject: [PATCH] =?UTF-8?q?fix(design):=20badge=20text=20clears=20AA=20?= =?UTF-8?q?=E2=80=94=20the=20ladder=20was=20painting=20a=20hue=20on=20a=20?= =?UTF-8?q?tint=20of=20itself=20(#3132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/assets/theme.css | 14 ++++++ frontend/src/components/PriorityBadge.vue | 6 +-- frontend/src/components/ProjectDesignTab.vue | 6 +-- frontend/src/components/StatusBadge.vue | 20 +++++---- frontend/src/components/SystemsSection.vue | 4 +- frontend/src/views/DesignSystemsView.vue | 4 +- frontend/src/views/KnowledgeView.vue | 14 +++--- scripts/check_design_tokens.py | 45 +++++++++++++++++++- 8 files changed, 87 insertions(+), 26 deletions(-) diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index 3a000e9..c4d7d9d 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -12,6 +12,13 @@ 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. + 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, radius, and every derived colour — is stated once, because a value built with var() resolves where it is USED, not where it is written. @@ -78,10 +85,13 @@ /* priority */ --fs-priority-low: var(--fs-info); --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-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-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 */ --fs-radius-sm: 4px; /* pills, tags, code spans */ @@ -116,12 +126,16 @@ /* status */ --fs-status-todo: var(--fs-border-color); --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-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-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-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 */ --fs-surface-page: #14171A; /* page bg, deepest surface */ diff --git a/frontend/src/components/PriorityBadge.vue b/frontend/src/components/PriorityBadge.vue index 22d732f..8314db2 100644 --- a/frontend/src/components/PriorityBadge.vue +++ b/frontend/src/components/PriorityBadge.vue @@ -34,14 +34,14 @@ const labels: Record = { } .priority-low { background: var(--fs-priority-low-bg); - color: var(--fs-priority-low); + color: var(--fs-priority-low-fg); } .priority-medium { background: var(--fs-priority-medium-bg); - color: var(--fs-priority-medium); + color: var(--fs-priority-medium-fg); } .priority-high { background: var(--fs-priority-high-bg); - color: var(--fs-priority-high); + color: var(--fs-priority-high-fg); } diff --git a/frontend/src/components/ProjectDesignTab.vue b/frontend/src/components/ProjectDesignTab.vue index fddba0b..fa00762 100644 --- a/frontend/src/components/ProjectDesignTab.vue +++ b/frontend/src/components/ProjectDesignTab.vue @@ -153,7 +153,7 @@ watch(() => [props.projectId, props.designSystemId], run); } .pdt-clean { - color: var(--fs-status-done); + color: var(--fs-status-done-fg); } .pdt-summary { @@ -206,12 +206,12 @@ watch(() => [props.projectId, props.designSystemId], run); .pdt-tag.unknown { background: var(--fs-priority-high-bg); - color: var(--fs-priority-high); + color: var(--fs-priority-high-fg); } .pdt-tag.local { background: var(--fs-priority-medium-bg); - color: var(--fs-priority-medium); + color: var(--fs-priority-medium-fg); } .pdt-tag.superseded { diff --git a/frontend/src/components/StatusBadge.vue b/frontend/src/components/StatusBadge.vue index db36296..f818178 100644 --- a/frontend/src/components/StatusBadge.vue +++ b/frontend/src/components/StatusBadge.vue @@ -37,21 +37,25 @@ const labels: Record = { text-transform: uppercase; 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 { - background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%); - color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%); + background: var(--fs-status-todo-bg); + color: var(--fs-status-todo-fg); } .status-in_progress { - background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%); - color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%); + background: var(--fs-status-in-progress-bg); + color: var(--fs-status-in-progress-fg); } .status-done { - background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%); - color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%); + background: var(--fs-status-done-bg); + color: var(--fs-status-done-fg); } .status-cancelled { - background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%); - color: var(--fs-text-tertiary); + background: var(--fs-status-todo-bg); + color: var(--fs-status-cancelled-fg); } .clickable { cursor: pointer; diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index fbc7e77..6bfbbf3 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -554,8 +554,8 @@ async function confirmDelete() { /* The two bases must never look alike — one is mechanical, the other is the 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--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); } -.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); } +.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-fg); } .area-offer { display: flex; diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index 68f50a8..af3b197 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -1427,12 +1427,12 @@ textarea.input { .spec-status.violated { background: var(--fs-priority-high-bg); - color: var(--fs-priority-high); + color: var(--fs-priority-high-fg); } .spec-status.missing { background: var(--fs-priority-medium-bg); - color: var(--fs-priority-medium); + color: var(--fs-priority-medium-fg); } .sheet { diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 75fced5..7d720c9 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -946,10 +946,10 @@ onUnmounted(() => { border-radius: 8px; font-weight: 500; } -.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); } -.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); } -.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); } -.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); text-decoration: line-through; } +.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-fg); } +.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-cancelled-fg); text-decoration: line-through; } .priority-badge { font-size: 0.7rem; @@ -957,9 +957,9 @@ onUnmounted(() => { border-radius: 8px; font-weight: 500; } -.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low); } -.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); } -.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); } +.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-fg); } +.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high-fg); } .task-due { font-size: 0.78rem; diff --git a/scripts/check_design_tokens.py b/scripts/check_design_tokens.py index 558f2b1..43a11e0 100644 --- a/scripts/check_design_tokens.py +++ b/scripts/check_design_tokens.py @@ -90,6 +90,32 @@ def style_source(path: pathlib.Path) -> str: 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: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--sheet", default="frontend/src/assets/theme.css") @@ -117,6 +143,7 @@ def main() -> int: ) unresolved: list[tuple[pathlib.Path, str]] = [] + same_hue_hits: list[tuple[pathlib.Path, str]] = [] superseded_hits: list[tuple[pathlib.Path, str, str]] = [] literal_count = 0 @@ -140,6 +167,9 @@ def main() -> int: literal_count += len(HEX_LITERAL.findall(css)) + for tok in same_hue_text_on_tint(css): + same_hue_hits.append((path, tok)) + if unresolved: print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).") 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() + 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: 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 " "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__":