feat(design): the panel now asks whether the app agrees with its own sheet
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Retiring rulebook #2 left the /design drift panel with no data source, and because its empty state was well-written the feature read as working while it could only ever render "nothing designated" (#2419). The original question is genuinely gone: theme.css is generated from design system 2, so checking the system against a sheet derived from it would be a tautology. The question that survives is the one no server can answer. A generated sheet still has to be LOADED and APPLIED, and nothing checked that it was: absent the record declares a token the app doesn't have — the sheet was never regenerated after the record changed, or never loaded differs the app has it with another value — a stale sheet, or a later rule that overrode it unrecorded the app declares a token in the record's own family that the record has never heard of Both sides go through the same engine so the comparison is honest: declared values are set on an offscreen probe and read back, which performs the same var() substitution the browser already did to the live values. Comparing raw strings would mark every derived token as drift. The designation moved with the feature — design_rulebook_id becomes ui_design_system_id, with a migration deleting the retired key rather than leaving an inert row. The prose extractor it fed goes too (#2288 said its runtime role ended when the import landed). Three orphans of the same shape, found alongside and fixed here: - darkOverriddenNames hardcoded [data-theme="dark"]. The sheet went dark-first months ago, so it matched nothing and the "mode-aware" flag silently left the gallery. Now matches the SHAPE of a mode selector, which also holds for an install whose modes aren't light and dark. - groupFor's prefix table never heard of --fs-, so 110 tokens sat under "other". Groups now come from the record where there is one; the table can only know families that shipped with the product (rule #115). - The type scale was a hand-written table of nine sizes marked "no token", true when written and false since the scale was recorded. Now rendered from whatever size tokens the sheet declares, so it can't go stale twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -1,161 +0,0 @@
|
||||
"""Rulebook prose → checkable claims (milestone #251 step 2).
|
||||
|
||||
This is the piece of the design explorer that most needed to be testable, which
|
||||
is why it lives in Python at all: the frontend has no test runner, so the fiddly
|
||||
extraction happens server-side and the browser only does set arithmetic over it.
|
||||
|
||||
Rule text below is representative of a real design rulebook rather than copied
|
||||
from this operator's — rule #115: the product must work for an install that has
|
||||
none of their data, and a test that only passes against their exact wording would
|
||||
be testing the instance, not the parser.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scribe.services.design_rulebook_import import (
|
||||
expand_token_shorthand,
|
||||
extract_expectations,
|
||||
normalize_hex,
|
||||
)
|
||||
|
||||
|
||||
def _rule(rule_id, title, statement, how_to_apply=None):
|
||||
return SimpleNamespace(
|
||||
id=rule_id, title=title, statement=statement, how_to_apply=how_to_apply
|
||||
)
|
||||
|
||||
|
||||
# --- hex normalisation -------------------------------------------------------
|
||||
|
||||
def test_normalize_hex_makes_shorthand_and_case_comparable():
|
||||
"""LOAD-BEARING. The rulebook writes `#FFFFFF` and components write `#fff`.
|
||||
If those don't compare equal, the single largest drift finding — 67 hardcoded
|
||||
white text colours (#2275) — reads as zero findings."""
|
||||
assert normalize_hex("#fff") == normalize_hex("#FFFFFF") == "#ffffff"
|
||||
assert normalize_hex("#E8E4D8") == "#e8e4d8"
|
||||
assert normalize_hex("#14171a") == "#14171a"
|
||||
|
||||
|
||||
def test_normalize_hex_keeps_alpha_rather_than_inventing_equality():
|
||||
"""`#fff` and `#ffff` are different colours. Dropping the alpha to make them
|
||||
match would manufacture agreement that isn't there."""
|
||||
assert normalize_hex("#ffff") == "#ffffffff"
|
||||
assert normalize_hex("#fff") != normalize_hex("#ffff")
|
||||
|
||||
|
||||
def test_normalize_hex_rejects_non_colours():
|
||||
for junk in ("", " ", "not-a-colour", "#", "#gg", "#12345"):
|
||||
assert normalize_hex(junk) is None
|
||||
|
||||
|
||||
# --- the slash shorthand -----------------------------------------------------
|
||||
|
||||
def test_expand_token_shorthand_handles_every_form_a_rulebook_uses():
|
||||
"""One rule expands all three shapes: take everything up to and including the
|
||||
LAST hyphen of the first segment as the prefix."""
|
||||
assert expand_token_shorthand("--fs-radius-sm/md/lg/xl") == [
|
||||
"--fs-radius-sm", "--fs-radius-md", "--fs-radius-lg", "--fs-radius-xl",
|
||||
]
|
||||
# Prefix is just `--fs-` here, and the same rule finds it.
|
||||
assert expand_token_shorthand("--fs-obsidian/iron/slate/pewter") == [
|
||||
"--fs-obsidian", "--fs-iron", "--fs-slate", "--fs-pewter",
|
||||
]
|
||||
assert expand_token_shorthand("--fs-dur-fast/base/slow") == [
|
||||
"--fs-dur-fast", "--fs-dur-base", "--fs-dur-slow",
|
||||
]
|
||||
|
||||
|
||||
def test_expand_token_shorthand_passes_plain_names_through():
|
||||
assert expand_token_shorthand("--fs-ease") == ["--fs-ease"]
|
||||
|
||||
|
||||
# --- extraction --------------------------------------------------------------
|
||||
|
||||
def test_negation_is_scoped_to_the_sentence_not_the_rule():
|
||||
"""THE trick that makes prohibition detection usable.
|
||||
|
||||
A single rule routinely states what the palette REQUIRES and what it FORBIDS
|
||||
in consecutive sentences. Detecting negation across the whole statement would
|
||||
mark the required colours as forbidden too — inverting the finding rather
|
||||
than missing it, which is worse.
|
||||
"""
|
||||
rule = _rule(
|
||||
52, "Text palette",
|
||||
"Text tokens: Parchment #E8E4D8 (primary), Vellum #C2BFB4 (secondary), "
|
||||
"Ash #9C9A92 (tertiary). Pure white #FFFFFF is NEVER used as text color.",
|
||||
)
|
||||
found = extract_expectations([rule])
|
||||
required = {e.value for e in found if e.kind == "color"}
|
||||
forbidden = {e.value for e in found if e.kind == "prohibited_color"}
|
||||
|
||||
assert required == {"#e8e4d8", "#c2bfb4", "#9c9a92"}
|
||||
assert forbidden == {"#ffffff"}
|
||||
assert not (required & forbidden)
|
||||
|
||||
|
||||
def test_token_names_are_extracted_and_expanded():
|
||||
rule = _rule(
|
||||
72, "CSS custom properties",
|
||||
"Expose the system as custom properties on :root — surfaces "
|
||||
"(--fs-obsidian/iron/slate/pewter), radius (--fs-radius-sm/md/lg/xl), "
|
||||
"and motion (--fs-ease).",
|
||||
)
|
||||
names = {e.value for e in extract_expectations([rule]) if e.kind == "token"}
|
||||
assert "--fs-obsidian" in names and "--fs-pewter" in names
|
||||
assert "--fs-radius-xl" in names
|
||||
assert "--fs-ease" in names
|
||||
assert len(names) == 9
|
||||
|
||||
|
||||
def test_how_to_apply_is_read_as_well_as_the_statement():
|
||||
"""Rulebooks routinely put the concrete values in how_to_apply and keep the
|
||||
statement declarative, so ignoring it would miss the checkable half."""
|
||||
rule = _rule(
|
||||
56, "Per-app accent", "Each app owns exactly one accent.",
|
||||
how_to_apply='[data-app="scribe"] #5B4A8A, [data-app="minstrel"] #4A6B5C.',
|
||||
)
|
||||
colours = {e.value for e in extract_expectations([rule]) if e.kind == "color"}
|
||||
assert colours == {"#5b4a8a", "#4a6b5c"}
|
||||
|
||||
|
||||
def test_claims_are_deduped_across_rules_keeping_the_first_source():
|
||||
"""A colour named by several rules is one expectation, attributed to the rule
|
||||
that introduced it — usually the most specific place to send a reader."""
|
||||
rules = [
|
||||
_rule(51, "Surfaces", "Obsidian #14171A is the page background."),
|
||||
_rule(99, "Elsewhere", "Obsidian #14171A again, mentioned in passing."),
|
||||
]
|
||||
found = [e for e in extract_expectations(rules) if e.kind == "color"]
|
||||
assert len(found) == 1
|
||||
assert found[0].rule_id == 51
|
||||
|
||||
|
||||
def test_prose_with_nothing_checkable_yields_nothing():
|
||||
"""Most rules are judgement, not specification. They must contribute no
|
||||
findings rather than a shrug — a panel that reports unparseable rules as
|
||||
problems would be unusable."""
|
||||
rule = _rule(
|
||||
68, "Voice and tone",
|
||||
"Voice is understated: plain language for anything functional, flavour "
|
||||
"only where the user is waiting or failing. Be brief.",
|
||||
)
|
||||
assert extract_expectations([rule]) == []
|
||||
|
||||
|
||||
def test_every_expectation_carries_the_sentence_it_came_from():
|
||||
"""The panel has to show its working — "the rulebook says X" is only
|
||||
actionable if you can see where, and in what context.
|
||||
|
||||
Asserts the context is the SENTENCE, not the whole statement: a rule that
|
||||
states a requirement and a prohibition in consecutive sentences would
|
||||
otherwise attribute both to the same undifferentiated blob of prose.
|
||||
"""
|
||||
rule = _rule(63, "Radius", "Radius: Small 4px. Pure white #FFFFFF is never used.")
|
||||
found = extract_expectations([rule])
|
||||
assert len(found) == 1
|
||||
|
||||
only = found[0]
|
||||
assert only.kind == "prohibited_color"
|
||||
assert only.rule_id == 63
|
||||
assert only.rule_title == "Radius"
|
||||
assert only.context == "Pure white #FFFFFF is never used."
|
||||
assert "Radius: Small 4px" not in only.context
|
||||
@@ -284,8 +284,9 @@ def test_a_shorter_hex_does_not_match_inside_a_longer_one():
|
||||
|
||||
|
||||
def test_the_literal_match_is_case_insensitive():
|
||||
"""Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
||||
check would silently find nothing — the same trap normalize_hex exists for."""
|
||||
"""A record writes `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
||||
check would silently find nothing — the same trap `normalizeColour` in
|
||||
utils/designDrift.ts exists for on the client side."""
|
||||
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
|
||||
assert report["superseded_literals"] == [
|
||||
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The /api/design blueprint — this install's UI, not the design-system record.
|
||||
|
||||
Modelled on tests/test_routes_design_systems.py. The URL enumeration is
|
||||
deliberate rather than a pattern match: this blueprint was repointed from
|
||||
`/expectations` to `/ui-system` (#2419), and the failure worth catching is the
|
||||
old rule surviving the change — a route nothing serves any more, answering with
|
||||
whatever the last handler registered on it did.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_design_blueprint_registered():
|
||||
from scribe.routes.design import design_bp
|
||||
assert design_bp.name == "design"
|
||||
assert design_bp.url_prefix == "/api/design"
|
||||
|
||||
|
||||
def test_design_blueprint_registered_in_app():
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
assert "design" in app.blueprints
|
||||
|
||||
|
||||
def test_the_blueprint_serves_exactly_one_rule():
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
rules = {
|
||||
str(r.rule) for r in app.url_map.iter_rules()
|
||||
if r.endpoint.startswith("design.")
|
||||
}
|
||||
assert rules == {"/api/design/ui-system"}
|
||||
|
||||
|
||||
def test_the_designation_reader_takes_user_id():
|
||||
"""The setting is per-user, so the read must be too (rule #78). A
|
||||
module-level or admin-scoped read would hand one user another's designation."""
|
||||
from scribe.services import design_systems as svc
|
||||
assert "user_id" in inspect.signature(svc.ui_design_system).parameters
|
||||
|
||||
|
||||
def test_the_setting_key_is_the_new_one():
|
||||
"""Named explicitly because the panel silently reading a retired key is the
|
||||
exact shape of the bug this replaced: a feature that renders, and reports
|
||||
nothing, forever."""
|
||||
from scribe.services.design_systems import UI_DESIGN_SYSTEM_SETTING
|
||||
assert UI_DESIGN_SYSTEM_SETTING == "ui_design_system_id"
|
||||
|
||||
|
||||
def test_the_rulebook_expectation_extractor_is_gone():
|
||||
"""It was retired with the rulebook it parsed (#2288, #2419). Left importable
|
||||
it would keep looking like a live path to the next reader."""
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
import scribe.services.design_rulebook_import # noqa: F401
|
||||
Reference in New Issue
Block a user