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
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:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user