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:
@@ -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 */
|
||||
|
||||
@@ -34,14 +34,14 @@ const labels: Record<TaskPriority, string> = {
|
||||
}
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,21 +37,25 @@ const labels: Record<TaskStatus, string> = {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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