revert(design-systems): drop the rulebook import — a migration, not a feature
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s

Operator's call, and it corrects a scope error rather than a bug:

  "this is a path for a user to go from a rulebook to a design system. we don't
   need to build this path in the app itself ... you should be the one that does
   the import ... going forward no one else should have to do such a migration."

Right. Nobody starting from a design system will ever go rulebook -> system, so
the whole path was permanent product code serving a single act on one install.
Rule #22: remove it, don't flag it off. Gone from the service, the REST route,
the MCP tool, the UI panel, the API client and its tests.

There is a second consequence I had missed, and it is the better argument. The
parser was WORSE at this than doing it by hand. `propose_tokens` leaves radius
steps and type sizes valueless because "Small 4px" is not a hex and nothing here
parses it — a limitation I documented carefully and shipped anyway. But that
limitation only exists because the importer had to run unattended. Done as work
rather than as a feature, those values are just read and written, and the result
is a complete design system instead of one with a dozen blanks and a count
explaining them.

Scaffolding built around my own absence from the loop, when I am the loop.

KEPT: `extract_expectations` and `design_expectations` in
services/design_rulebook_import.py. The live drift panel still reads them until
it is repointed at a resolved design system (#2295), and removing them now would
take the /design page's only content with it. They go with that change, not this
one.
This commit is contained in:
2026-07-31 09:46:12 -04:00
parent 8eef9e7845
commit 23a385e2db
9 changed files with 5 additions and 749 deletions
-36
View File
@@ -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. */
-157
View File
@@ -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);