Drop the rulebook import — a migration, not a product feature #90
@@ -136,42 +136,6 @@ export const setProjectDesignSystem = (
|
||||
{ design_system_id: designSystemId },
|
||||
);
|
||||
|
||||
/** One token an import proposes, with the evidence for it. */
|
||||
export interface ProposedToken {
|
||||
name: string;
|
||||
value_by_mode: Record<string, string>;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
supersedes: string[];
|
||||
source_rule_id: number | null;
|
||||
source_rule_title: string;
|
||||
source_context: string;
|
||||
}
|
||||
|
||||
export interface ImportReport {
|
||||
rulebook_id: number;
|
||||
proposed: ProposedToken[];
|
||||
created: DesignToken[];
|
||||
/** Proposals whose name the system already defines. Never overwritten. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** Seed a design system from a rulebook that describes one in prose.
|
||||
*
|
||||
* Defaults to a PREVIEW: an import is a proposal, since rulebooks are written
|
||||
* aspirationally and some of what they describe was never built. Pass
|
||||
* `apply: true` to write. Existing token names are never overwritten, so a
|
||||
* second run fills gaps and reports the rest. */
|
||||
export const importFromRulebook = (
|
||||
designSystemId: number,
|
||||
rulebookId: number,
|
||||
apply = false,
|
||||
) =>
|
||||
apiPost<ImportReport>(`/api/design-systems/${designSystemId}/import`, {
|
||||
rulebook_id: rulebookId,
|
||||
apply,
|
||||
});
|
||||
|
||||
export interface StylesheetResult {
|
||||
design_system_id: number;
|
||||
/** The master sheet: purpose tokens only, no element or class rules. */
|
||||
|
||||
@@ -31,17 +31,14 @@ import {
|
||||
fetchResolvedTokens,
|
||||
checkSnippets,
|
||||
fetchStylesheet,
|
||||
importFromRulebook,
|
||||
updateDesignSystem,
|
||||
updateDesignToken,
|
||||
type DesignSystem,
|
||||
type DesignToken,
|
||||
type ImportReport,
|
||||
type ResolvedToken,
|
||||
type SnippetCheck,
|
||||
type StylesheetResult,
|
||||
} from "@/api/designSystems";
|
||||
import { listRulebooks, type Rulebook } from "@/api/rulebooks";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
@@ -472,56 +469,6 @@ watch(selectedId, () => {
|
||||
snippetCheck.value = null;
|
||||
});
|
||||
|
||||
// --- import from a rulebook -------------------------------------------------
|
||||
|
||||
const rulebooks = ref<Rulebook[]>([]);
|
||||
const importRulebookId = ref<number | null>(null);
|
||||
const importReport = ref<ImportReport | null>(null);
|
||||
const importing = ref(false);
|
||||
const showImport = ref(false);
|
||||
|
||||
async function loadRulebooks() {
|
||||
try {
|
||||
rulebooks.value = await listRulebooks();
|
||||
} catch {
|
||||
rulebooks.value = [];
|
||||
}
|
||||
}
|
||||
onMounted(loadRulebooks);
|
||||
|
||||
/** Preview, then apply — never one step.
|
||||
*
|
||||
* A rulebook is prose written aspirationally, so an import is a proposal and
|
||||
* the operator has to be able to read it before it becomes records. Applying
|
||||
* from an unread preview is still one click; applying without one is not
|
||||
* possible, which is the point. */
|
||||
async function runImport(apply: boolean) {
|
||||
if (selectedId.value === null || importRulebookId.value === null || importing.value) return;
|
||||
importing.value = true;
|
||||
try {
|
||||
const report = await importFromRulebook(selectedId.value, importRulebookId.value, apply);
|
||||
importReport.value = report;
|
||||
if (apply) {
|
||||
await loadDetail(selectedId.value);
|
||||
toast.show(`Imported ${report.created.length} tokens`);
|
||||
}
|
||||
} catch {
|
||||
toast.show("Import failed", "error");
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Proposals the rulebook names but states no readable value for.
|
||||
*
|
||||
* Surfaced as a count rather than buried: the rulebook writes radius steps and
|
||||
* type sizes as prose ("Small 4px"), which nothing parses, so these arrive as
|
||||
* names awaiting a value. That is an honest gap, and hiding it would make the
|
||||
* import look more complete than it is. */
|
||||
const proposalsWithoutValues = computed(
|
||||
() => importReport.value?.proposed.filter((p) => !Object.keys(p.value_by_mode).length).length ?? 0,
|
||||
);
|
||||
|
||||
function isColourish(value: string): boolean {
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||
}
|
||||
@@ -836,106 +783,6 @@ function isColourish(value: string): boolean {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Import from a rulebook -->
|
||||
<section class="ds-section">
|
||||
<div class="section-head">
|
||||
<h2>Import from a rulebook</h2>
|
||||
<button class="btn-ghost btn-small" @click="showImport = !showImport">
|
||||
{{ showImport ? "Hide" : "Show" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="showImport">
|
||||
<p class="section-note">
|
||||
Reads a rulebook's colour and token declarations and proposes the
|
||||
tokens they add up to — joining "Obsidian #14171A" in one rule to
|
||||
<code>--fs-obsidian</code> in another, since neither alone is a
|
||||
token. Preview first: a rulebook is written aspirationally, so
|
||||
this is a proposal rather than a fact.
|
||||
</p>
|
||||
|
||||
<p v-if="!rulebooks.length" class="muted">
|
||||
No rulebooks to import from.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="field">
|
||||
<label class="field-label" for="import-rulebook">Rulebook</label>
|
||||
<select id="import-rulebook" v-model="importRulebookId" class="input">
|
||||
<option :value="null">Choose one…</option>
|
||||
<option v-for="rb in rulebooks" :key="rb.id" :value="rb.id">{{ rb.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row-actions">
|
||||
<button
|
||||
class="btn-ghost" :disabled="importRulebookId === null || importing"
|
||||
@click="runImport(false)"
|
||||
>
|
||||
{{ importing ? "Reading…" : "Preview" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="importReport && importReport.proposed.length"
|
||||
class="btn-primary" :disabled="importing" @click="runImport(true)"
|
||||
>
|
||||
Import {{ importReport.proposed.length - importReport.skipped.length }} tokens
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="importReport">
|
||||
<p class="section-note import-summary">
|
||||
<strong>{{ importReport.proposed.length }}</strong> proposed ·
|
||||
<strong>{{ proposalsWithoutValues }}</strong> with no value the
|
||||
rulebook states in a readable form ·
|
||||
<strong>{{ importReport.skipped.length }}</strong> already defined here
|
||||
<template v-if="importReport.created.length">
|
||||
· <strong>{{ importReport.created.length }}</strong> created
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<p v-if="!importReport.proposed.length" class="muted">
|
||||
That rulebook names no custom properties, so there is nothing
|
||||
to propose. Most rulebooks aren't design rulebooks.
|
||||
</p>
|
||||
|
||||
<ul v-else class="token-list">
|
||||
<li v-for="p in importReport.proposed" :key="p.name">
|
||||
<code class="token-name">{{ p.name }}</code>
|
||||
<span class="token-values">
|
||||
<span
|
||||
v-for="(value, mode) in p.value_by_mode" :key="mode" class="mode-chip"
|
||||
>
|
||||
<span
|
||||
v-if="isColourish(value)" class="swatch"
|
||||
:style="{ background: value }" aria-hidden="true"
|
||||
/>
|
||||
<code>{{ value }}</code>
|
||||
</span>
|
||||
<span v-if="!Object.keys(p.value_by_mode).length" class="supersedes">
|
||||
needs a value
|
||||
</span>
|
||||
</span>
|
||||
<span class="token-meta">
|
||||
{{ p.purpose || "" }}
|
||||
<span v-if="p.supersedes.length" class="supersedes">
|
||||
instead of {{ p.supersedes.join(", ") }}
|
||||
</span>
|
||||
<span v-if="p.source_rule_id" class="supersedes">
|
||||
rule #{{ p.source_rule_id }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="importReport.skipped.includes(p.name)" class="origin-badge"
|
||||
>
|
||||
already defined
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Overrides -->
|
||||
<section class="ds-section">
|
||||
<div class="section-head">
|
||||
@@ -1565,10 +1412,6 @@ textarea.input {
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.import-summary {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.supersedes {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -210,42 +210,6 @@ async def check_snippets_against_design_system(
|
||||
return result
|
||||
|
||||
|
||||
async def import_design_system_from_rulebook(
|
||||
design_system_id: int,
|
||||
rulebook_id: int,
|
||||
apply: bool = False,
|
||||
) -> dict:
|
||||
"""Seed a design system from a rulebook that describes one in prose.
|
||||
|
||||
Reads the rulebook's colour and token declarations and proposes the tokens
|
||||
they add up to — joining "Obsidian #14171A" in one rule to `--fs-obsidian`
|
||||
in another, since neither alone is a token.
|
||||
|
||||
Defaults to a PREVIEW. An import is a proposal: rulebooks are written
|
||||
aspirationally and some of what they describe was never built, so read
|
||||
`proposed` before setting apply=True. Every entry carries the rule and
|
||||
sentence it came from so the claim can be checked rather than trusted.
|
||||
|
||||
Existing token names are never overwritten — a second run fills gaps and
|
||||
lists the rest under `skipped`, so it is safe to repeat.
|
||||
|
||||
Args:
|
||||
design_system_id: The system to seed.
|
||||
rulebook_id: The rulebook to read.
|
||||
apply: False (default) previews; True writes the tokens.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
report = await ds_svc.import_from_rulebook(
|
||||
uid, design_system_id, rulebook_id, apply=apply,
|
||||
)
|
||||
if report is None:
|
||||
raise ValueError(
|
||||
f"design system {design_system_id} not writable, or rulebook "
|
||||
f"{rulebook_id} not readable"
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_design_token(
|
||||
@@ -388,7 +352,6 @@ def register(mcp) -> None:
|
||||
delete_design_system,
|
||||
get_design_system_stylesheet,
|
||||
check_snippets_against_design_system,
|
||||
import_design_system_from_rulebook,
|
||||
create_design_token,
|
||||
list_design_tokens,
|
||||
update_design_token,
|
||||
|
||||
@@ -156,30 +156,6 @@ async def check_snippets_against_system(design_system_id: int):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@design_systems_bp.post("/design-systems/<int:design_system_id>/import")
|
||||
@login_required
|
||||
async def import_design_system(design_system_id: int):
|
||||
"""Seed a design system from a rulebook's prose.
|
||||
|
||||
`{"rulebook_id": N}` previews; add `"apply": true` to write. Preview is the
|
||||
default because an import is a PROPOSAL — rulebooks are written
|
||||
aspirationally and some of what they describe was never built.
|
||||
|
||||
Existing token names are never overwritten, so a second run fills gaps and
|
||||
reports the rest rather than undoing corrections.
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
rulebook_id = data.get("rulebook_id")
|
||||
if not isinstance(rulebook_id, int) or rulebook_id <= 0:
|
||||
return jsonify({"error": "rulebook_id is required"}), 400
|
||||
report = await ds_svc.import_from_rulebook(
|
||||
_uid(), design_system_id, rulebook_id, apply=bool(data.get("apply")),
|
||||
)
|
||||
if report is None:
|
||||
return _not_found("design system or rulebook")
|
||||
return jsonify(report)
|
||||
|
||||
|
||||
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
|
||||
|
||||
@@ -230,175 +230,3 @@ 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
|
||||
|
||||
@@ -342,77 +342,6 @@ async def set_project_design_system(
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# --- the master sheet -------------------------------------------------------
|
||||
|
||||
async def stylesheet_for_system(
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Rulebook prose -> a PROPOSED design system (milestone #254 step 3).
|
||||
|
||||
The extraction tested in test_design_rulebook_import.py answers "what claims does
|
||||
this rulebook make". This answers a harder question — "what design system is it
|
||||
describing" — which needs the two halves joined: one rule names the colours,
|
||||
another names the custom properties, and neither alone is a token.
|
||||
|
||||
Rule text is representative rather than copied from this operator's rulebook
|
||||
(rule #115): a test that only passes against their exact wording would be
|
||||
testing the instance.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scribe.services.design_rulebook_import import propose_tokens
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
SURFACES = _rule(
|
||||
51, "Universal surfaces",
|
||||
"Obsidian #14171A (page bg, deepest surface), Iron #1E2228 (cards), "
|
||||
"Slate #2C313A (hovered surfaces).",
|
||||
)
|
||||
TEXT = _rule(
|
||||
52, "Text palette",
|
||||
"Text tokens: Parchment #E8E4D8 (primary text), Vellum #C2BFB4 (secondary). "
|
||||
"Pure white #FFFFFF is NEVER used as text color.",
|
||||
)
|
||||
PROPERTIES = _rule(
|
||||
72, "CSS custom properties",
|
||||
"Expose the system as custom properties: surfaces "
|
||||
"(--fs-obsidian/iron/slate), text (--fs-parchment/vellum), and radius "
|
||||
"(--fs-radius-sm/md/lg).",
|
||||
)
|
||||
|
||||
|
||||
def _by_name(proposals):
|
||||
return {p.name: p for p in proposals}
|
||||
|
||||
|
||||
# --- the join ---------------------------------------------------------------
|
||||
|
||||
def test_a_token_takes_its_value_from_the_colour_of_the_same_name():
|
||||
"""THE mechanism. `--fs-obsidian` and "Obsidian #14171A" are declared in
|
||||
different rules and neither is a token on its own. Joining them on the word
|
||||
is the only reason an import produces something usable instead of a list of
|
||||
empty names."""
|
||||
proposals = _by_name(propose_tokens([SURFACES, PROPERTIES]))
|
||||
assert proposals["--fs-obsidian"].value_by_mode == {"base": "#14171a"}
|
||||
assert proposals["--fs-iron"].value_by_mode == {"base": "#1e2228"}
|
||||
|
||||
|
||||
def test_the_parenthetical_becomes_the_tokens_purpose():
|
||||
"""Rulebooks say what a colour is FOR right beside its value, and that is
|
||||
the field a bare hex can never carry."""
|
||||
proposals = _by_name(propose_tokens([SURFACES, PROPERTIES]))
|
||||
assert proposals["--fs-obsidian"].purpose == "page bg, deepest surface"
|
||||
|
||||
|
||||
def test_a_token_with_no_matching_colour_is_proposed_with_no_value():
|
||||
"""HONEST OUTPUT, not a failure. The rulebook states radius steps as prose
|
||||
("Small 4px"), which nothing here parses. The name is real and the value
|
||||
needs a human — proposing the name with an empty value says exactly that,
|
||||
where dropping it would hide a token the rulebook asked for."""
|
||||
proposals = _by_name(propose_tokens([SURFACES, PROPERTIES]))
|
||||
assert proposals["--fs-radius-sm"].value_by_mode == {}
|
||||
assert "--fs-radius-lg" in proposals
|
||||
|
||||
|
||||
def test_every_proposal_carries_the_rule_and_sentence_it_came_from():
|
||||
"""An import is a proposal a human reviews, and a claim you cannot trace is
|
||||
a claim you have to take on faith."""
|
||||
obsidian = _by_name(propose_tokens([SURFACES, PROPERTIES]))["--fs-obsidian"]
|
||||
assert obsidian.source_rule_id == 51
|
||||
assert obsidian.source_rule_title == "Universal surfaces"
|
||||
assert "Obsidian #14171A" in obsidian.source_context
|
||||
|
||||
|
||||
# --- prohibitions become replacements ---------------------------------------
|
||||
|
||||
def test_a_prohibition_becomes_supersedes_on_that_rules_primary_token():
|
||||
"""The reframe, end to end. Rule 52 declares Parchment and forbids pure
|
||||
white in one breath; the import turns that into "write --fs-parchment
|
||||
instead of #ffffff" — the same fact, stated forwards, and actionable."""
|
||||
proposals = _by_name(propose_tokens([TEXT, PROPERTIES]))
|
||||
assert proposals["--fs-parchment"].supersedes == ["#ffffff"]
|
||||
|
||||
|
||||
def test_a_prohibition_attaches_to_one_token_not_every_token_of_its_rule():
|
||||
"""Rule 52 declares two colours. Attaching the prohibition to both would
|
||||
claim the rulebook said something it didn't — that Vellum is also the
|
||||
replacement for white."""
|
||||
proposals = _by_name(propose_tokens([TEXT, PROPERTIES]))
|
||||
assert proposals["--fs-vellum"].supersedes == []
|
||||
|
||||
|
||||
def test_a_forbidden_colour_never_becomes_a_token_value():
|
||||
"""Sentence-scoped negation carried through to the import: #FFFFFF appears
|
||||
in rule 52 as a hex, and a naive read would make it Parchment's value."""
|
||||
proposals = propose_tokens([TEXT, PROPERTIES])
|
||||
for proposal in proposals:
|
||||
assert proposal.value_by_mode.get("base") != "#ffffff"
|
||||
|
||||
|
||||
# --- grouping ---------------------------------------------------------------
|
||||
|
||||
def test_a_family_token_is_grouped_by_its_middle_segment():
|
||||
"""`--fs-radius-sm` -> "radius". Structural, so it works on a naming scheme
|
||||
this code has never seen — the prefix is each install's own (rule #115)."""
|
||||
proposals = _by_name(propose_tokens([SURFACES, PROPERTIES]))
|
||||
assert proposals["--fs-radius-sm"].group_name == "radius"
|
||||
|
||||
|
||||
def test_a_flat_token_is_left_ungrouped_rather_than_guessed_at():
|
||||
proposals = _by_name(propose_tokens([SURFACES, PROPERTIES]))
|
||||
assert proposals["--fs-obsidian"].group_name is None
|
||||
|
||||
|
||||
# --- shape ------------------------------------------------------------------
|
||||
|
||||
def test_each_token_name_is_proposed_exactly_once():
|
||||
"""The slash shorthand expands and rules repeat colours; neither may produce
|
||||
a duplicate, since two live rows with one name is the duplicate-definition
|
||||
bug the unique index exists to refuse."""
|
||||
names = [p.name for p in propose_tokens([SURFACES, TEXT, PROPERTIES])]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
|
||||
def test_a_rulebook_that_names_no_tokens_proposes_nothing():
|
||||
"""Most rulebooks are not design rulebooks. That has to be an empty result
|
||||
rather than an error — an install can point this at anything."""
|
||||
unrelated = _rule(1, "Branching", "Work happens on the dev branch.")
|
||||
assert propose_tokens([unrelated]) == []
|
||||
|
||||
|
||||
def test_colours_declared_without_a_token_name_are_not_invented_into_tokens():
|
||||
"""A rulebook naming a colour it never exposes as a custom property has not
|
||||
asked for a token, and inventing a name for it would put a token in the
|
||||
record that no rule sanctions."""
|
||||
proposals = propose_tokens([SURFACES])
|
||||
assert proposals == []
|
||||
@@ -24,7 +24,7 @@ def test_route_handlers_callable():
|
||||
for name in (
|
||||
"list_design_systems", "create_design_system", "get_design_system",
|
||||
"update_design_system", "delete_design_system", "resolve_design_system",
|
||||
"import_design_system", "get_design_system_stylesheet",
|
||||
"get_design_system_stylesheet",
|
||||
"check_snippets_against_system",
|
||||
"list_design_tokens", "create_design_token",
|
||||
"update_design_token", "delete_design_token", "set_project_design_system",
|
||||
@@ -46,7 +46,6 @@ def test_every_endpoint_is_reachable_on_the_app():
|
||||
"/api/design-systems",
|
||||
"/api/design-systems/<int:design_system_id>",
|
||||
"/api/design-systems/<int:design_system_id>/resolved",
|
||||
"/api/design-systems/<int:design_system_id>/import",
|
||||
"/api/design-systems/<int:design_system_id>/stylesheet",
|
||||
"/api/design-systems/<int:design_system_id>/snippet-check",
|
||||
"/api/design-systems/<int:design_system_id>/tokens",
|
||||
@@ -62,7 +61,7 @@ def test_service_functions_take_user_id():
|
||||
"create_design_system", "list_design_systems", "get_design_system",
|
||||
"update_design_system", "delete_design_system", "resolve_design_system",
|
||||
"create_token", "list_tokens", "update_token", "delete_token",
|
||||
"set_project_design_system", "import_from_rulebook",
|
||||
"set_project_design_system",
|
||||
"stylesheet_for_system", "check_snippets_against_system",
|
||||
):
|
||||
fn = getattr(svc, fn_name)
|
||||
@@ -91,11 +90,9 @@ def test_agent_and_web_surfaces_stay_at_parity():
|
||||
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
|
||||
assert callable(getattr(routes, name)), f"REST route missing: {name}"
|
||||
|
||||
# Import is the one verb whose handler names differ between the surfaces
|
||||
# (the tool says what it reads FROM; the route is already under the system),
|
||||
# so the loop above can't pair it. It still has to exist on both.
|
||||
assert callable(tools.import_design_system_from_rulebook)
|
||||
assert callable(routes.import_design_system)
|
||||
# The snippet check is the one verb whose handler names differ between the
|
||||
# surfaces (the tool says what it checks AGAINST; the route is already under
|
||||
# the system), so the loop above can't pair it. It still has to exist on both.
|
||||
assert callable(tools.check_snippets_against_design_system)
|
||||
assert callable(routes.check_snippets_against_system)
|
||||
|
||||
|
||||
@@ -276,105 +276,6 @@ async def test_create_token_records_the_literals_it_replaces():
|
||||
assert captured["supersedes"] == ["#fff", "#ffffff"]
|
||||
|
||||
|
||||
# --- import from a rulebook (step 3) ----------------------------------------
|
||||
|
||||
def _proposal(name, value=None):
|
||||
from scribe.services.design_rulebook_import import ProposedToken
|
||||
return ProposedToken(name=name, value_by_mode={"base": value} if value else {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_denied_without_write_on_the_system():
|
||||
with patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=False)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
assert await import_from_rulebook(1, 3, 9) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_proposes_without_creating_anything():
|
||||
"""apply=False is the default because an import is a PROPOSAL. A preview
|
||||
that quietly wrote would make the review step decorative."""
|
||||
created = AsyncMock()
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems.create_token", created), \
|
||||
patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
||||
patch("scribe.services.design_rulebook_import.propose_tokens",
|
||||
MagicMock(return_value=[_proposal("--fs-obsidian", "#14171a")])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=False)
|
||||
|
||||
assert [p["name"] for p in report["proposed"]] == ["--fs-obsidian"]
|
||||
assert report["created"] == []
|
||||
created.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_never_overwrites_a_token_the_system_already_defines():
|
||||
"""LOAD-BEARING for re-running it. A value already in the record was put
|
||||
there deliberately — very likely correcting this importer — and a second run
|
||||
must fill gaps rather than undo the correction."""
|
||||
existing = MagicMock()
|
||||
existing.name = "--fs-obsidian"
|
||||
created_token = MagicMock()
|
||||
created_token.to_dict.return_value = {"name": "--fs-iron"}
|
||||
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems.create_token",
|
||||
AsyncMock(return_value=created_token)) as create, \
|
||||
patch("scribe.services.design_systems.list_tokens",
|
||||
AsyncMock(return_value=[existing])), \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
||||
patch("scribe.services.design_rulebook_import.propose_tokens",
|
||||
MagicMock(return_value=[
|
||||
_proposal("--fs-obsidian", "#000000"),
|
||||
_proposal("--fs-iron", "#1e2228"),
|
||||
])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=True)
|
||||
|
||||
assert report["skipped"] == ["--fs-obsidian"]
|
||||
assert [c["name"] for c in report["created"]] == ["--fs-iron"]
|
||||
assert create.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_valueless_proposal_is_still_created():
|
||||
"""The rulebook names it, so its absence from the system is itself a
|
||||
finding. A named token with a blank value says "this exists and needs
|
||||
deciding"; silence says nothing at all."""
|
||||
token = MagicMock()
|
||||
token.to_dict.return_value = {"name": "--fs-radius-sm"}
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems.create_token",
|
||||
AsyncMock(return_value=token)) as create, \
|
||||
patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
||||
patch("scribe.services.design_rulebook_import.propose_tokens",
|
||||
MagicMock(return_value=[_proposal("--fs-radius-sm")])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=True)
|
||||
|
||||
assert len(report["created"]) == 1
|
||||
assert create.await_args.kwargs["value_by_mode"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unreadable_or_empty_rulebook_reports_nothing_rather_than_failing():
|
||||
"""An install can point this at any rulebook, and most rulebooks are not
|
||||
design rulebooks (rule #115)."""
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=True)
|
||||
assert report == {"rulebook_id": 9, "proposed": [], "created": [], "skipped": []}
|
||||
|
||||
|
||||
# --- the master sheet -------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user