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
+36
View File
@@ -123,3 +123,39 @@ export const setProjectDesignSystem = (
`/api/projects/${projectId}/design-system`,
{ 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,
});
+157
View File
@@ -29,12 +29,15 @@ import {
fetchDesignSystems,
fetchDesignTokens,
fetchResolvedTokens,
importFromRulebook,
updateDesignSystem,
updateDesignToken,
type DesignSystem,
type DesignToken,
type ImportReport,
type ResolvedToken,
} from "@/api/designSystems";
import { listRulebooks, type Rulebook } from "@/api/rulebooks";
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
@@ -382,6 +385,56 @@ const overrideCount = computed(
() => resolved.value.filter((t) => Object.values(t.origin_by_mode).some((id) => id === selectedId.value)).length,
);
// --- 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());
}
@@ -529,6 +582,106 @@ function isColourish(value: string): boolean {
</div>
</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">
@@ -1066,6 +1219,10 @@ function isColourish(value: string): boolean {
gap: 0.35rem;
}
.import-summary {
margin-top: 0.75rem;
}
.supersedes {
font-size: 0.75rem;
color: var(--color-text-muted);