Design systems as records — the stylesheet Scribe holds, plus two live bug fixes #88
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user