diff --git a/src/scribe/app.py b/src/scribe/app.py index abd7c2a..5f665ab 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -26,6 +26,7 @@ from scribe.routes.profile import profile_bp from scribe.routes.knowledge import knowledge_bp from scribe.routes.rulebooks import rulebooks_bp from scribe.routes.plugin import plugin_bp +from scribe.routes.design import design_bp from scribe.routes.trash import trash_bp from scribe.routes.dashboard import dashboard_bp from scribe.routes.systems import systems_bp @@ -89,6 +90,7 @@ def create_app() -> Quart: app.register_blueprint(knowledge_bp) app.register_blueprint(rulebooks_bp) app.register_blueprint(plugin_bp) + app.register_blueprint(design_bp) app.register_blueprint(trash_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(systems_bp) diff --git a/src/scribe/routes/design.py b/src/scribe/routes/design.py new file mode 100644 index 0000000..33b3146 --- /dev/null +++ b/src/scribe/routes/design.py @@ -0,0 +1,29 @@ +"""Design-system surface — what the rulebook expects of the stylesheet. + +The client owns the other half of the comparison: it reads live token values from +the browser (see `utils/designTokens.ts`), which is the only place they exist +resolved. This endpoint supplies the claims to check them against. +""" +from quart import Blueprint, jsonify + +from scribe.auth import get_current_user_id, login_required +from scribe.services import design_system as design_svc + +design_bp = Blueprint("design", __name__, url_prefix="/api/design") + + +@design_bp.get("/expectations") +@login_required +async def get_expectations(): + """Checkable claims from the rulebook this install designated as its design system. + + Returns `{"rulebook_id": int|null, "expectations": [...]}`. + + `rulebook_id: null` is the NORMAL case, not an error — an install that has + not designated a design rulebook has nothing to compare against, and the + client shows an explanatory empty state (rule #115). Distinguishing it from + "designated but empty" is why the id is returned alongside the list. + """ + uid = get_current_user_id() + result = await design_svc.design_expectations(uid) + return jsonify(result.as_dict()) diff --git a/src/scribe/services/design_system.py b/src/scribe/services/design_system.py new file mode 100644 index 0000000..7f637fd --- /dev/null +++ b/src/scribe/services/design_system.py @@ -0,0 +1,232 @@ +"""Design-system expectations — turning rulebook prose into checkable claims. + +Milestone #251 step 2. The drift panel compares what the design rulebook SAYS +against what the stylesheet and components actually DO. This module owns the +first half: reading a rulebook's rules and extracting the claims that can be +mechanically checked. + +WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit` +is the entire check — and this is the one genuinely fiddly piece of the feature. +Extraction happens here where pytest can assert on it; the comparison itself is +set arithmetic and stays in the browser, where the live token values are. + +WHY NOT NLP. Rule statements are prose written for humans, and they should stay +that way — they are read by people far more often than they are parsed. So this +extracts only what is unambiguous in ANY prose: the hex colours and CSS custom +property names a rule mentions. Everything subtler (padding scales, type ramps) +needs a rule author to opt into a structured form, which is deliberately left for +when someone wants it rather than invented up front. + +RULE #115. Nothing here assumes a design rulebook exists, or that it is this +operator's. An install designates one; an install that hasn't gets an empty +result and a panel that explains itself. +""" +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field + +from scribe.models.rulebook import Rule +from scribe.services.settings import get_setting + +logger = logging.getLogger(__name__) + +# Which rulebook describes this install's design system. A plain setting rather +# than a column: no migration, discoverable in the Settings UI (rule #25), and +# honest about being a per-install choice rather than a property of the rulebook. +DESIGN_RULEBOOK_SETTING = "design_rulebook_id" + +# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms. +_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b") + +# A custom-property name as written in prose, including the slash shorthand the +# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`. +_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)") + +# Sentence-ish split. Rules use semicolons as hard breaks as often as periods. +_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+") + +# Negation markers. Checked PER SENTENCE, which is the whole trick — see +# _extract_from_sentence. +_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded") + + +@dataclass +class Expectation: + """One mechanically-checkable claim a rule makes.""" + + kind: str # "token" | "color" | "prohibited_color" + value: str # "--fs-obsidian" | "#14171a" + rule_id: int + rule_title: str + context: str # the sentence it came from, for showing your work + + def as_dict(self) -> dict: + return { + "kind": self.kind, + "value": self.value, + "rule_id": self.rule_id, + "rule_title": self.rule_title, + "context": self.context, + } + + +@dataclass +class ExpectationSet: + rulebook_id: int | None = None + expectations: list[Expectation] = field(default_factory=list) + + def as_dict(self) -> dict: + return { + "rulebook_id": self.rulebook_id, + "expectations": [e.as_dict() for e in self.expectations], + } + + +def normalize_hex(value: str) -> str | None: + """Fold a hex colour to a comparable form, or None if it isn't one. + + Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the + code writes `#fff`, and those must compare equal or the single largest drift + finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases. + + Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the + same colour, and silently dropping the alpha would invent equality. + """ + match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip()) + if not match: + return None + digits = match.group(1).lower() + if len(digits) in (3, 4): + digits = "".join(c * 2 for c in digits) + if len(digits) not in (6, 8): + return None + return f"#{digits}" + + +def expand_token_shorthand(raw: str) -> list[str]: + """`--fs-radius-sm/md/lg/xl` -> the four names it stands for. + + The rulebook writes token families in a slash shorthand, and both forms it + uses expand correctly under one rule: take everything up to and including the + LAST hyphen of the first segment as the prefix, then append each alternative. + + --fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl + --fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate + --fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow + + A name with no slash is returned as-is. + """ + if "/" not in raw: + return [raw] + head, *rest = raw.split("/") + cut = head.rfind("-") + if cut <= 1: # no hyphen beyond the leading `--` + return [head, *rest] + prefix = head[: cut + 1] + return [head, *[f"{prefix}{part}" for part in rest if part]] + + +def _is_negated(sentence: str) -> bool: + return any(marker in sentence.lower() for marker in _NEGATIONS) + + +def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]: + """Claims in ONE sentence, with negation scoped to that sentence. + + Sentence scope is what makes the prohibition detection usable. Rule 52 reads: + + "Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 …. + Pure white #FFFFFF is NEVER used as text color." + + Three colours the palette REQUIRES and one it FORBIDS, in one statement. + Detecting negation across the whole statement would mark all four as + forbidden; detecting it per sentence gets all four right. + """ + out: list[Expectation] = [] + negated = _is_negated(sentence) + + for match in _HEX.finditer(sentence): + value = normalize_hex(match.group(0)) + if not value: + continue + out.append(Expectation( + kind="prohibited_color" if negated else "color", + value=value, + rule_id=int(rule.id), + rule_title=rule.title, + context=sentence.strip(), + )) + + # Token names are not negated in practice — a rule says which tokens should + # exist, never which must not — so they are recorded as expectations + # regardless. If that ever changes, it needs its own kind rather than + # borrowing the colour one. + for match in _TOKEN.finditer(sentence): + for name in expand_token_shorthand(match.group(1)): + out.append(Expectation( + kind="token", + value=name, + rule_id=int(rule.id), + rule_title=rule.title, + context=sentence.strip(), + )) + + return out + + +def extract_expectations(rules: list[Rule]) -> list[Expectation]: + """Every checkable claim across a set of rules, deduped on (kind, value). + + First occurrence wins so the reported rule is the one that introduced the + claim, which is usually the most specific place to send a reader. + """ + seen: set[tuple[str, str]] = set() + out: list[Expectation] = [] + for rule in rules: + text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""])) + for sentence in _SENTENCE_SPLIT.split(text): + if not sentence.strip(): + continue + for expectation in _extract_from_sentence(sentence, rule): + key = (expectation.kind, expectation.value) + if key in seen: + continue + seen.add(key) + out.append(expectation) + return out + + +async def get_design_rulebook_id(user_id: int) -> int | None: + """The rulebook this install designated as its design system, if any.""" + raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip() + if not raw: + return None + try: + value = int(raw) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + +async def design_expectations(user_id: int) -> ExpectationSet: + """Checkable claims from the designated design rulebook. + + Returns an empty set when no rulebook is designated — the normal case for + any install but the one that set it up (rule #115). The caller shows an + explanatory empty state rather than treating this as an error. + """ + rulebook_id = await get_design_rulebook_id(user_id) + if rulebook_id is None: + return ExpectationSet() + + from scribe.services import rulebooks as rulebooks_svc + + try: + rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id) + except Exception: + logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True) + return ExpectationSet(rulebook_id=rulebook_id) + + return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules)) diff --git a/tests/test_design_system.py b/tests/test_design_system.py new file mode 100644 index 0000000..0b29af2 --- /dev/null +++ b/tests/test_design_system.py @@ -0,0 +1,161 @@ +"""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_system 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