Design systems as records — the stylesheet Scribe holds (#88)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 17s

This commit was merged in pull request #88.
This commit is contained in:
2026-07-30 23:35:26 -04:00
37 changed files with 7551 additions and 18 deletions
+138
View File
@@ -0,0 +1,138 @@
"""design systems + tokens, and the project pointer
Revision ID: 0072
Revises: 0071
Create Date: 2026-07-30
Makes the design system a first-class record instead of prose in a rulebook: a
named set of tokens with an optional parent, so a family system holds the house
style and an app system holds only what it changes.
`design_systems.parent_id` is the whole model. It replaces both an `always_on`
flag (a family system is one with no parent) and a subscription join table (a
project points at ONE system, and the chain supplies the rest), which is less
schema than the rulebook shape it mirrors.
Two deliberate choices worth stating here rather than leaving to be re-derived:
- **`design_tokens.value_by_mode` is JSONB keyed by mode**, not `value_light` +
`value_dark` columns. In a child system an unset mode means "inherit"; in a
root it would mean "not mode-dependent", and as columns both are NULL and
indistinguishable. As a map, resolution is a dict merge at every level with
no special case for roots — and a third mode (high-contrast, print) is data
rather than a schema change. The cost is that a typo'd mode key is not
rejected by the database. Nothing filters tokens by value in SQL, so the
queryability the columns would have bought is for a query no caller makes.
(Named `value_by_mode` rather than `values`, which is reserved in SQL.)
- **`group_name` is free text, not a CHECK enum.** Groupings are each design
system's own vocabulary; a whitelist would bake one install's kit into the
schema. No CHECK is introduced anywhere in this migration.
`parent_id` and `projects.design_system_id` are both ON DELETE SET NULL. Deleting
a family system must orphan its children into roots that still hold their own
overrides, not cascade away every app system that inherited from it; deleting a
system a project points at must unstyle that project, not delete it.
Downgrade drops both tables and the column. Any design system defined this way
is lost — this is the migration that introduces the concept, so there is no
earlier representation to fall back to.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0072"
down_revision = "0071"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"design_systems",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"owner_user_id", sa.BigInteger(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"parent_id", sa.BigInteger(),
sa.ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
op.create_index(
"ix_design_systems_owner_user_id", "design_systems", ["owner_user_id"]
)
op.create_index("ix_design_systems_parent_id", "design_systems", ["parent_id"])
op.create_table(
"design_tokens",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"design_system_id", sa.BigInteger(),
sa.ForeignKey("design_systems.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
# NOT NULL with a '{}' default: a nullable JSONB column has two empty
# states (SQL NULL and JSON null) and every reader has to test for both.
sa.Column(
"value_by_mode", JSONB,
nullable=False, server_default=sa.text("'{}'::jsonb"),
),
sa.Column("group_name", sa.Text(), nullable=True),
sa.Column("purpose", sa.Text(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
op.create_index(
"ix_design_tokens_design_system_id", "design_tokens", ["design_system_id"]
)
# Partial unique: a name is unique among LIVE tokens in a system. Two live
# rows with the same name are a duplicate definition and the cascade would
# pick between them arbitrarily; a trashed row must not block reusing its
# name.
op.create_index(
"uq_token_per_design_system", "design_tokens", ["design_system_id", "name"],
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
)
op.add_column(
"projects", sa.Column("design_system_id", sa.BigInteger(), nullable=True)
)
op.create_foreign_key(
"fk_projects_design_system_id", "projects", "design_systems",
["design_system_id"], ["id"], ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_projects_design_system_id", "projects", type_="foreignkey")
op.drop_column("projects", "design_system_id")
op.drop_index("uq_token_per_design_system", table_name="design_tokens")
op.drop_index("ix_design_tokens_design_system_id", table_name="design_tokens")
op.drop_table("design_tokens")
op.drop_index("ix_design_systems_parent_id", table_name="design_systems")
op.drop_index("ix_design_systems_owner_user_id", table_name="design_systems")
op.drop_table("design_systems")
@@ -0,0 +1,55 @@
"""design_tokens.supersedes — the literals a token should be used instead of
Revision ID: 0073
Revises: 0072
Create Date: 2026-07-30
Records what a prohibition was actually trying to say.
A design rulebook writes "pure white #FFFFFF is NEVER used as text color". That
sentence has no row in a table of tokens, because a design system stores what
things ARE — which looked like a gap in the model and was really a sentence
written backwards. The positive fact is "text is Parchment", and the useful
record is the mapping from the literal someone would otherwise write to the
token they should write instead.
So `supersedes` is a JSONB array of literal values, e.g. `["#fff", "#ffffff"]`
on a text-on-action token. A finding built from it can say what to write, not
merely what not to.
It must be DECLARED rather than derived. `#fff` and Parchment `#E8E4D8` are
different colours, so no value-matching rule could ever have connected them —
which is exactly why the prohibition felt unrepresentable until it was turned
around.
Consumed by the source lint that reads component CSS, not by the drift panel:
these literals are in the components, which the panel cannot see.
NOT NULL with a `'[]'` default, matching `value_by_mode` — a nullable JSONB
column has two empty states and every reader has to test for both.
Downgrade drops the column; the declarations are lost, which costs the lint its
input and nothing else.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0073"
down_revision = "0072"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"design_tokens",
sa.Column(
"supersedes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")
),
)
def downgrade() -> None:
op.drop_column("design_tokens", "supersedes")
+44
View File
@@ -0,0 +1,44 @@
"""central prose on a design system, and rationale on a token
Revision ID: 0074
Revises: 0073
Create Date: 2026-07-31
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
on the system. Two fields rather than one per category:
design_systems.guidance the narrative a token table cannot hold — aesthetic,
voice and tone, what is deliberately out of scope.
Markdown, free-form.
design_tokens.rationale WHY this token is this value, which is a different
question from `purpose` (what it is FOR). "Success
equals Moss, aligned by design" is a rationale;
"page bg, deepest surface" is a purpose.
Free-form rather than a column per category on purpose. A schema with `voice`,
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
every install (rule #115), and the next install's design system would have three
empty columns and nowhere to put what it actually cares about.
Both nullable: a design system with no prose at all is complete, not a draft.
Downgrade drops both columns and the prose with them.
"""
from alembic import op
import sqlalchemy as sa
revision = "0074"
down_revision = "0073"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("design_systems", sa.Column("guidance", sa.Text(), nullable=True))
op.add_column("design_tokens", sa.Column("rationale", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("design_tokens", "rationale")
op.drop_column("design_systems", "guidance")
+9
View File
@@ -0,0 +1,9 @@
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<ExpectationResponse>("/api/design/expectations");
+216
View File
@@ -0,0 +1,216 @@
/**
* Design systems — the stylesheet held as records (milestone #254).
*
* A design system is a named set of tokens with an optional parent. A system
* with no parent is a "family"; one with a parent holds ONLY what it changes,
* so "what does this app alter?" is a plain list rather than a diff.
*/
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
export interface DesignSystem {
id: number;
owner_user_id: number;
title: string;
description: string;
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
guidance: string;
parent_id: number | null;
created_at: string | null;
updated_at: string | null;
}
/** A token as STORED — one system's own row for it. */
export interface DesignToken {
id: number;
design_system_id: number;
name: string;
/** Values keyed by mode. `base` applies when no mode is more specific. */
value_by_mode: Record<string, string>;
group_name: string | null;
purpose: string | null;
/** WHY it is this value — distinct from `purpose`, which is what it is FOR. */
rationale: string | null;
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
*
* How a design system records what a prohibition was trying to say: not
* "white is banned" but "write this token instead". Declared rather than
* inferred, because a superseded literal and the token's own value are
* usually different values and nothing could connect them by matching. */
supersedes: string[];
order_index: number;
}
export interface Contribution {
system_id: number;
value: string;
}
/**
* A token after the cascade.
*
* `contributions` is every system that offered a value, per mode, DEEPEST
* FIRST — entry 0 won and the rest were shadowed. `value_by_mode` and
* `origin_by_mode` are the winners, provided so the client never has to derive
* them (and so it cannot derive them differently).
*
* Provenance is per MODE because overriding is: a system can own `base` and
* inherit `dark` at the same time.
*/
export interface ResolvedToken {
name: string;
group_name: string | null;
purpose: string | null;
rationale: string | null;
supersedes: string[];
order_index: number;
value_by_mode: Record<string, string>;
origin_by_mode: Record<string, number>;
contributions: Record<string, Contribution[]>;
}
export const fetchDesignSystems = () =>
apiGet<{ design_systems: DesignSystem[] }>("/api/design-systems");
export const fetchDesignSystem = (id: number) =>
apiGet<DesignSystem>(`/api/design-systems/${id}`);
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
}) => apiPost<DesignSystem>("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
export const updateDesignSystem = (
id: number,
body: {
title?: string;
description?: string;
guidance?: string;
parent_id?: number | null;
},
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
export const deleteDesignSystem = (id: number) =>
apiDelete(`/api/design-systems/${id}`);
/** The EFFECTIVE set: everything inherited, with this system's on top. */
export const fetchResolvedTokens = (id: number) =>
apiGet<{ design_system_id: number; tokens: ResolvedToken[] }>(
`/api/design-systems/${id}/resolved`,
);
/** This system's OWN tokens — its override set. */
export const fetchDesignTokens = (id: number) =>
apiGet<{ tokens: DesignToken[] }>(`/api/design-systems/${id}/tokens`);
export const createDesignToken = (
designSystemId: number,
body: {
name: string;
value_by_mode?: Record<string, string>;
group_name?: string | null;
purpose?: string | null;
rationale?: string | null;
supersedes?: string[];
order_index?: number;
},
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
export const updateDesignToken = (
tokenId: number,
body: Partial<Omit<DesignToken, "id" | "design_system_id">>,
) => apiPatch<DesignToken>(`/api/design-tokens/${tokenId}`, body);
export const deleteDesignToken = (tokenId: number) =>
apiDelete(`/api/design-tokens/${tokenId}`);
/** Point a project at a design system. `null` clears it. */
export const setProjectDesignSystem = (
projectId: number,
designSystemId: number | null,
) =>
apiPut<{ project_id: number; design_system_id: number | null }>(
`/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,
});
export interface StylesheetResult {
design_system_id: number;
/** The master sheet: purpose tokens only, no element or class rules. */
css: string;
token_count: number;
/** Tokens the system names but has no value for yet. */
valueless: string[];
/** Values declared under more than one name — alias, or one idea twice. */
duplicates: Record<string, string[]>;
}
/** The master CSS sheet a design system generates.
*
* Purpose tokens only. Components (buttons, tables, input schemes) are
* snippets that reference these names, so a value is stated once and reused
* rather than restated per element. */
export const fetchStylesheet = (id: number) =>
apiGet<StylesheetResult>(`/api/design-systems/${id}/stylesheet`);
export interface SnippetFinding {
snippet_id: number;
title: string;
/** References that resolve against the sheet. */
used: string[];
/** `var(--x)` where the system has no `--x` — renders as nothing at all. */
unknown: string[];
/** Literals the sheet says to stop writing, paired with what to write. */
superseded_literals: { literal: string; use_instead: string }[];
/** Custom properties the snippet mints for itself instead of reusing. */
local_definitions: string[];
}
export interface SnippetCheck {
design_system_id: number;
checked: number;
/** Only snippets with something to act on; clean ones are omitted. */
findings: SnippetFinding[];
}
/** Which recorded snippets disagree with this design system's sheet. */
export const checkSnippets = (id: number) =>
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
+10 -1
View File
@@ -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, Settings, Trash2 } from "lucide-vue-next";
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -64,6 +64,13 @@ router.afterEach(() => {
<Moon v-else :size="16" />
</button>
<!-- Design explorer. An icon rather than a sixth primary nav link: it's a
meta-surface like Trash and Settings, but hiding it entirely would
defeat the point of having somewhere the design system is visible. -->
<router-link to="/design" class="btn-icon" aria-label="Design" title="Design">
<Palette :size="16" />
</router-link>
<!-- Trash link -->
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
<Trash2 :size="16" />
@@ -98,6 +105,8 @@ router.afterEach(() => {
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/design" class="nav-link">Design</router-link>
<router-link to="/design-systems" class="nav-link">Design systems</router-link>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div>
+14
View File
@@ -109,6 +109,20 @@ const router = createRouter({
name: "rules",
component: () => import("@/views/RulesView.vue"),
},
{
// Meta-surface, same family as /rules: it describes the app rather than
// holding the operator's records.
path: "/design",
name: "design",
component: () => import("@/views/DesignView.vue"),
},
{
// The editable half of the same surface: /design is what the browser
// renders, /design-systems is the record that ought to decide it.
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
},
{
path: "/tasks",
redirect: "/",
+169
View File
@@ -0,0 +1,169 @@
/**
* Drift comparison — what the rulebook claims vs what the stylesheet does.
*
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
* prose into claims) is server-side in `services/design_system.py`, where pytest
* can assert on it. What's left here is set arithmetic over live token values,
* which is the one thing the browser knows and the server doesn't.
*
* SCOPE, and it is a real limit rather than an omission. This compares the
* rulebook against the TOKENS. It cannot see the third category of drift — a
* literal hardcoded in a component where a token should be referenced (#2275,
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
*
* Catching it needs the component sources, which would mean bundling every SFC
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
* matters: a drift report that silently omits a category invites the reader to
* conclude the category is clean.
*/
import type { DesignToken } from "@/utils/designTokens";
export type ExpectationKind = "token" | "color" | "prohibited_color";
export interface Expectation {
kind: ExpectationKind;
value: string;
rule_id: number;
rule_title: string;
context: string;
}
export interface ExpectationResponse {
rulebook_id: number | null;
expectations: Expectation[];
}
export type FindingStatus = "ok" | "missing" | "violated";
export interface Finding {
expectation: Expectation;
status: FindingStatus;
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
matches: string[];
}
/**
* Normalise a colour for comparison — the client-side twin of
* `normalize_hex` in services/design_system.py.
*
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
* spellings of one colour, and a comparison that misses any of them under-reports
* rather than erroring. The rgb() case is browser-specific and therefore has no
* server-side counterpart, which is exactly why it is handled here.
*/
export function normalizeColour(value: string): string | null {
const raw = value.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) {
digits = digits.split("").map((c) => c + c).join("");
}
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
}
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
if (rgb) {
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
if (parts.length < 3) return null;
const channels = parts.slice(0, 3).map((p) => Number(p));
if (channels.some((n) => !Number.isFinite(n))) return null;
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
const base = `#${channels.map(hexOf).join("")}`;
if (parts.length === 3) return base;
const alpha = Number(parts[3]);
if (!Number.isFinite(alpha) || alpha >= 1) return base;
return `${base}${hexOf(alpha * 255)}`;
}
return null;
}
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
const index = new Map<string, string[]>();
for (const token of tokens) {
const colour = normalizeColour(token.value);
if (!colour) continue;
const names = index.get(colour);
if (names) names.push(token.name);
else index.set(colour, [token.name]);
}
return index;
}
/**
* Compare claims against the live tokens.
*
* A `token` claim asks whether a custom property of that name exists.
* A `color` claim asks whether any token resolves to that value.
* A `prohibited_color` claim INVERTS the test — present is the failure.
*/
export function compareToTokens(
expectations: Expectation[],
tokens: DesignToken[],
): Finding[] {
const names = new Set(tokens.map((t) => t.name));
const colours = colourIndex(tokens);
return expectations.map((expectation) => {
if (expectation.kind === "token") {
const present = names.has(expectation.value);
return {
expectation,
status: present ? "ok" : "missing",
matches: present ? [expectation.value] : [],
};
}
const matches = colours.get(expectation.value) ?? [];
if (expectation.kind === "prohibited_color") {
return {
expectation,
status: matches.length ? "violated" : "ok",
matches,
};
}
return {
expectation,
status: matches.length ? "ok" : "missing",
matches,
};
});
}
export interface DriftSummary {
ok: number;
missing: number;
violated: number;
total: number;
}
export function summarise(findings: Finding[]): DriftSummary {
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
for (const finding of findings) summary[finding.status] += 1;
return summary;
}
/**
* Findings worth leading with.
*
* A panel that opens with every row gets closed and never reopened — the same
* principle the auto-inject menu is built on: a short list that gets read beats
* a complete one that doesn't. Violations first (something is actively wrong),
* then missing (something was never built), and `ok` rows are not "findings" at
* all — they belong behind an expansion.
*/
export function rankFindings(findings: Finding[]): Finding[] {
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
return [...findings].sort((a, b) => {
const byStatus = order[a.status] - order[b.status];
if (byStatus !== 0) return byStatus;
return a.expectation.rule_id - b.expectation.rule_id;
});
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Design-token inventory — what tokens exist, and what they actually resolve to.
*
* Foundation for the design explorer (milestone #251): the gallery renders
* against these, and the drift panel compares them to the design rulebook.
*
* DESIGN NOTE — why this parses NAMES but never VALUES.
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
* its VALUE is not: values contain nested parens, commas inside rgba(),
* `var()` references to other tokens, multi-part shadows, and gradients — and
* `theme.css` has all of those today. So we take the names from the source and
* ask the BROWSER for every value.
*
* That is not just easier, it is more correct. getComputedStyle reports what
* actually won the cascade, resolves `var()` chains, and — critically for this
* milestone — reflects live overrides set on a container, which is exactly what
* the preview surface needs (see #2261). Parsing the source would report what
* the file says rather than what the user is looking at.
*
* It also means this module needs no unit tests to be trustworthy: the only
* logic here is a name regex and a group lookup. The frontend has no test
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
* error-prone half in the browser rather than in our code is deliberate.
*/
import themeCss from "@/assets/theme.css?raw";
export type TokenGroup =
| "color"
| "radius"
| "gradient"
| "glow"
| "focus"
| "layout"
| "other";
export type ThemeMode = "light" | "dark";
export interface DesignToken {
/** Full custom-property name, including the leading `--`. */
name: string;
/** Coarse family, derived from the name prefix. */
group: TokenGroup;
/** Resolved value in the requested context, straight from the browser. */
value: string;
/** True when the declaration appears inside the dark block in source. */
overriddenInDark: boolean;
}
/**
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
*
* The discriminator is the COLON, not the preceding character. A declaration is
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
* actively wrong: an earlier version required the match to follow `{` or `;`,
* which silently dropped every declaration that came after a comment —
* including `--color-bg`, the first and most-used token in the file.
*/
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
/** Comments are stripped first so a commented-out declaration isn't counted. */
const COMMENT = /\/\*[\s\S]*?\*\//g;
/** The dark block's selector, as written in theme.css. */
const DARK_SELECTOR = '[data-theme="dark"]';
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
["--color-", "color"],
["--radius-", "radius"],
["--gradient-", "gradient"],
["--glow-", "glow"],
["--focus-", "focus"],
["--page-", "layout"],
["--sidebar-", "layout"],
["--chat-", "layout"],
];
export function groupFor(name: string): TokenGroup {
for (const [prefix, group] of GROUP_PREFIXES) {
if (name.startsWith(prefix)) return group;
}
return "other";
}
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
export function tokenNames(css: string = themeCss): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
const name = match[1];
if (!seen.has(name)) {
seen.add(name);
out.push(name);
}
}
return out;
}
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
export function darkOverriddenNames(css: string = themeCss): Set<string> {
const bare = css.replace(COMMENT, "");
const start = bare.indexOf(DARK_SELECTOR);
if (start === -1) return new Set();
const open = bare.indexOf("{", start);
const close = bare.indexOf("}", open);
if (open === -1 || close === -1) return new Set();
return new Set(tokenNames(bare.slice(open, close)));
}
/**
* Read the resolved value of every token in `host`'s context.
*
* Pass a container to read the tokens as they apply INSIDE it — which is how
* the preview surface reads a scoped override without disturbing the page.
* Defaults to the document root, i.e. the app-wide values.
*/
export function readTokens(host: Element = document.documentElement): DesignToken[] {
const computed = getComputedStyle(host);
const dark = darkOverriddenNames();
return tokenNames().map((name) => ({
name,
group: groupFor(name),
value: computed.getPropertyValue(name).trim(),
overriddenInDark: dark.has(name),
}));
}
/**
* Read tokens as they would resolve in a given mode, without touching the page.
*
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
* mutated to take a reading.
*
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
* function: light is declared on `:root` while dark is declared on
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
* subtree, but there is no `[data-theme="light"]` block to add the light ones
* back. So reading "light" from inside a dark page returns the dark values —
* the probe has nothing to match.
*
* Concretely: dark-inside-light previews work, light-inside-dark previews do
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
* (milestone #251 step 6) is what makes this symmetric, and until then callers
* should treat a cross-mode read as best-effort.
*/
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
const probe = document.createElement("div");
probe.setAttribute("data-theme", mode);
probe.style.display = "none";
document.body.appendChild(probe);
try {
return readTokens(probe);
} finally {
probe.remove();
}
}
/** Tokens grouped by family, preserving source order within each group. */
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
const out = new Map<TokenGroup, DesignToken[]>();
for (const token of tokens) {
const bucket = out.get(token.group);
if (bucket) bucket.push(token);
else out.set(token.group, [token]);
}
return out;
}
/**
* Tokens declared in the stylesheet that nothing references with `var()`.
*
* Dead tokens are drift too: `--chat-reading-width` and
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
* in the MCP-first pivot, and nothing has referenced them since. Takes the
* corpus of source files to search as an argument so the caller decides what
* "used" means — this module has no opinion about the project layout.
*/
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
const haystack = sources.join("\n");
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
}
File diff suppressed because it is too large Load Diff
+519
View File
@@ -0,0 +1,519 @@
<script setup lang="ts">
/**
* Design explorer — the gallery (milestone #251 step 3).
*
* Renders the design system against the tokens that are actually live, read at
* runtime rather than parsed from source, so what you see here is what the app
* is using right now.
*
* HONESTY RULE, and the reason parts of this page say "not implemented":
* a gallery of hand-written look-alikes drifts from the app within a month and
* then lies — which is the same failure this whole surface exists to catch. So
* every specimen below is either a REAL component imported from the app, or a
* real token read from the browser, or it is explicitly marked as missing.
*
* Buttons are the case where that bites. Rule 65 specifies four variants, but
* `.btn-primary` is defined four separate times in four `<style scoped>` blocks
* and 30 of 54 SFCs carry their own button CSS (#2273). There is no shared
* button to import, so rendering one here would just make this a fifth copy.
* It is reported as a gap instead.
*/
import { computed, onMounted, ref } from "vue";
import { fetchDesignExpectations } from "@/api/design";
import PriorityBadge from "@/components/PriorityBadge.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import TagPill from "@/components/TagPill.vue";
import {
compareToTokens,
rankFindings,
summarise,
type Expectation,
type Finding,
} from "@/utils/designDrift";
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
const tokens = ref<DesignToken[]>([]);
const expectations = ref<Expectation[]>([]);
const designRulebookId = ref<number | null>(null);
const driftLoaded = ref(false);
const showCleanRows = ref(false);
/**
* Read on mount, not at module scope: the values depend on the live cascade,
* which needs the app's stylesheets applied and the theme attribute set.
*/
onMounted(async () => {
tokens.value = readTokens();
try {
const response = await fetchDesignExpectations();
designRulebookId.value = response.rulebook_id;
expectations.value = response.expectations;
} catch {
// The gallery is useful without the panel, so a failed fetch degrades to
// "no drift data" rather than taking the page down with it.
designRulebookId.value = null;
} finally {
driftLoaded.value = true;
}
});
const findings = computed<Finding[]>(() =>
rankFindings(compareToTokens(expectations.value, tokens.value)),
);
const driftSummary = computed(() => summarise(findings.value));
const visibleFindings = computed(() =>
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
);
const grouped = computed(() => groupTokens(tokens.value));
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
const orderedGroups = computed(() =>
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
);
/** A token whose value reads as a colour is worth showing as a swatch. */
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
}
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
const RULEBOOK_BUTTONS = [
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
];
const TYPE_SPECIMENS = [
{ token: "Display", spec: "40 / 500 / Fraunces" },
{ token: "H1", spec: "32 / 500 / Fraunces" },
{ token: "H2", spec: "24 / 500 / Fraunces" },
{ token: "H3", spec: "18 / 500 / Inter" },
{ token: "Body", spec: "15 / 400 / Inter" },
{ token: "Body small", spec: "13 / 400 / Inter" },
{ token: "Label", spec: "12 / 500 / Inter" },
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
];
</script>
<template>
<div class="design-view">
<header class="design-header">
<h1>Design</h1>
<p class="lede">
The system as it actually is. Token values are read from the browser at
runtime, so this page reflects the live cascade rather than what the
stylesheet says. Components shown are the real ones where a piece of
the system has no shared implementation, it is marked missing rather
than mocked up.
</p>
<p class="lede">
For the other half the design system as a record you can edit, with
family&nbsp;&rarr;&nbsp;app inheritance see
<router-link to="/design-systems">design systems</router-link>.
</p>
</header>
<!-- Drift: what the rulebook claims vs what the tokens do. -->
<section class="design-section">
<h2>Rulebook drift</h2>
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook</p>
<div v-else-if="designRulebookId === null" class="gap-notice">
<strong>No design rulebook designated.</strong>
<p>
This install hasn't said which rulebook describes its design system, so
there is nothing to check the tokens against. Designate one in
<router-link to="/settings">Settings</router-link> and this panel will
compare every colour and token the rulebook names against what the
stylesheet actually resolves to.
</p>
</div>
<template v-else>
<p class="section-note">
<strong>{{ driftSummary.violated }}</strong> violated ·
<strong>{{ driftSummary.missing }}</strong> missing ·
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
claims in rulebook #{{ designRulebookId }}.
</p>
<div class="gap-notice">
<strong>This compares the rulebook against the TOKENS only.</strong>
<p>
A value hardcoded in a component — where a token should have been
referenced — is invisible here, because the drift isn't in the tokens
at all. Reading it would mean bundling every component's source into
the app. That check belongs in CI and is tracked separately, so treat
a clean panel as "the tokens agree", not "the app agrees".
</p>
</div>
<p v-if="!findings.length" class="muted">
The rulebook names nothing this panel can check. Rules that state values
— colours, token names — produce claims; rules that state judgement
don't, by design.
</p>
<ul v-else class="spec-list">
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
<span class="spec-name">
<span
v-if="finding.expectation.kind !== 'token'"
class="swatch"
:style="{ background: finding.expectation.value }"
aria-hidden="true"
/>
<code>{{ finding.expectation.value }}</code>
</span>
<span class="spec-detail">
rule #{{ finding.expectation.rule_id }} {{ finding.expectation.rule_title }}
<span v-if="finding.matches.length" class="matches">
· {{ finding.matches.join(", ") }}
</span>
</span>
<span class="spec-status" :class="finding.status">
{{ finding.status === "violated" ? "forbidden, but present"
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
</span>
</li>
</ul>
<button
v-if="findings.length && driftSummary.ok"
class="reveal-toggle"
@click="showCleanRows = !showCleanRows"
>
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
</button>
</template>
</section>
<!-- Real components: these are imported, not recreated. -->
<section class="design-section">
<h2>Components</h2>
<p class="section-note">Imported from the app. What you see is what ships.</p>
<div class="specimen">
<span class="specimen-label">Status badge</span>
<div class="specimen-row">
<StatusBadge status="todo" />
<StatusBadge status="in_progress" />
<StatusBadge status="done" />
<StatusBadge status="cancelled" />
</div>
</div>
<div class="specimen">
<span class="specimen-label">Priority badge</span>
<div class="specimen-row">
<PriorityBadge priority="low" />
<PriorityBadge priority="medium" />
<PriorityBadge priority="high" />
<span class="muted">(<code>none</code> renders nothing, by design)</span>
</div>
</div>
<div class="specimen">
<span class="specimen-label">Tag pill</span>
<div class="specimen-row">
<TagPill tag="design-system" />
<TagPill tag="dismissible" dismissible />
</div>
</div>
</section>
<!-- The honest gap. -->
<section class="design-section">
<h2>Buttons</h2>
<div class="gap-notice">
<strong>Not implemented as a shared component.</strong>
<p>
Rule 65 specifies four variants. In practice <code>.btn-primary</code> is
defined four separate times in four <code>&lt;style scoped&gt;</code>
blocks, and 30 of 54 single-file components carry their own button CSS.
There is no shared button to render here, and drawing one would make
this page a fifth copy of it the exact drift this surface exists to
catch. Tracked as issue #2273.
</p>
</div>
<ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status missing">missing</span>
</li>
</ul>
</section>
<!-- Typography: the families load, the scale does not exist as tokens. -->
<section class="design-section">
<h2>Type scale</h2>
<div class="gap-notice">
<strong>Families load; the scale has no tokens.</strong>
<p>
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
scale is not expressed as custom properties, so sizes and weights are
set ad hoc per component. Listed here as specification, not as a live
specimen there is nothing to read.
</p>
</div>
<ul class="spec-list">
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
<span class="spec-name">{{ t.token }}</span>
<span class="spec-detail">{{ t.spec }}</span>
<span class="spec-status missing">no token</span>
</li>
</ul>
</section>
<!-- Tokens: entirely real, read live. -->
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
<ul class="token-list">
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
<span
v-if="isColourish(token.value)"
class="swatch"
:style="{ background: token.value }"
aria-hidden="true"
/>
<span v-else class="swatch swatch-none" aria-hidden="true" />
<code class="token-name">{{ token.name }}</code>
<code class="token-value">{{ token.value || "—" }}</code>
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
mode-aware
</span>
</li>
</ul>
</section>
<p v-if="!tokens.length" class="muted">Reading tokens</p>
</div>
</template>
<style scoped>
.design-view {
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem var(--page-padding-x) 4rem;
}
.design-header {
margin-bottom: 2rem;
}
.lede {
color: var(--color-text-secondary);
max-width: 60ch;
line-height: 1.7;
}
.design-section {
margin-bottom: 2.5rem;
}
.design-section h2 {
margin-bottom: 0.25rem;
}
.token-group-heading {
text-transform: capitalize;
}
.count {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 400;
}
.section-note,
.muted {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 1rem;
}
/* Specimens -------------------------------------------------------------- */
.specimen {
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
}
.specimen-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.specimen-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* Gaps ------------------------------------------------------------------- */
.gap-notice {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.gap-notice p {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.6;
max-width: 70ch;
}
.spec-list {
list-style: none;
padding: 0;
margin: 0;
}
.spec-list li {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.spec-name {
min-width: 8rem;
font-weight: 500;
}
.spec-detail {
color: var(--color-text-secondary);
font-size: 0.85rem;
flex: 1;
}
.spec-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
}
.spec-status.missing {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.spec-status.violated {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.ok {
background: var(--color-status-done-bg);
color: var(--color-status-done);
}
.spec-name .swatch {
vertical-align: middle;
margin-right: 0.4rem;
}
.matches {
color: var(--color-text-muted);
}
.reveal-toggle {
margin-top: 0.75rem;
padding: 0.35rem 0.75rem;
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
}
.reveal-toggle:hover {
border-color: var(--color-text-muted);
}
/* Tokens ----------------------------------------------------------------- */
.token-list {
list-style: none;
padding: 0;
margin: 0;
}
.token-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.swatch {
width: 1.25rem;
height: 1.25rem;
flex: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.swatch-none {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
var(--color-border) 3px,
var(--color-border) 4px
);
}
.token-name {
min-width: 16rem;
font-size: 0.8rem;
}
.token-value {
color: var(--color-text-secondary);
font-size: 0.8rem;
flex: 1;
word-break: break-all;
}
.token-flag {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.05rem 0.35rem;
}
@media (max-width: 640px) {
.token-name {
min-width: 0;
}
}
</style>
+48 -5
View File
@@ -9,6 +9,11 @@ import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import {
fetchDesignSystems,
setProjectDesignSystem,
type DesignSystem,
} from "@/api/designSystems";
import {
LayoutGrid,
Clock,
@@ -41,6 +46,7 @@ interface Project {
goal: string | null;
status: "active" | "paused" | "completed" | "archived";
color: string | null;
design_system_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -69,6 +75,12 @@ const toast = useToastStore();
const tasksStore = useTasksStore();
const project = ref<Project | null>(null);
// Design system the project is styled from. Loaded separately because an
// install with none is the ordinary case (rule #115) and the picker simply
// doesn't render — a failed fetch must not take the project page with it.
const designSystems = ref<DesignSystem[]>([]);
const editDesignSystemId = ref<number | null>(null);
const loading = ref(false);
const showStartPlanning = ref(false);
@@ -175,6 +187,7 @@ async function loadProject() {
editDescription.value = data.description ?? "";
editGoal.value = data.goal ?? "";
editStatus.value = data.status;
editDesignSystemId.value = data.design_system_id ?? null;
editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
@@ -336,8 +349,20 @@ onMounted(async () => {
await loadProject();
loadTasks();
loadNotes();
loadDesignSystems();
});
/** Populate the design-system picker. Swallows failure on purpose: with no
* design systems the picker doesn't render at all, which is the ordinary state
* for most installs — so this must never be able to break the project page. */
async function loadDesignSystems() {
try {
designSystems.value = (await fetchDesignSystems()).design_systems;
} catch {
designSystems.value = [];
}
}
watch(projectId, async () => {
await loadProject();
loadTasks();
@@ -345,28 +370,39 @@ watch(projectId, async () => {
});
watch(
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
() => {
if (!project.value) return;
editDirty.value =
editTitle.value !== project.value.title ||
editDescription.value !== (project.value.description ?? "") ||
editGoal.value !== (project.value.goal ?? "") ||
editStatus.value !== project.value.status;
editStatus.value !== project.value.status ||
editDesignSystemId.value !== (project.value.design_system_id ?? null);
}
);
async function saveProject() {
if (!project.value || saving.value) return;
// Bound once rather than re-read: the checks below straddle two awaits, and
// `project.value` is a ref whose narrowing doesn't survive them.
const current = project.value;
if (!current || saving.value) return;
saving.value = true;
try {
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
title: editTitle.value.trim(),
description: editDescription.value.trim() || null,
goal: editGoal.value.trim() || null,
status: editStatus.value,
});
project.value = { ...project.value, ...updated };
// The design-system pointer is its own endpoint (PUT, because clearing it
// is a real outcome rather than an omission), so it saves separately —
// only when it actually changed, to keep the common save at one request.
if (editDesignSystemId.value !== (current.design_system_id ?? null)) {
await setProjectDesignSystem(current.id, editDesignSystemId.value);
updated.design_system_id = editDesignSystemId.value;
}
project.value = { ...current, ...updated };
editDirty.value = false;
toast.show("Project saved");
} catch {
@@ -505,6 +541,13 @@ async function confirmDelete() {
<option value="archived">Archived</option>
</select>
</div>
<div v-if="designSystems.length" class="edit-field">
<label class="edit-label" for="project-design-system">Design system</label>
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
{{ saving ? "Saving..." : "Save Changes" }}
</button>
+33
View File
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
import { listRulebooks } from "@/api/rulebooks";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -31,6 +32,11 @@ const kbWritePathThreshold = ref("0.68");
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
// suggests a merge the operator reviews (services/dedup.py).
const kbDuplicateThreshold = ref("0.82");
// Which rulebook describes this install's design system, for the /design drift
// panel. Empty = none designated, which is the normal state for a fresh install
// rather than a misconfiguration — the panel explains itself when unset.
const designRulebookId = ref("");
const designRulebooks = ref<{ id: number; title: string }[]>([]);
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);
@@ -100,6 +106,9 @@ async function saveKbInject() {
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
kb_writepath_threshold: String(wpT),
kb_duplicate_threshold: String(dupT),
// Empty string DELETES the setting (see routes/settings.py), which is
// exactly right for "no design rulebook" — absent rather than zero.
design_rulebook_id: designRulebookId.value,
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -490,6 +499,14 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
}
designRulebookId.value = allSettings.design_rulebook_id ?? "";
// Best-effort: the picker degrades to "none available" rather than blocking
// the whole settings page if rulebooks can't be listed.
try {
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
} catch {
designRulebooks.value = [];
}
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -1260,6 +1277,22 @@ function formatUserDate(iso: string): string {
location, not by resemblance.
</p>
</div>
<div class="field">
<label for="design-rulebook">Design-system rulebook</label>
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
<option value="">None — don't check for design drift</option>
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
{{ rb.title }}
</option>
</select>
<p class="field-hint">
Which rulebook describes how this app should look. Once set, the
<router-link to="/design">Design</router-link> 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.
</p>
</div>
<div class="field">
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
<input
+4
View File
@@ -26,6 +26,8 @@ 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
from scribe.routes.systems import systems_bp
@@ -89,6 +91,8 @@ 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)
app.register_blueprint(systems_bp)
+3 -1
View File
@@ -5,7 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets, systems, tags, tasks, trash,
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
systems, tags, tasks, trash,
)
@@ -17,6 +18,7 @@ def register_all(mcp) -> None:
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
repos.register(mcp)
+398
View File
@@ -0,0 +1,398 @@
"""Design system + token MCP tools — wrappers over services/design_systems.py.
A design system is a stylesheet held as records: a named set of tokens with an
optional parent, so a family system carries the house style and an app system
carries only what it changes. Precedence by name along the parent chain IS the
CSS cascade, which is why "what does this app alter?" is a plain list rather
than a diff.
Parity with the REST surface is a rule, not a nicety (see
`tests/test_routes_design_systems.py`): an agent and a browser are two callers
of one service.
Sentinels, matching the milestone/task tool conventions:
- title="" / description="" / etc. -> "leave unchanged" on update
- parent_id / design_system_id: 0 = leave unchanged, -1 = clear, positive = set
(three states, because clearing a parent is a real operation and not the
same as omitting the argument)
- order_index=-1 -> "leave unchanged" (0 is a valid order_index)
"""
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
async def create_design_system(
title: str,
description: str = "",
guidance: str = "",
parent_id: int = 0,
) -> dict:
"""Create a design system, optionally inheriting from another.
Args:
title: What this system is, e.g. "FabledSword" or "Scribe" (required).
description: What it covers and when it applies.
guidance: The narrative a token table cannot hold — aesthetic, voice and
tone, what is deliberately out of scope. Markdown, free-form.
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.
"""
uid = current_user_id()
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
guidance=guidance or None,
parent_id=parent_id or None,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
rows = await ds_svc.list_design_systems(uid)
return {"design_systems": [s.to_dict() for s in rows]}
async def get_design_system(design_system_id: int) -> dict:
"""Fetch a design system plus its OWN tokens — i.e. what it changes.
For what it actually resolves to once inheritance is applied, use
`resolve_design_system`. The two answer different questions and a system
that overrides nothing has an empty token list but a full resolved set.
"""
uid = current_user_id()
system = await ds_svc.get_design_system(uid, design_system_id)
if system is None:
raise ValueError(f"design system {design_system_id} not found")
tokens = await ds_svc.list_tokens(uid, design_system_id)
return {
"design_system": system.to_dict(),
"tokens": [t.to_dict() for t in tokens],
}
async def resolve_design_system(design_system_id: int) -> dict:
"""The EFFECTIVE token set — everything inherited, with this system's on top.
Each token carries `origin_by_mode` (which system supplied each mode's
value) and `contributions` (every system that offered one, deepest first —
so entry 0 won and the rest were shadowed). Reach for this when you need to
know what a value actually IS; reach for `get_design_system` when you need
to know what this system CHANGES.
"""
uid = current_user_id()
resolved = await ds_svc.resolve_design_system(uid, design_system_id)
if resolved is None:
raise ValueError(f"design system {design_system_id} not found")
return {
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
}
async def update_design_system(
design_system_id: int,
title: str = "",
description: str = "",
guidance: str = "",
parent_id: int = 0,
) -> dict:
"""Update a design system.
Args:
design_system_id: The system to update.
title: New title, or "" to leave unchanged.
description: New description, or "" to leave unchanged.
guidance: New guidance prose, or "" to leave unchanged.
parent_id: 0 = leave unchanged, -1 = clear (make this a top-level
family system), positive = inherit from that system. A parent that
already inherits from this system is refused — that would be a loop.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if guidance:
fields["guidance"] = guidance
if parent_id:
fields["parent_id"] = None if parent_id == -1 else parent_id
try:
system = await ds_svc.update_design_system(uid, design_system_id, **fields)
except DesignSystemCycle as exc:
raise ValueError(str(exc)) from exc
if system is None:
raise ValueError(f"design system {design_system_id} not found or not writable")
return system.to_dict()
async def delete_design_system(design_system_id: int) -> dict:
"""Soft-delete a design system (recoverable).
Systems that inherited from it become top-level systems keeping their own
tokens — deleting a family does not delete the apps under it.
"""
uid = current_user_id()
if not await ds_svc.delete_design_system(uid, design_system_id):
raise ValueError(f"design system {design_system_id} not found or not writable")
return {"message": f"Design system {design_system_id} deleted."}
async def get_design_system_stylesheet(
design_system_id: int,
root_selector: str = ":root",
) -> dict:
"""The master CSS sheet a design system generates.
Purpose tokens only — this sheet declares what values MEAN and styles no
elements. Components (buttons, tables, input schemes) are SNIPPETS that
reference these names, so a value is stated once and reused rather than
restated per element. Reach for this when you need the tokens a snippet is
allowed to use.
Also returns `valueless` (tokens the system names but has no value for) and
`duplicates` (values declared under more than one name — a deliberate alias,
or one idea recorded twice).
Args:
design_system_id: The system to render.
root_selector: Selector for the base layer. Defaults to `:root`; pass a
container selector to scope the sheet to a preview region.
"""
uid = current_user_id()
result = await ds_svc.stylesheet_for_system(uid, design_system_id, root_selector)
if result is None:
raise ValueError(f"design system {design_system_id} not found")
return result
async def check_snippets_against_design_system(
design_system_id: int,
project_id: int = 0,
) -> dict:
"""Which recorded snippets disagree with a design system's sheet.
Snippets are the component layer — buttons, tables, input schemes — and they
are supposed to use the tags the sheet declares. Three findings per snippet,
each currently silent in the codebase:
unknown `var(--x)` where the system has no `--x`. Renders as
NOTHING: no error, no failing test, just an element
that quietly isn't styled.
superseded_literals a literal the sheet says to stop writing, paired with
the token to write instead.
local_definitions custom properties the snippet mints for itself rather
than using shared ones — the bloat a shared sheet
exists to prevent.
Snippets with nothing to report are omitted. Reach for this before writing
or reviewing component CSS.
Args:
design_system_id: The system whose sheet is authoritative.
project_id: Narrow to one project, or 0 for every project.
"""
uid = current_user_id()
result = await ds_svc.check_snippets_against_system(
uid, design_system_id, project_id
)
if result is None:
raise ValueError(f"design system {design_system_id} not found")
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(
design_system_id: int,
name: str,
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
rationale: str = "",
supersedes: list | None = None,
order_index: int = 0,
) -> dict:
"""Add a token to a design system.
Args:
design_system_id: The system that owns this token.
name: The custom-property name, e.g. "--fs-obsidian" (required).
value_by_mode: Values keyed by mode, e.g.
{"base": "#f7f5ef", "dark": "#14171a"}. Use "base" for the value
that applies when no mode is more specific; a token that is not
mode-dependent needs only "base". In a system WITH a parent, an
omitted mode is inherited rather than blanked.
group_name: Free-text grouping — "surface", "text", "radius", whatever
this system's own vocabulary is.
purpose: What the token is for, e.g. "page bg, deepest surface".
rationale: WHY it is this value — a different question from purpose.
"Success equals Moss, aligned by design" is a rationale; "page bg,
deepest surface" is a purpose.
supersedes: Literal values this token should be used INSTEAD OF, e.g.
["#fff", "#ffffff"]. This is how a design system records what a
prohibition was trying to say — not "white is banned" but "write
this token instead". Declare it rather than expecting it to be
inferred: a superseded literal and the token's own value are
usually different values, so nothing can connect them by matching.
order_index: Display position within its group.
"""
uid = current_user_id()
token = await ds_svc.create_token(
uid,
design_system_id=design_system_id,
name=name,
value_by_mode=value_by_mode,
group_name=group_name or None,
purpose=purpose or None,
rationale=rationale or None,
supersedes=supersedes,
order_index=order_index,
)
if token is None:
raise ValueError(f"design system {design_system_id} not found or not writable")
return token.to_dict()
async def list_design_tokens(design_system_id: int) -> dict:
"""A design system's OWN tokens — its override set, not its effective set."""
uid = current_user_id()
rows = await ds_svc.list_tokens(uid, design_system_id)
return {"tokens": [t.to_dict() for t in rows]}
async def update_design_token(
token_id: int,
name: str = "",
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
rationale: str = "",
supersedes: list | None = None,
order_index: int = -1,
) -> dict:
"""Update a token. Empty/None args leave a field unchanged.
`value_by_mode` and `supersedes` REPLACE their whole value rather than
merging into it, so send every entry you want the token to keep. Pass `[]`
to clear `supersedes` entirely.
"""
uid = current_user_id()
fields: dict = {}
if name:
fields["name"] = name
if value_by_mode is not None:
fields["value_by_mode"] = value_by_mode
if group_name:
fields["group_name"] = group_name
if purpose:
fields["purpose"] = purpose
if rationale:
fields["rationale"] = rationale
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
# superseded literal) and would otherwise be unreachable.
if supersedes is not None:
fields["supersedes"] = supersedes
if order_index >= 0:
fields["order_index"] = order_index
token = await ds_svc.update_token(uid, token_id, **fields)
if token is None:
raise ValueError(f"design token {token_id} not found or not writable")
return token.to_dict()
async def delete_design_token(token_id: int) -> dict:
"""Soft-delete a token (recoverable).
In a system with a parent this restores inheritance: the token stops being
overridden here and resolves to the parent's value again.
"""
uid = current_user_id()
if not await ds_svc.delete_token(uid, token_id):
raise ValueError(f"design token {token_id} not found or not writable")
return {"message": f"Design token {token_id} deleted."}
async def set_project_design_system(project_id: int, design_system_id: int = 0) -> dict:
"""Point a project at a design system.
Args:
project_id: The project to style.
design_system_id: The system it uses, or -1 to clear it. Pointing at a
system only requires READ access to it — consuming a design system
is not changing it.
"""
uid = current_user_id()
target = None if design_system_id == -1 else design_system_id
ok = await ds_svc.set_project_design_system(uid, project_id, target)
if not ok:
raise ValueError(
f"project {project_id} not writable, or design system "
f"{design_system_id} not found"
)
return {"project_id": project_id, "design_system_id": target}
def register(mcp) -> None:
for fn in (
create_design_system,
list_design_systems,
get_design_system,
resolve_design_system,
update_design_system,
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,
delete_design_token,
set_project_design_system,
):
mcp.tool(name=fn.__name__)(fn)
+1
View File
@@ -43,3 +43,4 @@ from scribe.models.rulebook import ( # noqa: E402, F401
)
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
+156
View File
@@ -0,0 +1,156 @@
"""Design systems — a stylesheet held as records, inherited family -> app.
A DesignSystem is a named set of design tokens with an OPTIONAL parent, and that
single self-FK is the whole model. A system with no parent is a family system; a
system WITH one holds only what it changes. "What does this app alter?" is
therefore `list its tokens` — nothing to compute, nothing to diff — which is why
inheritance won over a flat family-plus-loose-overrides shape.
Resolution walks the chain and lets the deepest system win by token name. That
is the CSS cascade rather than an analogy to it, which is why the storage model
and the stylesheet model come out the same shape.
`parent_id` also replaces two things the rulebook model needs to express the same
idea: an `always_on` flag (a family system is simply one with no parent) and a
subscription join table (a project points at ONE system, and the chain supplies
the rest). Less schema for more structure.
"""
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "design_systems"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
owner_user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
)
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# The narrative a token table cannot hold: aesthetic, voice and tone, what
# is deliberately out of scope. Free-form markdown rather than a column per
# category — a schema with `voice`/`aesthetic`/`scope` columns would bake one
# rulebook's table of contents into every install.
guidance: Mapped[str | None] = mapped_column(Text, nullable=True)
# SET NULL, not CASCADE: deleting a family system must not delete every app
# system that inherited from it. Orphaning turns each child into a root that
# still holds its own overrides — recoverable. A cascade would destroy data
# the operator never asked to touch.
parent_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("design_systems.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
def to_dict(self) -> dict:
return {
"id": self.id,
"owner_user_id": self.owner_user_id,
"title": self.title,
"description": self.description or "",
"guidance": self.guidance or "",
"parent_id": self.parent_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"""One custom property in one system: its name, and its value per mode.
`value_by_mode` is a JSONB map of mode -> value, e.g.
`{"base": "#f7f5ef", "dark": "#14171a"}`. Three reasons it beats a pair of
`value_light` / `value_dark` columns here:
- **Absence means one thing.** In a child system an unset mode means
"inherit"; in a root it would have to mean "not mode-dependent". With
columns those are both NULL and the resolver cannot tell them apart. With
a map, resolution is `{**parent_map, **child_map}` at every level —
one rule, no special case for roots.
- **Per-mode overrides are already real.** A palette rule in this operator's
own kit deepens one accent on light backgrounds for contrast while
leaving the dark value alone. Mode is a second override axis, not a
second column.
- **Nothing filters tokens by value in SQL.** Drift comparison resolves the
set first and compares in the client; the importer diffs in Python. The
queryability columns would buy is for a query no caller makes.
The cost is real — a third mode is data rather than schema, so the DB will
not reject a typo'd mode key. That is the trade taken.
NOT NULL with a `{}` default deliberately: a JSONB column otherwise has two
empty states (SQL NULL and JSON null) and code has to test for both.
"""
__tablename__ = "design_tokens"
__table_args__ = (
# Partial unique: a name is unique among LIVE tokens in a system, so a
# trashed token doesn't block recreating the same name. Two live rows
# named `--fs-obsidian` in one system is a duplicate definition, and the
# cascade would pick between them arbitrarily.
Index(
"uq_token_per_design_system", "design_system_id", "name",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
design_system_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(Text)
# Named for what it holds rather than the bare word `values`, which is
# reserved in SQL — the same reason `group_name` is not `group`.
value_by_mode: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
)
# `group` is a reserved word in SQL; `group_name` throughout — model, column
# and payload — so no layer has to remember which spelling it is on.
# Free text, not a CHECK enum: groupings are the design system's own
# vocabulary, and a whitelist would bake one install's kit into the schema.
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
# WHY this token is this value — a different question from `purpose`, which
# is what it is FOR. "Success equals Moss, aligned by design" is a rationale;
# "page bg, deepest surface" is a purpose. Rules carry the first routinely
# and a token row had nowhere to put it.
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
# "#ffffff"] on a text-on-action token.
#
# This is how a design system records the thing a prohibition was trying to
# say. "Pure white is never text" is the shadow of a positive fact — text is
# Parchment — and a system that stores what things ARE has no row for a ban.
# Recording the replacement keeps the check and makes it actionable: a
# finding can name what to write instead of merely objecting.
#
# It has to be DECLARED rather than inferred, because the superseded literal
# and the token's own value are usually different colours (#fff is not
# #E8E4D8). No value-matching rule could ever connect them.
#
# Consumed by the source lint (#2277), not by the drift panel: these
# literals live in component CSS, which the panel cannot see and says so.
supersedes: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
)
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
def to_dict(self) -> dict:
return {
"id": self.id,
"design_system_id": self.design_system_id,
"name": self.name,
"value_by_mode": self.value_by_mode or {},
"group_name": self.group_name,
"purpose": self.purpose,
"rationale": self.rationale,
"supersedes": self.supersedes or [],
"order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+8 -1
View File
@@ -1,5 +1,5 @@
import enum
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin
@@ -21,6 +21,12 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
# The design system this project's UI is built from, or NULL. NULL is the
# ordinary state, not a degraded one — most installs have no design system
# at all and nothing may assume one exists.
design_system_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
)
def to_dict(self) -> dict:
return {
@@ -31,6 +37,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"goal": self.goal,
"status": self.status,
"color": self.color,
"design_system_id": self.design_system_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+29
View File
@@ -0,0 +1,29 @@
"""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())
+260
View File
@@ -0,0 +1,260 @@
"""Design system + token REST endpoints (milestone #254 step 4).
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
closer shape.
Two failures this layer has to keep apart, which is why the service raises for
one and returns None for the other:
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
from this system" is a correctable mistake and the caller needs to be told
which one they made.
- None -> 404, covering both "no such system" and "not yours". Conflating
those two IS the intent: distinguishing them would confirm the existence of
records the caller may not see.
"""
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_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
def _not_found(what: str = "design system"):
return jsonify({"error": f"{what} not found"}), 404
# ── Design systems ──────────────────────────────────────────────────────
@design_systems_bp.get("/design-systems")
@login_required
async def list_design_systems():
"""The caller's design systems. An empty list is the ordinary state for an
install that has never made one, not an error."""
rows = await ds_svc.list_design_systems(_uid())
return jsonify({"design_systems": [s.to_dict() for s in rows]})
@design_systems_bp.post("/design-systems")
@login_required
async def create_design_system():
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
system = await ds_svc.create_design_system(
user_id=_uid(),
title=title,
description=data.get("description") or None,
guidance=data.get("guidance") or None,
parent_id=data.get("parent_id"),
)
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/<int:design_system_id>")
@login_required
async def get_design_system(design_system_id: int):
system = await ds_svc.get_design_system(_uid(), design_system_id)
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
@login_required
async def update_design_system(design_system_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items() if k in ("title", "description", "guidance")
}
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
# which a `if data.get("parent_id")` filter would silently drop.
if "parent_id" in data:
fields["parent_id"] = data["parent_id"]
try:
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
except DesignSystemCycle as exc:
return jsonify({"error": str(exc)}), 400
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
@login_required
async def delete_design_system(design_system_id: int):
if not await ds_svc.delete_design_system(_uid(), design_system_id):
return _not_found()
return "", 204
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
@login_required
async def resolve_design_system(design_system_id: int):
"""The EFFECTIVE token set — everything inherited, with this system's on top.
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
this returns what it ends up being. Both are real questions and answering
only one would make the other a client-side computation.
"""
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
if resolved is None:
return _not_found()
return jsonify({
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
})
@design_systems_bp.get("/design-systems/<int:design_system_id>/stylesheet")
@login_required
async def get_design_system_stylesheet(design_system_id: int):
"""The master CSS sheet this design system generates.
JSON by default (the UI wants the reuse report alongside the CSS); add
`?format=css` for the raw stylesheet as `text/css`, which is what a build
step or a `curl` wants.
`?root=` overrides the base selector — a container-scoped preview cannot use
`:root`, so the generator takes it as a parameter.
"""
root = (request.args.get("root") or ":root").strip() or ":root"
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root)
if result is None:
return _not_found()
if request.args.get("format") == "css":
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
return jsonify(result)
@design_systems_bp.get("/design-systems/<int:design_system_id>/snippet-check")
@login_required
async def check_snippets_against_system(design_system_id: int):
"""Which recorded snippets disagree with this system's sheet.
`?project_id=` narrows to one project; omit it to check every project, which
is usually right — a component recorded elsewhere still has to use the same
tags.
"""
project_id = request.args.get("project_id", type=int) or 0
result = await ds_svc.check_snippets_against_system(
_uid(), design_system_id, project_id
)
if result is None:
return _not_found()
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")
@login_required
async def list_design_tokens(design_system_id: int):
"""This system's OWN tokens — its override set, not its effective set."""
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
return _not_found()
rows = await ds_svc.list_tokens(_uid(), design_system_id)
return jsonify({"tokens": [t.to_dict() for t in rows]})
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
@login_required
async def create_design_token(design_system_id: int):
data = await request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
token = await ds_svc.create_token(
user_id=_uid(),
design_system_id=design_system_id,
name=name,
value_by_mode=data.get("value_by_mode"),
group_name=data.get("group_name") or None,
purpose=data.get("purpose") or None,
rationale=data.get("rationale") or None,
supersedes=data.get("supersedes"),
order_index=data.get("order_index") or 0,
)
if token is None:
return _not_found()
return jsonify(token.to_dict()), 201
@design_systems_bp.patch("/design-tokens/<int:token_id>")
@login_required
async def update_design_token(token_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in (
"name", "value_by_mode", "group_name", "purpose", "rationale",
"supersedes", "order_index",
)
}
token = await ds_svc.update_token(_uid(), token_id, **fields)
if token is None:
return _not_found("design token")
return jsonify(token.to_dict())
@design_systems_bp.delete("/design-tokens/<int:token_id>")
@login_required
async def delete_design_token(token_id: int):
if not await ds_svc.delete_token(_uid(), token_id):
return _not_found("design token")
return "", 204
# ── The project pointer ─────────────────────────────────────────────────
@design_systems_bp.put("/projects/<int:project_id>/design-system")
@login_required
async def set_project_design_system(project_id: int):
"""Point a project at a design system. `{"design_system_id": null}` clears it.
PUT rather than PATCH: this sets one field to exactly what is sent, and
clearing it is a first-class outcome rather than an omission.
"""
data = await request.get_json() or {}
ok = await ds_svc.set_project_design_system(
_uid(), project_id, data.get("design_system_id")
)
if not ok:
return _not_found("project or design system")
return jsonify({"project_id": project_id,
"design_system_id": data.get("design_system_id")})
+84
View File
@@ -12,11 +12,13 @@ import logging
from sqlalchemy import or_, select
from scribe.models import async_session
from scribe.models.design_system import DesignSystem
from scribe.models.group import GroupMembership
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare
from scribe.models.user import User
from scribe.services.design_cascade import ancestry
logger = logging.getLogger(__name__)
@@ -164,6 +166,88 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
return perm in ("editor", "admin", "owner")
# ---------------------------------------------------------------------------
# Design-system permissions
# ---------------------------------------------------------------------------
async def get_design_system_permission(
user_id: int, design_system_id: int
) -> str | None:
"""Effective permission on a design system, or None.
Two ways in, and the asymmetry between them is the point:
- **Owning it** grants "owner" — full read and write.
- **Reaching it through a project you can see** grants "viewer", and only
ever "viewer". Being an editor on a shared project must NOT confer the
right to rewrite the family system that project inherits from: one
project's collaborator would be editing tokens every other project in
the family resolves through. Editing a design system stays the owner's
act, and it is the same reasoning that keeps a rulebook owner-scoped.
Reachability follows the parent chain UPWARD. Rendering a project's UI means
resolving its whole chain, so read access to a system implies read access to
its ancestors — otherwise a shared project would resolve to a truncated
cascade and silently render with the wrong values.
"""
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
if system.owner_user_id == user_id:
return "owner"
shared_project_ids = select(ProjectShare.project_id).where(
or_(
ProjectShare.shared_with_user_id == user_id,
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
)
)
entry_points = set(
(
await session.execute(
select(Project.design_system_id).where(
Project.design_system_id.is_not(None),
Project.deleted_at.is_(None),
or_(
Project.user_id == user_id,
Project.id.in_(shared_project_ids),
),
)
)
).scalars().all()
)
if not entry_points:
return None
# One narrow query for the whole forest's shape. Design systems are a
# handful of rows per install — a family and one per app — so walking
# from each entry point in memory beats a recursive CTE per check.
parents = dict(
(
await session.execute(
select(DesignSystem.id, DesignSystem.parent_id).where(
DesignSystem.deleted_at.is_(None)
)
)
).all()
)
for entry in entry_points:
if design_system_id in ancestry(entry, parents):
return "viewer"
return None
async def can_read_design_system(user_id: int, design_system_id: int) -> bool:
return (await get_design_system_permission(user_id, design_system_id)) is not None
async def can_write_design_system(user_id: int, design_system_id: int) -> bool:
perm = await get_design_system_permission(user_id, design_system_id)
return perm in ("editor", "admin", "owner")
# ---------------------------------------------------------------------------
# Set-based visibility (for LIST queries)
#
+251
View File
@@ -0,0 +1,251 @@
"""The design-system cascade, as pure functions over already-loaded rows.
Deliberately free of every database and service import — including
`services/access.py`, which needs `ancestry` to answer "can this caller read
this system?" and would otherwise form an import cycle with the service that
needs `access` back. A module that imports nothing can be imported by both.
Being pure is also what makes the cascade rule testable without a database:
these take a plain `{id: parent_id}` map, so a test states the shape of a
hierarchy in one literal instead of building one.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
# The mode key that applies when no more specific one does. Emitted CSS puts it
# on the base selector and every other key on a mode selector, mirroring how a
# stylesheet is actually written: light on `:root`, dark layered over it.
BASE_MODE = "base"
def ancestry(system_id: int, parents: Mapping[int, int | None]) -> list[int]:
"""The inheritance chain from `system_id` up to its root, nearest first.
Includes `system_id` itself at index 0, because resolution wants
deepest-to-shallowest and the system being resolved is the deepest link.
A system missing from `parents` terminates the chain rather than raising: a
parent whose row was soft-deleted or filtered out is a truncated chain, not
a failed request.
The visited-set is defensive, not the primary guard — writes already refuse
to create a cycle (`would_cycle`). It is here because a loop introduced by a
direct DB edit or a future bug must degrade to a truncated chain instead of
spinning forever. Truncation shows up in the result; a hang shows up as an
outage.
"""
chain: list[int] = []
seen: set[int] = set()
current: int | None = system_id
while current is not None and current not in seen:
seen.add(current)
chain.append(current)
current = parents.get(current)
return chain
def would_cycle(
system_id: int,
proposed_parent_id: int | None,
parents: Mapping[int, int | None],
) -> bool:
"""Would making `proposed_parent_id` the parent of `system_id` close a loop?
True when the proposed parent IS the system, or already inherits from it.
The walk goes UP from the proposed parent, which is the cheap direction —
each system has at most one parent, so the chain is a line. Asking the
equivalent downward question ("is the proposed parent among my
descendants?") would mean searching a whole forest for the same answer.
`proposed_parent_id=None` clears the parent and can never cycle.
"""
if proposed_parent_id is None:
return False
if proposed_parent_id == system_id:
return True
return system_id in ancestry(proposed_parent_id, parents)
# ---------------------------------------------------------------------------
# Resolution — flattening a chain into an effective token set
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Contribution:
"""One system's offer for one token in one mode."""
system_id: int
value: str
@dataclass(frozen=True)
class ResolvedToken:
"""A token after the cascade, carrying the whole argument rather than the verdict.
`contributions` holds every system that supplied a value, per mode, DEEPEST
FIRST — so `[0]` is the winner and `[1:]` are what it shadowed. Storing the
contest rather than a winner plus a separate provenance field means there is
nothing to keep in sync: "which system supplied this?" and "what did it
override?" are both reads of the same list, and they cannot disagree.
Provenance is per MODE, not per token, because overriding is. A system that
deepens one accent for light backgrounds while leaving dark alone owns the
base value and inherits the dark one, and a token-level "overridden here"
flag would have to lie about one of them.
"""
name: str
contributions: dict[str, tuple[Contribution, ...]]
group_name: str | None
purpose: str | None
rationale: str | None
supersedes: tuple[str, ...]
order_index: int
@property
def value_by_mode(self) -> dict[str, str]:
"""The effective value for each mode — the winner of each contest."""
return {mode: entries[0].value for mode, entries in self.contributions.items()}
@property
def origin_by_mode(self) -> dict[str, int]:
"""Which system supplied each mode's effective value."""
return {mode: entries[0].system_id for mode, entries in self.contributions.items()}
def value_for(self, mode: str) -> str | None:
"""The value to render in `mode`, falling back to the base mode.
This is the read rule the storage shape implies: a token that is not
mode-dependent carries only `base`, and asking it for "dark" must yield
the base value rather than nothing.
"""
entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE)
return entries[0].value if entries else None
def to_dict(self) -> dict:
"""Payload shape for both surfaces — and it carries the SHADOWED entries.
Serialising only the winner would throw away the provenance at the last
step, which is the one thing this type exists to preserve. `contributions`
is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so
a client renders without re-deriving anything, and cannot derive it
differently.
"""
return {
"name": self.name,
"group_name": self.group_name,
"purpose": self.purpose,
"rationale": self.rationale,
"supersedes": list(self.supersedes),
"order_index": self.order_index,
"value_by_mode": self.value_by_mode,
"origin_by_mode": self.origin_by_mode,
"contributions": {
mode: [
{"system_id": c.system_id, "value": c.value} for c in entries
]
for mode, entries in self.contributions.items()
},
}
def is_overridden_in(self, system_id: int) -> bool:
"""Does `system_id` win any mode of this token AND shadow something?
The distinction the UI needs: a token this system introduced is not an
override, and a token it merely inherits is not either.
"""
return any(
len(entries) > 1 and entries[0].system_id == system_id
for entries in self.contributions.values()
)
def _sort_key(token: ResolvedToken) -> tuple:
# Ungrouped tokens sort last rather than first: a design system that has
# started grouping should read as its groups, with the not-yet-filed
# remainder at the end.
return (token.group_name is None, token.group_name or "", token.order_index, token.name)
def resolve_tokens(
system_id: int,
parents: Mapping[int, int | None],
tokens_by_system: Mapping[int, Sequence],
) -> list[ResolvedToken]:
"""Flatten a system's inheritance chain into its effective token set.
Walks from `system_id` up to the root and applies tokens by name, deepest
winning. That is the CSS cascade — precedence by name along a parent chain —
rather than an analogy to it, which is why the storage model and the
stylesheet model came out the same shape.
`tokens_by_system` maps a system id to its own token rows. Any object with
`.name`, `.value_by_mode`, `.group_name`, `.purpose` and `.order_index` will
do, so a test can state a hierarchy in literals and the service can pass ORM
rows to the same function.
The result includes tokens the system never mentions — inheriting one is
what puts it in the effective set. A system with no tokens of its own
resolves to its parent's set entire, which is the correct answer for an app
that has not departed from the family yet.
Merging is per (name, MODE): a child that supplies only a dark value
overrides only dark and keeps inheriting base. Metadata (`group_name`,
`purpose`, `order_index`) cascades separately by the same deepest-wins rule,
since a child overriding a value routinely leaves the family's description
of what the token is FOR untouched — and inheriting it beats blanking it.
"""
chain = ancestry(system_id, parents)
contributions: dict[str, dict[str, list[Contribution]]] = {}
metadata: dict[str, dict[str, object]] = {}
# Deepest first, so the first contribution seen for a (name, mode) wins and
# every later one is a shadowed ancestor appended behind it.
for depth_system_id in chain:
for token in tokens_by_system.get(depth_system_id) or ():
per_mode = contributions.setdefault(token.name, {})
for mode, value in (token.value_by_mode or {}).items():
per_mode.setdefault(mode, []).append(
Contribution(system_id=depth_system_id, value=value)
)
meta = metadata.setdefault(
token.name,
{
"group_name": None, "purpose": None, "rationale": None,
"supersedes": None, "order_index": None,
},
)
for field in ("group_name", "purpose", "rationale"):
if meta[field] is None:
meta[field] = getattr(token, field, None)
# order_index alone treats 0 as UNSTATED rather than "first",
# because 0 is the column default. Reading it as a real value would
# let any child override drag its token to the top of the group and
# lose the family's ordering — a visible reshuffle in return for a
# change that only touched a colour.
if not meta["order_index"]:
meta["order_index"] = getattr(token, "order_index", 0) or None
# `supersedes` cascades on EMPTINESS, not on None: a child that
# overrides a colour and says nothing about which literals it
# replaces should keep the family's declaration, and an empty list
# is what "said nothing" looks like once the column is NOT NULL.
# A child that states its own list replaces the whole thing.
if not meta["supersedes"]:
meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None
resolved = [
ResolvedToken(
name=name,
contributions={
mode: tuple(entries) for mode, entries in per_mode.items()
},
group_name=metadata[name]["group_name"],
purpose=metadata[name]["purpose"],
rationale=metadata[name]["rationale"],
supersedes=metadata[name]["supersedes"] or (),
order_index=metadata[name]["order_index"] or 0,
)
for name, per_mode in contributions.items()
]
return sorted(resolved, key=_sort_key)
@@ -0,0 +1,404 @@
"""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))
# ---------------------------------------------------------------------------
# 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
+301
View File
@@ -0,0 +1,301 @@
"""Render a design system's resolved tokens as its master CSS sheet.
Pure, like `design_cascade` and for the same reasons: no database import, so the
rendering rule is testable without one and the preview surface can reuse it.
WHAT THIS SHEET IS, AND DELIBERATELY IS NOT
-------------------------------------------
It declares **purpose tokens only** — custom properties, grouped by what they
mean. It contains no rules for elements or classes: no `.btn-primary`, no
`table`, no `input`.
That is the point rather than a limitation. A sheet that styled every element
would restate the same handful of values once per element and grow with the UI;
a sheet of purpose-named values states each once and is reused. Components —
buttons, tables, input schemes — live as SNIPPETS that reference these names and
carry prose about the idea, which is a surface that already exists and already
has recall, locations, drift checks and merge.
So the division is: this sheet says what the values MEAN; snippets say what
things LOOK LIKE, in terms of those values. A token named after an element
(`--fs-button-bg`) is the smell that the two have been mixed — it multiplies
with every new element, where a purpose name (`--fs-action-primary`) is reused.
SAFETY
------
Values are interpolated into a stylesheet, and design systems are shareable
records (rule #47). A value of `red; } body { display: none` in a system someone
shared with you would otherwise inject CSS into your page. Everything rendered
here is validated or dropped — never escaped-and-hoped.
"""
from __future__ import annotations
import re
from collections.abc import Sequence
BASE_MODE = "base"
# A custom property name, strictly. Anything else is dropped rather than
# sanitised: a name is an identifier, and a "cleaned up" identifier is a
# different token than the one the operator recorded.
_VALID_NAME = re.compile(r"^--[A-Za-z0-9_-]+$")
# Characters that would end the declaration, open a block, start an at-rule, or
# begin a tag. A value containing any of them is not a value.
_UNSAFE_VALUE = re.compile(r"[{};@<>]|\*/|/\*|\n|\r")
def is_valid_token_name(name: str) -> bool:
return bool(_VALID_NAME.match((name or "").strip()))
def safe_value(value: str) -> str | None:
"""A CSS value that cannot escape its declaration, or None.
Rejects rather than strips. A partially-sanitised value is a value the
operator did not write, and silently rendering a different colour than the
record holds is worse than rendering none — the sheet's whole claim is that
it IS the record.
"""
candidate = (value or "").strip()
if not candidate or _UNSAFE_VALUE.search(candidate):
return None
return candidate
def safe_comment(text: str) -> str:
"""Comment text that cannot close its comment or break the line."""
return re.sub(r"\*/|/\*|[\n\r]", " ", (text or "")).strip()
def selector_for_mode(mode: str, root_selector: str = ":root") -> str:
"""Which selector a mode's declarations belong under.
`base` gets the caller's root selector; every other mode gets a bare
attribute selector, matching the convention already in the codebase.
The root selector is a PARAMETER because a container-scoped preview cannot
use `:root` — #251 recorded that mode scoping is one-way (light lives on
`:root`, dark layers over it), so a generator that hardcoded `:root` could
not serve a preview at all.
"""
if mode == BASE_MODE:
return root_selector
safe_mode = re.sub(r"[^A-Za-z0-9_-]", "", mode)
return f'[data-theme="{safe_mode}"]' if safe_mode else root_selector
def _grouped(tokens: Sequence) -> list[tuple[str | None, list]]:
"""Tokens by group, preserving the order they arrive in (already sorted)."""
groups: dict[str | None, list] = {}
for token in tokens:
groups.setdefault(getattr(token, "group_name", None), []).append(token)
return list(groups.items())
def _modes_present(tokens: Sequence) -> list[str]:
"""Every mode any token declares, base first then the rest alphabetically."""
modes = {
mode
for token in tokens
for mode in (getattr(token, "value_by_mode", None) or {})
}
rest = sorted(modes - {BASE_MODE})
return ([BASE_MODE] if BASE_MODE in modes else []) + rest
def render_stylesheet(
tokens: Sequence,
*,
root_selector: str = ":root",
title: str = "",
design_system_id: int | None = None,
) -> str:
"""The master sheet for a resolved token set.
One block per mode. Within a block, only the tokens that declare a value for
that mode — so a mode block is an override layer, exactly as the storage
model has it.
Tokens with no value at all are emitted as COMMENTED-OUT declarations in
their group, not dropped. The rulebook named them, so their absence is a
finding, and a commented line puts that finding where the reader is already
looking.
"""
header = [
"/*",
f" * {safe_comment(title) or 'Design system'} — generated stylesheet",
]
if design_system_id is not None:
header.append(f" * Source: design system {int(design_system_id)}.")
header += [
" *",
" * Purpose tokens only. This sheet declares what values MEAN; it styles",
" * no elements. Buttons, tables and input schemes live as snippets that",
" * reference these names, so each value is stated once and reused rather",
" * than restated per element.",
" *",
" * Generated — edit the design system, not this file.",
" */",
"",
]
lines = list(header)
valueless = [
t for t in tokens
if is_valid_token_name(getattr(t, "name", ""))
and not (getattr(t, "value_by_mode", None) or {})
]
for mode in _modes_present(tokens):
block: list[str] = []
for group, group_tokens in _grouped(tokens):
entries: list[str] = []
for token in group_tokens:
name = (getattr(token, "name", "") or "").strip()
if not is_valid_token_name(name):
continue
raw = (getattr(token, "value_by_mode", None) or {}).get(mode)
if raw is None:
continue
value = safe_value(str(raw))
if value is None:
entries.append(
f" /* {name}: value rejected — not a safe CSS value */"
)
continue
# Purpose first — what the token is FOR is what a reader of the
# stylesheet needs. Rationale is the fallback so a token that
# only carries the why still says something.
purpose = safe_comment(
getattr(token, "purpose", "")
or getattr(token, "rationale", "")
or ""
)
comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else ""
entries.append(f" {name}: {value};{comment}")
if entries:
if group:
block.append(f" /* {safe_comment(group)} */")
block.extend(entries)
block.append("")
# Declared-but-valueless tokens belong to the base layer: they have no
# mode to sit under, and repeating them per mode would triple the noise.
if mode == BASE_MODE and valueless:
block.append(" /* Declared by the design system, no value set yet: */")
block.extend(f" /* {t.name}: ; */" for t in valueless)
block.append("")
if not block:
continue
lines.append(f"{selector_for_mode(mode, root_selector)} {{")
lines.extend(block[:-1] if block[-1] == "" else block)
lines.append("}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def duplicate_values(tokens: Sequence) -> dict[str, list[str]]:
"""Values declared by more than one token, mapped to the names declaring them.
Two names for one value are either a deliberate alias or the same idea
recorded twice — the token-level form of the duplicated-definition shape.
Reported rather than refused: a design system legitimately aligns colours on
purpose ("Success = Moss, by design"), and a generator that rejected that
would be wrong about the operator's intent.
Compares the BASE value only. Two tokens agreeing in one mode and diverging
in another are not duplicates of each other — they are a near-miss, which is
a different and less interesting finding.
"""
by_value: dict[str, list[str]] = {}
for token in tokens:
name = getattr(token, "name", "")
base = (getattr(token, "value_by_mode", None) or {}).get(BASE_MODE)
if not name or not base:
continue
by_value.setdefault(str(base).strip().lower(), []).append(name)
return {value: names for value, names in by_value.items() if len(names) > 1}
# ---------------------------------------------------------------------------
# Reading a sheet from the other side: does this code use it correctly?
# ---------------------------------------------------------------------------
#
# "The snippets use the tags from the sheet" is a verifiable relation, and
# nothing checked it before. Three questions, each a different failure:
#
# var(--x) where no --x exists -> renders as NOTHING. No error, no test
# failure, no visual clue beyond the thing
# silently not being styled.
# a superseded literal in code -> the value the sheet said to stop writing,
# and the sheet knows what to write instead.
# --x: declared inside a snippet -> a component minting its own token is the
# bloat a shared sheet exists to prevent.
#
# The first is not hypothetical: `--color-accent` was used throughout a new view
# in this codebase and does not exist.
_VAR_REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
_LOCAL_DEFINITION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
def referenced_tokens(code: str) -> set[str]:
"""Every custom property the code reads through `var()`."""
return set(_VAR_REFERENCE.findall(code or ""))
def defined_tokens(code: str) -> set[str]:
"""Every custom property the code declares itself.
Excludes names it also reads: `--x: var(--x, fallback)` is a redeclaration
of something the sheet owns, which the unknown-reference check already
covers more precisely.
"""
return set(_LOCAL_DEFINITION.findall(code or "")) - referenced_tokens(code or "")
def _literal_pattern(literal: str) -> re.Pattern:
"""Match a literal value without matching a longer one that contains it.
`#fff` must not match inside `#ffffff` — they are different colours, and a
finding that fired on the wrong one would send someone to change code that
was already correct.
"""
escaped = re.escape(literal)
lead = r"(?<![0-9A-Za-z_#-])"
trail = r"(?![0-9A-Za-z_-])" if literal.startswith("#") else r"(?![0-9A-Za-z_-])"
return re.compile(lead + escaped + trail, re.IGNORECASE)
def check_code_against_tokens(code: str, tokens) -> dict:
"""What this code gets wrong about that token set.
`tokens` is any sequence with `.name`, `.value_by_mode` and `.supersedes` —
resolved tokens, or stored ones.
Reports rather than scores. Every finding here has a legitimate exception:
a snippet may target a system it isn't being checked against, and a literal
may be deliberate in a context the token doesn't cover. What it removes is
the SILENCE — all three currently fail with no signal at all.
"""
known = {getattr(t, "name", "") for t in tokens}
referenced = referenced_tokens(code)
superseded: list[dict] = []
for token in tokens:
for literal in getattr(token, "supersedes", None) or ():
if _literal_pattern(str(literal)).search(code or ""):
superseded.append({
"literal": literal,
"use_instead": getattr(token, "name", ""),
})
return {
"used": sorted(referenced & known),
"unknown": sorted(referenced - known),
"superseded_literals": superseded,
"local_definitions": sorted(defined_tokens(code)),
}
+501
View File
@@ -0,0 +1,501 @@
"""Design system + token persistence, and the guard on the parent chain.
Access goes through `services/access.py` (rule #78), where reaching a system via
a project you can see grants READ but never write — see
`get_design_system_permission` for why that asymmetry exists.
The cascade itself lives in `services/design_cascade.py` as pure functions. This
module is the part that needs a database: loading the shape of the hierarchy,
refusing writes that would break it, and storing tokens.
Nothing here seeds or implies a default system. An install with no design
systems is an ordinary install, and every caller must handle an empty list as
the normal case rather than a missing prerequisite.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.project import Project
from scribe.services import access
from scribe.services.design_stylesheet import (
check_code_against_tokens,
duplicate_values,
render_stylesheet,
)
from scribe.services.design_cascade import (
ResolvedToken,
ancestry,
resolve_tokens,
would_cycle,
)
logger = logging.getLogger(__name__)
class DesignSystemCycle(ValueError):
"""A parent assignment that would close an inheritance loop.
Raised rather than returned as None because the two outcomes need different
answers: None already means "not found, or not yours", and a caller that
conflated them would show "no such design system" for what is really "that
parent is one of its own descendants". Routes map this to a 400.
"""
async def _parent_map(session, owner_user_id: int) -> dict[int, int | None]:
"""`{id: parent_id}` for one owner's live systems — the hierarchy's shape.
Scoped to the OWNER of the systems, not the caller, and the distinction is
load-bearing on the read path: a caller reading through a shared project
does not own any link in the chain, and a caller-scoped map would hand them
an empty forest and a cascade truncated to one system. They would get a page
that renders with plausible wrong values and no error anywhere.
Owner-scoping is also the constraint on parenting: a chain may only be built
from systems its owner controls, or someone else's delete or re-parent would
silently restyle your app.
"""
rows = (
await session.execute(
select(DesignSystem.id, DesignSystem.parent_id).where(
DesignSystem.owner_user_id == owner_user_id,
DesignSystem.deleted_at.is_(None),
)
)
).all()
return dict(rows)
# --- design systems ---------------------------------------------------------
async def create_design_system(
user_id: int,
title: str,
description: str | None = None,
guidance: str | None = None,
parent_id: int | None = None,
) -> 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.
"""
if parent_id is not None and not await access.can_write_design_system(
user_id, parent_id
):
return None
async with async_session() as session:
system = DesignSystem(
owner_user_id=user_id,
title=title.strip(),
description=description,
guidance=guidance,
parent_id=parent_id,
)
session.add(system)
await session.commit()
await session.refresh(system)
return system
async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem | None:
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_read_design_system(user_id, design_system_id):
return None
return system
async def list_design_systems(user_id: int) -> list[DesignSystem]:
"""The caller's own systems, ordered by title. Empty is normal."""
async with async_session() as session:
rows = await session.execute(
select(DesignSystem)
.where(
DesignSystem.owner_user_id == user_id,
DesignSystem.deleted_at.is_(None),
)
.order_by(DesignSystem.title)
)
return list(rows.scalars().all())
async def update_design_system(
user_id: int, design_system_id: int, **fields: object
) -> DesignSystem | None:
"""Partial update. Raises DesignSystemCycle if `parent_id` would loop.
`parent_id` is handled apart from the other fields because None is a
meaningful value for it — "make this a root" — where for every other field
None means "leave alone". Callers signal it by passing the key at all.
"""
if not await access.can_write_design_system(user_id, design_system_id):
return None
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
if "parent_id" in fields:
parent_id = fields.pop("parent_id")
if parent_id is not None:
parent_id = int(parent_id)
if not await access.can_write_design_system(user_id, parent_id):
return None
if would_cycle(
design_system_id,
parent_id,
await _parent_map(session, system.owner_user_id),
):
raise DesignSystemCycle(
f"Design system {design_system_id} cannot inherit from "
f"{parent_id}: that system already inherits from it."
)
system.parent_id = parent_id
for key, value in fields.items():
if key in ("title", "description", "guidance") and value is not None:
setattr(system, key, value)
system.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(system)
return system
async def delete_design_system(user_id: int, design_system_id: int) -> bool:
"""Soft-delete a system. Children survive as roots (the FK is SET NULL)."""
if not await access.can_write_design_system(user_id, design_system_id):
return False
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return False
system.deleted_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- tokens -----------------------------------------------------------------
async def create_token(
user_id: int,
design_system_id: int,
name: str,
value_by_mode: dict | None = None,
group_name: str | None = None,
purpose: str | None = None,
rationale: str | None = None,
supersedes: list | None = None,
order_index: int = 0,
) -> DesignToken | None:
if not await access.can_write_design_system(user_id, design_system_id):
return None
async with async_session() as session:
token = DesignToken(
design_system_id=design_system_id,
name=name.strip(),
# `or {}` and not the argument as given: the column is NOT NULL so
# that absence has exactly one spelling. Passing None here would
# otherwise store JSON null and reintroduce the second empty state.
value_by_mode=value_by_mode or {},
group_name=group_name,
purpose=purpose,
rationale=rationale,
# `or []` for the same reason as value_by_mode above: the column is
# NOT NULL so absence has one spelling, and None would store JSON
# null instead of an empty array.
supersedes=supersedes or [],
order_index=order_index,
)
session.add(token)
await session.commit()
await session.refresh(token)
return token
async def list_tokens(user_id: int, design_system_id: int) -> list[DesignToken]:
"""One system's OWN tokens — its override set, not its effective set.
Resolving the chain is step 2's job; this deliberately answers the narrower
question ("what does this system change?") that the model exists to make
free.
"""
if not await access.can_read_design_system(user_id, design_system_id):
return []
async with async_session() as session:
rows = await session.execute(
select(DesignToken)
.where(
DesignToken.design_system_id == design_system_id,
DesignToken.deleted_at.is_(None),
)
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
)
return list(rows.scalars().all())
async def resolve_design_system(
user_id: int, design_system_id: int
) -> list[ResolvedToken] | None:
"""A system's EFFECTIVE token set — everything it inherits, with its own on top.
None when the caller may not read the system; an empty list when the chain
genuinely holds no tokens, which is an ordinary state for a system that has
just been created.
Two queries regardless of how deep the chain runs: one for the hierarchy's
shape, one for every token in it. The flattening itself is
`design_cascade.resolve_tokens` — pure, so the cascade rule is tested
without a database and this function is only the loading.
"""
if not await access.can_read_design_system(user_id, design_system_id):
return None
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
parents = await _parent_map(session, system.owner_user_id)
chain = ancestry(design_system_id, parents)
rows = (
await session.execute(
select(DesignToken)
.where(
DesignToken.design_system_id.in_(chain),
DesignToken.deleted_at.is_(None),
)
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
)
).scalars().all()
tokens_by_system: dict[int, list[DesignToken]] = {}
for token in rows:
tokens_by_system.setdefault(token.design_system_id, []).append(token)
return resolve_tokens(design_system_id, parents, tokens_by_system)
async def update_token(
user_id: int, token_id: int, **fields: object
) -> DesignToken | None:
allowed = {
"name", "value_by_mode", "group_name", "purpose", "rationale",
"supersedes", "order_index",
}
async with async_session() as session:
token = await session.get(DesignToken, token_id)
if token is None or token.deleted_at is not None:
return None
if not await access.can_write_design_system(user_id, token.design_system_id):
return None
for key, value in fields.items():
if key in allowed and value is not None:
setattr(token, key, value)
token.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(token)
return token
async def delete_token(user_id: int, token_id: int) -> bool:
async with async_session() as session:
token = await session.get(DesignToken, token_id)
if token is None or token.deleted_at is not None:
return False
if not await access.can_write_design_system(user_id, token.design_system_id):
return False
token.deleted_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- the project pointer ----------------------------------------------------
async def set_project_design_system(
user_id: int, project_id: int, design_system_id: int | None
) -> bool:
"""Point a project at a design system, or at nothing (None clears it).
Needs write on the project and READ on the system: pointing at a system is
consuming it, not changing it, so a system shared with you through another
project is a legitimate choice here.
"""
if not await access.can_write_project(user_id, project_id):
return False
if design_system_id is not None and not await access.can_read_design_system(
user_id, design_system_id
):
return False
async with async_session() as session:
project = await session.get(Project, project_id)
if project is None or project.deleted_at is not None:
return False
project.design_system_id = design_system_id
project.updated_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- import from a rulebook -------------------------------------------------
async def import_from_rulebook(
user_id: int,
design_system_id: int,
rulebook_id: int,
apply: bool = False,
) -> dict | None:
"""Propose (and optionally create) tokens for a system from a rulebook.
Returns None when the caller may not write the system or read the rulebook.
Otherwise a report with three lists, and the split between them is the whole
point of running it with `apply=False` first:
proposed — everything the rulebook describes, each entry carrying the
rule and sentence it came from
created — what was actually written (empty unless `apply`)
skipped — proposals whose name the system already defines
**Existing tokens are never overwritten.** An import is a proposal built by
reading prose; a value already in the record was put there deliberately, and
a re-run must not undo an operator's correction. That also makes the whole
operation safe to repeat — it fills gaps and reports the rest.
Tokens with no value are still created when `apply` is set. The rulebook
names them, so their absence from the system is itself a finding, and a
named token with a blank value says "this exists and needs deciding" where
silence says nothing at all.
"""
if not await access.can_write_design_system(user_id, design_system_id):
return None
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.design_rulebook_import import propose_tokens
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
if not rules:
return {"rulebook_id": rulebook_id, "proposed": [], "created": [], "skipped": []}
proposals = propose_tokens(rules)
existing = {t.name for t in await list_tokens(user_id, design_system_id)}
created: list[dict] = []
skipped: list[str] = []
for index, proposal in enumerate(proposals):
if proposal.name in existing:
skipped.append(proposal.name)
continue
if not apply:
continue
token = await create_token(
user_id,
design_system_id=design_system_id,
name=proposal.name,
value_by_mode=proposal.value_by_mode,
group_name=proposal.group_name,
purpose=proposal.purpose,
supersedes=proposal.supersedes,
order_index=index,
)
if token is not None:
created.append(token.to_dict())
return {
"rulebook_id": rulebook_id,
"proposed": [p.as_dict() for p in proposals],
"created": created,
"skipped": skipped,
}
# --- the master sheet -------------------------------------------------------
async def stylesheet_for_system(
user_id: int, design_system_id: int, root_selector: str = ":root"
) -> dict | None:
"""The master CSS sheet a design system generates, plus its reuse report.
Purpose tokens only — the sheet styles no elements. See
`services/design_stylesheet.py` for why that split is the design rather than
a shortcut.
Returns None if the caller may not read the system. The `duplicates` half is
advisory: two tokens sharing a value are either a deliberate alias or one
idea recorded twice, and only the operator knows which.
"""
resolved = await resolve_design_system(user_id, design_system_id)
if resolved is None:
return None
system = await get_design_system(user_id, design_system_id)
return {
"design_system_id": design_system_id,
"css": render_stylesheet(
resolved,
root_selector=root_selector,
title=system.title if system else "",
design_system_id=design_system_id,
),
"token_count": len(resolved),
"valueless": [t.name for t in resolved if not t.value_by_mode],
"duplicates": duplicate_values(resolved),
}
async def check_snippets_against_system(
user_id: int, design_system_id: int, project_id: int = 0
) -> dict | None:
"""Which recorded snippets disagree with this design system's sheet.
The relation the operator named — "the snippets use the tags from the sheet"
— turned into a check. For each snippet: `var(--x)` references with no such
token, literals the sheet says to stop writing, and custom properties the
snippet mints for itself instead of using shared ones.
Snippets with nothing to report are omitted entirely. A list of everything
that is fine is a list nobody reads twice.
"""
resolved = await resolve_design_system(user_id, design_system_id)
if resolved is None:
return None
from scribe.services import snippets as snippets_svc
# `list_snippets` returns (rows, total) and caps limit at 100; `project_id`
# must be None — not 0 — to reach across every project, since 0 would filter
# to a project with that id.
rows, _total = await snippets_svc.list_snippets(
user_id=user_id, project_id=project_id or None, limit=100,
)
findings: list[dict] = []
for row in rows:
snippet_id = row.get("id")
if snippet_id is None:
continue
# The list rows carry a preview, not the code. The check has to read the
# whole body or it would report on a truncation.
note = await snippets_svc.get_snippet(user_id=user_id, snippet_id=int(snippet_id))
if note is None:
continue
report = check_code_against_tokens(note.body or "", resolved)
if not (
report["unknown"] or report["superseded_literals"] or report["local_definitions"]
):
continue
findings.append({
"snippet_id": int(snippet_id),
"title": note.title or "",
**report,
})
return {
"design_system_id": design_system_id,
"checked": len(rows),
"findings": findings,
}
+40 -6
View File
@@ -89,6 +89,38 @@ async def log_error(
await session.commit()
def parse_filter_datetime(value: str | None, *, end_of_day: bool = False) -> datetime | None:
"""An ISO date/datetime from a query string as an AWARE UTC datetime.
The admin log filters arrive as raw `request.args` strings and used to be
compared straight against `AppLog.created_at`. asyncpg binds a str as
VARCHAR and Postgres has no `timestamptz >= text` operator, so supplying
either date filter raised — the same defect as #1727 in notifications, in a
second place (#2254).
Returns None for anything unparseable: a malformed filter should narrow
nothing rather than 500 the log viewer.
`end_of_day` matters for the upper bound. A bare "2026-07-30" parses to
midnight, so `created_at <= that` would exclude the whole of the day the
user asked for — the one day they most likely wanted. With this flag a
date-only value is pushed to the last microsecond of that day; a value that
already carries a time is left exactly as given.
"""
raw = (value or "").strip()
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if end_of_day and len(raw) == 10: # date-only, no time component
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
async def get_logs(
category: str | None = None,
user_id: int | None = None,
@@ -118,12 +150,14 @@ async def get_logs(
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
if date_from:
query = query.where(AppLog.created_at >= date_from)
count_query = count_query.where(AppLog.created_at >= date_from)
if date_to:
query = query.where(AppLog.created_at <= date_to)
count_query = count_query.where(AppLog.created_at <= date_to)
start = parse_filter_datetime(date_from)
end = parse_filter_datetime(date_to, end_of_day=True)
if start:
query = query.where(AppLog.created_at >= start)
count_query = count_query.where(AppLog.created_at >= start)
if end:
query = query.where(AppLog.created_at <= end)
count_query = count_query.where(AppLog.created_at <= end)
total = (await session.execute(count_query)).scalar() or 0
+22 -4
View File
@@ -3,7 +3,7 @@
import asyncio
import json
import logging
from datetime import date, datetime, timezone
from datetime import date, datetime, time, timezone
from sqlalchemy import func, select, text
@@ -149,13 +149,25 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
def utc_day_start(day: date) -> datetime:
"""Midnight UTC on `day`, as an AWARE datetime — the reminder dedup window.
Deliberately a `datetime` and not the bare `date` (#1727). Comparing a
`timestamptz` column against a `date` does work, via an implicit cast — but
Postgres resolves that cast in the SESSION's TimeZone, so the window would
move with a server setting nobody remembers is load-bearing. An aware UTC
datetime says what it means and compares the same way everywhere.
"""
return datetime.combine(day, time.min, tzinfo=timezone.utc)
async def check_due_tasks() -> None:
"""Check for tasks due today and send reminder emails."""
if not await is_smtp_configured():
return
today = date.today()
today_str = today.isoformat()
window_start = utc_day_start(today)
async with async_session() as session:
# Find tasks due today or overdue, not done
@@ -188,12 +200,18 @@ async def check_due_tasks() -> None:
if not email:
continue
# Dedup: check if we already sent a reminder today
# Dedup: check if we already sent a reminder today.
# `window_start` is an aware datetime, NOT `today.isoformat()`.
# asyncpg binds a str as VARCHAR and Postgres has no
# `timestamptz >= text` operator, so this raised on every run
# that got this far — swallowed by the per-user `except` below,
# which is why the only symptom was reminders silently never
# sending plus an hourly traceback in the DB log (#1727).
dedup_result = await session.execute(
select(func.count(AppLog.id)).where(
AppLog.action == "task_reminder",
AppLog.user_id == user_id,
AppLog.created_at >= today_str,
AppLog.created_at >= window_start,
)
)
if (dedup_result.scalar() or 0) > 0:
+420
View File
@@ -0,0 +1,420 @@
"""The parent chain: walking it, and refusing to close it (milestone #254 step 1).
Pure functions over a `{id: parent_id}` literal, so a whole hierarchy is one line
of setup and no database is involved. That is the reason the cascade lives in its
own import-free module — see services/design_cascade.py.
"""
from types import SimpleNamespace
from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
# --- ancestry ---------------------------------------------------------------
def test_ancestry_returns_the_chain_deepest_first():
"""Deepest first because that is the order resolution consumes it in: the
system being resolved wins over its parent, which wins over the root."""
parents = {1: None, 2: 1, 3: 2}
assert ancestry(3, parents) == [3, 2, 1]
def test_a_root_is_its_own_whole_chain():
"""A family system has no parent, and that is an ordinary state — not an
incomplete one. It must resolve to exactly itself."""
assert ancestry(1, {1: None}) == [1]
def test_a_missing_parent_truncates_rather_than_raising():
"""A parent that was soft-deleted (or filtered out of the caller's scope) is
a shorter chain, not a failed request. The alternative — raising — would make
one deleted system break every descendant's rendering."""
assert ancestry(3, {3: 2}) == [3, 2]
def test_a_system_absent_from_the_map_still_yields_itself():
assert ancestry(9, {}) == [9]
def test_ancestry_terminates_on_a_cycle_instead_of_hanging():
"""LOAD-BEARING, and the reason a visited-set exists even though writes are
guarded. A loop introduced by a direct DB edit or a future bug must degrade
to a truncated chain: truncation shows up in the result, a hang shows up as
an outage. Every id appears exactly once."""
parents = {1: 3, 2: 1, 3: 2}
chain = ancestry(1, parents)
assert chain == [1, 3, 2]
assert len(chain) == len(set(chain))
def test_ancestry_terminates_on_a_self_parent():
assert ancestry(1, {1: 1}) == [1]
# --- would_cycle ------------------------------------------------------------
def test_clearing_the_parent_never_cycles():
"""None means "make this a root", which is always safe."""
assert would_cycle(2, None, {1: None, 2: 1}) is False
def test_a_system_cannot_be_its_own_parent():
assert would_cycle(1, 1, {1: None}) is True
def test_an_ordinary_reparent_is_allowed():
"""family <- app is the shape the whole model exists for; it must not trip
the guard."""
assert would_cycle(2, 1, {1: None, 2: None}) is False
def test_a_direct_swap_is_refused():
"""A -> B, then B -> A. The two-system case, and the one a UI produces first
because both systems are on screen together."""
parents = {1: None, 2: 1}
assert would_cycle(1, 2, parents) is True
def test_an_indirect_loop_is_refused():
"""Three deep: root <- mid <- leaf, then root's parent set to leaf. Catching
this is what makes the check a chain walk rather than a parent comparison."""
parents = {1: None, 2: 1, 3: 2}
assert would_cycle(1, 3, parents) is True
def test_reparenting_onto_a_sibling_subtree_is_allowed():
"""Two branches off one root. Moving one under the other is legitimate — the
guard must refuse loops, not reorganisation."""
parents = {1: None, 2: 1, 3: 1}
assert would_cycle(3, 2, parents) is False
def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
"""If a cycle somehow already exists, the guard still has to answer rather
than spin — the write path is exactly where such a hierarchy gets repaired."""
parents = {1: 2, 2: 1, 3: None}
assert would_cycle(3, 1, parents) is False
assert would_cycle(1, 2, parents) is True
# --- resolution -------------------------------------------------------------
#
# The cascade proper (milestone #254 step 2). Hierarchies are stated as literals
# because resolve_tokens is pure and duck-typed — which is the whole reason it
# lives here rather than inside the service.
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
supersedes=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
)
# A family (1) and an app inheriting from it (2) — the shape the model exists for.
FAMILY, APP = 1, 2
PARENTS = {FAMILY: None, APP: FAMILY}
def _by_name(tokens):
return {t.name: t for t in tokens}
def test_a_system_with_no_tokens_of_its_own_inherits_the_whole_family_set():
"""The correct answer for an app that has not departed from the family yet —
and the state every app system starts in."""
resolved = resolve_tokens(
APP, PARENTS,
{FAMILY: [_token("--fs-obsidian", {"base": "#14171a"})], APP: []},
)
assert [t.name for t in resolved] == ["--fs-obsidian"]
assert resolved[0].value_by_mode == {"base": "#14171a"}
assert resolved[0].origin_by_mode == {"base": FAMILY}
def test_the_deepest_system_wins_and_says_what_it_overrode():
"""Provenance is the point of the whole model: the effective set is just a
list without it, and "where does this app depart from the family?" becomes a
diff someone has to compute."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
},
))
accent = resolved["--fs-accent"]
assert accent.value_by_mode == {"base": "#5b4a8a"}
assert accent.origin_by_mode == {"base": APP}
# The shadowed ancestor is still there, behind the winner.
assert [c.system_id for c in accent.contributions["base"]] == [APP, FAMILY]
assert accent.contributions["base"][1].value == "#6b2118"
def test_overriding_one_mode_leaves_the_others_inherited():
"""LOAD-BEARING, and the argument that decided the storage shape. An accent
deepened for light backgrounds while dark is left alone must own `base` and
still inherit `dark` — which is why merging is per (name, MODE) and why the
value column is a map rather than a pair of columns."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#34a877", "dark": "#34a877"})],
APP: [_token("--fs-accent", {"base": "#15803d"})],
},
))
accent = resolved["--fs-accent"]
assert accent.value_by_mode == {"base": "#15803d", "dark": "#34a877"}
assert accent.origin_by_mode == {"base": APP, "dark": FAMILY}
def test_a_token_only_the_app_defines_is_not_an_override():
"""Introducing a token and overriding one are different acts, and a UI that
labelled both "overridden here" would misdescribe the first."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{FAMILY: [], APP: [_token("--fs-editor-caret", {"base": "#5b4a8a"})]},
))
caret = resolved["--fs-editor-caret"]
assert caret.origin_by_mode == {"base": APP}
assert caret.is_overridden_in(APP) is False
def test_is_overridden_in_is_true_only_for_the_system_that_shadowed():
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
},
))
accent = resolved["--fs-accent"]
assert accent.is_overridden_in(APP) is True
assert accent.is_overridden_in(FAMILY) is False
def test_three_levels_stack_nearest_first():
"""A chain deeper than family->app, to prove the walk isn't a single hop."""
parents = {1: None, 2: 1, 3: 2}
resolved = _by_name(resolve_tokens(
3, parents,
{
1: [_token("--fs-bg", {"base": "a"})],
2: [_token("--fs-bg", {"base": "b"})],
3: [_token("--fs-bg", {"base": "c"})],
},
))
bg = resolved["--fs-bg"]
assert bg.value_by_mode == {"base": "c"}
assert [c.value for c in bg.contributions["base"]] == ["c", "b", "a"]
def test_resolving_the_family_itself_ignores_its_children():
"""Inheritance runs one way. A family system resolved on its own must not
pick up an app's overrides — otherwise every app would restyle the house."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
},
))
assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"}
def test_value_for_falls_back_to_the_base_mode():
"""A token that isn't mode-dependent carries only `base`, and asking it for
"dark" must yield that rather than nothing — the read rule the storage shape
implies."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
))
radius = resolved["--fs-radius-md"]
assert radius.value_for("dark") == "8px"
assert radius.value_for("base") == "8px"
def test_value_for_prefers_an_explicit_mode_over_the_fallback():
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [_token("--fs-bg", {"base": "#f7f5ef", "dark": "#14171a"})]},
))
assert resolved["--fs-bg"].value_for("dark") == "#14171a"
def test_metadata_is_inherited_when_the_override_leaves_it_blank():
"""A child overriding a colour routinely says nothing about what the token is
FOR. Inheriting the family's description beats blanking it — the override was
about the value, not the meaning."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token(
"--fs-obsidian", {"base": "#14171a"},
group_name="surface", purpose="page bg, deepest surface",
)],
APP: [_token("--fs-obsidian", {"base": "#101317"})],
},
))
obsidian = resolved["--fs-obsidian"]
assert obsidian.group_name == "surface"
assert obsidian.purpose == "page bg, deepest surface"
assert obsidian.value_by_mode == {"base": "#101317"} # the value still won
def test_an_override_that_states_metadata_wins_it_too():
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-x", {"base": "a"}, purpose="family says")],
APP: [_token("--fs-x", {"base": "b"}, purpose="app says")],
},
))
assert resolved["--fs-x"].purpose == "app says"
def test_an_override_at_default_order_keeps_the_familys_position():
"""order_index 0 is the column DEFAULT, so it reads as unstated. Treating it
as "first" would let a colour-only override drag its token to the top of the
group — a visible reshuffle nobody asked for."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-x", {"base": "a"}, order_index=7)],
APP: [_token("--fs-x", {"base": "b"})],
},
))
assert resolved["--fs-x"].order_index == 7
def test_the_effective_set_is_ordered_by_group_then_position_with_ungrouped_last():
resolved = resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [
_token("--fs-z", {"base": "1"}), # ungrouped
_token("--fs-b", {"base": "2"}, group_name="text", order_index=1),
_token("--fs-a", {"base": "3"}, group_name="surface", order_index=2),
_token("--fs-c", {"base": "4"}, group_name="surface", order_index=1),
]},
)
assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"]
def test_resolution_terminates_on_a_corrupt_hierarchy():
"""Resolution inherits ancestry's visited-set. A loop reaching this function
must produce a truncated set, not a hung request — the defensive half of the
cycle guard, exercised end to end."""
parents = {1: 2, 2: 1}
resolved = _by_name(resolve_tokens(
1, parents,
{1: [_token("--fs-a", {"base": "one"})], 2: [_token("--fs-b", {"base": "two"})]},
))
assert set(resolved) == {"--fs-a", "--fs-b"}
# Each system contributes exactly once, not endlessly.
assert len(resolved["--fs-a"].contributions["base"]) == 1
# --- supersedes -------------------------------------------------------------
#
# The declaration that replaces a prohibition. A design system stores what things
# ARE, so "pure white is never text" has no row — but "write this token instead
# of #fff" does, and it is the same fact stated forwards.
def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
"""LOAD-BEARING. A child overriding a colour says nothing about which
literals it replaces, and blanking the family's declaration there would
silently disarm the check for every app that customises the token."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "#f0ece0"})],
},
))
text = resolved["--fs-text"]
assert text.supersedes == ("#fff", "#ffffff")
assert text.value_by_mode == {"base": "#f0ece0"} # the value still overrode
def test_an_override_that_states_its_own_supersedes_replaces_the_list():
"""Whole-list replacement, not a merge — an app that means "only #fff" must
be able to say so without inheriting entries it deliberately dropped."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])],
},
))
assert resolved["--fs-text"].supersedes == ("#fff",)
def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
"""Most tokens replace nothing. That has to be an empty sequence rather than
None, so no caller has to test for two kinds of nothing."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
))
assert resolved["--fs-radius-md"].supersedes == ()
def test_supersedes_survives_serialisation_as_a_list():
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
))
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
def test_the_superseded_literal_need_not_match_the_tokens_own_value():
"""The whole reason this is DECLARED rather than derived. `#fff` and
Parchment are different colours, so a value-matching rule could never have
connected them — which is why the prohibition looked unrepresentable until
it was turned around."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
))
text = resolved["--fs-text"]
assert text.value_by_mode["base"] not in text.supersedes
def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
"""Duck-typed input: a caller passing rows from before the column existed
must not crash the cascade."""
legacy = SimpleNamespace(
name="--fs-x", value_by_mode={"base": "a"},
group_name=None, purpose=None, order_index=0,
)
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
assert resolved["--fs-x"].supersedes == ()
# --- rationale --------------------------------------------------------------
def test_rationale_cascades_like_purpose_and_is_a_different_question():
"""`purpose` is what the token is FOR; `rationale` is why it is this value.
Rules carry the second routinely ("Success = Moss, aligned by design") and a
token row had nowhere to put it until now."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [SimpleNamespace(
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
group_name="semantic", purpose="success states",
rationale="equals Moss, aligned by design",
order_index=0, supersedes=[],
)],
APP: [_token("--fs-success", {"base": "#3f5236"})],
},
))
token = resolved["--fs-success"]
assert token.rationale == "equals Moss, aligned by design"
assert token.purpose == "success states"
assert token.value_by_mode == {"base": "#3f5236"} # the value still overrode
def test_a_token_without_a_rationale_resolves_to_none():
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-x", {"base": "1px"})]},
))
assert resolved["--fs-x"].rationale is None
+161
View File
@@ -0,0 +1,161 @@
"""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
+145
View File
@@ -0,0 +1,145 @@
"""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 == []
+342
View File
@@ -0,0 +1,342 @@
"""Rendering a design system as its master CSS sheet.
The generator is pure, so a whole sheet is one literal of tokens in and a string
out. Two things here are load-bearing rather than cosmetic: what the sheet
deliberately does NOT contain, and the fact that a value cannot escape its
declaration.
"""
from types import SimpleNamespace
from scribe.services.design_stylesheet import (
check_code_against_tokens,
duplicate_values,
is_valid_token_name,
render_stylesheet,
safe_comment,
safe_value,
selector_for_mode,
)
def _token(name, value_by_mode, group_name=None, purpose=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode,
group_name=group_name, purpose=purpose,
)
# --- safety -----------------------------------------------------------------
#
# Design systems are shareable records. A value that can close its declaration
# can inject arbitrary CSS into the page of anyone the system was shared with,
# which makes this a real boundary rather than tidiness.
def test_a_value_that_would_escape_its_declaration_is_refused():
"""`red; } body { display: none` is the whole attack: end the declaration,
close the block, open your own."""
assert safe_value("red; } body { display: none") is None
assert safe_value("#fff}") is None
assert safe_value("#fff;") is None
def test_at_rules_and_tags_are_refused():
assert safe_value("@import url(evil.css)") is None
assert safe_value("</style><script>") is None
def test_comment_delimiters_in_a_value_are_refused():
"""A value is not rendered inside a comment, but `/*` would comment out
every declaration after it — silently blanking the rest of the sheet."""
assert safe_value("red /* ") is None
assert safe_value("*/ red") is None
def test_a_newline_in_a_value_is_refused():
assert safe_value("red\n color: blue") is None
def test_ordinary_values_survive_untouched():
for value in ("#14171a", "8px", "cubic-bezier(0.2, 0.6, 0.2, 1)",
"0 4px 12px rgba(0,0,0,0.35)", "color-mix(in srgb, red 15%, transparent)"):
assert safe_value(value) == value
def test_a_rejected_value_is_dropped_not_cleaned_up():
"""Rejecting beats stripping. A partially-sanitised value is one the operator
never wrote, and the sheet's entire claim is that it IS the record — quietly
rendering a different colour would break that claim invisibly."""
css = render_stylesheet([_token("--fs-x", {"base": "red; } body { color: blue"})])
assert "body" not in css
assert "value rejected" in css
def test_comment_text_cannot_close_its_comment():
"""`purpose` is operator prose rendered into a comment — `*/` in it would
end the comment and spill the rest into the stylesheet as code."""
assert "*/" not in safe_comment("ends the comment */ then color: red")
def test_a_malformed_token_name_is_dropped():
"""A name is an identifier. A 'cleaned up' identifier is a different token
than the one recorded, so it is dropped rather than repaired."""
assert is_valid_token_name("--fs-obsidian")
assert not is_valid_token_name("--fs obsidian")
assert not is_valid_token_name("color: red")
assert not is_valid_token_name("fs-obsidian") # no leading --
css = render_stylesheet([_token("--bad name", {"base": "red"})])
assert "bad name" not in css
def test_a_mode_name_cannot_break_out_of_its_selector():
assert selector_for_mode('dark"] body {') == '[data-theme="darkbody"]'
# --- what the sheet is ------------------------------------------------------
def test_the_sheet_declares_properties_and_styles_no_elements():
"""THE shape decision. A sheet that styled elements would restate the same
handful of values once per element and grow with the UI. Purpose tokens are
stated once and reused; components are snippets that reference them."""
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
_token("--fs-moss", {"base": "#4a5d3f"}, group_name="action"),
])
assert "--fs-obsidian: #14171a;" in css
# No element or class rules — the sheet has exactly one block here, and
# every declaration in it is a custom property.
assert css.count("{") == 1
declarations = [
line.strip() for line in css.splitlines()
if ":" in line and line.strip().endswith(";")
]
assert declarations and all(d.startswith("--") for d in declarations)
def test_the_header_says_what_the_sheet_is_for():
"""A generated file with no explanation gets hand-edited, and then it has
diverged from the record it claims to be."""
css = render_stylesheet([_token("--fs-x", {"base": "1px"})], title="FabledSword")
assert "FabledSword" in css
assert "Generated" in css
assert "snippets" in css
# --- modes ------------------------------------------------------------------
def test_base_goes_on_the_root_selector_and_other_modes_layer_over_it():
"""Matches the convention already in the codebase, and the one-way scoping
#251 recorded: light on `:root`, dark layered on an attribute selector."""
css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
])
assert ":root {" in css
assert '[data-theme="dark"] {' in css
assert css.index(":root {") < css.index('[data-theme="dark"] {')
def test_a_mode_block_contains_only_what_that_mode_declares():
"""A mode block is an OVERRIDE layer, exactly as the storage model has it.
Repeating every token in every block would make the sheet claim each mode
redefines the whole system."""
css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
_token("--fs-radius-md", {"base": "8px"}),
])
dark_block = css.split('[data-theme="dark"] {')[1]
assert "--fs-bg" in dark_block
assert "--fs-radius-md" not in dark_block
def test_the_root_selector_is_caller_chosen():
"""A container-scoped preview cannot use `:root`. A generator that hardcoded
it could not serve the preview surface at all."""
css = render_stylesheet(
[_token("--fs-x", {"base": "1px"})], root_selector="[data-preview]"
)
assert "[data-preview] {" in css
assert ":root {" not in css
# --- grouping and honesty ---------------------------------------------------
def test_tokens_are_grouped_by_purpose_with_the_group_named():
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
_token("--fs-radius-md", {"base": "8px"}, group_name="radius"),
])
assert "/* surface */" in css
assert "/* radius */" in css
def test_a_purpose_becomes_an_inline_comment_on_the_base_layer_only():
"""Repeating the same prose in every mode block is noise: the token means
the same thing in dark mode."""
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a", "dark": "#000000"},
purpose="page bg, deepest surface"),
])
assert css.count("page bg, deepest surface") == 1
def test_a_declared_token_with_no_value_appears_as_a_comment_not_a_silence():
"""The rulebook named it, so its absence is a FINDING. A commented line puts
that finding where the reader is already looking; dropping it would make the
sheet look complete."""
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}),
_token("--fs-radius-sm", {}),
])
assert "--fs-radius-sm" in css
assert "no value set yet" in css
# Commented, so it cannot be mistaken for a live declaration.
assert " --fs-radius-sm:" not in css
def test_an_empty_system_renders_a_sheet_with_no_blocks_rather_than_failing():
"""A system with no tokens is an ordinary state (rule #115), including one
that was just created."""
css = render_stylesheet([])
assert "{" not in css
assert "Generated" in css
# --- the reuse report -------------------------------------------------------
def test_two_tokens_sharing_a_value_are_reported_not_refused():
"""The operator's constraint, made checkable: reuse consistent values rather
than restating them. But a design system legitimately aligns colours on
purpose — "Success = Moss, by design" — so this reports and lets a human
decide which it is."""
dupes = duplicate_values([
_token("--fs-moss", {"base": "#4A5D3F"}),
_token("--fs-success", {"base": "#4a5d3f"}),
_token("--fs-obsidian", {"base": "#14171a"}),
])
assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
def test_tokens_that_agree_in_one_mode_but_differ_in_another_are_not_duplicates():
"""A near-miss is a different, weaker finding, and calling it a duplicate
would send someone to merge two tokens that genuinely diverge."""
assert duplicate_values([
_token("--fs-a", {"base": "#fff", "dark": "#000"}),
_token("--fs-b", {"base": "#fff", "dark": "#111"}),
]) == {"#fff": ["--fs-a", "--fs-b"]}
def test_valueless_tokens_never_count_as_duplicates_of_each_other():
"""Otherwise every unfilled token would collide with every other one and the
report would be nothing but noise on a fresh import."""
assert duplicate_values([_token("--fs-a", {}), _token("--fs-b", {})]) == {}
# --- reading the sheet from the other side ----------------------------------
#
# "The snippets use the tags from the sheet" made checkable. All three findings
# below are currently SILENT in this codebase — no error, no failing test.
def _tok(name, base=None, supersedes=()):
return SimpleNamespace(
name=name,
value_by_mode={"base": base} if base else {},
supersedes=list(supersedes),
group_name=None, purpose=None,
)
SHEET = [
_tok("--fs-obsidian", "#14171a"),
_tok("--fs-parchment", "#e8e4d8", supersedes=["#fff", "#ffffff"]),
]
def test_a_reference_to_a_token_that_does_not_exist_is_reported():
"""THE bug this exists for. `var(--color-accent)` where no such token exists
renders as nothing at all — invisible focus rings, unstyled elements, no
error and no failing test. It happened in this codebase this session."""
report = check_code_against_tokens("outline: 2px solid var(--color-accent);", SHEET)
assert report["unknown"] == ["--color-accent"]
assert report["used"] == []
def test_a_reference_that_resolves_is_reported_as_used_not_as_a_finding():
report = check_code_against_tokens("background: var(--fs-obsidian);", SHEET)
assert report["used"] == ["--fs-obsidian"]
assert report["unknown"] == []
def test_a_superseded_literal_is_found_and_names_its_replacement():
"""The reframe paying off end to end: the finding says what to WRITE, not
merely what is wrong. That is only possible because the token declared it."""
report = check_code_against_tokens("color: #fff;", SHEET)
assert report["superseded_literals"] == [
{"literal": "#fff", "use_instead": "--fs-parchment"}
]
def test_a_shorter_hex_does_not_match_inside_a_longer_one():
"""`#fff` must not fire on `#ffffff` — different colours, and a finding on
the wrong one sends someone to change code that was already correct."""
report = check_code_against_tokens("color: #ffffffee;", SHEET)
assert [f["literal"] for f in report["superseded_literals"]] == []
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."""
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
assert report["superseded_literals"] == [
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
]
def test_a_token_defined_and_read_locally_is_reported_once_not_twice():
"""A snippet minting its own custom property is the bloat a shared sheet
exists to prevent — but when it also reads it back, that is ONE fact. The
unknown-reference check owns it, because "--btn-bg does not exist in the
sheet" is the more precise statement of the same problem."""
report = check_code_against_tokens(".btn { --btn-bg: #333; background: var(--btn-bg); }", SHEET)
assert report["unknown"] == ["--btn-bg"]
assert report["local_definitions"] == []
def test_a_local_definition_never_read_back_is_still_reported():
report = check_code_against_tokens(".btn { --btn-bg: #333; }", SHEET)
assert report["local_definitions"] == ["--btn-bg"]
def test_a_declaration_that_is_not_a_custom_property_is_not_mistaken_for_one():
report = check_code_against_tokens(".btn { color: red; background: blue; }", SHEET)
assert report["local_definitions"] == []
def test_clean_code_reports_nothing_to_act_on():
report = check_code_against_tokens(
".btn { background: var(--fs-obsidian); color: var(--fs-parchment); }", SHEET
)
assert report["unknown"] == []
assert report["superseded_literals"] == []
assert report["local_definitions"] == []
assert report["used"] == ["--fs-obsidian", "--fs-parchment"]
def test_the_inline_comment_falls_back_to_rationale_when_there_is_no_purpose():
"""A token carrying only the why still says something in the sheet, rather
than rendering bare because the other field happened to be empty."""
token = SimpleNamespace(
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
group_name=None, purpose=None, rationale="equals Moss, by design",
)
assert "equals Moss, by design" in render_stylesheet([token])
def test_purpose_wins_over_rationale_in_the_comment():
"""What a token is FOR is what a reader of the stylesheet needs first."""
token = SimpleNamespace(
name="--fs-x", value_by_mode={"base": "1px"},
group_name=None, purpose="hairline borders", rationale="because thin",
)
css = render_stylesheet([token])
assert "hairline borders" in css
assert "because thin" not in css
+212
View File
@@ -0,0 +1,212 @@
"""MCP design-system tools — the sentinel translations, mostly.
The tools are thin wrappers, so the only logic worth testing is where the MCP
calling convention meets the service's: an agent cannot omit an argument, so
"leave unchanged", "clear" and "set" have to be encoded in the value. Getting
that mapping wrong is silent — the call succeeds and changes the wrong thing.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.services.design_systems import DesignSystemCycle
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_system():
s = MagicMock()
s.to_dict.return_value = {"id": 1, "title": "FabledSword", "parent_id": None}
return s
def _fake_token():
t = MagicMock()
t.to_dict.return_value = {"id": 9, "name": "--fs-obsidian"}
return t
# --- create -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_creating_without_a_parent_passes_none_not_zero():
"""0 is the "omitted" sentinel, and it must not reach the service as a
system id — there is no system 0, so the create would fail an ACL check for
a record that cannot exist."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="FabledSword")
assert svc.create_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_creating_with_a_parent_passes_it_through():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="Scribe", parent_id=4)
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
@pytest.mark.asyncio
async def test_create_raises_when_the_parent_is_not_writable():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=None)
from scribe.mcp.tools.design_systems import create_design_system
with pytest.raises(ValueError):
await create_design_system(title="Scribe", parent_id=4)
# --- the three-state parent -------------------------------------------------
@pytest.mark.asyncio
async def test_update_with_parent_id_zero_leaves_the_parent_alone():
"""The common case — renaming a system must not silently re-root it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, title="Renamed")
fields = svc.update_design_system.await_args.kwargs
assert "parent_id" not in fields
assert fields["title"] == "Renamed"
@pytest.mark.asyncio
async def test_update_with_parent_id_minus_one_clears_it():
"""-1 means "make this a family system". It has to arrive at the service as
None, which is the value the service reads as "become a root" — where
omitting the key means "leave alone"."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=-1)
assert svc.update_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_update_with_a_positive_parent_id_sets_it():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=4)
assert svc.update_design_system.await_args.kwargs["parent_id"] == 4
@pytest.mark.asyncio
async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found():
"""The service raises so this layer can keep the two apart. An agent told
"not found" would retry the same call; one told what the loop is can fix it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(
side_effect=DesignSystemCycle("2 already inherits from 1")
)
from scribe.mcp.tools.design_systems import update_design_system
with pytest.raises(ValueError, match="already inherits"):
await update_design_system(design_system_id=1, parent_id=2)
# --- tokens -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_token_treats_order_index_minus_one_as_unchanged():
"""0 is a VALID order_index, so it cannot double as the omitted sentinel —
the same reason the systems tools use -1."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="page bg")
fields = svc.update_token.await_args.kwargs
assert "order_index" not in fields
assert fields["purpose"] == "page bg"
@pytest.mark.asyncio
async def test_update_token_accepts_order_index_zero():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, order_index=0)
assert svc.update_token.await_args.kwargs["order_index"] == 0
@pytest.mark.asyncio
async def test_update_token_can_set_an_empty_value_map():
"""`value_by_mode={}` is meaningful — it strips every mode from a token. The
guard is `is not None`, not truthiness, or that edit would be unreachable."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, value_by_mode={})
assert svc.update_token.await_args.kwargs["value_by_mode"] == {}
# --- the project pointer ----------------------------------------------------
@pytest.mark.asyncio
async def test_clearing_a_projects_design_system_passes_none():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.set_project_design_system = AsyncMock(return_value=True)
from scribe.mcp.tools.design_systems import set_project_design_system
result = await set_project_design_system(project_id=2, design_system_id=-1)
assert svc.set_project_design_system.await_args.args[2] is None
assert result["design_system_id"] is None
@pytest.mark.asyncio
async def test_resolve_returns_serialised_tokens_with_their_provenance():
"""The payload has to carry the shadowed entries, not just the winner —
dropping them at the serialisation boundary would discard the one thing
resolution was built to preserve."""
from scribe.services.design_cascade import resolve_tokens
class _T:
def __init__(self, name, value_by_mode):
self.name, self.value_by_mode = name, value_by_mode
self.group_name = self.purpose = None
self.order_index = 0
resolved = resolve_tokens(
2, {1: None, 2: 1},
{1: [_T("--fs-accent", {"base": "#6b2118"})],
2: [_T("--fs-accent", {"base": "#5b4a8a"})]},
)
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.resolve_design_system = AsyncMock(return_value=resolved)
from scribe.mcp.tools.design_systems import resolve_design_system
payload = await resolve_design_system(design_system_id=2)
token = payload["tokens"][0]
assert token["value_by_mode"] == {"base": "#5b4a8a"}
assert token["origin_by_mode"] == {"base": 2}
assert token["contributions"]["base"] == [
{"system_id": 2, "value": "#5b4a8a"},
{"system_id": 1, "value": "#6b2118"},
]
@pytest.mark.asyncio
async def test_update_token_can_clear_supersedes_with_an_empty_list():
"""`[]` means "this token replaces nothing after all" — a real edit. Guarded
on `is not None` so it isn't swallowed as "unchanged", the same trap #2077
recorded for update_snippet."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, supersedes=[])
assert svc.update_token.await_args.kwargs["supersedes"] == []
@pytest.mark.asyncio
async def test_update_token_leaves_supersedes_alone_when_omitted():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="text on action surfaces")
assert "supersedes" not in svc.update_token.await_args.kwargs
+121
View File
@@ -0,0 +1,121 @@
"""Structural tests for the design-systems blueprint + the parity guard.
Modelled on tests/test_routes_snippets.py. The enumerations below are
deliberate: relaxing one to a pattern would stop it catching the thing it was
written for, which is a capability landing on one surface and not the other.
"""
import inspect
def test_design_systems_blueprint_registered():
from scribe.routes.design_systems import design_systems_bp
assert design_systems_bp.name == "design_systems"
assert design_systems_bp.url_prefix == "/api"
def test_design_systems_blueprint_registered_in_app():
from scribe.app import create_app
app = create_app()
assert "design_systems" in app.blueprints
def test_route_handlers_callable():
from scribe.routes import design_systems as routes
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",
"check_snippets_against_system",
"list_design_tokens", "create_design_token",
"update_design_token", "delete_design_token", "set_project_design_system",
):
assert callable(getattr(routes, name))
def test_every_endpoint_is_reachable_on_the_app():
"""The handlers existing is not the same as them being routed. This catches
a decorator that was copied without its path changing — two handlers on one
rule, where the second silently never runs."""
from scribe.app import create_app
app = create_app()
rules = {
str(r.rule) for r in app.url_map.iter_rules()
if r.endpoint.startswith("design_systems.")
}
assert rules == {
"/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",
"/api/design-tokens/<int:token_id>",
"/api/projects/<int:project_id>/design-system",
}
def test_service_functions_take_user_id():
"""Routes must call the services with user_id — verify the contract (#33)."""
from scribe.services import design_systems as svc
for fn_name in (
"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",
"stylesheet_for_system", "check_snippets_against_system",
):
fn = getattr(svc, fn_name)
assert callable(fn)
assert "user_id" in inspect.signature(fn).parameters
def test_agent_and_web_surfaces_stay_at_parity():
"""The MCP tools and the REST routes are two callers of one service; a
capability on one has to exist on the other (rule #33).
This guard exists because the snippet surfaces drifted apart once — MCP had
no delete, the web side had no near-duplicate gate — and neither failed
anything until someone went looking.
"""
from scribe.mcp.tools import design_systems as tools
from scribe.routes import design_systems as routes
for name in (
"create_design_system", "list_design_systems", "get_design_system",
"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",
):
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)
assert callable(tools.check_snippets_against_design_system)
assert callable(routes.check_snippets_against_system)
def test_every_mcp_tool_in_the_module_is_registered():
"""A tool written but never registered is invisible to an agent, and nothing
else in the codebase would notice."""
from scribe.mcp.tools import design_systems as tools
registered = []
class _Recorder:
def tool(self, name):
registered.append(name)
return lambda fn: fn
tools.register(_Recorder())
public = {
name for name, obj in vars(tools).items()
if inspect.iscoroutinefunction(obj) and not name.startswith("_")
}
assert set(registered) == public
+37
View File
@@ -132,3 +132,40 @@ def test_clauses_are_pure_builders(clause_fn):
import inspect
assert not inspect.iscoroutinefunction(clause_fn)
assert _sql(clause_fn(7)) # builds without touching a database
# --- design systems ----------------------------------------------------------
#
# A design system is reachable two ways: you own it, or you can see a project
# that inherits from it. The gap between what those two grant is the invariant
# worth guarding — see get_design_system_permission.
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permission, readable, writable",
[
("owner", True, True),
# Project-derived. Being an EDITOR on a shared project must not confer
# the right to rewrite the family system that project inherits from —
# that would let one project's collaborator restyle every other project
# in the family.
("viewer", True, False),
(None, False, False),
],
)
async def test_reaching_a_design_system_via_a_project_reads_but_never_writes(
permission, readable, writable
):
from unittest.mock import AsyncMock, patch
from scribe.services.access import (
can_read_design_system,
can_write_design_system,
)
with patch(
"scribe.services.access.get_design_system_permission",
AsyncMock(return_value=permission),
):
assert await can_read_design_system(1, 3) is readable
assert await can_write_design_system(1, 3) is writable
+422
View File
@@ -0,0 +1,422 @@
"""ACL gating and field handling for services/design_systems.py (unit, mocked).
Mirrors tests/test_services_systems.py — same mocked-session shape, because the
question is the same one: does the service refuse before it touches a row?
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.design_systems import DesignSystemCycle
def _make_mock_session():
s = AsyncMock()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
s.add = MagicMock()
s.commit = AsyncMock()
s.refresh = AsyncMock()
return s
# --- creating ---------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_with_a_parent_is_denied_without_write_on_that_parent():
"""Parenting to someone else's system would let their delete or re-parent
silently restyle your app, so the chain may only be built from systems you
can write — which, per the ACL, means ones you own."""
with patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import create_design_system
result = await create_design_system(user_id=1, title="Scribe", parent_id=7)
assert result is None
@pytest.mark.asyncio
async def test_create_without_a_parent_never_consults_the_parent_acl():
"""A family system has no parent, and creating one must not be gated on a
permission check for a system that does not exist."""
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(
title=obj.title, parent_id=obj.parent_id
)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=False)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_design_system
await create_design_system(user_id=1, title=" FabledSword ")
assert captured["title"] == "FabledSword" # stripped
assert captured["parent_id"] is None
acc.can_write_design_system.assert_not_awaited()
# --- the cycle guard, through the service -----------------------------------
@pytest.mark.asyncio
async def test_reparenting_into_a_loop_raises_rather_than_returning_none():
"""None already means "not found, or not yours". A caller that conflated the
two would report "no such design system" for what is really "that parent is
one of its own descendants", so the cycle gets its own exception type."""
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=MagicMock(deleted_at=None, parent_id=None))
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={1: None, 2: 1})):
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import update_design_system
with pytest.raises(DesignSystemCycle):
await update_design_system(user_id=1, design_system_id=1, parent_id=2)
mock_session.commit.assert_not_awaited() # refused BEFORE the write
@pytest.mark.asyncio
async def test_clearing_the_parent_is_allowed_and_is_not_read_as_no_change():
"""`parent_id=None` means "make this a root" — the one field where None is a
value rather than "leave alone", which is why it is handled apart from the
others."""
system = MagicMock(deleted_at=None, parent_id=5)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=system)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={1: 5, 5: None})):
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import update_design_system
await update_design_system(user_id=1, design_system_id=1, parent_id=None)
assert system.parent_id is None
# --- tokens -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_token_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 create_token
result = await create_token(user_id=1, design_system_id=3, name="--fs-obsidian")
assert result is None
@pytest.mark.asyncio
async def test_token_values_default_to_an_empty_map_not_json_null():
"""The column is NOT NULL so that absence has exactly ONE spelling. Passing
None straight through would store JSON null and hand every reader back the
second empty state the schema was shaped to remove."""
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(value_by_mode=obj.value_by_mode)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_token
await create_token(
user_id=1, design_system_id=3, name="--fs-radius-md", value_by_mode=None
)
assert captured["value_by_mode"] == {}
@pytest.mark.asyncio
async def test_list_tokens_denied_returns_empty():
with patch("scribe.services.design_systems.access") as acc:
acc.can_read_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import list_tokens
assert await list_tokens(user_id=1, design_system_id=3) == []
# --- the project pointer ----------------------------------------------------
@pytest.mark.asyncio
async def test_pointing_a_project_at_a_system_needs_write_on_the_project():
with patch("scribe.services.design_systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=False)
acc.can_read_design_system = AsyncMock(return_value=True)
from scribe.services.design_systems import set_project_design_system
assert await set_project_design_system(1, project_id=5, design_system_id=3) is False
@pytest.mark.asyncio
async def test_pointing_a_project_needs_only_READ_on_the_system():
"""Consuming a design system is not changing it, so a system reachable
through another project is a legitimate choice here. Requiring write would
make a shared family style unusable by the people it was shared with."""
project = MagicMock(deleted_at=None, design_system_id=None)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=project)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=True)
acc.can_read_design_system = AsyncMock(return_value=True)
acc.can_write_design_system = AsyncMock(return_value=False)
mock_cls.return_value = mock_session
from scribe.services.design_systems import set_project_design_system
assert await set_project_design_system(1, project_id=5, design_system_id=3) is True
assert project.design_system_id == 3
@pytest.mark.asyncio
async def test_clearing_a_projects_design_system_skips_the_system_acl():
"""Un-styling a project must not require permission on the system it is
letting go of — including one that has since been deleted."""
project = MagicMock(deleted_at=None, design_system_id=3)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=project)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=True)
acc.can_read_design_system = AsyncMock(return_value=False)
mock_cls.return_value = mock_session
from scribe.services.design_systems import set_project_design_system
assert await set_project_design_system(1, project_id=5, design_system_id=None) is True
assert project.design_system_id is None
acc.can_read_design_system.assert_not_awaited()
# --- resolution (step 2) ----------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_denied_returns_none_not_an_empty_set():
"""None and [] mean different things here: "you may not see this" versus
"this chain genuinely holds no tokens". A caller that got [] for a denial
would render an empty design system as though it were real."""
with patch("scribe.services.design_systems.access") as acc:
acc.can_read_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import resolve_design_system
assert await resolve_design_system(user_id=1, design_system_id=3) is None
@pytest.mark.asyncio
async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller():
"""LOAD-BEARING for shared projects. A caller reading through a project
shared with them owns no link in the chain, so a caller-scoped parent map
returns an empty forest and the cascade truncates to one system — a page
that renders with plausible wrong values and no error anywhere.
The ACL already grants read on the whole chain (reachability propagates
upward); this is the loading side keeping that promise.
"""
owner, caller = 42, 7
system = MagicMock(deleted_at=None, owner_user_id=owner)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=system)
mock_session.execute = AsyncMock(
return_value=MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: [])))
)
parent_map = AsyncMock(return_value={3: None})
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems._parent_map", parent_map), \
patch("scribe.services.design_systems.access") as acc:
acc.can_read_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import resolve_design_system
result = await resolve_design_system(user_id=caller, design_system_id=3)
assert result == [] # empty chain, not None
assert parent_map.await_args.args[1] == owner # NOT `caller`
# --- supersedes (step 6's reframe) ------------------------------------------
@pytest.mark.asyncio
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
"""Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_token
await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
assert captured["supersedes"] == []
@pytest.mark.asyncio
async def test_create_token_records_the_literals_it_replaces():
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_token
await create_token(
user_id=1, design_system_id=3, name="--fs-text-on-action",
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
)
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
async def test_stylesheet_denied_returns_none():
with patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=None)):
from scribe.services.design_systems import stylesheet_for_system
assert await stylesheet_for_system(1, 3) is None
@pytest.mark.asyncio
async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
"""The CSS alone hides two things a reviewer needs: which tokens are still
valueless, and which values are declared twice. Both ride alongside rather
than being left for the reader to derive from the text."""
from scribe.services.design_cascade import Contribution, ResolvedToken
def _resolved(name, base=None):
return ResolvedToken(
name=name,
contributions=(
{"base": (Contribution(system_id=1, value=base),)} if base else {}
),
group_name=None, purpose=None, rationale=None,
supersedes=(), order_index=0,
)
system = MagicMock()
system.title = "FabledSword"
with patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=[
_resolved("--fs-moss", "#4a5d3f"),
_resolved("--fs-success", "#4a5d3f"),
_resolved("--fs-radius-sm"),
])), \
patch("scribe.services.design_systems.get_design_system",
AsyncMock(return_value=system)):
from scribe.services.design_systems import stylesheet_for_system
result = await stylesheet_for_system(1, 3)
assert result["token_count"] == 3
assert result["valueless"] == ["--fs-radius-sm"]
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
assert "--fs-moss: #4a5d3f;" in result["css"]
assert "FabledSword" in result["css"]
+145
View File
@@ -0,0 +1,145 @@
"""Timestamp filters must bind datetimes, never strings (#1727, #2254).
`AppLog.created_at` is `timestamp with time zone`. asyncpg binds a Python `str`
as VARCHAR, and Postgres has no `timestamptz >= text` operator — so comparing
the column against `date.today().isoformat()` or a raw `request.args` string
raises at query time, not at import time and not in any unit test that doesn't
touch a database.
That is what made this class of bug survive: it is invisible to every test that
doesn't execute the SQL, and in the notifications case the exception was
swallowed by a per-user `except Exception`, so the only outward symptom was
reminder emails silently never sending.
Found twice in the same afternoon, in two services written months apart, so the
last test here guards the CLASS rather than the two instances.
"""
import ast
import re
from datetime import date, datetime, timezone
from pathlib import Path
SRC = Path(__file__).resolve().parents[1] / "src" / "scribe"
# --- #1727: the reminder dedup window ----------------------------------------
def test_utc_day_start_is_an_aware_datetime_not_a_string():
from scribe.services.notifications import utc_day_start
start = utc_day_start(date(2026, 7, 30))
assert isinstance(start, datetime)
assert not isinstance(start, str)
# Aware, and pinned to UTC rather than whatever the DB session's TimeZone
# happens to be — a bare `date` would resolve midnight server-side.
assert start.tzinfo is not None
assert start.utcoffset() == timezone.utc.utcoffset(None)
assert (start.year, start.month, start.day) == (2026, 7, 30)
assert (start.hour, start.minute, start.second, start.microsecond) == (0, 0, 0, 0)
# --- #2254: the admin log viewer's date filters ------------------------------
def test_filter_datetime_parses_dates_and_datetimes_to_aware_utc():
from scribe.services.logging import parse_filter_datetime
d = parse_filter_datetime("2026-07-30")
assert isinstance(d, datetime) and d.tzinfo is not None
assert (d.hour, d.minute) == (0, 0)
# An explicit time is honoured, not overwritten.
dt = parse_filter_datetime("2026-07-30T14:35:00")
assert (dt.hour, dt.minute) == (14, 35)
# Already-aware input keeps its offset rather than being stamped UTC.
aware = parse_filter_datetime("2026-07-30T14:35:00+02:00")
assert aware.utcoffset().total_seconds() == 2 * 3600
def test_filter_datetime_upper_bound_covers_the_whole_requested_day():
"""`date_to=2026-07-30` must INCLUDE that day. Parsed as bare midnight it
would exclude every event on the one day the user asked for."""
from scribe.services.logging import parse_filter_datetime
end = parse_filter_datetime("2026-07-30", end_of_day=True)
assert (end.hour, end.minute, end.second) == (23, 59, 59)
assert end.microsecond == 999999
# But a value that already carries a time is left alone — the caller meant it.
exact = parse_filter_datetime("2026-07-30T09:00:00", end_of_day=True)
assert (exact.hour, exact.minute, exact.second) == (9, 0, 0)
def test_filter_datetime_rejects_garbage_instead_of_raising():
"""A malformed filter should narrow nothing, not 500 the log viewer."""
from scribe.services.logging import parse_filter_datetime
for junk in ("", " ", None, "not-a-date", "2026-13-45", "yesterday"):
assert parse_filter_datetime(junk) is None
# --- the class-level guard ---------------------------------------------------
def _timestamp_comparison_offenders() -> list[str]:
"""Every place a `*_at` column is compared against a known-string name.
TWO shapes, because the two real bugs had different ones and a guard built
from only the first would have missed the second:
1. a local bound to `.isoformat()` — #1727, `today_str = today.isoformat()`
2. a parameter annotated `str` — #2254, `date_from: str | None`, straight
off `request.args`, never touching
isoformat at all
Shape 2 is the nastier one: nothing in the file looks date-ish, so grepping
for isoformat finds nothing. It was caught by reading, not by tooling —
which is exactly why it belongs in tooling now.
Deliberately narrow on both. A blanket "no strings near SQL" rule would fire
on every to_dict() in the codebase and get muted, and a muted guard is worse
than none.
"""
offenders: list[str] = []
files = sorted((SRC / "services").rglob("*.py")) + sorted((SRC / "routes").rglob("*.py"))
for path in files:
src = path.read_text()
rel = path.relative_to(SRC)
suspect_names: dict[str, str] = {}
# Shape 1 — locals bound to an isoformat() result.
for name in re.findall(r"^\s*(\w+)\s*=\s*[\w.()]+\.isoformat\(\)", src, re.M):
suspect_names[name] = "an isoformat() string"
# Shape 2 — parameters annotated as str (incl. `str | None`, Optional[str]).
try:
tree = ast.parse(src)
except SyntaxError: # pragma: no cover - not our concern here
tree = None
if tree is not None:
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
args = node.args
for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs]:
if arg.annotation is None:
continue
ann = ast.unparse(arg.annotation)
if re.fullmatch(r"(?:Optional\[)?str\]?(?:\s*\|\s*None)?", ann):
suspect_names[arg.arg] = f"a str-annotated parameter ({ann})"
for name, why in suspect_names.items():
if re.search(rf"\w*_at\s*(?:>=|<=|>|<)\s*{re.escape(name)}\b", src):
offenders.append(f"{rel}: timestamp column compared against `{name}` — {why}")
return offenders
def test_no_service_compares_a_timestamp_column_against_a_string():
offenders = _timestamp_comparison_offenders()
assert not offenders, (
"asyncpg binds a str as VARCHAR and Postgres has no `timestamptz >= text` "
"operator, so this raises at query time — invisible to any test that "
"doesn't execute the SQL:\n " + "\n ".join(offenders)
)