diff --git a/alembic/versions/0075_retire_design_rulebook_setting.py b/alembic/versions/0075_retire_design_rulebook_setting.py
new file mode 100644
index 0000000..6a3250a
--- /dev/null
+++ b/alembic/versions/0075_retire_design_rulebook_setting.py
@@ -0,0 +1,47 @@
+"""retire the two settings that designated a design source for the app itself
+
+Revision ID: 0075
+Revises: 0074
+Create Date: 2026-08-03
+
+Two keys, retired for the same reason a week apart, so they go in one change
+rather than one migration each:
+
+ design_rulebook_id which rulebook described how this app should look
+ ui_design_system_id which design system this app's own UI was built from
+
+Both named a design source for THE RUNNING INSTALL. The design surface is for
+the projects an install tracks, and a project already carries its own pointer
+(`projects.design_system_id`) — so an install-wide designation had nothing left
+to mean. `ui_design_system_id` was introduced by this same migration's first
+draft and never reached a deployed database; it is listed here rather than
+undone by an 0076 that would reverse a change nobody ran.
+
+Deleting settings rows by key is safe in a way dropping a column is not — the
+table is free-form key/value, so an install that never designated one simply has
+no row to delete.
+
+Downgrade cannot restore what it never recorded, so it is a no-op rather than a
+lie: the pointer lives on the project now, and always did for anyone who set it
+there.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0075"
+down_revision = "0074"
+branch_labels = None
+depends_on = None
+
+def upgrade() -> None:
+ op.execute(
+ sa.text(
+ "DELETE FROM settings "
+ "WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
+ )
+ )
+
+
+def downgrade() -> None:
+ pass
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 25b887f..044e3d6 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -300,7 +300,7 @@ onUnmounted(() => {
.shortcuts-overlay {
position: fixed;
inset: 0;
- background: var(--color-overlay, rgba(0, 0, 0, 0.45));
+ background: var(--color-overlay);
z-index: 9000;
display: flex;
align-items: center;
@@ -309,8 +309,8 @@ onUnmounted(() => {
.shortcuts-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
- border-radius: var(--radius-md, 8px);
- box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
+ border-radius: var(--radius-md);
+ box-shadow: 0 8px 32px var(--color-shadow);
width: min(420px, 92vw);
overflow: hidden;
}
diff --git a/frontend/src/api/design.ts b/frontend/src/api/design.ts
deleted file mode 100644
index cbdfe09..0000000
--- a/frontend/src/api/design.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { apiGet } from "@/api/client";
-import type { ExpectationResponse } from "@/utils/designDrift";
-
-/** Checkable claims from the rulebook this install designated as its design system.
- *
- * `rulebook_id: null` means none has been designated — the normal state for a
- * fresh install, not an error. The caller shows an explanatory empty state. */
-export const fetchDesignExpectations = () =>
- apiGet("/api/design/expectations");
diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts
index 89cb9ba..7641ccf 100644
--- a/frontend/src/api/designSystems.ts
+++ b/frontend/src/api/designSystems.ts
@@ -74,11 +74,28 @@ export const fetchDesignSystems = () =>
export const fetchDesignSystem = (id: number) =>
apiGet(`/api/design-systems/${id}`);
+export interface StarterRoleGroup {
+ group: string;
+ description: string;
+ token_count: number;
+ names: string[];
+}
+
+/** The starter token ROLES offered at creation — names and purposes, never
+ * values. A default palette would be one install's taste shipped as product
+ * (rule #115), so the values are always the operator's to fill. */
+export const listStarterRoleGroups = () =>
+ apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
+ "/api/design-systems/starter-roles",
+ );
+
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
+ starter_role_groups?: string[];
+ token_prefix?: string;
}) => apiPost("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
@@ -183,6 +200,14 @@ export interface SnippetCheck {
findings: SnippetFinding[];
}
-/** Which recorded snippets disagree with this design system's sheet. */
-export const checkSnippets = (id: number) =>
- apiGet(`/api/design-systems/${id}/snippet-check`);
+/** Which recorded snippets disagree with this design system's sheet.
+ *
+ * `projectId` narrows to the snippets one project owns — which is how a
+ * project asks about its OWN code. Omit it to check every project, which is
+ * the right default from the system's side: a component recorded elsewhere
+ * still has to use the same tags. */
+export const checkSnippets = (id: number, projectId?: number) =>
+ apiGet(
+ `/api/design-systems/${id}/snippet-check`
+ + (projectId ? `?project_id=${projectId}` : ""),
+ );
diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css
index eaecc14..fa0af46 100644
--- a/frontend/src/assets/editor-shared.css
+++ b/frontend/src/assets/editor-shared.css
@@ -98,8 +98,8 @@
color: var(--fs-text-on-action);
}
.tag-pill.applied {
- background: var(--color-success, #2ecc71);
- border-color: var(--color-success, #2ecc71);
+ background: var(--color-success);
+ border-color: var(--color-success);
color: var(--fs-text-on-action);
cursor: default;
}
diff --git a/frontend/src/assets/prose.css b/frontend/src/assets/prose.css
index b3cf455..6056d48 100644
--- a/frontend/src/assets/prose.css
+++ b/frontend/src/assets/prose.css
@@ -219,7 +219,7 @@
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
- color: var(--color-text-muted, var(--color-text-secondary));
+ color: var(--color-text-muted);
content: attr(data-placeholder);
float: left;
height: 0;
@@ -234,5 +234,5 @@
}
.tiptap-wrapper:focus-within {
- box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
+ box-shadow: var(--focus-ring);
}
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index ac3ef6e..5ce2c85 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
-import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
+import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -50,6 +50,12 @@ router.afterEach(() => {
ProjectsSnippetsRulebooks
+
+ Design
@@ -64,16 +70,6 @@ router.afterEach(() => {
-
-
-
-
-
@@ -106,9 +102,9 @@ router.afterEach(() => {
ProjectsSnippetsRulebooks
+ DesignShared
- DesignTrashSettings
@@ -129,7 +125,7 @@ router.afterEach(() => {
diff --git a/frontend/src/components/DiffView.vue b/frontend/src/components/DiffView.vue
index 95531c0..01fb67f 100644
--- a/frontend/src/components/DiffView.vue
+++ b/frontend/src/components/DiffView.vue
@@ -110,8 +110,8 @@ function markerFor(type: DiffLine['type']): string {
font-weight: 600;
}
-.diff-summary-ins { color: var(--color-success, #2ecc71); }
-.diff-summary-del { color: var(--color-danger, #e74c3c); }
+.diff-summary-ins { color: var(--color-success); }
+.diff-summary-del { color: var(--color-danger); }
.diff-scroll {
flex: 1;
@@ -136,13 +136,13 @@ function markerFor(type: DiffLine['type']): string {
}
.diff-delete {
- background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
- color: var(--color-danger, #e74c3c);
+ background: color-mix(in srgb, var(--color-danger) 12%, transparent);
+ color: var(--color-danger);
}
.diff-insert {
- background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
- color: var(--color-success, #2ecc71);
+ background: color-mix(in srgb, var(--color-success) 12%, transparent);
+ color: var(--color-success);
}
.diff-equal {
diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue
index a4ddd89..4b2211f 100644
--- a/frontend/src/components/HistoryPanel.vue
+++ b/frontend/src/components/HistoryPanel.vue
@@ -403,12 +403,12 @@ onMounted(loadVersions);
font-size: 0.85em;
line-height: 1;
}
-.pin-badge-manual { color: var(--color-primary, #6366f1); }
-.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
+.pin-badge-manual { color: var(--color-primary); }
+.pin-badge-auto { color: var(--color-text-muted); }
.history-item-label {
font-size: 0.72rem;
- color: var(--color-primary, #6366f1);
+ color: var(--color-primary);
font-style: italic;
margin-top: 0.15rem;
overflow: hidden;
@@ -430,7 +430,7 @@ onMounted(loadVersions);
}
.pin-state {
font-style: italic;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
+ color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
@@ -442,13 +442,13 @@ onMounted(loadVersions);
font-size: 0.78rem;
background: transparent;
color: inherit;
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
+ border: 1px solid var(--color-border);
border-radius: 999px;
cursor: pointer;
}
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
- border-color: var(--color-primary, #6366f1);
+ border-color: var(--color-primary);
}
.btn-unpin:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.10);
@@ -463,27 +463,27 @@ onMounted(loadVersions);
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
- background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
- border-radius: var(--radius-sm, 4px);
+ background: var(--color-input-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
color: inherit;
}
.pin-label-input:focus {
outline: none;
- border-color: var(--color-primary, #6366f1);
+ border-color: var(--color-primary);
}
.btn-pin-save, .btn-pin-cancel {
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
background: transparent;
color: inherit;
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
- border-radius: var(--radius-sm, 4px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-pin-save:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
- border-color: var(--color-primary, #6366f1);
+ border-color: var(--color-primary);
}
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
diff --git a/frontend/src/components/InlineAssistPanel.vue b/frontend/src/components/InlineAssistPanel.vue
index f43dceb..741a5e0 100644
--- a/frontend/src/components/InlineAssistPanel.vue
+++ b/frontend/src/components/InlineAssistPanel.vue
@@ -135,8 +135,8 @@ const markers: Record = {
flex-shrink: 0;
}
.iap-btn-cancel:hover {
- border-color: var(--color-danger, #e74c3c);
- color: var(--color-danger, #e74c3c);
+ border-color: var(--color-danger);
+ color: var(--color-danger);
}
.iap-stream-preview {
@@ -191,19 +191,19 @@ const markers: Record = {
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
- background: var(--color-success, #22c55e);
+ background: var(--color-success);
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
.iap-btn-reject {
- background: var(--color-bg-card, var(--color-bg));
+ background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.iap-btn-reject:hover {
- border-color: var(--color-danger, #e74c3c);
- color: var(--color-danger, #e74c3c);
+ border-color: var(--color-danger);
+ color: var(--color-danger);
}
/* ── Diff ── */
@@ -226,12 +226,12 @@ const markers: Record = {
.iap-diff-equal { color: var(--color-text-muted); }
.iap-diff-delete {
- background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
- color: var(--color-danger, #e74c3c);
+ background: color-mix(in srgb, var(--color-danger) 10%, transparent);
+ color: var(--color-danger);
}
.iap-diff-insert {
- background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
- color: var(--color-success, #22c55e);
+ background: color-mix(in srgb, var(--color-success) 10%, transparent);
+ color: var(--color-success);
}
.iap-diff-marker {
diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue
index 89a0abf..1e42c90 100644
--- a/frontend/src/components/NoteCard.vue
+++ b/frontend/src/components/NoteCard.vue
@@ -64,11 +64,11 @@ function goEdit() {
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--color-primary) 6%, transparent);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--color-primary) 14.0%, transparent);
transform: translateY(-2px);
}
@@ -89,7 +89,7 @@ function goEdit() {
}
.note-card.compact:hover {
box-shadow: none;
- background: rgba(91, 74, 138, 0.04);
+ background: color-mix(in srgb, var(--color-primary) 4%, transparent);
transform: none;
}
.note-title-compact {
diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue
index d24c659..d3318d5 100644
--- a/frontend/src/components/NotificationBell.vue
+++ b/frontend/src/components/NotificationBell.vue
@@ -81,7 +81,7 @@ onUnmounted(() => {
position: absolute;
top: -5px;
right: -5px;
- background: var(--color-danger, #ef4444);
+ background: var(--color-danger);
color: var(--fs-text-on-action);
font-size: 0.6rem;
font-weight: 700;
diff --git a/frontend/src/components/ProjectDesignTab.vue b/frontend/src/components/ProjectDesignTab.vue
new file mode 100644
index 0000000..01f9bd4
--- /dev/null
+++ b/frontend/src/components/ProjectDesignTab.vue
@@ -0,0 +1,234 @@
+
+
+
+
+
+ No design system for this project.
+
+ Bind one in the sidebar and this tab reports where the project's recorded
+ components disagree with it — references to tokens the system doesn't
+ have, literals it says to stop writing, and properties a component mints
+ for itself instead of reusing.
+
+
+
+
Checking this project's snippets…
+
+
+ The check couldn't run.
+
Nothing was compared — this is a failure, not a clean result.
+
+
+
+
+ This project has no recorded snippets, so nothing was checked. Record the
+ components you reuse and they get measured against the sheet.
+
+
+
+ {{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
+ every reference resolves, and none mints a property of its own.
+
+
+
+
+ {{ check.findings.length }} of {{ check.checked }}
+ snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
+
- Which rulebook describes how this app should look. Once set, the
- Design page compares every colour
- and token your rules name against what the stylesheet actually resolves
- to, and reports where they disagree. Leave it as None if your rules
- don't describe a design system — nothing else depends on this.
-
-
+
+
{
}
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
-.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
+.perm-admin { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
.empty-msg {
color: var(--color-muted);
diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue
index 28ffed9..625d1c2 100644
--- a/frontend/src/views/SnippetDetailView.vue
+++ b/frontend/src/views/SnippetDetailView.vue
@@ -239,7 +239,7 @@ async function confirmDelete() {
}
.snippet-name {
margin: 0;
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
font-size: 1.4rem;
word-break: break-word;
}
@@ -291,7 +291,7 @@ async function confirmDelete() {
}
.meta-grid code,
.tag-row + * code {
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
font-size: 0.82rem;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
@@ -379,7 +379,7 @@ async function confirmDelete() {
overflow-x: auto;
}
.code-block code {
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.6;
color: var(--color-text);
diff --git a/frontend/src/views/SnippetEditorView.vue b/frontend/src/views/SnippetEditorView.vue
index 01774b7..435a0ba 100644
--- a/frontend/src/views/SnippetEditorView.vue
+++ b/frontend/src/views/SnippetEditorView.vue
@@ -447,7 +447,7 @@ function cancel() {
box-shadow: var(--focus-ring);
}
.mono {
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
}
.code-area {
resize: vertical;
@@ -542,7 +542,7 @@ function cancel() {
gap: 0.4rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
- border-left: 3px solid var(--color-warning, var(--color-primary));
+ border-left: 3px solid var(--color-warning);
border-radius: 8px;
background: var(--color-bg-secondary);
}
diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue
index c0828b8..ed622d3 100644
--- a/frontend/src/views/SnippetListView.vue
+++ b/frontend/src/views/SnippetListView.vue
@@ -574,7 +574,7 @@ function usageTitle(s: SnippetListItem): string {
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
box-sizing: border-box;
}
.loc-input-wide {
@@ -633,7 +633,7 @@ function usageTitle(s: SnippetListItem): string {
color: var(--color-text-muted);
}
.empty-icon {
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.35;
@@ -720,7 +720,7 @@ function usageTitle(s: SnippetListItem): string {
min-width: 0;
flex: 1;
word-break: break-word;
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
}
/* Language tag — accent pill per the design system's tag treatment. */
@@ -770,7 +770,7 @@ function usageTitle(s: SnippetListItem): string {
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
- background: var(--color-surface-alt, var(--color-surface));
+ background: var(--color-surface-alt);
}
.dup-empty,
@@ -828,8 +828,8 @@ function usageTitle(s: SnippetListItem): string {
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
- background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
- color: var(--color-danger, #b91c1c);
+ background: color-mix(in srgb, var(--color-danger) 15%, transparent);
+ color: var(--color-danger);
}
.usage-tag {
@@ -845,8 +845,8 @@ function usageTitle(s: SnippetListItem): string {
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
- background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
- color: var(--color-warning, #b45309);
+ background: color-mix(in srgb, var(--color-warning) 18%, transparent);
+ color: var(--color-warning);
}
/* Header + select-mode */
@@ -906,7 +906,7 @@ function usageTitle(s: SnippetListItem): string {
.modal-overlay {
position: fixed;
inset: 0;
- background: var(--color-overlay, rgba(0, 0, 0, 0.45));
+ background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -955,7 +955,7 @@ function usageTitle(s: SnippetListItem): string {
.merge-choice-name {
flex: 1;
min-width: 0;
- font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
+ font-family: var(--font-mono);
font-size: 0.85rem;
word-break: break-word;
}
diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue
index fedbcf9..b0d1932 100644
--- a/frontend/src/views/TaskEditorView.vue
+++ b/frontend/src/views/TaskEditorView.vue
@@ -874,7 +874,7 @@ useEditorGuards(dirty, save);
padding: 0 0.2rem;
flex-shrink: 0;
}
-.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
+.btn-clear-parent:hover { color: var(--color-danger); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
@@ -1037,13 +1037,13 @@ useEditorGuards(dirty, save);
margin: 0.5rem 0 0.25rem;
}
.task-goal-label {
- font-family: var(--font-display, "Fraunces", serif);
+ font-family: var(--font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
+ color: var(--color-text-muted);
}
.task-goal-input {
width: 100%;
@@ -1053,14 +1053,14 @@ useEditorGuards(dirty, save);
font: inherit;
font-size: 0.95rem;
line-height: 1.4;
- color: var(--color-text, inherit);
- background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
- border-radius: var(--radius-md, 8px);
+ color: var(--color-text);
+ background: var(--color-input-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
}
.task-goal-input:focus {
outline: none;
- border-color: var(--color-primary, #6366f1);
+ border-color: var(--color-primary);
}
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
@@ -1072,13 +1072,13 @@ useEditorGuards(dirty, save);
margin-bottom: 0.5rem;
font-size: 0.82rem;
font-style: italic;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
+ color: var(--color-text-muted);
background: rgba(99, 102, 241, 0.06);
- border-left: 2px solid var(--color-primary, #6366f1);
- border-radius: var(--radius-sm, 4px);
+ border-left: 2px solid var(--color-primary);
+ border-radius: var(--radius-sm);
}
.auto-summary-banner-editor .auto-summary-icon {
- color: var(--color-primary, #6366f1);
+ color: var(--color-primary);
font-style: normal;
}
\ No newline at end of file
diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue
index 2cc007d..a05e60f 100644
--- a/frontend/src/views/TaskViewerView.vue
+++ b/frontend/src/views/TaskViewerView.vue
@@ -561,7 +561,7 @@ const subTaskProgress = computed(() => {
}
.subtasks-fill {
height: 100%;
- background: var(--color-status-done, #22c55e);
+ background: var(--color-status-done);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -602,13 +602,13 @@ const subTaskProgress = computed(() => {
border: 2px solid var(--color-text-muted);
}
.dot-in-progress {
- background: var(--color-status-in-progress, #3b82f6);
+ background: var(--color-status-in-progress);
}
.dot-done {
- background: var(--color-status-done, #22c55e);
+ background: var(--color-status-done);
}
.dot-cancelled {
- background: var(--color-text-muted, #6b7280);
+ background: var(--color-text-muted);
}
.sub-title {
flex: 1;
@@ -749,26 +749,26 @@ const subTaskProgress = computed(() => {
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
.task-goal-display {
- border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
+ border-left: 2px solid var(--color-border);
padding: 0.4rem 0 0.4rem 0.9rem;
margin: 0.75rem 0 1.25rem;
background: rgba(255, 255, 255, 0.02);
}
.goal-label {
- font-family: var(--font-display, "Fraunces", serif);
+ font-family: var(--font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
+ color: var(--color-text-muted);
margin: 0 0 0.25rem;
}
.goal-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.45;
- color: var(--color-text, inherit);
+ color: var(--color-text);
white-space: pre-wrap;
}
.auto-summary-banner {
@@ -777,11 +777,11 @@ const subTaskProgress = computed(() => {
gap: 0.5rem;
font-size: 0.78rem;
font-style: italic;
- color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
+ color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.auto-summary-icon {
- color: var(--color-primary, #6366f1);
+ color: var(--color-primary);
font-size: 0.85rem;
}
diff --git a/frontend/src/views/TrashView.vue b/frontend/src/views/TrashView.vue
index 8c087e3..e954f60 100644
--- a/frontend/src/views/TrashView.vue
+++ b/frontend/src/views/TrashView.vue
@@ -68,7 +68,7 @@ onMounted(() => store.fetchTrash());
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
-.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
+.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border); background: none; color: inherit; }
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index b0fd4a3..98685c7 100644
--- a/plugin/.claude-plugin/plugin.json
+++ b/plugin/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
- "version": "0.1.22",
+ "version": "0.1.23",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh
index 4c6b52c..77b5a17 100755
--- a/plugin/hooks/scribe_session_context.sh
+++ b/plugin/hooks/scribe_session_context.sh
@@ -61,6 +61,31 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
+# --- Which version is actually RUNNING (keyless, networkless) ---
+#
+# An install has two halves and only one self-updates:
+#
+# marketplaces/…/scribe-plugin/ git clone — pulls on its own
+# cache/…/scribe// what EXECUTES — refreshed only when the
+# manifest version changes
+#
+# So inspecting the clone shows a fix present while the broken copy keeps
+# running, and the obvious debugging move actively misleads (#2209). Twice, the
+# only detector was the operator saying "I don't think it updated".
+#
+# Naming the running version in every session makes that answerable from the
+# transcript instead of by archaeology in the cache directory. Deliberately NOT
+# a server round-trip or a stored per-user record: the state most needing
+# diagnosis is the one where credentials never arrive, and this line still
+# appears there.
+manifest="$here/../.claude-plugin/plugin.json"
+if [ -f "$manifest" ]; then
+ plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
+ if [ -n "$plugin_version" ]; then
+ append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
+ fi
+fi
+
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py
index 22c706b..1fb44ca 100755
--- a/scripts/check_plugin.py
+++ b/scripts/check_plugin.py
@@ -307,6 +307,44 @@ def check_local_prior_art_needs_no_instance() -> None:
ok("prior-art local arm: answers with no instance configured")
+def check_session_context_reports_its_version() -> None:
+ """The SessionStart context must name the plugin version it is running.
+
+ An install has two halves and only one self-updates: the marketplace clone
+ pulls on its own, while the CACHE is what executes and refreshes only when
+ the manifest version changes. So a shipped fix can sit unreached while
+ inspecting the clone shows it present — the obvious debugging move
+ misleads, and twice the only detector was a human saying "I don't think it
+ updated" (#2209, #2220).
+
+ Asserted WITHOUT credentials on purpose. The state most needing diagnosis
+ is the one where the token never arrives, and a marker that vanished there
+ would be missing exactly when it is wanted.
+ """
+ script = HOOKS_DIR / "scribe_session_context.sh"
+ if not script.is_file() or not shutil.which("jq"):
+ skip("version marker: hook or jq missing")
+ return
+
+ manifest_v = manifest_version()
+ if manifest_v is None:
+ fail("version marker: could not read the manifest version")
+ return
+
+ try:
+ proc = _run_hook(script, json.dumps({"source": "startup"}), {})
+ except subprocess.TimeoutExpired:
+ fail("version marker: hook hung")
+ return
+ if proc.returncode != 0:
+ fail(f"version marker: hook exited {proc.returncode}")
+ elif manifest_v not in proc.stdout:
+ fail(f"version marker: session context never names v{manifest_v} — "
+ f"a stale install would be undetectable from the transcript")
+ else:
+ ok(f"version marker: session context reports v{manifest_v}, no credentials needed")
+
+
def _git(*args: str) -> tuple[int, str]:
proc = subprocess.run(
["git", *args], capture_output=True, text=True, cwd=ROOT
@@ -399,6 +437,7 @@ def main() -> int:
check_shellcheck()
check_fail_open()
check_local_prior_art_needs_no_instance()
+ check_session_context_reports_its_version()
if not args.no_version:
check_version_bump(args.base)
diff --git a/src/scribe/app.py b/src/scribe/app.py
index c5ac59a..e89a190 100644
--- a/src/scribe/app.py
+++ b/src/scribe/app.py
@@ -26,7 +26,6 @@ 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.design_systems import design_systems_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
@@ -91,7 +90,6 @@ 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(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py
index c8af5db..63bb7e3 100644
--- a/src/scribe/mcp/tools/design_systems.py
+++ b/src/scribe/mcp/tools/design_systems.py
@@ -22,6 +22,11 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
+from scribe.services.design_starter_roles import (
+ ALL_GROUPS,
+ DEFAULT_TOKEN_PREFIX,
+ describe_groups,
+)
async def create_design_system(
@@ -29,6 +34,8 @@ async def create_design_system(
description: str = "",
guidance: str = "",
parent_id: int = 0,
+ starter_role_groups: list[str] | None = None,
+ token_prefix: str = "",
) -> dict:
"""Create a design system, optionally inheriting from another.
@@ -41,20 +48,47 @@ async def create_design_system(
parent_id: Inherit from this system — it holds the defaults this one
overrides. Omit (0) for a top-level "family" system, which is what
a first design system usually is.
+ starter_role_groups: Seed the system with named but VALUELESS token
+ roles, so there is something to reach for before a literal gets
+ written instead. Call list_starter_role_groups() for the catalogue.
+ Pass ["all"] for every group. Omit for none — a system with three
+ hand-written tokens is a legitimate design system.
+ token_prefix: Naming convention for the seeded roles, e.g. "--fs-".
+ Defaults to a neutral "--ds-"; pass the install's own if it has one.
+ Ignored when no starter groups are requested.
"""
uid = current_user_id()
+ groups = starter_role_groups
+ if groups and len(groups) == 1 and groups[0] == "all":
+ groups = list(ALL_GROUPS)
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
guidance=guidance or None,
parent_id=parent_id or None,
+ starter_role_groups=groups,
+ token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
+async def list_starter_role_groups() -> dict:
+ """The starter token ROLES offered at design-system creation.
+
+ Roles, not values. Every group is a set of named questions — "page
+ background, the deepest surface" — that the operator answers with their own
+ palette. Nothing here carries a colour, because a default palette would be
+ one install's taste shipped as product.
+
+ Reach for this before create_design_system so the choice is informed, and
+ pass the group names you want as `starter_role_groups`.
+ """
+ return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX}
+
+
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
@@ -350,6 +384,7 @@ async def set_project_design_system(project_id: int, design_system_id: int = 0)
def register(mcp) -> None:
for fn in (
create_design_system,
+ list_starter_role_groups,
list_design_systems,
get_design_system,
resolve_design_system,
diff --git a/src/scribe/routes/design.py b/src/scribe/routes/design.py
deleted file mode 100644
index 630e710..0000000
--- a/src/scribe/routes/design.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""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_rulebook_import 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())
diff --git a/src/scribe/routes/design_systems.py b/src/scribe/routes/design_systems.py
index 6487ed5..1bb3628 100644
--- a/src/scribe/routes/design_systems.py
+++ b/src/scribe/routes/design_systems.py
@@ -19,6 +19,10 @@ from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
from scribe.services import design_systems as ds_svc
+from scribe.services.design_starter_roles import (
+ DEFAULT_TOKEN_PREFIX,
+ describe_groups,
+)
from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
@@ -56,12 +60,27 @@ async def create_design_system():
description=data.get("description") or None,
guidance=data.get("guidance") or None,
parent_id=data.get("parent_id"),
+ starter_role_groups=data.get("starter_role_groups"),
+ token_prefix=data.get("token_prefix") or DEFAULT_TOKEN_PREFIX,
)
if system is None:
return jsonify({"error": "parent design system not found"}), 404
return jsonify(system.to_dict()), 201
+@design_systems_bp.get("/design-systems/starter-roles")
+@login_required
+async def list_starter_role_groups():
+ """The starter role catalogue, for the creation form's checklist.
+
+ Roles and purposes only — no values, ever. See services/design_starter_roles.
+ """
+ return jsonify({
+ "groups": describe_groups(),
+ "default_prefix": DEFAULT_TOKEN_PREFIX,
+ })
+
+
@design_systems_bp.get("/design-systems/")
@login_required
async def get_design_system(design_system_id: int):
diff --git a/src/scribe/services/design_rulebook_import.py b/src/scribe/services/design_rulebook_import.py
deleted file mode 100644
index 7f637fd..0000000
--- a/src/scribe/services/design_rulebook_import.py
+++ /dev/null
@@ -1,232 +0,0 @@
-"""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))
diff --git a/src/scribe/services/design_starter_roles.py b/src/scribe/services/design_starter_roles.py
new file mode 100644
index 0000000..ab9c456
--- /dev/null
+++ b/src/scribe/services/design_starter_roles.py
@@ -0,0 +1,185 @@
+"""A starter set of token ROLES, offered when a design system is created.
+
+WHY THIS EXISTS
+---------------
+A literal gets written into a stylesheet when there is no role to reach for.
+That is the mechanism, and this codebase produced a clean demonstration of it:
+the house style had no "text on a filled colour" role, so 76 call sites wrote
+a pure-white literal — not out of defiance, but because nothing existed to write
+instead (#2275). The correction was not a better ban list. It was declaring the
+missing role.
+
+So the useful moment is CREATION. A system whose roles are named on day one
+never presents the occasion for a literal, and never needs a list of values it
+forbids.
+
+WHAT SHIPS AND WHAT DOES NOT (rule #115)
+----------------------------------------
+The ROLES ship: `surface-page`, `text-primary`, `action-destructive` are
+generic CSS-design vocabulary, not one operator's kit. Every install that has a
+page has a page background.
+
+The VALUES never ship. Each token is created with an empty `value_by_mode`, so
+a fresh system is a set of named, deliberately-unanswered questions. No hex
+appears anywhere in this file, and none should ever be added to it — a default
+palette would be this operator's palette wearing product clothes.
+
+A valueless token is already legible downstream: `render_stylesheet` emits it as
+a commented-out declaration in its group (#2299), and `stylesheet_for_system`
+reports it under `valueless`. So a blank role reads as "to be decided" rather
+than as breakage, without anything new.
+
+THE PREFIX IS THE INSTALL'S
+---------------------------
+`--fs-` is FabledSword's convention, not the product's. The prefix is a
+parameter with a neutral default; a caller that has a house convention passes
+it. Baking `--fs-` in would put one family's naming into every install.
+
+FLAT, NOT PRESET
+----------------
+One list, every group individually skippable, all on by default (operator's
+call, 2026-08-03). Presets keyed to app shape — web / CLI / docs — were
+considered and rejected: they would require the product to hold opinions about
+app categories, and a wrong category is worse than a generic list someone
+prunes once.
+"""
+from __future__ import annotations
+
+DEFAULT_TOKEN_PREFIX = "--ds-"
+
+# group -> (what the group is for, ((role suffix, purpose), ...))
+#
+# Purposes are written as the QUESTION the operator is answering, because that
+# is what an unfilled role is. "Page background, the deepest surface" tells you
+# what to put there; "Colour 1" does not.
+STARTER_ROLE_GROUPS: dict[str, tuple[str, tuple[tuple[str, str], ...]]] = {
+ "surface": (
+ "Backgrounds, by elevation",
+ (
+ ("surface-page", "Page background, the deepest surface"),
+ ("surface-raised", "Cards and raised elements"),
+ ("surface-hover", "Hovered surfaces, secondary elevation"),
+ ),
+ ),
+ "text": (
+ "Foreground colours, by emphasis",
+ (
+ ("text-primary", "Primary text on a page or raised surface"),
+ ("text-secondary", "Secondary text and captions"),
+ ("text-tertiary", "Hints and metadata"),
+ # The role whose absence caused 76 literals. It is in the starter
+ # set deliberately: text on a filled colour is NOT the page text
+ # colour, because the surface under it does not change with the
+ # mode while the page does.
+ ("text-on-action", "Text on a filled colour — buttons, badges"),
+ ),
+ ),
+ "action": (
+ "What the user can do — kept separate from the accent, which is identity",
+ (
+ ("action-primary", "The confirming action: Save, Submit"),
+ ("action-secondary", "Non-destructive alternates"),
+ ("action-destructive", "Irreversible actions — delete, revoke"),
+ ),
+ ),
+ "semantic": (
+ "What the system is telling you",
+ (
+ ("success", "Something worked"),
+ ("warning", "Something needs attention"),
+ ("error", "Something failed — distinct from destructive"),
+ ("info", "Neutral information"),
+ ),
+ ),
+ "border": (
+ "Boundaries and dividers",
+ (
+ ("border-color", "The line colour itself"),
+ ("border", "The default structural border, as a shorthand"),
+ ("border-hover", "Border on hover or emphasis"),
+ ("border-active", "Selected or current — the one border that may carry the accent"),
+ ),
+ ),
+ "accent": (
+ "This install's identity — not its actions",
+ (
+ ("accent", "The single signature colour"),
+ ("accent-soft", "Tinted backgrounds — pills, tags"),
+ ("accent-faint", "The faintest wash"),
+ ),
+ ),
+ "radius": (
+ "Corner rounding",
+ (
+ ("radius-sm", "Pills, tags, code spans"),
+ ("radius-md", "Buttons, inputs, small cards"),
+ ("radius-lg", "Cards, panels, modals"),
+ ),
+ ),
+ "space": (
+ "The spacing scale — a gap not on the scale is a decision to justify",
+ tuple((f"space-{i}", f"Spacing step {i}") for i in range(1, 11)),
+ ),
+ "motion": (
+ "Transition timing — motion supports the interaction, never performs",
+ (
+ ("ease", "The one easing curve, used by every transition"),
+ ("dur-fast", "Hovers, colour and border changes"),
+ ("dur-base", "Most state changes"),
+ ("dur-slow", "Larger surface or layout shifts"),
+ ),
+ ),
+ "state": (
+ "Cross-cutting states that are otherwise improvised per view",
+ (
+ ("disabled-opacity", "Opacity for disabled controls"),
+ ("overlay", "Scrim behind modals and dialogs"),
+ ),
+ ),
+}
+
+ALL_GROUPS: tuple[str, ...] = tuple(STARTER_ROLE_GROUPS)
+
+
+def starter_tokens(
+ groups: list[str] | tuple[str, ...] | None = None,
+ prefix: str = DEFAULT_TOKEN_PREFIX,
+) -> list[dict]:
+ """Token rows for the chosen groups — names and purposes only, no values.
+
+ `groups` of None means every group; an empty list means none, which is a
+ real answer and not the same as None. An operator who wants three tokens
+ should be able to get three.
+
+ Unknown group names are ignored rather than raising: this feeds a
+ checkbox list, and a stale name from an older client should not fail a
+ creation that is otherwise fine.
+ """
+ chosen = ALL_GROUPS if groups is None else [g for g in groups if g in STARTER_ROLE_GROUPS]
+ rows: list[dict] = []
+ for group in chosen:
+ _, roles = STARTER_ROLE_GROUPS[group]
+ for index, (suffix, purpose) in enumerate(roles, start=1):
+ rows.append({
+ "name": f"{prefix}{suffix}",
+ "group_name": group,
+ "purpose": purpose,
+ # Empty, not absent: the column is NOT NULL with a {} default,
+ # so absence has exactly one spelling here as it does there.
+ "value_by_mode": {},
+ "order_index": index,
+ })
+ return rows
+
+
+def describe_groups() -> list[dict]:
+ """The catalogue, for a UI to render as a checklist."""
+ return [
+ {
+ "group": group,
+ "description": description,
+ "token_count": len(roles),
+ "names": [suffix for suffix, _ in roles],
+ }
+ for group, (description, roles) in STARTER_ROLE_GROUPS.items()
+ ]
diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py
index e4bb63b..522829d 100644
--- a/src/scribe/services/design_systems.py
+++ b/src/scribe/services/design_systems.py
@@ -27,6 +27,10 @@ from scribe.services.design_stylesheet import (
duplicate_values,
render_stylesheet,
)
+from scribe.services.design_starter_roles import (
+ DEFAULT_TOKEN_PREFIX,
+ starter_tokens,
+)
from scribe.services.design_cascade import (
ResolvedToken,
ancestry,
@@ -79,11 +83,23 @@ async def create_design_system(
description: str | None = None,
guidance: str | None = None,
parent_id: int | None = None,
+ starter_role_groups: list[str] | None = None,
+ token_prefix: str = DEFAULT_TOKEN_PREFIX,
) -> DesignSystem | None:
"""Create a system, with or without a parent.
Returns None when `parent_id` names a system the caller may not write —
which, per the ACL, means one they do not own.
+
+ `starter_role_groups` seeds the system with named, VALUELESS token roles
+ (#2349) — the moment a role is missing is the moment a literal gets written
+ instead, so the cheapest time to name them is now. Pass a list of group
+ names to choose, `[]` for none, or None for none.
+
+ None and `[]` deliberately mean the same thing here, unlike in
+ `starter_tokens` where None means "all": creation must not seed 40 rows
+ into a system whose caller never asked. Opting in is the caller's job, and
+ the UI's default of everything-checked lives in the UI.
"""
if parent_id is not None and not await access.can_write_design_system(
user_id, parent_id
@@ -100,6 +116,11 @@ async def create_design_system(
session.add(system)
await session.commit()
await session.refresh(system)
+
+ if starter_role_groups:
+ for row in starter_tokens(starter_role_groups, prefix=token_prefix):
+ session.add(DesignToken(design_system_id=system.id, **row))
+ await session.commit()
return system
diff --git a/tests/test_design_rulebook_import.py b/tests/test_design_rulebook_import.py
deleted file mode 100644
index 9a0c942..0000000
--- a/tests/test_design_rulebook_import.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""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_rulebook_import 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
diff --git a/tests/test_design_starter_roles.py b/tests/test_design_starter_roles.py
new file mode 100644
index 0000000..7f9e79c
--- /dev/null
+++ b/tests/test_design_starter_roles.py
@@ -0,0 +1,136 @@
+"""The starter role set (#2349).
+
+The premise: a literal gets written when there is no role to reach for. This
+codebase demonstrated it — the house style had no "text on a filled colour"
+role, so 76 call sites wrote `color: #fff` (#2275). Naming the roles at
+creation removes the occasion.
+
+The tests that matter here are about the BOUNDARY, not the content. Roles ship
+with the product; values never do. A default palette would be one operator's
+taste shipped as product code (rule #115), and it would be very easy to add by
+accident while "being helpful".
+"""
+import re
+
+import pytest
+
+from scribe.services import design_starter_roles as roles
+
+
+def test_no_role_carries_a_VALUE():
+ """THE rule-115 guard. Every seeded token is a named question with no
+ answer. The moment one ships a hex, the product is prescribing an install's
+ palette."""
+ for row in roles.starter_tokens():
+ assert row["value_by_mode"] == {}, f"{row['name']} shipped a value"
+
+
+def _colour_literals(text: str) -> list[str]:
+ """Hex colours in `text`, NOT counting issue references.
+
+ `#2275` is four hex-valid digits and also how this codebase cites an issue —
+ so the naive pattern flags its own documentation, which is the third time
+ that has happened here (#2353). A real colour either contains a letter
+ a–f or is a full 6/8-digit value; an all-decimal 3- or 4-digit match is an
+ issue number.
+ """
+ out = []
+ for m in re.findall(r"#[0-9a-fA-F]{3,8}\b", text):
+ digits = m[1:]
+ if len(digits) not in (3, 4, 6, 8):
+ continue
+ if len(digits) in (6, 8) or any(c in "abcdefABCDEF" for c in digits):
+ out.append(m)
+ return out
+
+
+def test_the_module_contains_no_colour_literals_at_all():
+ """Belt and braces on the above, and the stronger claim: not just that
+ tokens are blank, but that no palette hides in a comment or a docstring
+ waiting to be pasted in. Checks the SOURCE, not the output."""
+ import pathlib
+ src = pathlib.Path(roles.__file__).read_text()
+ hexes = _colour_literals(src)
+ assert not hexes, f"colour literals in product code: {hexes}"
+
+
+def test_the_colour_check_does_not_flag_issue_references():
+ """Pins the exclusion above, because without it this file fails on its own
+ citations and the obvious 'fix' is to delete the check."""
+ assert _colour_literals("see #2275 and #2349") == []
+ assert _colour_literals("color: #fff") == ["#fff"]
+ assert _colour_literals("#E8E4D8 on #14171A") == ["#E8E4D8", "#14171A"]
+ assert _colour_literals("#000000") == ["#000000"]
+
+
+def test_every_group_is_individually_selectable():
+ """Operator's call: one flat list, all skippable. An install that wants
+ three tokens must be able to get three."""
+ only_text = roles.starter_tokens(["text"])
+ assert {r["group_name"] for r in only_text} == {"text"}
+ assert len(only_text) == 4
+
+
+def test_empty_selection_yields_nothing_and_is_not_the_same_as_None():
+ """`[]` is a real answer — "none of them" — and must not be read as
+ "unspecified, so give me everything". Getting this backwards would seed 40
+ rows into a system whose creator explicitly declined."""
+ assert roles.starter_tokens([]) == []
+ assert len(roles.starter_tokens(None)) > 30
+
+
+def test_unknown_group_names_are_ignored_not_fatal():
+ """This feeds a checkbox list. A stale name from an older client should not
+ fail an otherwise-fine creation."""
+ out = roles.starter_tokens(["text", "not-a-real-group"])
+ assert {r["group_name"] for r in out} == {"text"}
+
+
+def test_the_prefix_is_the_installs_choice():
+ """`--fs-` is FabledSword's convention, not the product's. Baking it in
+ would put one family's naming into every install."""
+ assert all(r["name"].startswith("--ds-") for r in roles.starter_tokens(["text"]))
+ custom = roles.starter_tokens(["text"], prefix="--acme-")
+ assert all(r["name"].startswith("--acme-") for r in custom)
+ assert "--acme-text-primary" in {r["name"] for r in custom}
+
+
+def test_text_on_action_is_in_the_starter_set():
+ """The specific role whose absence produced 76 literals. It is separate
+ from text-primary on purpose: the surfaces it sits on do not change with
+ the mode, while the page does — so reusing text-primary there passes in
+ dark and fails contrast in light (#2275)."""
+ names = {r["name"] for r in roles.starter_tokens(["text"])}
+ assert "--ds-text-on-action" in names
+ assert "--ds-text-primary" in names
+
+
+def test_names_are_valid_custom_properties():
+ """They go straight into a stylesheet; an invalid name is a silent no-op
+ rather than an error, which is the worst failure mode available."""
+ valid = re.compile(r"^--[A-Za-z0-9_-]+$")
+ for row in roles.starter_tokens():
+ assert valid.match(row["name"]), row["name"]
+
+
+def test_no_duplicate_names_across_the_whole_set():
+ """A design system has a partial-unique index on (system, name); a
+ duplicate in the starter set would make creation fail at the DB with a
+ constraint error rather than anything legible."""
+ names = [r["name"] for r in roles.starter_tokens()]
+ assert len(names) == len(set(names))
+
+
+def test_every_role_states_a_purpose():
+ """An unfilled role is only useful if it says what belongs there. "Colour
+ 1" is a blank with extra steps."""
+ for row in roles.starter_tokens():
+ assert row["purpose"].strip(), row["name"]
+
+
+def test_describe_groups_matches_what_starter_tokens_produces():
+ """The catalogue a UI renders and the rows creation writes must not drift —
+ a checklist offering a group that seeds nothing is a lie in the UI."""
+ described = {g["group"]: g["token_count"] for g in roles.describe_groups()}
+ for group, count in described.items():
+ assert len(roles.starter_tokens([group])) == count
diff --git a/tests/test_design_stylesheet.py b/tests/test_design_stylesheet.py
index 4c2c844..245fc71 100644
--- a/tests/test_design_stylesheet.py
+++ b/tests/test_design_stylesheet.py
@@ -284,8 +284,9 @@ def test_a_shorter_hex_does_not_match_inside_a_longer_one():
def test_the_literal_match_is_case_insensitive():
- """Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
- check would silently find nothing — the same trap normalize_hex exists for."""
+ """A record writes `#FFFFFF` and code writes `#ffffff`. A case-sensitive
+ check would silently find nothing — the same trap `normalizeColour` in
+ utils/designDrift.ts exists for on the client side."""
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
assert report["superseded_literals"] == [
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
diff --git a/tests/test_routes_design_systems.py b/tests/test_routes_design_systems.py
index 8b0b54a..8ad2389 100644
--- a/tests/test_routes_design_systems.py
+++ b/tests/test_routes_design_systems.py
@@ -44,6 +44,11 @@ def test_every_endpoint_is_reachable_on_the_app():
}
assert rules == {
"/api/design-systems",
+ # Static segment, declared before the rule reads it — Quart's
+ # int converter will not match "starter-roles", so the two cannot
+ # collide. Worth stating: a static-vs-dynamic sibling on the same prefix
+ # is exactly where a silently-shadowed route hides.
+ "/api/design-systems/starter-roles",
"/api/design-systems/",
"/api/design-systems//resolved",
"/api/design-systems//stylesheet",
@@ -85,7 +90,7 @@ def test_agent_and_web_surfaces_stay_at_parity():
"resolve_design_system", "update_design_system", "delete_design_system",
"create_design_token", "list_design_tokens", "update_design_token",
"delete_design_token", "set_project_design_system",
- "get_design_system_stylesheet",
+ "get_design_system_stylesheet", "list_starter_role_groups",
):
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
assert callable(getattr(routes, name)), f"REST route missing: {name}"