feat(design-systems): import a design system out of a rulebook's prose
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 38s

Milestone #254 step 3 (#2288). Reuses #251's prose extractor as the reader and
adds the part that makes it an import rather than a list of claims.

**The join is the whole trick.** A rulebook states a design system in two places
and neither half is a token: one rule names the colours ("Obsidian #14171A (page
bg, deepest surface)"), another names the custom properties
(`--fs-obsidian/iron/slate`). The import pairs them on the word — `--fs-obsidian`
ends with `obsidian` — which is the only reason it produces something usable
instead of seventy empty names. The parenthetical becomes the token's purpose,
which is the field a bare hex could never carry.

**Prohibitions arrive as replacements, per the operator's reframe.** Rule 52
declares Parchment and forbids pure white in one breath, so the import emits
"write --fs-parchment instead of #ffffff" — the same fact stated forwards. It
attaches to the FIRST token that rule supplied a value for, not to every token
of that rule, because claiming Vellum is also the replacement for white would be
putting words in the rulebook's mouth.

**A token the rulebook names but states no readable value for is still
proposed, with an empty value.** Radius steps and type sizes are prose ("Small
4px") and nothing here parses them; inventing a parse per shape would be
guessing. The name is real and the value needs a human, so the proposal says
exactly that — and the UI leads with the COUNT of those, because an import that
hid them would look more complete than it is.

Preview is the default on both surfaces and in the UI. An import is a proposal:
rulebooks are written aspirationally and some of what they describe was never
built, so every entry carries the rule id and the sentence it came from and a
reviewer can check the claim rather than trust it.

Existing token names are never overwritten. A value already in the record was
put there deliberately — most likely correcting this importer — so a re-run
fills gaps and lists the rest as skipped, which also makes it safe to repeat.

Colours the rulebook names but never exposes as a custom property produce no
token: it never asked for one, and inventing a name would put something in the
record no rule sanctions.
This commit is contained in:
2026-07-30 21:23:32 -04:00
parent 3da40abcb8
commit 4dc57f8ab2
9 changed files with 751 additions and 3 deletions
@@ -230,3 +230,175 @@ async def design_expectations(user_id: int) -> ExpectationSet:
return ExpectationSet(rulebook_id=rulebook_id)
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
# ---------------------------------------------------------------------------
# Import — turning a rulebook into a PROPOSED design system (milestone #254 step 3)
# ---------------------------------------------------------------------------
#
# The extraction above answers "what claims does this rulebook make?", which is
# what a drift panel needs. Seeding a design system needs a different shape:
# tokens with names AND values, which the rulebook states in two separate
# places. Rule 51 names the colours ("Obsidian #14171A (page bg, deepest
# surface)"); rule 72 names the custom properties (`--fs-obsidian/iron/...`).
# Neither alone is a token.
#
# So the import joins them on the WORD: `--fs-obsidian` ends with `obsidian`,
# and a colour called Obsidian was declared elsewhere. That join is mechanical
# and it is the only reason an import produces something usable rather than 70
# empty names.
#
# AN IMPORT IS A PROPOSAL, NOT A TRUTH. Rulebooks are written aspirationally and
# some of what they describe was never built. Every proposed token therefore
# carries the rule and sentence it came from, so a reviewer can check the claim
# rather than trust it.
# "Obsidian #14171A (page bg, deepest surface)" — a capitalised name, a hex, and
# an optional parenthetical saying what it is for.
_NAMED_COLOUR = re.compile(
r"\b([A-Z][A-Za-z]*(?:\s+[A-Z][A-Za-z]*)?)\s+(#[0-9a-fA-F]{3,8})\b"
r"(?:\s*\(([^)]{0,80})\))?"
)
@dataclass
class ProposedToken:
"""One token an import suggests, with the evidence for it.
`value_by_mode` is empty when the rulebook names the token but states no
value this can read — radius steps, type sizes and durations are prose
(`Small 4px`), not hex, and inventing a parse for each would be guessing.
An empty value is the honest output: the name is real, the value needs a
human. Reporting how many landed that way is part of the result.
"""
name: str
value_by_mode: dict[str, str] = field(default_factory=dict)
group_name: str | None = None
purpose: str | None = None
supersedes: list[str] = field(default_factory=list)
source_rule_id: int | None = None
source_rule_title: str = ""
source_context: str = ""
def as_dict(self) -> dict:
return {
"name": self.name,
"value_by_mode": self.value_by_mode,
"group_name": self.group_name,
"purpose": self.purpose,
"supersedes": self.supersedes,
"source_rule_id": self.source_rule_id,
"source_rule_title": self.source_rule_title,
"source_context": self.source_context,
}
@dataclass
class _NamedColour:
value: str
purpose: str | None
rule_id: int
rule_title: str
context: str
def _group_from_name(name: str) -> str | None:
"""`--fs-radius-sm` -> "radius"; `--fs-obsidian` -> None.
A family name has a middle segment; a flat one does not. Structural rather
than a lookup table, so it works on a naming scheme this code has never
seen — which rule #115 requires, since the prefix is each install's own.
"""
parts = [p for p in name.lstrip("-").split("-") if p]
return parts[1] if len(parts) >= 3 else None
def _named_colours(rules: list[Rule]) -> dict[str, _NamedColour]:
"""Every `Name #hex (purpose)` a rulebook declares, keyed by lowercased name.
First declaration wins, matching `extract_expectations` — the rule that
introduces a colour is the one worth citing.
"""
out: dict[str, _NamedColour] = {}
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() or _is_negated(sentence):
continue
for match in _NAMED_COLOUR.finditer(sentence):
label, raw_hex, purpose = match.groups()
value = normalize_hex(raw_hex)
key = label.strip().lower()
if not value or key in out:
continue
out[key] = _NamedColour(
value=value,
purpose=(purpose or "").strip() or None,
rule_id=int(rule.id),
rule_title=rule.title,
context=sentence.strip(),
)
return out
def _prohibitions_by_rule(rules: list[Rule]) -> dict[int, list[str]]:
"""Forbidden colours, grouped by the rule that forbids them."""
out: dict[int, list[str]] = {}
for expectation in extract_expectations(rules):
if expectation.kind == "prohibited_color":
out.setdefault(expectation.rule_id, []).append(expectation.value)
return out
def propose_tokens(rules: list[Rule]) -> list[ProposedToken]:
"""Turn a rulebook into the design system it is describing.
One proposal per custom-property NAME the rulebook declares, valued from the
named colour whose word matches the token's last segment.
Prohibitions attach as `supersedes` on the first token drawn from the SAME
rule that forbids them. Rule 52 declares Parchment/Vellum/Ash and forbids
pure white in one breath, so pure white becomes "write --fs-parchment
instead" — the positive form of what the rule was saying. Guessing which
token inherits the prohibition is acceptable precisely because this is a
proposal a human reviews; guessing silently would not be, which is why every
entry carries its source sentence.
"""
colours = _named_colours(rules)
prohibited = _prohibitions_by_rule(rules)
claimed_prohibitions: set[int] = set()
proposals: list[ProposedToken] = []
seen: set[str] = set()
for expectation in extract_expectations(rules):
if expectation.kind != "token" or expectation.value in seen:
continue
seen.add(expectation.value)
suffix = expectation.value.rsplit("-", 1)[-1].lower()
colour = colours.get(suffix)
proposal = ProposedToken(
name=expectation.value,
value_by_mode={"base": colour.value} if colour else {},
group_name=_group_from_name(expectation.value),
purpose=colour.purpose if colour else None,
source_rule_id=colour.rule_id if colour else expectation.rule_id,
source_rule_title=colour.rule_title if colour else expectation.rule_title,
source_context=colour.context if colour else expectation.context,
)
# The prohibition rides on the first token that rule supplied a value
# for — its primary. Attaching it to every token of that rule would
# claim the rulebook said something it didn't.
if colour and colour.rule_id not in claimed_prohibitions:
forbidden = prohibited.get(colour.rule_id)
if forbidden:
proposal.supersedes = list(forbidden)
claimed_prohibitions.add(colour.rule_id)
proposals.append(proposal)
return proposals
+71
View File
@@ -331,3 +331,74 @@ async def set_project_design_system(
project.updated_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- import from a rulebook -------------------------------------------------
async def import_from_rulebook(
user_id: int,
design_system_id: int,
rulebook_id: int,
apply: bool = False,
) -> dict | None:
"""Propose (and optionally create) tokens for a system from a rulebook.
Returns None when the caller may not write the system or read the rulebook.
Otherwise a report with three lists, and the split between them is the whole
point of running it with `apply=False` first:
proposed — everything the rulebook describes, each entry carrying the
rule and sentence it came from
created — what was actually written (empty unless `apply`)
skipped — proposals whose name the system already defines
**Existing tokens are never overwritten.** An import is a proposal built by
reading prose; a value already in the record was put there deliberately, and
a re-run must not undo an operator's correction. That also makes the whole
operation safe to repeat — it fills gaps and reports the rest.
Tokens with no value are still created when `apply` is set. The rulebook
names them, so their absence from the system is itself a finding, and a
named token with a blank value says "this exists and needs deciding" where
silence says nothing at all.
"""
if not await access.can_write_design_system(user_id, design_system_id):
return None
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.design_rulebook_import import propose_tokens
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
if not rules:
return {"rulebook_id": rulebook_id, "proposed": [], "created": [], "skipped": []}
proposals = propose_tokens(rules)
existing = {t.name for t in await list_tokens(user_id, design_system_id)}
created: list[dict] = []
skipped: list[str] = []
for index, proposal in enumerate(proposals):
if proposal.name in existing:
skipped.append(proposal.name)
continue
if not apply:
continue
token = await create_token(
user_id,
design_system_id=design_system_id,
name=proposal.name,
value_by_mode=proposal.value_by_mode,
group_name=proposal.group_name,
purpose=proposal.purpose,
supersedes=proposal.supersedes,
order_index=index,
)
if token is not None:
created.append(token.to_dict())
return {
"rulebook_id": rulebook_id,
"proposed": [p.as_dict() for p in proposals],
"created": created,
"skipped": skipped,
}