Compare commits

...
Author SHA1 Message Date
bvandeusenandClaude Opus 5 5b824c1626 feat(design): the panel now asks whether the app agrees with its own sheet
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Retiring rulebook #2 left the /design drift panel with no data source, and
because its empty state was well-written the feature read as working while it
could only ever render "nothing designated" (#2419). The original question is
genuinely gone: theme.css is generated from design system 2, so checking the
system against a sheet derived from it would be a tautology.

The question that survives is the one no server can answer. A generated sheet
still has to be LOADED and APPLIED, and nothing checked that it was:

  absent      the record declares a token the app doesn't have — the sheet was
              never regenerated after the record changed, or never loaded
  differs     the app has it with another value — a stale sheet, or a later
              rule that overrode it
  unrecorded  the app declares a token in the record's own family that the
              record has never heard of

Both sides go through the same engine so the comparison is honest: declared
values are set on an offscreen probe and read back, which performs the same
var() substitution the browser already did to the live values. Comparing raw
strings would mark every derived token as drift.

The designation moved with the feature — design_rulebook_id becomes
ui_design_system_id, with a migration deleting the retired key rather than
leaving an inert row. The prose extractor it fed goes too (#2288 said its
runtime role ended when the import landed).

Three orphans of the same shape, found alongside and fixed here:

- darkOverriddenNames hardcoded [data-theme="dark"]. The sheet went dark-first
  months ago, so it matched nothing and the "mode-aware" flag silently left the
  gallery. Now matches the SHAPE of a mode selector, which also holds for an
  install whose modes aren't light and dark.
- groupFor's prefix table never heard of --fs-, so 110 tokens sat under
  "other". Groups now come from the record where there is one; the table can
  only know families that shipped with the product (rule #115).
- The type scale was a hand-written table of nine sizes marked "no token",
  true when written and false since the scale was recorded. Now rendered from
  whatever size tokens the sheet declares, so it can't go stale twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 20:50:03 -04:00
bvandeusenandClaude Opus 5 841506b10c feat(plugin): every session says which plugin version is executing
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 19s
Twice a shipped fix failed to reach a live install, and both times the only
detector was the operator saying "I don't think it updated" (#2198, #2209).

The reason it is hard to see: an install has two halves and only one
self-updates. The marketplace clone pulls on its own; the CACHE is what
executes and refreshes only when the manifest version changes. So inspecting
the clone shows the fix present while the broken copy keeps running — the
obvious debugging move actively misleads.

The SessionStart context now names the version it is running. That makes "what
is actually executing?" answerable from the transcript rather than by
archaeology in the cache directory.

DELIBERATELY SMALLER THAN THE ISSUE PROPOSED. #2220 recommended reporting the
version to the server, storing last-seen per user, and surfacing it in
Settings. That is three surfaces and a migration to answer a question the
session can answer about itself. Per the operator's framing on #2338 — "I'm
afraid of building another integration between two more surfaces, you being
able to notice is enough" — the visibility is the deliverable, not the
plumbing.

It also lands the issue's own caveat, which the server-side design could not:
the state most needing diagnosis is the one where credentials never arrive,
and there the dynamic tier does not run at all. This marker is keyless and
networkless, so it still appears — verified against both the unconfigured and
unreachable-instance paths.

CI pins it, asserting WITHOUT credentials for the same reason. Manifest 0.1.22
-> 0.1.23, because a shipped hook changed and an unbumped manifest is precisely
the failure this commit is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:57:33 -04:00
bvandeusenandClaude Opus 5 c34454b840 refactor(theme): the accent was hand-written 16 times — now derived
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 43s
`rgba(91, 74, 138, …)` is Scribe's accent in decimal. It appears sixteen times
across five files at ten different opacities, plus once as #5B4A8A. Change the
accent in the design system and none of them would have moved — which is the
precise failure the token system exists to prevent, hiding in a notation that
does not look like a colour constant.

Now `color-mix(in srgb, var(--color-primary) N%, transparent)`, so every one
follows the accent. The design system already uses this form for its own tints
(--fs-accent-soft, -faint, -wash), so this is the established idiom rather than
a new one.

The CI literal count barely moves (45 -> 44) because its regex matches #hex and
fifteen of these were rgba(). Worth stating plainly: **the count was never the
goal, and the check is blind to this whole class.** An rgba triple is a colour
literal in every sense that matters and the report does not see it.

Not touched: the badge palette in KnowledgeView (#7A6DA8, #fbbf24, #818cf8 for
note/task/plan) and the remaining greys. Those are genuine unmade decisions —
what colour IS a plan badge — not drift, and inventing tokens for them would be
deciding by implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:53:54 -04:00
bvandeusenandClaude Opus 5 bd60d679d9 refactor(theme): remove 184 var() fallbacks — every one was unreachable
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 43s
#2277 counted ~150 "raw colour literals bypassing the tokens". Measuring them
told a different story: 184 sat in `var(--token, #fallback)` position, and a
check against theme.css shows every one of those tokens IS declared. So the
fallbacks could not render. Not drift — vestigial.

They were also not this palette. The most common were Tailwind and Flat-UI
defaults — #6366f1 indigo, #22c55e green, #f59e0b amber, #3b82f6 blue,
#e74c3c and #27ae60 — a second, unsanctioned colour scheme sitting in the
codebase looking like the app's colours to anyone reading it.

Removing them is not tidying. #2319's lesson is that a fallback is WORSE than a
missing token: a missing token renders as nothing and someone eventually
notices, while a fallback renders something plausible forever. These 184 were
one token rename away from silently repainting the app in Tailwind. The design
token check would catch the rename — but the fallback is precisely the thing
that would make it invisible if the check were ever bypassed.

Literal count 152 -> 45, which matters beyond the number: a report that is
mostly unreachable noise is one people stop reading, and then it stops working
while still passing. What remains should be genuinely worth looking at.

Done with a paren-aware transform, not a regex — `var(--x, rgba(0,0,0,.5))`
nests parens and `[^)]+` would cut at the first one and leave `))` behind.
Verified after: every changed line is a fallback strip and nothing else, and
every var() reference still resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:52:04 -04:00
bvandeusenandClaude Opus 5 174ec8af46 feat(design): the starter-role checklist, in the creation UI
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 44s
Rule #27 — the backend half shipped without a surface an operator can touch,
so this is the other half of #2349.

StarterRolePicker is a component rather than inline markup because
DesignSystemsView has TWO creation forms: the empty state is a sibling branch
of the body, not a parent, so a form written into one is unreachable from the
other. Inlining the checklist would have made it the next thing in this
codebase defined twice and free to drift — which is what the button migration
spent nine commits undoing.

What it offers is names and purposes, never values. "Named now, valued later":
a role you haven't filled shows as to-be-decided, while a role that doesn't
exist is what gets written as a literal instead. Every group unchecks
individually, and the prefix is editable because `--fs-` is one family's
convention, not the product's.

Three deliberate details:

- All groups checked by DEFAULT, and that default lives in the UI, not the
  service. create_design_system treats None and [] alike (seed nothing) so it
  can never write 40 rows into a system whose caller never asked; a UI default
  is visible and reversible before the click. Different layers, different
  safe answers.
- A failed catalogue fetch is NOT fatal and does not read as an error. Starter
  roles are an accelerator, not a prerequisite — the form still creates, and
  the operator adds tokens by hand.
- The refs are not cleared after a successful create. The picker owns them and
  re-seeds on mount; resetting here would race that and silently create the
  next system with no roles.

props + defineEmits rather than defineModel, matching TagInput and the rest of
components/. defineModel is available (Vue 3.5) and would be shorter, but being
the only file in the codebase using a different binding idiom costs more than
the lines it saves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:45:48 -04:00
bvandeusenandClaude Opus 5 4852b0d3df test(design): add the starter-roles endpoint to the URL enumeration
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 35s
CI caught it: tests/test_routes_design_systems.py enumerates every routed rule,
and I added a handler without adding its rule. The guard doing exactly what its
docstring says it is for.

Two enumerations govern this blueprint and I had only extended one — the
parity list (handlers exist on both surfaces) but not the URL list (handlers
are actually routed). They catch different failures, which is why both exist.

Noted in place: /api/design-systems/starter-roles is a static segment sharing a
prefix with /api/design-systems/<int:design_system_id>. That pairing is where a
silently-shadowed route hides, so it is worth being explicit that the int
converter cannot match "starter-roles" — verified rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:42:11 -04:00
bvandeusenandClaude Opus 5 22f907c44d feat(design): offer starter token ROLES at creation, never values
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 46s
CI & Build / Build & push image (push) Skipped
A literal gets written into a stylesheet when there is no role to reach for.
This codebase demonstrated it: the house style had no "text on a filled colour"
role, so 76 call sites wrote a pure-white literal — not out of defiance, but
because nothing existed to write instead. The correction was not a better ban
list; it was declaring the missing role (#2275, #2349).

So the useful moment is creation. A system whose roles are named on day one
never presents the occasion.

Ten groups, ~40 roles: surface, text, action, semantic, border, accent, radius,
space, motion, state. Operator's call was one flat list, every group
individually skippable — presets keyed to app shape (web / CLI / docs) were
rejected because they need the product to hold opinions about app categories,
and a wrong category is worse than a list someone prunes once.

TWO BOUNDARIES THIS HAS TO HOLD, both rule #115:

- The ROLES ship; the VALUES never do. Every seeded token has an empty
  value_by_mode, so a fresh system is a set of named, deliberately-unanswered
  questions. A test asserts no hex appears anywhere in the module — not just
  that tokens are blank, but that no palette hides in a comment waiting to be
  pasted in.
- The PREFIX is the install's. `--fs-` is FabledSword's convention, not the
  product's; the default is a neutral `--ds-` and callers pass their own.

Valueless roles are already legible downstream — render_stylesheet emits them
as commented-out declarations and stylesheet_for_system reports them under
`valueless` (#2299) — so "declared but undecided" reads correctly with nothing
new built.

Both surfaces, per rule #33: MCP gains starter_role_groups/token_prefix plus
list_starter_role_groups(); REST gains the same on POST plus
GET /api/design-systems/starter-roles. The parity enumeration is extended
rather than loosened.

Note create_design_system treats None and [] alike (seed nothing), while
starter_tokens treats None as "all". Deliberate: creation must never write 40
rows into a system whose caller never asked, and the everything-checked default
belongs in the UI where the operator can see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:37:20 -04:00
57 changed files with 1610 additions and 865 deletions
@@ -0,0 +1,44 @@
"""retire the design_rulebook_id setting
Revision ID: 0075
Revises: 0074
Create Date: 2026-08-03
The /design panel used to compare a design RULEBOOK's prose claims against the
live tokens. That rulebook was imported into the design system and retired, and
the panel now compares the running app against the design system it was
generated from (#2419). Its designation moved with it:
design_rulebook_id -> ui_design_system_id
Nothing reads the old key any more, so this deletes the row rather than leaving
an inert one behind (rule #22 — remove the old path, including the setting it
read from). The values are not translatable: a rulebook id and a design system
id are ids in different tables, and guessing a mapping would silently point the
new panel at the wrong system.
Deleting settings rows by key is safe in a way dropping a column is not — the
table is free-form key/value, so an install that never designated one simply has
no row to delete.
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
lie: the operator re-designates in Settings.
"""
from alembic import op
import sqlalchemy as sa
revision = "0075"
down_revision = "0074"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
sa.text("DELETE FROM settings WHERE key = 'design_rulebook_id'")
)
def downgrade() -> None:
pass
+3 -3
View File
@@ -300,7 +300,7 @@ onUnmounted(() => {
.shortcuts-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
background: var(--color-overlay);
z-index: 9000;
display: flex;
align-items: center;
@@ -309,8 +309,8 @@ onUnmounted(() => {
.shortcuts-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
border-radius: var(--radius-md);
box-shadow: 0 8px 32px var(--color-shadow);
width: min(420px, 92vw);
overflow: hidden;
}
+12 -6
View File
@@ -1,9 +1,15 @@
import { apiGet } from "@/api/client";
import type { ExpectationResponse } from "@/utils/designDrift";
/** Checkable claims from the rulebook this install designated as its design system.
export interface UiSystemResponse {
design_system_id: number | null;
title: string | null;
}
/** The design system this install says its own UI is built from.
*
* `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");
* Both nulls means none designated — the normal state for a fresh install, not
* an error; the caller shows an explanatory empty state. An id with a null
* title means designated but deleted or unreadable, which is a
* misconfiguration and must not be rendered as "none". */
export const fetchUiDesignSystem = () =>
apiGet<UiSystemResponse>("/api/design/ui-system");
+17
View File
@@ -74,11 +74,28 @@ export const fetchDesignSystems = () =>
export const fetchDesignSystem = (id: number) =>
apiGet<DesignSystem>(`/api/design-systems/${id}`);
export interface StarterRoleGroup {
group: string;
description: string;
token_count: number;
names: string[];
}
/** The starter token ROLES offered at creation — names and purposes, never
* values. A default palette would be one install's taste shipped as product
* (rule #115), so the values are always the operator's to fill. */
export const listStarterRoleGroups = () =>
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
"/api/design-systems/starter-roles",
);
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
starter_role_groups?: string[];
token_prefix?: string;
}) => apiPost<DesignSystem>("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
+2 -2
View File
@@ -98,8 +98,8 @@
color: var(--fs-text-on-action);
}
.tag-pill.applied {
background: var(--color-success, #2ecc71);
border-color: var(--color-success, #2ecc71);
background: var(--color-success);
border-color: var(--color-success);
color: var(--fs-text-on-action);
cursor: default;
}
+2 -2
View File
@@ -219,7 +219,7 @@
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--color-text-muted, var(--color-text-secondary));
color: var(--color-text-muted);
content: attr(data-placeholder);
float: left;
height: 0;
@@ -234,5 +234,5 @@
}
.tiptap-wrapper:focus-within {
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
box-shadow: var(--focus-ring);
}
+3 -3
View File
@@ -129,7 +129,7 @@ router.afterEach(() => {
<style scoped>
.app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 18%, transparent);
position: relative;
}
.nav {
@@ -197,8 +197,8 @@ router.afterEach(() => {
.nav-link.router-link-active {
color: var(--color-primary-solid);
font-weight: 500;
background: rgba(91, 74, 138, 0.25);
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
background: color-mix(in srgb, var(--color-primary) 25%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
/* Status indicator */
+6 -6
View File
@@ -110,8 +110,8 @@ function markerFor(type: DiffLine['type']): string {
font-weight: 600;
}
.diff-summary-ins { color: var(--color-success, #2ecc71); }
.diff-summary-del { color: var(--color-danger, #e74c3c); }
.diff-summary-ins { color: var(--color-success); }
.diff-summary-del { color: var(--color-danger); }
.diff-scroll {
flex: 1;
@@ -136,13 +136,13 @@ function markerFor(type: DiffLine['type']): string {
}
.diff-delete {
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
color: var(--color-danger, #e74c3c);
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
}
.diff-insert {
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
color: var(--color-success, #2ecc71);
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
}
.diff-equal {
+13 -13
View File
@@ -403,12 +403,12 @@ onMounted(loadVersions);
font-size: 0.85em;
line-height: 1;
}
.pin-badge-manual { color: var(--color-primary, #6366f1); }
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
.pin-badge-manual { color: var(--color-primary); }
.pin-badge-auto { color: var(--color-text-muted); }
.history-item-label {
font-size: 0.72rem;
color: var(--color-primary, #6366f1);
color: var(--color-primary);
font-style: italic;
margin-top: 0.15rem;
overflow: hidden;
@@ -430,7 +430,7 @@ onMounted(loadVersions);
}
.pin-state {
font-style: italic;
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
color: var(--color-text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
@@ -442,13 +442,13 @@ onMounted(loadVersions);
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border: 1px solid var(--color-border);
border-radius: 999px;
cursor: pointer;
}
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--color-primary, #6366f1);
border-color: var(--color-primary);
}
.btn-unpin:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.10);
@@ -463,27 +463,27 @@ onMounted(loadVersions);
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
background: var(--color-input-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: inherit;
}
.pin-label-input:focus {
outline: none;
border-color: var(--color-primary, #6366f1);
border-color: var(--color-primary);
}
.btn-pin-save, .btn-pin-cancel {
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-pin-save:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--color-primary, #6366f1);
border-color: var(--color-primary);
}
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
+10 -10
View File
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
flex-shrink: 0;
}
.iap-btn-cancel:hover {
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger);
color: var(--color-danger);
}
.iap-stream-preview {
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
background: var(--color-success, #22c55e);
background: var(--color-success);
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
.iap-btn-reject {
background: var(--color-bg-card, var(--color-bg));
background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.iap-btn-reject:hover {
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger);
color: var(--color-danger);
}
/* ── Diff ── */
@@ -226,12 +226,12 @@ const markers: Record<DiffLine["type"], string> = {
.iap-diff-equal { color: var(--color-text-muted); }
.iap-diff-delete {
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
color: var(--color-danger, #e74c3c);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
}
.iap-diff-insert {
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
color: var(--color-success, #22c55e);
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
}
.iap-diff-marker {
+3 -3
View File
@@ -64,11 +64,11 @@ function goEdit() {
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--color-primary) 6%, transparent);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--color-primary) 14.0%, transparent);
transform: translateY(-2px);
}
@@ -89,7 +89,7 @@ function goEdit() {
}
.note-card.compact:hover {
box-shadow: none;
background: rgba(91, 74, 138, 0.04);
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
transform: none;
}
.note-title-compact {
+1 -1
View File
@@ -81,7 +81,7 @@ onUnmounted(() => {
position: absolute;
top: -5px;
right: -5px;
background: var(--color-danger, #ef4444);
background: var(--color-danger);
color: var(--fs-text-on-action);
font-size: 0.6rem;
font-weight: 700;
+1 -1
View File
@@ -159,7 +159,7 @@ const calendarDayMax = computed(() =>
.rec-num-input {
width: 4rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-input-border, var(--color-border));
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
@@ -0,0 +1,212 @@
<script setup lang="ts">
/**
* The starter token ROLES offered when a design system is created (#2349).
*
* WHY THIS IS A COMPONENT
* DesignSystemsView has two creation forms — the empty state and the one inside
* the body — because the empty state is a sibling branch, not a parent. Putting
* the checklist inline would make it the third thing in this codebase defined
* twice and free to drift, which is what the whole button migration was about.
*
* WHAT IT OFFERS
* Names and purposes, never values. A role is a question the operator answers
* with their own palette; a default palette would be one install's taste
* shipped as product (rule #115). Every group is individually skippable —
* an operator who wants three tokens should get three.
*
* All groups are checked by default. That default lives HERE rather than in the
* service, because the service must never seed rows into a system whose caller
* did not ask; a UI default is visible and reversible before the click.
*/
import { onMounted, ref } from "vue";
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
// props + emit rather than defineModel, matching TagInput and the rest of
// components/ — being the only file using a different binding idiom costs more
// than the few lines it saves.
const props = defineProps<{ selected: string[]; prefix: string }>();
const emit = defineEmits<{
"update:selected": [value: string[]];
"update:prefix": [value: string];
}>();
const groups = ref<StarterRoleGroup[]>([]);
const defaultPrefix = ref("--ds-");
const loading = ref(false);
const failed = ref(false);
onMounted(async () => {
loading.value = true;
try {
const data = await listStarterRoleGroups();
groups.value = data.groups;
defaultPrefix.value = data.default_prefix;
if (!props.prefix) emit("update:prefix", data.default_prefix);
// Everything on by default — see the note above.
if (!props.selected.length) {
emit("update:selected", data.groups.map((g) => g.group));
}
} catch {
// A creation form must still work when this fails. Roles are an
// accelerator, not a prerequisite: the operator can add tokens by hand.
failed.value = true;
} finally {
loading.value = false;
}
});
function toggle(group: string) {
emit(
"update:selected",
props.selected.includes(group)
? props.selected.filter((g) => g !== group)
: [...props.selected, group],
);
}
const totalTokens = () =>
groups.value
.filter((g) => props.selected.includes(g.group))
.reduce((n, g) => n + g.token_count, 0);
</script>
<template>
<div v-if="loading" class="srp-note">Loading starter roles</div>
<!-- Failure is not fatal and should not read as one. -->
<div v-else-if="failed" class="srp-note">
Starter roles unavailable you can add tokens by hand after creating.
</div>
<fieldset v-else-if="groups.length" class="srp">
<legend class="srp-legend">Start with these token roles</legend>
<p class="srp-intro">
Named now, valued later. A role you haven't filled in shows as
<em>to be decided</em>; a role that doesn't exist is what gets written as a
literal instead. Uncheck anything this system won't have.
</p>
<div class="srp-grid">
<label v-for="g in groups" :key="g.group" class="srp-item">
<input
type="checkbox"
:checked="props.selected.includes(g.group)"
@change="toggle(g.group)"
/>
<span class="srp-name">{{ g.group }}</span>
<span class="srp-count">{{ g.token_count }}</span>
<span class="srp-desc">{{ g.description }}</span>
</label>
</div>
<div class="srp-footer">
<label class="srp-prefix">
<span>Prefix</span>
<input
:value="props.prefix" class="input srp-prefix-input" type="text"
:placeholder="defaultPrefix"
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
/>
</label>
<span class="srp-total">
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
</span>
</div>
</fieldset>
</template>
<style scoped>
.srp {
border: var(--fs-border);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-4);
margin: 0 0 var(--fs-space-4);
min-width: 0;
}
.srp-legend {
font-size: var(--fs-size-label);
font-weight: var(--fs-weight-medium);
color: var(--color-text);
padding: 0 var(--fs-space-2);
}
.srp-intro,
.srp-note {
margin: 0 0 var(--fs-space-3);
font-size: var(--fs-size-body-sm);
color: var(--color-text-secondary);
line-height: var(--fs-leading-body);
max-width: 62ch;
}
.srp-note {
margin-bottom: var(--fs-space-4);
}
.srp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--fs-space-2);
}
.srp-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: baseline;
gap: var(--fs-space-2);
padding: var(--fs-space-1) var(--fs-space-2);
border-radius: var(--fs-radius-sm);
cursor: pointer;
min-width: 0;
}
.srp-item:hover { background: var(--color-hover); }
.srp-name {
font-size: var(--fs-size-body-sm);
color: var(--color-text);
}
.srp-count {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* The description is the useful part on a wide card and the first thing worth
dropping on a narrow one — the group name alone still identifies the row. */
.srp-desc {
grid-column: 1 / -1;
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
line-height: var(--fs-leading-body);
}
.srp-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--fs-space-3);
margin-top: var(--fs-space-4);
}
.srp-prefix {
display: flex;
align-items: center;
gap: var(--fs-space-2);
font-size: var(--fs-size-body-sm);
color: var(--color-text-secondary);
}
.srp-prefix-input {
width: 8rem;
font-family: var(--fs-font-mono);
font-size: var(--fs-size-code);
}
.srp-total {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
}
</style>
+2 -2
View File
@@ -445,7 +445,7 @@ async function confirmDelete() {
}
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
.action-delete:hover { color: var(--color-danger, #e74c3c); }
.action-delete:hover { color: var(--color-danger); }
/* ── Empty ────────────────────────────────────────────────────── */
.systems-empty {
@@ -483,7 +483,7 @@ async function confirmDelete() {
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
background: var(--color-overlay);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
+1 -1
View File
@@ -222,7 +222,7 @@ function focusInput() {
}
.tag-autocomplete-item:hover,
.tag-autocomplete-item.selected {
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
background: var(--color-bg-hover);
color: var(--color-primary);
}
</style>
+8 -8
View File
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--color-primary) 6%, transparent);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--color-primary) 14.0%, transparent);
transform: translateY(-2px);
}
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
opacity: 0.8;
}
.dot-todo {
background: var(--color-status-todo, #94a3b8);
border: 2px solid var(--color-status-todo, #94a3b8);
background: var(--color-status-todo);
border: 2px solid var(--color-status-todo);
background: transparent;
border: 2px solid var(--color-text-muted);
}
.dot-in-progress {
background: var(--color-status-in-progress, #3b82f6);
background: var(--color-status-in-progress);
}
.dot-done {
background: var(--color-status-done, #22c55e);
background: var(--color-status-done);
}
.dot-cancelled {
background: var(--color-status-cancelled, #6b7280);
background: var(--color-status-cancelled);
}
.task-title-compact {
@@ -190,7 +190,7 @@ function isOverdue(): boolean {
flex-shrink: 0;
}
.due-compact.overdue {
color: var(--color-danger, #e74c3c);
color: var(--color-danger);
font-weight: 600;
}
/* Full layout */
@@ -463,7 +463,7 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-card, var(--color-bg-secondary));
background: var(--color-bg-card);
}
.rail-header {
@@ -387,7 +387,7 @@ defineExpose({ reload: loadAll });
.task-add-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
background: var(--color-input-bg);
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.28rem 0.5rem;
@@ -413,7 +413,7 @@ defineExpose({ reload: loadAll });
gap: 0.4rem;
width: 100%;
padding: 0.4rem 0.65rem;
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
background: var(--color-surface-raised);
border: none;
cursor: pointer;
text-align: left;
@@ -433,7 +433,7 @@ defineExpose({ reload: loadAll });
text-transform: capitalize;
}
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
.ms-status-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.task-items {
list-style: none;
@@ -466,7 +466,7 @@ defineExpose({ reload: loadAll });
justify-content: center;
}
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
.status-dot.status-done { border-color: var(--color-success); color: var(--color-success); }
.task-title {
flex: 1;
@@ -522,7 +522,7 @@ defineExpose({ reload: loadAll });
margin-left: auto;
}
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
.status-badge.status-done { border-color: var(--color-success); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 10%, transparent); }
.btn-edit-task { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; }
@@ -614,7 +614,7 @@ defineExpose({ reload: loadAll });
flex-shrink: 0;
}
.task-due.overdue {
color: var(--color-danger, #e74c3c);
color: var(--color-danger);
font-weight: 500;
}
@@ -46,7 +46,7 @@ watch(() => props.projectId, load);
<style scoped>
.plan-rules {
margin-top: 1.5rem;
border-top: 1px solid var(--color-border, #2a2a2e);
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.plan-rules h3 {
@@ -60,7 +60,7 @@ watch(() => props.projectId, load);
}
.plan-rules ul {
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
border-left: 2px solid var(--color-primary, #6366f1);
border-left: 2px solid var(--color-primary);
}
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
@@ -329,7 +329,7 @@ h3 {
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 0.25rem;
background: var(--color-primary-bg, rgba(99,102,241,0.15));
background: var(--color-primary-bg);
padding: 0.25rem 0.5rem; border-radius: 999px;
}
.chip a { cursor: pointer; }
@@ -337,13 +337,13 @@ h3 {
.chip-remove:hover { opacity: 1; }
.add {
background: none;
border: 1px dashed var(--color-border, #2a2a2e);
border: 1px dashed var(--color-border);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
color: inherit;
}
select {
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--color-bg); color: inherit;
border: 1px solid var(--color-border); border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.applicable { margin-top: 2rem; }
@@ -355,7 +355,7 @@ select {
}
ul { list-style: none; padding: 0; margin: 0; }
.rule {
border-left: 2px solid var(--color-primary, #6366f1);
border-left: 2px solid var(--color-primary);
padding-left: 0.75rem; margin: 0.5rem 0;
}
.rule-head { cursor: pointer; }
@@ -363,12 +363,12 @@ ul { list-style: none; padding: 0; margin: 0; }
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
.rule-detail {
margin-top: 0.5rem; padding: 0.5rem;
background: var(--color-bg, #111113); border-radius: 6px;
background: var(--color-bg); border-radius: 6px;
}
.rule-detail > div { margin-bottom: 0.5rem; }
.edit-link {
background: none; border: none; cursor: pointer;
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
color: var(--color-primary); padding: 0.5rem 0 0 0;
}
.empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; }
@@ -377,18 +377,18 @@ ul { list-style: none; padding: 0; margin: 0; }
.new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem;
padding: 0.75rem; margin: 0.5rem 0;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--color-bg);
border: 1px solid var(--color-border); border-radius: 6px;
}
.new-rule-form input, .new-rule-form textarea {
background: var(--color-surface, #18181b); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--color-surface); color: inherit;
border: 1px solid var(--color-border); border-radius: 6px;
padding: 0.5rem; font: inherit; resize: vertical;
}
.rule-list { margin-top: 0.5rem; }
.delete-link {
background: none; border: none; cursor: pointer;
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
color: var(--color-destructive); padding: 0.5rem 0 0 0;
}
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
@@ -400,14 +400,14 @@ ul { list-style: none; padding: 0; margin: 0; }
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--color-muted, #888); font-size: 0.75rem;
color: var(--color-muted); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
.skip-btn:hover { color: var(--color-destructive); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
@@ -426,13 +426,13 @@ ul { list-style: none; padding: 0; margin: 0; }
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e);
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--color-primary, #6366f1); font-size: 0.85em;
color: var(--color-primary); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style>
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
.slide-over {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(520px, 90vw);
background: var(--color-surface, #18181b);
border-left: 2px solid var(--color-primary, #6366f1);
background: var(--color-surface);
border-left: 2px solid var(--color-primary);
padding: 1.5rem;
overflow-y: auto;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
@@ -110,11 +110,11 @@ header h2 {
font-family: Fraunces, serif; font-style: italic;
}
label { display: block; margin-bottom: 1rem; }
.required { color: var(--color-primary, #6366f1); }
.required { color: var(--color-primary); }
input, textarea {
width: 100%; margin-top: 0.25rem;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--color-bg); color: inherit;
border: 1px solid var(--color-border); border-radius: 6px;
padding: 0.5rem; font: inherit;
font-family: inherit;
}
@@ -22,18 +22,18 @@ const emit = defineEmits<{
</template>
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li {
padding: 0.75rem;
cursor: pointer;
border-radius: 6px;
border-left: 2px solid var(--color-primary, #6366f1);
border-left: 2px solid var(--color-primary);
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.02);
}
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
li:hover { background: var(--color-hover); }
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
.new-rule { cursor: pointer; }
@@ -122,7 +122,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</template>
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle {
@@ -133,18 +133,18 @@ header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem
.always-on-toggle input { cursor: pointer; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
li.active { background: var(--color-primary-bg); }
li:hover { background: var(--color-hover); }
.new-topic input {
width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--color-bg); color: inherit;
border: 1px solid var(--color-border); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--color-border, #2a2a2e);
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
@@ -48,27 +48,27 @@ async function submitNew() {
</template>
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
li.active { background: var(--color-primary-bg); }
li:hover { background: var(--color-hover); }
.always-on-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--color-accent, rgba(91,74,138,0.25));
color: var(--color-accent-fg, inherit);
background: var(--color-accent);
color: var(--color-accent-fg);
margin-left: auto;
}
.new-rulebook { margin-top: 1rem; }
.new-rulebook input {
width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--color-bg); color: inherit;
border: 1px solid var(--color-border); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
+167 -101
View File
@@ -1,58 +1,80 @@
/**
* Drift comparison — what the rulebook claims vs what the stylesheet does.
* Agreement — does the running app match the design system it was built from?
*
* 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.
* This panel used to compare a design RULEBOOK's prose claims against the live
* tokens. That rulebook was retired into the design system on 2026-08-01, and
* the feature spent two days rendering a reassuring empty state instead of
* failing (#2419). Its replacement is not the same question re-aimed: comparing
* a design system against a stylesheet generated from that same design system
* would be a tautology.
*
* The question that survives is the one no server can answer. A sheet still has
* to be LOADED and APPLIED, and until now nothing checked that it was. Three
* failures live in that gap:
*
* absent the app has no such token at all — the sheet was never
* regenerated after the record changed, or never loaded
* differs the app has the token with another value — a stale copy of the
* sheet, or a later rule that overrode it
* unrecorded the app declares a token in the record's own family that the
* record has never heard of — hand-editing that outlived its reason
*
* 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.
* record against the TOKENS. A literal hardcoded in a component where a token
* should be referenced is invisible here, because the drift isn't in the tokens
* at all — that check has the component sources and belongs in CI (#2277).
* Saying so in the panel matters: a 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";
/** The base mode's key in a token's `value_by_mode`, mirroring services/design_stylesheet. */
export const BASE_MODE = "base";
export interface Expectation {
kind: ExpectationKind;
/** One token as the RECORD has it, already narrowed to the mode being checked. */
export interface RecordedToken {
name: string;
/** Declared value for this mode, or "" when the role is named but unvalued. */
value: string;
rule_id: number;
rule_title: string;
context: string;
groupName: string | null;
}
export interface ExpectationResponse {
rulebook_id: number | null;
expectations: Expectation[];
}
export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded";
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[];
export interface Agreement {
name: string;
groupName: string | null;
/** What the record declares, resolved. Empty for an `unrecorded` row. */
recorded: string;
/** What the browser resolved. Empty for an `absent` row. */
live: string;
status: AgreementStatus;
}
/**
* Normalise a colour for comparison — the client-side twin of
* `normalize_hex` in services/design_system.py.
* Which declared value applies when the page is in `mode`.
*
* 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.
* Falls back to base, which is the storage model rather than a convenience: a
* mode block is an OVERRIDE layer, so a token with no entry for the current
* mode is not missing — it is inheriting, exactly as the sheet has it.
*/
export function valueForMode(
valueByMode: Record<string, string>,
mode: string,
): string {
const own = valueByMode[mode];
if (own !== undefined && own !== "") return own;
return valueByMode[BASE_MODE] ?? "";
}
/**
* Normalise a colour for comparison.
*
* A record writes `#FFFFFF`, a sheet writes `#fff`, and
* getComputedStyle can hand back `rgb(255, 255, 255)` — three spellings of one
* colour, and a comparison that misses any of them over-reports drift, which is
* the failure that gets a panel ignored. The rgb() case is browser-specific and
* therefore has no server-side counterpart, which is exactly why it is here.
*/
export function normalizeColour(value: string): string | null {
const raw = value.trim().toLowerCase();
@@ -66,7 +88,9 @@ export function normalizeColour(value: string): string | null {
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
}
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
// getComputedStyle reports real colour properties as rgb()/rgba(), never as
// authored. Custom properties are token streams and usually come back as
// written, so this arm is insurance rather than the common path.
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
if (rgb) {
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
@@ -84,86 +108,128 @@ export function normalizeColour(value: string): string | null {
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 two CSS values for sameness, not for identical text.
*
* Whitespace inside a compound value is not meaningful — `0 2px 10px` and
* `0 2px 10px` are one shadow — and neither is case, since a custom property
* carries no font names or content strings that would be changed by folding it.
* Colours go through the normaliser first so spelling differences don't read as
* drift.
*/
export function sameValue(a: string, b: string): boolean {
const canon = (v: string) => {
const trimmed = v.trim();
return normalizeColour(trimmed) ?? trimmed.replace(/\s+/g, " ").toLowerCase();
};
return canon(a) === canon(b);
}
/**
* Compare claims against the live tokens.
* The families the record claims, as name prefixes.
*
* 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.
* Used to decide which live tokens count as `unrecorded`. An app's stylesheet
* legitimately carries names the record never owned — Scribe's own sheet keeps
* a `--color-*` alias layer over the design system's `--fs-*` block — and
* reporting those as drift would bury the real findings under a compatibility
* shim. So the record is treated as owning a FAMILY, identified by the prefix
* up to the first separator, and nothing outside it is judged.
*
* Derived from the data rather than configured, because the prefix is the
* install's choice (see design_starter_roles) and hardcoding one would put a
* single operator's naming into every install (rule #115).
*/
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] : [],
};
export function recordedFamilies(names: Iterable<string>): string[] {
const families = new Set<string>();
for (const name of names) {
const match = /^(--[A-Za-z0-9]+-)/.exec(name);
if (match) families.add(match[1]);
}
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,
};
});
return [...families];
}
export interface DriftSummary {
/**
* Compare the record against the running app.
*
* `resolved` is the record's declared values after the browser has substituted
* `var()` in them (see `resolveDeclared`) — the same treatment the live values
* already received, which is what makes the two comparable.
*
* Tokens the record names but has no value for are SKIPPED, not reported. A
* valueless token is a role awaiting a decision, and the stylesheet already
* reports those under `valueless`; counting them as drift would mean a system
* created with starter roles opens this panel red on day one.
*/
export function compareToApp(
recorded: RecordedToken[],
resolved: Map<string, string>,
live: DesignToken[],
): Agreement[] {
const liveByName = new Map<string, string>();
for (const token of live) liveByName.set(token.name, token.value);
const out: Agreement[] = [];
for (const token of recorded) {
if (!token.value) continue;
const declared = resolved.get(token.name) ?? token.value;
const actual = liveByName.get(token.name) ?? "";
out.push({
name: token.name,
groupName: token.groupName,
recorded: declared,
live: actual,
status: !actual ? "absent" : sameValue(declared, actual) ? "ok" : "differs",
});
}
const known = new Set(recorded.map((t) => t.name));
const families = recordedFamilies(known);
for (const token of live) {
if (known.has(token.name)) continue;
if (!families.some((prefix) => token.name.startsWith(prefix))) continue;
out.push({
name: token.name,
groupName: null,
recorded: "",
live: token.value,
status: "unrecorded",
});
}
return out;
}
export interface AgreementSummary {
ok: number;
missing: number;
violated: number;
absent: number;
differs: number;
unrecorded: 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;
export function summarise(agreements: Agreement[]): AgreementSummary {
const summary: AgreementSummary = {
ok: 0, absent: 0, differs: 0, unrecorded: 0, total: agreements.length,
};
for (const a of agreements) summary[a.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.
* A panel that opens with every row gets closed and never reopened. `absent`
* leads because it is the one status that can mean the whole sheet is missing;
* `differs` next, because a wrong value is being rendered right now;
* `unrecorded` last, since it is a bookkeeping gap rather than a visible fault.
* `ok` rows are not findings at all and 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) => {
export function rankAgreements(agreements: Agreement[]): Agreement[] {
const order: Record<AgreementStatus, number> = {
absent: 0, differs: 1, unrecorded: 2, ok: 3,
};
return [...agreements].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;
return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name);
});
}
+90 -28
View File
@@ -2,7 +2,8 @@
* 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.
* against these, and the agreement panel compares them to the design system the
* install says its UI is built from (#2419).
*
* DESIGN NOTE — why this parses NAMES but never VALUES.
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
@@ -42,8 +43,8 @@ export interface DesignToken {
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;
/** True when the token is re-declared under a mode selector in source. */
modeAware: boolean;
}
/**
@@ -61,8 +62,21 @@ 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"]';
/**
* Any mode-override block, whichever mode it names.
*
* This used to hardcode `[data-theme="dark"]`, and that stopped being true the
* day the sheet went dark-first: `:root` now carries dark and
* `[data-theme="light"]` overrides it. The hardcoded selector matched nothing,
* `overriddenInDark` was false for all 186 tokens, and the "mode-aware" flag
* silently vanished from the gallery — a UI that kept rendering, wrongly.
*
* Matching the SHAPE rather than one mode name is what makes that unrepeatable,
* and it is also the only version that holds for an install whose modes aren't
* light and dark (rule #115). `selector_for_mode` in services/design_stylesheet
* emits exactly this shape, so the two ends agree by construction.
*/
const MODE_SELECTOR = /\[data-theme=["']?[\w-]+["']?\]/g;
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
["--color-", "color"],
@@ -96,15 +110,21 @@ export function tokenNames(css: string = themeCss): string[] {
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> {
/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */
export function modeOverriddenNames(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 names = new Set<string>();
for (const match of bare.matchAll(MODE_SELECTOR)) {
if (match.index === undefined) continue;
const open = bare.indexOf("{", match.index + match[0].length);
if (open === -1) continue;
// A custom-property block is flat, so the first `}` closes it. Anything
// nested would be a rule, not a declaration, and has no tokens to find.
const close = bare.indexOf("}", open);
if (open === -1 || close === -1) return new Set();
return new Set(tokenNames(bare.slice(open, close)));
if (close === -1) continue;
for (const name of tokenNames(bare.slice(open, close))) names.add(name);
}
return names;
}
/**
@@ -116,15 +136,46 @@ export function darkOverriddenNames(css: string = themeCss): Set<string> {
*/
export function readTokens(host: Element = document.documentElement): DesignToken[] {
const computed = getComputedStyle(host);
const dark = darkOverriddenNames();
const modal = modeOverriddenNames();
return tokenNames().map((name) => ({
name,
group: groupFor(name),
value: computed.getPropertyValue(name).trim(),
overriddenInDark: dark.has(name),
modeAware: modal.has(name),
}));
}
/**
* Read a set of DECLARED values as the browser would resolve them.
*
* The point is to compare like with like. A design system records
* `color-mix(in srgb, var(--fs-accent) 15%, transparent)`; the browser reports
* the same token with `var()` already substituted. Comparing those two strings
* marks every derived token as drift, which is a report nobody can read.
*
* So both sides go through the same engine: set the declarations on an
* offscreen probe, read them back, and the substitution is done by the
* implementation that will do it for real rather than by a parser of ours.
* Undeclared references fall through to the page's own values, which is what
* the cascade would do anyway.
*/
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
const probe = document.createElement("div");
probe.style.display = "none";
for (const [name, value] of declared) probe.style.setProperty(name, value);
document.body.appendChild(probe);
try {
const computed = getComputedStyle(probe);
const out = new Map<string, string>();
for (const name of declared.keys()) {
out.set(name, computed.getPropertyValue(name).trim());
}
return out;
} finally {
probe.remove();
}
}
/**
* Read tokens as they would resolve in a given mode, without touching the page.
*
@@ -132,16 +183,15 @@ export function readTokens(host: Element = document.documentElement): DesignToke
* 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.
* function: mode scoping is one-way. Whichever mode the sheet treats as its
* BASE lives on `:root` and has no attribute selector of its own, so a probe
* can add an overriding mode to a subtree but can never add the base mode back.
*
* 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.
* The sheet is dark-first today — `:root` carries dark, `[data-theme="light"]`
* overrides it — so light-inside-dark previews work and dark-inside-light ones
* return the light values. That direction flipped when the sheet did, which is
* why this says "the base mode" rather than naming one: callers should treat a
* cross-mode read as best-effort either way.
*/
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
const probe = document.createElement("div");
@@ -155,13 +205,25 @@ export function readTokensForMode(mode: ThemeMode): DesignToken[] {
}
}
/** Tokens grouped by family, preserving source order within each group. */
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
const out = new Map<TokenGroup, DesignToken[]>();
/**
* Tokens grouped by family, preserving source order within each group.
*
* `overrides` maps a token name to the group it should sit under, and exists
* because the prefix table above can only know the families that shipped with
* the product. An install's own design system knows the groups it authored, so
* a caller holding the record passes them here rather than the taxonomy growing
* one operator's prefixes (rule #115).
*/
export function groupTokens(
tokens: DesignToken[],
overrides: Map<string, string> = new Map(),
): Map<string, DesignToken[]> {
const out = new Map<string, DesignToken[]>();
for (const token of tokens) {
const bucket = out.get(token.group);
const group = overrides.get(token.name) ?? token.group;
const bucket = out.get(group);
if (bucket) bucket.push(token);
else out.set(token.group, [token]);
else out.set(group, [token]);
}
return out;
}
+18
View File
@@ -42,6 +42,7 @@ import {
import DesignTabs from "@/components/DesignTabs.vue";
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import StarterRolePicker from "@/components/StarterRolePicker.vue";
const toast = useToastStore();
@@ -148,6 +149,10 @@ const newTitle = ref("");
const newDescription = ref("");
const newParentId = ref<number | null>(null);
const creating = ref(false);
// Starter roles (#2349). The picker fills these on mount; empty means the
// operator unchecked everything, which is a real answer.
const starterGroups = ref<string[]>([]);
const tokenPrefix = ref("");
async function submitCreate() {
const title = newTitle.value.trim();
@@ -158,11 +163,16 @@ async function submitCreate() {
title,
description: newDescription.value.trim() || undefined,
parent_id: newParentId.value,
starter_role_groups: starterGroups.value.length ? starterGroups.value : undefined,
token_prefix: tokenPrefix.value.trim() || undefined,
});
newTitle.value = "";
newDescription.value = "";
newParentId.value = null;
showCreate.value = false;
// NOT reset: the picker owns these and re-seeds on mount. Clearing them
// here would race the next mount and silently create the following system
// with no roles at all.
await loadSystems();
selectedId.value = created.id;
toast.show(`Created ${created.title}`);
@@ -540,6 +550,10 @@ function isColourish(value: string): boolean {
placeholder="What it covers"
/>
</div>
<StarterRolePicker
v-model:selected="starterGroups"
v-model:prefix="tokenPrefix"
/>
<div class="row-actions">
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
{{ creating ? "Creating…" : "Create" }}
@@ -601,6 +615,10 @@ function isColourish(value: string): boolean {
A system with a parent stores only its differences from it.
</p>
</div>
<StarterRolePicker
v-model:selected="starterGroups"
v-model:prefix="tokenPrefix"
/>
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
{{ creating ? "Creating…" : "Create" }}
</button>
+237 -102
View File
@@ -6,7 +6,7 @@
* 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":
* HONESTY RULE, and the reason parts of this page can 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
@@ -18,87 +18,179 @@
* `assets/components.css` is now the single definition (#2273), so the
* specimens below are the app's real classes — they cannot drift from the app
* without drifting the app itself.
*
* The panel at the top applies the same rule one level up: the sheet the app
* loads is generated from a design system, and nothing checked that the app
* ever loaded it (#2419).
*/
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { fetchDesignExpectations } from "@/api/design";
import { fetchUiDesignSystem } from "@/api/design";
import { fetchResolvedTokens, type ResolvedToken } from "@/api/designSystems";
import DesignTabs from "@/components/DesignTabs.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import TagPill from "@/components/TagPill.vue";
import { useTheme } from "@/composables/useTheme";
import {
compareToTokens,
rankFindings,
compareToApp,
rankAgreements,
summarise,
type Expectation,
type Finding,
valueForMode,
type Agreement,
type RecordedToken,
} from "@/utils/designDrift";
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
import {
groupTokens,
readTokens,
resolveDeclared,
type DesignToken,
type TokenGroup,
} from "@/utils/designTokens";
const { theme } = useTheme();
const tokens = ref<DesignToken[]>([]);
const expectations = ref<Expectation[]>([]);
const designRulebookId = ref<number | null>(null);
const driftLoaded = ref(false);
const showCleanRows = ref(false);
/** The designation, and the two ways it can be absent — see api/design.ts. */
const systemId = ref<number | null>(null);
const systemTitle = ref<string | null>(null);
const checkLoaded = ref(false);
const checkFailed = ref(false);
const showAgreeingRows = ref(false);
/** The record, as fetched. Kept raw because the mode narrowing has to be redone
* whenever the theme changes — a comparison against the wrong mode's values
* would report every mode-aware token as drift. */
const records = ref<ResolvedToken[]>([]);
const recorded = ref<RecordedToken[]>([]);
const resolved = ref<Map<string, string>>(new Map());
/** Narrow the record to the live mode and re-read the app. Idempotent. */
function recheck() {
tokens.value = readTokens();
recorded.value = records.value.map((t) => ({
name: t.name,
value: valueForMode(t.value_by_mode, theme.value),
groupName: t.group_name,
}));
const declared = new Map<string, string>();
for (const token of recorded.value) {
if (token.value) declared.set(token.name, token.value);
}
resolved.value = resolveDeclared(declared);
}
/**
* 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();
recheck();
try {
const response = await fetchDesignExpectations();
designRulebookId.value = response.rulebook_id;
expectations.value = response.expectations;
const designation = await fetchUiDesignSystem();
systemId.value = designation.design_system_id;
systemTitle.value = designation.title;
if (designation.design_system_id !== null && designation.title !== null) {
records.value = (await fetchResolvedTokens(designation.design_system_id)).tokens;
recheck();
}
} 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;
// "couldn't check" rather than taking the page down with it. It says so
// rather than showing the same empty state as "nothing designated" — that
// conflation is what let this feature sit dead (#2419).
checkFailed.value = true;
} finally {
driftLoaded.value = true;
checkLoaded.value = true;
}
});
const findings = computed<Finding[]>(() =>
rankFindings(compareToTokens(expectations.value, tokens.value)),
// Toggling the theme on this page is the most likely thing anyone does here.
watch(theme, () => recheck());
const agreements = computed<Agreement[]>(() =>
rankAgreements(compareToApp(recorded.value, resolved.value, tokens.value)),
);
const driftSummary = computed(() => summarise(findings.value));
const visibleFindings = computed(() =>
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
const summary = computed(() => summarise(agreements.value));
const visibleAgreements = computed(() =>
showAgreeingRows.value
? agreements.value
: agreements.value.filter((a) => a.status !== "ok"),
);
/** Named but unvalued roles — skipped by the comparison, worth stating once. */
const unvaluedCount = computed(
() => records.value.filter((t) => !valueForMode(t.value_by_mode, theme.value)).length,
);
const grouped = computed(() => groupTokens(tokens.value));
const STATUS_LABEL: Record<Agreement["status"], string> = {
absent: "not in the app",
differs: "app renders another value",
unrecorded: "not in the record",
ok: "agrees",
};
/* Gallery ------------------------------------------------------------------ */
/**
* Groups come from the RECORD where there is one, and from the name prefix
* otherwise. The prefix table in designTokens.ts can only know the families
* that shipped with the product; the record knows the ones this install
* authored, and grouping 110 tokens under "other" because a table never heard
* of their prefix is a gallery nobody reads.
*/
const recordGroups = computed(() => {
const out = new Map<string, string>();
for (const token of records.value) {
if (token.group_name) out.set(token.name, token.group_name);
}
return out;
});
const grouped = computed(() => groupTokens(tokens.value, recordGroups.value));
/** Order for the prefix-derived groups only; record groups sort by name. */
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)! })),
);
const orderedGroups = computed(() => {
const fromRecord = new Set(recordGroups.value.values());
const rank = (g: string) => {
const i = GROUP_ORDER.indexOf(g as TokenGroup);
return i < 0 ? GROUP_ORDER.length : i;
};
const keys = [...grouped.value.keys()].sort((a, b) => {
// The record's own groups lead: they are the system, and the prefix-derived
// ones are whatever else the sheet happens to carry.
const byOrigin = Number(!fromRecord.has(a)) - Number(!fromRecord.has(b));
if (byOrigin !== 0) return byOrigin;
if (fromRecord.has(a)) return a.localeCompare(b);
return rank(a) - rank(b);
});
return keys.map((group) => ({ group, tokens: grouped.value.get(group)! }));
});
/** 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" },
/** The button family as shared classes, described by the roles they reach for. */
const BUTTON_VARIANTS = [
{ name: "Primary", spec: "action-primary background, text-on-action label, no border" },
{ name: "Secondary", spec: "action-secondary background, text-on-action label, no border" },
{ name: "Ghost", spec: "transparent, primary text, one-pixel border" },
{ name: "Danger", spec: "action-destructive background, text-on-action label" },
];
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" },
];
/**
* The type scale, read from whatever size tokens the sheet actually declares.
*
* Was a hand-written table of nine sizes marked "no token" — true when written,
* and false since the scale was recorded. Deriving it from the live tokens is
* what stops it going stale a second time: if the scale is removed, this
* section empties out and says so.
*/
const sizeTokens = computed(() => tokens.value.filter((t) => /-size(-|$)/.test(t.name)));
</script>
<template>
@@ -116,78 +208,108 @@ const TYPE_SPECIMENS = [
</p>
</header>
<!-- Drift: what the rulebook claims vs what the tokens do. -->
<!-- Does the running app match the design system it was generated from? -->
<section class="design-section">
<h2>Rulebook drift</h2>
<h2>Agreement with the record</h2>
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook</p>
<p v-if="!checkLoaded" class="muted">
Checking the running app against its design system
</p>
<div v-else-if="designRulebookId === null" class="gap-notice">
<strong>No design rulebook designated.</strong>
<div v-else-if="checkFailed" class="gap-notice">
<strong>The design system couldn't be read.</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.
The gallery below is still live — it comes from the browser, not the
server — but nothing is being compared against the record right now.
This is a failure, not an empty result.
</p>
</div>
<div v-else-if="systemId === null" class="gap-notice">
<strong>No design system designated for this UI.</strong>
<p>
This install hasn't said which design system its own interface is
built from, so there is nothing to check the running app against.
Designate one in <router-link to="/settings">Settings</router-link>
and this panel will report every token the record declares that the
app doesn't have, renders differently, or has never heard of.
</p>
</div>
<div v-else-if="systemTitle === null" class="gap-notice">
<strong>Design system #{{ systemId }} could not be read.</strong>
<p>
It is designated in <router-link to="/settings">Settings</router-link>,
but it has since been deleted or is no longer shared with you. Nothing
is being checked — this is a misconfiguration, not a clean result.
</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 }}.
<strong>{{ summary.absent }}</strong> not in the app ·
<strong>{{ summary.differs }}</strong> rendering another value ·
{{ summary.unrecorded }} not in the record ·
{{ summary.ok }} agreeing, across {{ summary.total }} tokens checked
against <strong>{{ systemTitle }}</strong> in
<code>{{ theme }}</code> mode.
<template v-if="unvaluedCount">
{{ unvaluedCount }} recorded {{ unvaluedCount === 1 ? "role has" : "roles have" }}
no value yet and {{ unvaluedCount === 1 ? "was" : "were" }} not checked.
</template>
</p>
<div class="gap-notice">
<strong>This compares the rulebook against the TOKENS only.</strong>
<strong>This compares the record 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".
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 v-if="!agreements.length" class="muted">
The record declares no valued tokens, so there is nothing to compare
yet. Give its roles values and this panel starts reporting.
</p>
<ul v-else class="spec-list">
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
<li v-for="row in visibleAgreements" :key="row.name">
<span class="spec-name">
<span
v-if="finding.expectation.kind !== 'token'"
v-if="isColourish(row.live || row.recorded)"
class="swatch"
:style="{ background: finding.expectation.value }"
:style="{ background: row.live || row.recorded }"
aria-hidden="true"
/>
<code>{{ finding.expectation.value }}</code>
<code>{{ row.name }}</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" }}
<template v-if="row.status === 'differs'">
record <code>{{ row.recorded }}</code> · app
<code>{{ row.live }}</code>
</template>
<template v-else-if="row.status === 'unrecorded'">
app <code>{{ row.live }}</code>
</template>
<template v-else>
record <code>{{ row.recorded }}</code>
</template>
<span v-if="row.groupName" class="matches"> · {{ row.groupName }}</span>
</span>
<span class="spec-status" :class="row.status">{{ STATUS_LABEL[row.status] }}</span>
</li>
</ul>
<button
v-if="findings.length && driftSummary.ok"
v-if="agreements.length && summary.ok"
class="reveal-toggle"
@click="showCleanRows = !showCleanRows"
@click="showAgreeingRows = !showAgreeingRows"
>
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
{{ showAgreeingRows ? "Hide" : "Show" }} the {{ summary.ok }} agreeing tokens
</button>
</template>
</section>
@@ -249,7 +371,7 @@ const TYPE_SPECIMENS = [
<button class="btn-primary btn-inline">Inline</button>
</div>
<ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<li v-for="b in BUTTON_VARIANTS" :key="b.name">
<span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status ok">shared</span>
@@ -257,23 +379,21 @@ const TYPE_SPECIMENS = [
</ul>
</section>
<!-- Typography: the families load, the scale does not exist as tokens. -->
<!-- Type: rendered at the sizes the sheet declares, not described. -->
<section class="design-section">
<h2>Type scale</h2>
<div class="gap-notice">
<strong>Families load; the scale has no tokens.</strong>
<div v-if="!sizeTokens.length" class="gap-notice">
<strong>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.
Nothing in the stylesheet declares a size token, so sizes are being set
ad hoc per component. There is nothing live to show here.
</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>
<ul v-else class="spec-list">
<li v-for="t in sizeTokens" :key="t.name">
<span class="spec-name" :style="{ fontSize: t.value }">Ag</span>
<span class="spec-detail"><code>{{ t.name }}</code></span>
<span class="spec-status ok">{{ t.value || "—" }}</span>
</li>
</ul>
</section>
@@ -292,7 +412,7 @@ const TYPE_SPECIMENS = [
<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">
<span v-if="token.modeAware" class="token-flag" title="Re-declared under a mode selector">
mode-aware
</span>
</li>
@@ -430,6 +550,7 @@ const TYPE_SPECIMENS = [
color: var(--color-text-secondary);
font-size: 0.85rem;
flex: 1;
word-break: break-word;
}
.spec-status {
@@ -438,6 +559,25 @@ const TYPE_SPECIMENS = [
letter-spacing: 0.08em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
white-space: nowrap;
}
/* `absent` is the one status that can mean the whole sheet failed to load, so
it carries the same weight as a high-priority finding; `differs` is a wrong
value on screen right now; `unrecorded` is bookkeeping and reads quietest. */
.spec-status.absent {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.differs {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.spec-status.unrecorded {
background: var(--color-surface);
color: var(--color-text-muted);
}
.spec-status.missing {
@@ -445,11 +585,6 @@ const TYPE_SPECIMENS = [
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);
+2 -2
View File
@@ -600,7 +600,7 @@ onUnmounted(() => {
.graph-page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height, 52px));
height: calc(100vh - var(--header-height));
overflow: hidden;
}
@@ -750,7 +750,7 @@ onUnmounted(() => {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15));
box-shadow: 0 4px 16px var(--color-shadow);
padding: 0.5rem 0.75rem;
pointer-events: none;
z-index: 10;
+20 -20
View File
@@ -490,7 +490,7 @@ onUnmounted(() => {
.knowledge-root {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height, 56px));
height: calc(100vh - var(--header-height));
overflow: hidden;
}
@@ -502,7 +502,7 @@ onUnmounted(() => {
gap: 12px;
padding: 8px 20px;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
font-size: 0.82rem;
flex-wrap: wrap;
@@ -539,7 +539,7 @@ onUnmounted(() => {
width: var(--sidebar-width);
flex-shrink: 0;
padding: 16px 12px;
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-right: 1px solid var(--color-border);
overflow-y: auto;
background: var(--color-bg-secondary);
}
@@ -548,7 +548,7 @@ onUnmounted(() => {
content: '· · ·';
display: block;
text-align: center;
color: rgba(91, 74, 138, 0.3);
color: color-mix(in srgb, var(--color-primary) 30%, transparent);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
@@ -662,7 +662,7 @@ onUnmounted(() => {
flex-shrink: 0;
}
.filter-btn.active .filter-count {
background: rgba(91, 74, 138, 0.2);
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
color: var(--color-primary);
}
.filter-tag { font-size: 0.78rem; }
@@ -683,7 +683,7 @@ onUnmounted(() => {
gap: 10px;
padding: 12px 20px;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-bottom: 1px solid var(--color-border);
}
.search-wrap {
flex: 1;
@@ -701,8 +701,8 @@ onUnmounted(() => {
width: 100%;
padding: 7px 12px 7px 32px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text);
font-size: 0.88rem;
outline: none;
@@ -712,8 +712,8 @@ onUnmounted(() => {
.sort-select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
@@ -735,9 +735,9 @@ onUnmounted(() => {
.k-card {
position: relative;
background: var(--color-surface, rgba(255,255,255,0.03));
border: 1px solid var(--color-border, rgba(255,255,255,0.07));
border-radius: var(--radius-lg, 14px);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 14px;
cursor: pointer;
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
@@ -749,12 +749,12 @@ onUnmounted(() => {
}
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(91, 74, 138, 0.35);
box-shadow: 0 8px 28px color-mix(in srgb, var(--color-primary) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
}
/* Type-specific card DNA */
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
.k-card--note { border-color: color-mix(in srgb, var(--color-primary) 20%, transparent); }
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
/* Top gradient bars */
@@ -769,7 +769,7 @@ onUnmounted(() => {
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
background: linear-gradient(90deg, var(--color-primary), #7A6DA8);
}
.k-card--task::before {
right: 0;
@@ -788,7 +788,7 @@ onUnmounted(() => {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
.badge--note { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: #7A6DA8; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
@@ -917,7 +917,7 @@ onUnmounted(() => {
.graph-panel {
width: 500px;
flex-shrink: 0;
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
@@ -933,7 +933,7 @@ onUnmounted(() => {
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 500;
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
/* Override GraphView's 100vh height so it fills the panel instead */
+3 -3
View File
@@ -709,8 +709,8 @@ onUnmounted(() => assist.clearSelection());
width: 100%;
padding: 5px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
border: 1px solid var(--color-input-border);
background: var(--color-bg-tertiary);
color: var(--color-text);
font-size: 0.82rem;
font-family: inherit;
@@ -822,7 +822,7 @@ onUnmounted(() => assist.clearSelection());
background: var(--color-surface);
color: var(--color-text);
/* Prompts are plain markdown — a code-style editor, not rich text. */
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-family: var(--font-mono);
font-size: 0.88rem;
line-height: 1.55;
tab-size: 2;
+1 -1
View File
@@ -563,7 +563,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
+12 -12
View File
@@ -847,9 +847,9 @@ async function confirmDelete() {
}
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
.plan-title-input {
background: var(--color-bg, #111113);
background: var(--color-bg);
color: inherit;
border: 1px solid var(--color-border, #2a2a2e);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font: inherit;
@@ -932,8 +932,8 @@ async function confirmDelete() {
flex-shrink: 0;
}
.dot-todo { background: transparent; border: 2px solid var(--color-text-muted); }
.dot-inprogress { background: var(--color-status-in-progress, #3b82f6); }
.dot-done { background: var(--color-status-done, #22c55e); }
.dot-inprogress { background: var(--color-status-in-progress); }
.dot-done { background: var(--color-status-done); }
.stat-todo { background: color-mix(in srgb, var(--color-text-muted) 8%, transparent); color: var(--color-text-secondary); border-color: var(--color-border); }
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
@@ -1066,7 +1066,7 @@ async function confirmDelete() {
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
.ms-plan-editor {
width: 100%;
font-family: var(--font-mono, monospace);
font-family: var(--font-mono);
font-size: 0.8rem;
line-height: 1.5;
padding: 0.5rem;
@@ -1133,7 +1133,7 @@ async function confirmDelete() {
border-radius: var(--radius-sm);
}
.ms-action-btn:hover { background: var(--color-bg-card); color: var(--color-text); }
.ms-action-delete:hover { color: var(--color-danger, #e74c3c); }
.ms-action-delete:hover { color: var(--color-danger); }
.ms-rename-input {
flex: 1;
@@ -1168,8 +1168,8 @@ async function confirmDelete() {
border-top: 3px solid;
}
.col-todo { border-top-color: var(--color-border); }
.col-inprogress { border-top-color: var(--color-status-in-progress, #3b82f6); }
.col-done { border-top-color: var(--color-status-done, #22c55e); }
.col-inprogress { border-top-color: var(--color-status-in-progress); }
.col-done { border-top-color: var(--color-status-done); }
.kanban-col-header {
display: flex;
@@ -1230,7 +1230,7 @@ async function confirmDelete() {
transform: translateY(-2px);
}
/* Priority left-border colors */
.task-card.pri-high { border-left-color: var(--color-danger, #e74c3c); }
.task-card.pri-high { border-left-color: var(--color-danger); }
.task-card.pri-medium { border-left-color: #f59e0b; }
.task-card.pri-low { border-left-color: var(--color-success); }
@@ -1256,7 +1256,7 @@ async function confirmDelete() {
}
.task-card:hover .task-advance-btn { opacity: 1; }
.task-advance-btn:hover { background: var(--color-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: var(--fs-text-on-action); }
.task-advance-btn--done:hover { background: var(--color-success); border-color: var(--color-success); color: var(--fs-text-on-action); }
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
.priority-dot {
@@ -1265,7 +1265,7 @@ async function confirmDelete() {
border-radius: 50%;
flex-shrink: 0;
}
.dot-pri-high { background: var(--color-danger, #e74c3c); }
.dot-pri-high { background: var(--color-danger); }
.dot-pri-medium { background: #f59e0b; }
.dot-pri-low { background: var(--color-success); }
@@ -1310,7 +1310,7 @@ async function confirmDelete() {
/* ── Modal ───────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
background: var(--color-overlay);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
+2 -2
View File
@@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute);
grid-template-columns: 280px 300px 1fr;
height: 100vh;
gap: 1px;
background: var(--color-border, #2a2a2e);
background: var(--color-border);
}
.pane.empty {
background: var(--color-surface, #18181b);
background: var(--color-surface);
padding: 1rem;
opacity: 0.6;
font-style: italic;
+39 -33
View File
@@ -4,7 +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 { fetchDesignSystems } from "@/api/designSystems";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -32,11 +32,12 @@ 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 }[]>([]);
// Which design system this install's own UI is built from, for the /design
// agreement panel. Empty = none designated, which is the normal state for a
// fresh install rather than a misconfiguration — the panel explains itself when
// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
const uiDesignSystemId = ref("");
const designSystems = ref<{ id: number; title: string }[]>([]);
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);
@@ -107,8 +108,8 @@ async function saveKbInject() {
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,
// exactly right for "no design system" — absent rather than zero.
ui_design_system_id: uiDesignSystemId.value,
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -499,13 +500,15 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
}
designRulebookId.value = allSettings.design_rulebook_id ?? "";
uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
// Best-effort: the picker degrades to "none available" rather than blocking
// the whole settings page if rulebooks can't be listed.
// the whole settings page if design systems can't be listed.
try {
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
designSystems.value = (await fetchDesignSystems()).design_systems.map(
(s) => ({ id: s.id, title: s.title }),
);
} catch {
designRulebooks.value = [];
designSystems.value = [];
}
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
@@ -1278,19 +1281,22 @@ function formatUserDate(iso: string): string {
</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 }}
<label for="ui-design-system">This app's design system</label>
<select id="ui-design-system" v-model="uiDesignSystemId" class="input" style="max-width: 22rem">
<option value="">None don't check the interface against a record</option>
<option v-for="ds in designSystems" :key="ds.id" :value="String(ds.id)">
{{ ds.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.
Which design system this interface is supposed to be built from. Once
set, the <router-link to="/design">Design</router-link> page compares
every token the system declares against what the browser has actually
resolved, and reports the ones the app is missing, renders differently,
or has never heard of. That catches a stylesheet that was regenerated
but never shipped — which the record alone cannot tell you, since the
sheet is generated from it. Leave it as None if this install's
interface isn't described by one of your design systems.
</p>
</div>
<div class="field">
@@ -2404,7 +2410,7 @@ function formatUserDate(iso: string): string {
}
.sidebar-item.active {
color: var(--color-primary);
background: rgba(91, 74, 138, 0.08);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-left-color: var(--color-primary);
font-weight: 500;
}
@@ -2789,11 +2795,11 @@ function formatUserDate(iso: string): string {
color: var(--color-text-muted);
}
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
.perm-denied { background: color-mix(in srgb, var(--color-danger) 15%, transparent); color: var(--color-danger); }
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.push-error {
font-size: 0.82rem;
color: var(--color-danger, #e74c3c);
color: var(--color-danger);
margin: 0.25rem 0 0;
}
@@ -3048,7 +3054,7 @@ function formatUserDate(iso: string): string {
.group-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
border-radius: var(--radius-md);
overflow: hidden;
}
@@ -3166,7 +3172,7 @@ function formatUserDate(iso: string): string {
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.role-owner { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
.role-owner { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
.role-member { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
.members-empty {
@@ -3278,7 +3284,7 @@ function formatUserDate(iso: string): string {
}
.api-key-value {
flex: 1;
background: var(--color-surface-2, var(--color-surface));
background: var(--color-surface-2);
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
@@ -3396,7 +3402,7 @@ function formatUserDate(iso: string): string {
.mcp-code-row .btn-sm { white-space: nowrap; }
.mcp-advanced {
margin-top: 1.25rem;
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
}
.mcp-advanced summary {
@@ -3470,7 +3476,7 @@ function formatUserDate(iso: string): string {
flex: 1;
}
.voice-library-id {
font-family: var(--font-mono, monospace);
font-family: var(--font-mono);
font-size: 0.9rem;
font-weight: 500;
}
@@ -3511,9 +3517,9 @@ function formatUserDate(iso: string): string {
border-radius: 999px;
}
.status-on {
background: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent);
color: var(--color-success, #22c55e);
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
}
.status-off {
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
+1 -1
View File
@@ -244,7 +244,7 @@ onMounted(async () => {
}
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
.perm-admin { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
.empty-msg {
color: var(--color-muted);
+3 -3
View File
@@ -239,7 +239,7 @@ async function confirmDelete() {
}
.snippet-name {
margin: 0;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
font-size: 1.4rem;
word-break: break-word;
}
@@ -291,7 +291,7 @@ async function confirmDelete() {
}
.meta-grid code,
.tag-row + * code {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
font-size: 0.82rem;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
@@ -379,7 +379,7 @@ async function confirmDelete() {
overflow-x: auto;
}
.code-block code {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.6;
color: var(--color-text);
+2 -2
View File
@@ -447,7 +447,7 @@ function cancel() {
box-shadow: var(--focus-ring);
}
.mono {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
}
.code-area {
resize: vertical;
@@ -542,7 +542,7 @@ function cancel() {
gap: 0.4rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning, var(--color-primary));
border-left: 3px solid var(--color-warning);
border-radius: 8px;
background: var(--color-bg-secondary);
}
+10 -10
View File
@@ -574,7 +574,7 @@ function usageTitle(s: SnippetListItem): string {
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
box-sizing: border-box;
}
.loc-input-wide {
@@ -633,7 +633,7 @@ function usageTitle(s: SnippetListItem): string {
color: var(--color-text-muted);
}
.empty-icon {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.35;
@@ -720,7 +720,7 @@ function usageTitle(s: SnippetListItem): string {
min-width: 0;
flex: 1;
word-break: break-word;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
}
/* Language tag — accent pill per the design system's tag treatment. */
@@ -770,7 +770,7 @@ function usageTitle(s: SnippetListItem): string {
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-alt, var(--color-surface));
background: var(--color-surface-alt);
}
.dup-empty,
@@ -828,8 +828,8 @@ function usageTitle(s: SnippetListItem): string {
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
color: var(--color-danger, #b91c1c);
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--color-danger);
}
.usage-tag {
@@ -845,8 +845,8 @@ function usageTitle(s: SnippetListItem): string {
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
color: var(--color-warning, #b45309);
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--color-warning);
}
/* Header + select-mode */
@@ -906,7 +906,7 @@ function usageTitle(s: SnippetListItem): string {
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -955,7 +955,7 @@ function usageTitle(s: SnippetListItem): string {
.merge-choice-name {
flex: 1;
min-width: 0;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--font-mono);
font-size: 0.85rem;
word-break: break-word;
}
+12 -12
View File
@@ -874,7 +874,7 @@ useEditorGuards(dirty, save);
padding: 0 0.2rem;
flex-shrink: 0;
}
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
.btn-clear-parent:hover { color: var(--color-danger); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
@@ -1037,13 +1037,13 @@ useEditorGuards(dirty, save);
margin: 0.5rem 0 0.25rem;
}
.task-goal-label {
font-family: var(--font-display, "Fraunces", serif);
font-family: var(--font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
color: var(--color-text-muted);
}
.task-goal-input {
width: 100%;
@@ -1053,14 +1053,14 @@ useEditorGuards(dirty, save);
font: inherit;
font-size: 0.95rem;
line-height: 1.4;
color: var(--color-text, inherit);
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-md, 8px);
color: var(--color-text);
background: var(--color-input-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.task-goal-input:focus {
outline: none;
border-color: var(--color-primary, #6366f1);
border-color: var(--color-primary);
}
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
@@ -1072,13 +1072,13 @@ useEditorGuards(dirty, save);
margin-bottom: 0.5rem;
font-size: 0.82rem;
font-style: italic;
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
color: var(--color-text-muted);
background: rgba(99, 102, 241, 0.06);
border-left: 2px solid var(--color-primary, #6366f1);
border-radius: var(--radius-sm, 4px);
border-left: 2px solid var(--color-primary);
border-radius: var(--radius-sm);
}
.auto-summary-banner-editor .auto-summary-icon {
color: var(--color-primary, #6366f1);
color: var(--color-primary);
font-style: normal;
}
</style>
+10 -10
View File
@@ -561,7 +561,7 @@ const subTaskProgress = computed(() => {
}
.subtasks-fill {
height: 100%;
background: var(--color-status-done, #22c55e);
background: var(--color-status-done);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -602,13 +602,13 @@ const subTaskProgress = computed(() => {
border: 2px solid var(--color-text-muted);
}
.dot-in-progress {
background: var(--color-status-in-progress, #3b82f6);
background: var(--color-status-in-progress);
}
.dot-done {
background: var(--color-status-done, #22c55e);
background: var(--color-status-done);
}
.dot-cancelled {
background: var(--color-text-muted, #6b7280);
background: var(--color-text-muted);
}
.sub-title {
flex: 1;
@@ -749,26 +749,26 @@ const subTaskProgress = computed(() => {
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
.task-goal-display {
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-left: 2px solid var(--color-border);
padding: 0.4rem 0 0.4rem 0.9rem;
margin: 0.75rem 0 1.25rem;
background: rgba(255, 255, 255, 0.02);
}
.goal-label {
font-family: var(--font-display, "Fraunces", serif);
font-family: var(--font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
color: var(--color-text-muted);
margin: 0 0 0.25rem;
}
.goal-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.45;
color: var(--color-text, inherit);
color: var(--color-text);
white-space: pre-wrap;
}
.auto-summary-banner {
@@ -777,11 +777,11 @@ const subTaskProgress = computed(() => {
gap: 0.5rem;
font-size: 0.78rem;
font-style: italic;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.auto-summary-icon {
color: var(--color-primary, #6366f1);
color: var(--color-primary);
font-size: 0.85rem;
}
</style>
+1 -1
View File
@@ -68,7 +68,7 @@ onMounted(() => store.fetchTrash());
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border); background: none; color: inherit; }
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.22",
"version": "0.1.23",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+25
View File
@@ -61,6 +61,31 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
# --- Which version is actually RUNNING (keyless, networkless) ---
#
# An install has two halves and only one self-updates:
#
# marketplaces/…/scribe-plugin/ git clone — pulls on its own
# cache/…/scribe/<version>/ what EXECUTES — refreshed only when the
# manifest version changes
#
# So inspecting the clone shows a fix present while the broken copy keeps
# running, and the obvious debugging move actively misleads (#2209). Twice, the
# only detector was the operator saying "I don't think it updated".
#
# Naming the running version in every session makes that answerable from the
# transcript instead of by archaeology in the cache directory. Deliberately NOT
# a server round-trip or a stored per-user record: the state most needing
# diagnosis is the one where credentials never arrive, and this line still
# appears there.
manifest="$here/../.claude-plugin/plugin.json"
if [ -f "$manifest" ]; then
plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
if [ -n "$plugin_version" ]; then
append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
fi
fi
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
+39
View File
@@ -307,6 +307,44 @@ def check_local_prior_art_needs_no_instance() -> None:
ok("prior-art local arm: answers with no instance configured")
def check_session_context_reports_its_version() -> None:
"""The SessionStart context must name the plugin version it is running.
An install has two halves and only one self-updates: the marketplace clone
pulls on its own, while the CACHE is what executes and refreshes only when
the manifest version changes. So a shipped fix can sit unreached while
inspecting the clone shows it present — the obvious debugging move
misleads, and twice the only detector was a human saying "I don't think it
updated" (#2209, #2220).
Asserted WITHOUT credentials on purpose. The state most needing diagnosis
is the one where the token never arrives, and a marker that vanished there
would be missing exactly when it is wanted.
"""
script = HOOKS_DIR / "scribe_session_context.sh"
if not script.is_file() or not shutil.which("jq"):
skip("version marker: hook or jq missing")
return
manifest_v = manifest_version()
if manifest_v is None:
fail("version marker: could not read the manifest version")
return
try:
proc = _run_hook(script, json.dumps({"source": "startup"}), {})
except subprocess.TimeoutExpired:
fail("version marker: hook hung")
return
if proc.returncode != 0:
fail(f"version marker: hook exited {proc.returncode}")
elif manifest_v not in proc.stdout:
fail(f"version marker: session context never names v{manifest_v}"
f"a stale install would be undetectable from the transcript")
else:
ok(f"version marker: session context reports v{manifest_v}, no credentials needed")
def _git(*args: str) -> tuple[int, str]:
proc = subprocess.run(
["git", *args], capture_output=True, text=True, cwd=ROOT
@@ -399,6 +437,7 @@ def main() -> int:
check_shellcheck()
check_fail_open()
check_local_prior_art_needs_no_instance()
check_session_context_reports_its_version()
if not args.no_version:
check_version_bump(args.base)
+35
View File
@@ -22,6 +22,11 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
from scribe.services.design_starter_roles import (
ALL_GROUPS,
DEFAULT_TOKEN_PREFIX,
describe_groups,
)
async def create_design_system(
@@ -29,6 +34,8 @@ async def create_design_system(
description: str = "",
guidance: str = "",
parent_id: int = 0,
starter_role_groups: list[str] | None = None,
token_prefix: str = "",
) -> dict:
"""Create a design system, optionally inheriting from another.
@@ -41,20 +48,47 @@ async def create_design_system(
parent_id: Inherit from this system — it holds the defaults this one
overrides. Omit (0) for a top-level "family" system, which is what
a first design system usually is.
starter_role_groups: Seed the system with named but VALUELESS token
roles, so there is something to reach for before a literal gets
written instead. Call list_starter_role_groups() for the catalogue.
Pass ["all"] for every group. Omit for none — a system with three
hand-written tokens is a legitimate design system.
token_prefix: Naming convention for the seeded roles, e.g. "--fs-".
Defaults to a neutral "--ds-"; pass the install's own if it has one.
Ignored when no starter groups are requested.
"""
uid = current_user_id()
groups = starter_role_groups
if groups and len(groups) == 1 and groups[0] == "all":
groups = list(ALL_GROUPS)
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
guidance=guidance or None,
parent_id=parent_id or None,
starter_role_groups=groups,
token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
async def list_starter_role_groups() -> dict:
"""The starter token ROLES offered at design-system creation.
Roles, not values. Every group is a set of named questions — "page
background, the deepest surface" — that the operator answers with their own
palette. Nothing here carries a colour, because a default palette would be
one install's taste shipped as product.
Reach for this before create_design_system so the choice is informed, and
pass the group names you want as `starter_role_groups`.
"""
return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX}
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
@@ -350,6 +384,7 @@ async def set_project_design_system(project_id: int, design_system_id: int = 0)
def register(mcp) -> None:
for fn in (
create_design_system,
list_starter_role_groups,
list_design_systems,
get_design_system,
resolve_design_system,
+31 -15
View File
@@ -1,29 +1,45 @@
"""Design-system surface — what the rulebook expects of the stylesheet.
"""This install's UI surface — which design system it claims to be built from.
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.
Kept separate from the design-systems CRUD blueprint on purpose. That one is
the RECORD: create a system, move a token, read the cascade. This one answers a
question about the RUNNING APP, and it exists because those are not the same
question. A design system can be a perfect record of a stylesheet the app never
loaded.
The client owns the other half. `utils/designTokens.ts` reads what the browser
actually resolved, which is the one thing no server can report, and compares it
to what this endpoint's system declares. So the comparison is
"does the app agree with its own sheet?" rather than "is the record
self-consistent?", which would be a tautology — the sheet is generated from the
record (#2419).
"""
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
from scribe.services import design_systems as ds_svc
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
@design_bp.get("/expectations")
@design_bp.get("/ui-system")
@login_required
async def get_expectations():
"""Checkable claims from the rulebook this install designated as its design system.
async def get_ui_system():
"""The design system this install designated as the source of its own UI.
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
Returns `{"design_system_id": int|null, "title": str|null}`.
`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.
Both nulls is the NORMAL case, not an error — an install that has not
designated one has nothing to check the running app against, and the client
shows an explanatory empty state (rule #115).
An id with a null title is the third case and the reason the id is returned
separately: designated, but deleted or not readable by this caller. Folding
that into "none designated" is precisely how a feature comes to render a
reassuring empty state forever.
"""
uid = get_current_user_id()
result = await design_svc.design_expectations(uid)
return jsonify(result.as_dict())
system_id, system = await ds_svc.ui_design_system(uid)
return jsonify({
"design_system_id": system_id,
"title": system.title if system else None,
})
+19
View File
@@ -19,6 +19,10 @@ from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
from scribe.services import design_systems as ds_svc
from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX,
describe_groups,
)
from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
@@ -56,12 +60,27 @@ async def create_design_system():
description=data.get("description") or None,
guidance=data.get("guidance") or None,
parent_id=data.get("parent_id"),
starter_role_groups=data.get("starter_role_groups"),
token_prefix=data.get("token_prefix") or DEFAULT_TOKEN_PREFIX,
)
if system is None:
return jsonify({"error": "parent design system not found"}), 404
return jsonify(system.to_dict()), 201
@design_systems_bp.get("/design-systems/starter-roles")
@login_required
async def list_starter_role_groups():
"""The starter role catalogue, for the creation form's checklist.
Roles and purposes only — no values, ever. See services/design_starter_roles.
"""
return jsonify({
"groups": describe_groups(),
"default_prefix": DEFAULT_TOKEN_PREFIX,
})
@design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required
async def get_design_system(design_system_id: int):
@@ -1,232 +0,0 @@
"""Design-system expectations — turning rulebook prose into checkable claims.
Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
against what the stylesheet and components actually DO. This module owns the
first half: reading a rulebook's rules and extracting the claims that can be
mechanically checked.
WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
is the entire check — and this is the one genuinely fiddly piece of the feature.
Extraction happens here where pytest can assert on it; the comparison itself is
set arithmetic and stays in the browser, where the live token values are.
WHY NOT NLP. Rule statements are prose written for humans, and they should stay
that way — they are read by people far more often than they are parsed. So this
extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
property names a rule mentions. Everything subtler (padding scales, type ramps)
needs a rule author to opt into a structured form, which is deliberately left for
when someone wants it rather than invented up front.
RULE #115. Nothing here assumes a design rulebook exists, or that it is this
operator's. An install designates one; an install that hasn't gets an empty
result and a panel that explains itself.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from scribe.models.rulebook import Rule
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
# Which rulebook describes this install's design system. A plain setting rather
# than a column: no migration, discoverable in the Settings UI (rule #25), and
# honest about being a per-install choice rather than a property of the rulebook.
DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
# A custom-property name as written in prose, including the slash shorthand the
# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
# Negation markers. Checked PER SENTENCE, which is the whole trick — see
# _extract_from_sentence.
_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
@dataclass
class Expectation:
"""One mechanically-checkable claim a rule makes."""
kind: str # "token" | "color" | "prohibited_color"
value: str # "--fs-obsidian" | "#14171a"
rule_id: int
rule_title: str
context: str # the sentence it came from, for showing your work
def as_dict(self) -> dict:
return {
"kind": self.kind,
"value": self.value,
"rule_id": self.rule_id,
"rule_title": self.rule_title,
"context": self.context,
}
@dataclass
class ExpectationSet:
rulebook_id: int | None = None
expectations: list[Expectation] = field(default_factory=list)
def as_dict(self) -> dict:
return {
"rulebook_id": self.rulebook_id,
"expectations": [e.as_dict() for e in self.expectations],
}
def normalize_hex(value: str) -> str | None:
"""Fold a hex colour to a comparable form, or None if it isn't one.
Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
code writes `#fff`, and those must compare equal or the single largest drift
finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
same colour, and silently dropping the alpha would invent equality.
"""
match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
if not match:
return None
digits = match.group(1).lower()
if len(digits) in (3, 4):
digits = "".join(c * 2 for c in digits)
if len(digits) not in (6, 8):
return None
return f"#{digits}"
def expand_token_shorthand(raw: str) -> list[str]:
"""`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
The rulebook writes token families in a slash shorthand, and both forms it
uses expand correctly under one rule: take everything up to and including the
LAST hyphen of the first segment as the prefix, then append each alternative.
--fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
--fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
--fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
A name with no slash is returned as-is.
"""
if "/" not in raw:
return [raw]
head, *rest = raw.split("/")
cut = head.rfind("-")
if cut <= 1: # no hyphen beyond the leading `--`
return [head, *rest]
prefix = head[: cut + 1]
return [head, *[f"{prefix}{part}" for part in rest if part]]
def _is_negated(sentence: str) -> bool:
return any(marker in sentence.lower() for marker in _NEGATIONS)
def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
"""Claims in ONE sentence, with negation scoped to that sentence.
Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
"Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
Pure white #FFFFFF is NEVER used as text color."
Three colours the palette REQUIRES and one it FORBIDS, in one statement.
Detecting negation across the whole statement would mark all four as
forbidden; detecting it per sentence gets all four right.
"""
out: list[Expectation] = []
negated = _is_negated(sentence)
for match in _HEX.finditer(sentence):
value = normalize_hex(match.group(0))
if not value:
continue
out.append(Expectation(
kind="prohibited_color" if negated else "color",
value=value,
rule_id=int(rule.id),
rule_title=rule.title,
context=sentence.strip(),
))
# Token names are not negated in practice — a rule says which tokens should
# exist, never which must not — so they are recorded as expectations
# regardless. If that ever changes, it needs its own kind rather than
# borrowing the colour one.
for match in _TOKEN.finditer(sentence):
for name in expand_token_shorthand(match.group(1)):
out.append(Expectation(
kind="token",
value=name,
rule_id=int(rule.id),
rule_title=rule.title,
context=sentence.strip(),
))
return out
def extract_expectations(rules: list[Rule]) -> list[Expectation]:
"""Every checkable claim across a set of rules, deduped on (kind, value).
First occurrence wins so the reported rule is the one that introduced the
claim, which is usually the most specific place to send a reader.
"""
seen: set[tuple[str, str]] = set()
out: list[Expectation] = []
for rule in rules:
text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""]))
for sentence in _SENTENCE_SPLIT.split(text):
if not sentence.strip():
continue
for expectation in _extract_from_sentence(sentence, rule):
key = (expectation.kind, expectation.value)
if key in seen:
continue
seen.add(key)
out.append(expectation)
return out
async def get_design_rulebook_id(user_id: int) -> int | None:
"""The rulebook this install designated as its design system, if any."""
raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
if not raw:
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
return value if value > 0 else None
async def design_expectations(user_id: int) -> ExpectationSet:
"""Checkable claims from the designated design rulebook.
Returns an empty set when no rulebook is designated — the normal case for
any install but the one that set it up (rule #115). The caller shows an
explanatory empty state rather than treating this as an error.
"""
rulebook_id = await get_design_rulebook_id(user_id)
if rulebook_id is None:
return ExpectationSet()
from scribe.services import rulebooks as rulebooks_svc
try:
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
except Exception:
logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
return ExpectationSet(rulebook_id=rulebook_id)
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
+185
View File
@@ -0,0 +1,185 @@
"""A starter set of token ROLES, offered when a design system is created.
WHY THIS EXISTS
---------------
A literal gets written into a stylesheet when there is no role to reach for.
That is the mechanism, and this codebase produced a clean demonstration of it:
the house style had no "text on a filled colour" role, so 76 call sites wrote
a pure-white literal — not out of defiance, but because nothing existed to write
instead (#2275). The correction was not a better ban list. It was declaring the
missing role.
So the useful moment is CREATION. A system whose roles are named on day one
never presents the occasion for a literal, and never needs a list of values it
forbids.
WHAT SHIPS AND WHAT DOES NOT (rule #115)
----------------------------------------
The ROLES ship: `surface-page`, `text-primary`, `action-destructive` are
generic CSS-design vocabulary, not one operator's kit. Every install that has a
page has a page background.
The VALUES never ship. Each token is created with an empty `value_by_mode`, so
a fresh system is a set of named, deliberately-unanswered questions. No hex
appears anywhere in this file, and none should ever be added to it — a default
palette would be this operator's palette wearing product clothes.
A valueless token is already legible downstream: `render_stylesheet` emits it as
a commented-out declaration in its group (#2299), and `stylesheet_for_system`
reports it under `valueless`. So a blank role reads as "to be decided" rather
than as breakage, without anything new.
THE PREFIX IS THE INSTALL'S
---------------------------
`--fs-` is FabledSword's convention, not the product's. The prefix is a
parameter with a neutral default; a caller that has a house convention passes
it. Baking `--fs-` in would put one family's naming into every install.
FLAT, NOT PRESET
----------------
One list, every group individually skippable, all on by default (operator's
call, 2026-08-03). Presets keyed to app shape — web / CLI / docs — were
considered and rejected: they would require the product to hold opinions about
app categories, and a wrong category is worse than a generic list someone
prunes once.
"""
from __future__ import annotations
DEFAULT_TOKEN_PREFIX = "--ds-"
# group -> (what the group is for, ((role suffix, purpose), ...))
#
# Purposes are written as the QUESTION the operator is answering, because that
# is what an unfilled role is. "Page background, the deepest surface" tells you
# what to put there; "Colour 1" does not.
STARTER_ROLE_GROUPS: dict[str, tuple[str, tuple[tuple[str, str], ...]]] = {
"surface": (
"Backgrounds, by elevation",
(
("surface-page", "Page background, the deepest surface"),
("surface-raised", "Cards and raised elements"),
("surface-hover", "Hovered surfaces, secondary elevation"),
),
),
"text": (
"Foreground colours, by emphasis",
(
("text-primary", "Primary text on a page or raised surface"),
("text-secondary", "Secondary text and captions"),
("text-tertiary", "Hints and metadata"),
# The role whose absence caused 76 literals. It is in the starter
# set deliberately: text on a filled colour is NOT the page text
# colour, because the surface under it does not change with the
# mode while the page does.
("text-on-action", "Text on a filled colour — buttons, badges"),
),
),
"action": (
"What the user can do — kept separate from the accent, which is identity",
(
("action-primary", "The confirming action: Save, Submit"),
("action-secondary", "Non-destructive alternates"),
("action-destructive", "Irreversible actions — delete, revoke"),
),
),
"semantic": (
"What the system is telling you",
(
("success", "Something worked"),
("warning", "Something needs attention"),
("error", "Something failed — distinct from destructive"),
("info", "Neutral information"),
),
),
"border": (
"Boundaries and dividers",
(
("border-color", "The line colour itself"),
("border", "The default structural border, as a shorthand"),
("border-hover", "Border on hover or emphasis"),
("border-active", "Selected or current — the one border that may carry the accent"),
),
),
"accent": (
"This install's identity — not its actions",
(
("accent", "The single signature colour"),
("accent-soft", "Tinted backgrounds — pills, tags"),
("accent-faint", "The faintest wash"),
),
),
"radius": (
"Corner rounding",
(
("radius-sm", "Pills, tags, code spans"),
("radius-md", "Buttons, inputs, small cards"),
("radius-lg", "Cards, panels, modals"),
),
),
"space": (
"The spacing scale — a gap not on the scale is a decision to justify",
tuple((f"space-{i}", f"Spacing step {i}") for i in range(1, 11)),
),
"motion": (
"Transition timing — motion supports the interaction, never performs",
(
("ease", "The one easing curve, used by every transition"),
("dur-fast", "Hovers, colour and border changes"),
("dur-base", "Most state changes"),
("dur-slow", "Larger surface or layout shifts"),
),
),
"state": (
"Cross-cutting states that are otherwise improvised per view",
(
("disabled-opacity", "Opacity for disabled controls"),
("overlay", "Scrim behind modals and dialogs"),
),
),
}
ALL_GROUPS: tuple[str, ...] = tuple(STARTER_ROLE_GROUPS)
def starter_tokens(
groups: list[str] | tuple[str, ...] | None = None,
prefix: str = DEFAULT_TOKEN_PREFIX,
) -> list[dict]:
"""Token rows for the chosen groups — names and purposes only, no values.
`groups` of None means every group; an empty list means none, which is a
real answer and not the same as None. An operator who wants three tokens
should be able to get three.
Unknown group names are ignored rather than raising: this feeds a
checkbox list, and a stale name from an older client should not fail a
creation that is otherwise fine.
"""
chosen = ALL_GROUPS if groups is None else [g for g in groups if g in STARTER_ROLE_GROUPS]
rows: list[dict] = []
for group in chosen:
_, roles = STARTER_ROLE_GROUPS[group]
for index, (suffix, purpose) in enumerate(roles, start=1):
rows.append({
"name": f"{prefix}{suffix}",
"group_name": group,
"purpose": purpose,
# Empty, not absent: the column is NOT NULL with a {} default,
# so absence has exactly one spelling here as it does there.
"value_by_mode": {},
"order_index": index,
})
return rows
def describe_groups() -> list[dict]:
"""The catalogue, for a UI to render as a checklist."""
return [
{
"group": group,
"description": description,
"token_count": len(roles),
"names": [suffix for suffix, _ in roles],
}
for group, (description, roles) in STARTER_ROLE_GROUPS.items()
]
+56
View File
@@ -27,12 +27,17 @@ from scribe.services.design_stylesheet import (
duplicate_values,
render_stylesheet,
)
from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX,
starter_tokens,
)
from scribe.services.design_cascade import (
ResolvedToken,
ancestry,
resolve_tokens,
would_cycle,
)
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -79,11 +84,23 @@ async def create_design_system(
description: str | None = None,
guidance: str | None = None,
parent_id: int | None = None,
starter_role_groups: list[str] | None = None,
token_prefix: str = DEFAULT_TOKEN_PREFIX,
) -> DesignSystem | None:
"""Create a system, with or without a parent.
Returns None when `parent_id` names a system the caller may not write —
which, per the ACL, means one they do not own.
`starter_role_groups` seeds the system with named, VALUELESS token roles
(#2349) — the moment a role is missing is the moment a literal gets written
instead, so the cheapest time to name them is now. Pass a list of group
names to choose, `[]` for none, or None for none.
None and `[]` deliberately mean the same thing here, unlike in
`starter_tokens` where None means "all": creation must not seed 40 rows
into a system whose caller never asked. Opting in is the caller's job, and
the UI's default of everything-checked lives in the UI.
"""
if parent_id is not None and not await access.can_write_design_system(
user_id, parent_id
@@ -100,6 +117,11 @@ async def create_design_system(
session.add(system)
await session.commit()
await session.refresh(system)
if starter_role_groups:
for row in starter_tokens(starter_role_groups, prefix=token_prefix):
session.add(DesignToken(design_system_id=system.id, **row))
await session.commit()
return system
@@ -113,6 +135,40 @@ async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem
return system
# Which design system this install's own UI is built from. A plain setting
# rather than a column: no migration, discoverable in the Settings UI (rule
# #25), and honest about being a per-install claim rather than a property of the
# system — the same system can be the record for an app that never loads it.
UI_DESIGN_SYSTEM_SETTING = "ui_design_system_id"
async def ui_design_system(user_id: int) -> tuple[int | None, DesignSystem | None]:
"""The design system this install says its UI is built from.
Returns `(id, system)`. Three outcomes, deliberately distinguishable:
- `(None, None)` — nothing designated. The NORMAL state for any install but
the one that set it up (rule #115), not an error.
- `(id, None)` — designated, but gone or not readable by this caller. A
misconfiguration worth naming rather than silently degrading to "none",
which is exactly the failure that orphaned the panel this feeds (#2419).
- `(id, system)` — designated and readable.
A non-numeric setting value reads as nothing designated: the value is only
ever written by a `<select>` of real ids, so garbage here means hand-edited
or stale, and refusing to guess is better than raising on a page load.
"""
raw = (await get_setting(user_id, UI_DESIGN_SYSTEM_SETTING, "")).strip()
if not raw:
return None, None
try:
system_id = int(raw)
except ValueError:
logger.warning("Ignoring non-numeric %s: %r", UI_DESIGN_SYSTEM_SETTING, raw)
return None, None
return system_id, await get_design_system(user_id, system_id)
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:
-161
View File
@@ -1,161 +0,0 @@
"""Rulebook prose → checkable claims (milestone #251 step 2).
This is the piece of the design explorer that most needed to be testable, which
is why it lives in Python at all: the frontend has no test runner, so the fiddly
extraction happens server-side and the browser only does set arithmetic over it.
Rule text below is representative of a real design rulebook rather than copied
from this operator's — rule #115: the product must work for an install that has
none of their data, and a test that only passes against their exact wording would
be testing the instance, not the parser.
"""
from types import SimpleNamespace
from scribe.services.design_rulebook_import import (
expand_token_shorthand,
extract_expectations,
normalize_hex,
)
def _rule(rule_id, title, statement, how_to_apply=None):
return SimpleNamespace(
id=rule_id, title=title, statement=statement, how_to_apply=how_to_apply
)
# --- hex normalisation -------------------------------------------------------
def test_normalize_hex_makes_shorthand_and_case_comparable():
"""LOAD-BEARING. The rulebook writes `#FFFFFF` and components write `#fff`.
If those don't compare equal, the single largest drift finding — 67 hardcoded
white text colours (#2275) — reads as zero findings."""
assert normalize_hex("#fff") == normalize_hex("#FFFFFF") == "#ffffff"
assert normalize_hex("#E8E4D8") == "#e8e4d8"
assert normalize_hex("#14171a") == "#14171a"
def test_normalize_hex_keeps_alpha_rather_than_inventing_equality():
"""`#fff` and `#ffff` are different colours. Dropping the alpha to make them
match would manufacture agreement that isn't there."""
assert normalize_hex("#ffff") == "#ffffffff"
assert normalize_hex("#fff") != normalize_hex("#ffff")
def test_normalize_hex_rejects_non_colours():
for junk in ("", " ", "not-a-colour", "#", "#gg", "#12345"):
assert normalize_hex(junk) is None
# --- the slash shorthand -----------------------------------------------------
def test_expand_token_shorthand_handles_every_form_a_rulebook_uses():
"""One rule expands all three shapes: take everything up to and including the
LAST hyphen of the first segment as the prefix."""
assert expand_token_shorthand("--fs-radius-sm/md/lg/xl") == [
"--fs-radius-sm", "--fs-radius-md", "--fs-radius-lg", "--fs-radius-xl",
]
# Prefix is just `--fs-` here, and the same rule finds it.
assert expand_token_shorthand("--fs-obsidian/iron/slate/pewter") == [
"--fs-obsidian", "--fs-iron", "--fs-slate", "--fs-pewter",
]
assert expand_token_shorthand("--fs-dur-fast/base/slow") == [
"--fs-dur-fast", "--fs-dur-base", "--fs-dur-slow",
]
def test_expand_token_shorthand_passes_plain_names_through():
assert expand_token_shorthand("--fs-ease") == ["--fs-ease"]
# --- extraction --------------------------------------------------------------
def test_negation_is_scoped_to_the_sentence_not_the_rule():
"""THE trick that makes prohibition detection usable.
A single rule routinely states what the palette REQUIRES and what it FORBIDS
in consecutive sentences. Detecting negation across the whole statement would
mark the required colours as forbidden too — inverting the finding rather
than missing it, which is worse.
"""
rule = _rule(
52, "Text palette",
"Text tokens: Parchment #E8E4D8 (primary), Vellum #C2BFB4 (secondary), "
"Ash #9C9A92 (tertiary). Pure white #FFFFFF is NEVER used as text color.",
)
found = extract_expectations([rule])
required = {e.value for e in found if e.kind == "color"}
forbidden = {e.value for e in found if e.kind == "prohibited_color"}
assert required == {"#e8e4d8", "#c2bfb4", "#9c9a92"}
assert forbidden == {"#ffffff"}
assert not (required & forbidden)
def test_token_names_are_extracted_and_expanded():
rule = _rule(
72, "CSS custom properties",
"Expose the system as custom properties on :root — surfaces "
"(--fs-obsidian/iron/slate/pewter), radius (--fs-radius-sm/md/lg/xl), "
"and motion (--fs-ease).",
)
names = {e.value for e in extract_expectations([rule]) if e.kind == "token"}
assert "--fs-obsidian" in names and "--fs-pewter" in names
assert "--fs-radius-xl" in names
assert "--fs-ease" in names
assert len(names) == 9
def test_how_to_apply_is_read_as_well_as_the_statement():
"""Rulebooks routinely put the concrete values in how_to_apply and keep the
statement declarative, so ignoring it would miss the checkable half."""
rule = _rule(
56, "Per-app accent", "Each app owns exactly one accent.",
how_to_apply='[data-app="scribe"] #5B4A8A, [data-app="minstrel"] #4A6B5C.',
)
colours = {e.value for e in extract_expectations([rule]) if e.kind == "color"}
assert colours == {"#5b4a8a", "#4a6b5c"}
def test_claims_are_deduped_across_rules_keeping_the_first_source():
"""A colour named by several rules is one expectation, attributed to the rule
that introduced it — usually the most specific place to send a reader."""
rules = [
_rule(51, "Surfaces", "Obsidian #14171A is the page background."),
_rule(99, "Elsewhere", "Obsidian #14171A again, mentioned in passing."),
]
found = [e for e in extract_expectations(rules) if e.kind == "color"]
assert len(found) == 1
assert found[0].rule_id == 51
def test_prose_with_nothing_checkable_yields_nothing():
"""Most rules are judgement, not specification. They must contribute no
findings rather than a shrug — a panel that reports unparseable rules as
problems would be unusable."""
rule = _rule(
68, "Voice and tone",
"Voice is understated: plain language for anything functional, flavour "
"only where the user is waiting or failing. Be brief.",
)
assert extract_expectations([rule]) == []
def test_every_expectation_carries_the_sentence_it_came_from():
"""The panel has to show its working — "the rulebook says X" is only
actionable if you can see where, and in what context.
Asserts the context is the SENTENCE, not the whole statement: a rule that
states a requirement and a prohibition in consecutive sentences would
otherwise attribute both to the same undifferentiated blob of prose.
"""
rule = _rule(63, "Radius", "Radius: Small 4px. Pure white #FFFFFF is never used.")
found = extract_expectations([rule])
assert len(found) == 1
only = found[0]
assert only.kind == "prohibited_color"
assert only.rule_id == 63
assert only.rule_title == "Radius"
assert only.context == "Pure white #FFFFFF is never used."
assert "Radius: Small 4px" not in only.context
+136
View File
@@ -0,0 +1,136 @@
"""The starter role set (#2349).
The premise: a literal gets written when there is no role to reach for. This
codebase demonstrated it — the house style had no "text on a filled colour"
role, so 76 call sites wrote `color: #fff` (#2275). Naming the roles at
creation removes the occasion.
The tests that matter here are about the BOUNDARY, not the content. Roles ship
with the product; values never do. A default palette would be one operator's
taste shipped as product code (rule #115), and it would be very easy to add by
accident while "being helpful".
"""
import re
import pytest
from scribe.services import design_starter_roles as roles
def test_no_role_carries_a_VALUE():
"""THE rule-115 guard. Every seeded token is a named question with no
answer. The moment one ships a hex, the product is prescribing an install's
palette."""
for row in roles.starter_tokens():
assert row["value_by_mode"] == {}, f"{row['name']} shipped a value"
def _colour_literals(text: str) -> list[str]:
"""Hex colours in `text`, NOT counting issue references.
`#2275` is four hex-valid digits and also how this codebase cites an issue —
so the naive pattern flags its own documentation, which is the third time
that has happened here (#2353). A real colour either contains a letter
af or is a full 6/8-digit value; an all-decimal 3- or 4-digit match is an
issue number.
"""
out = []
for m in re.findall(r"#[0-9a-fA-F]{3,8}\b", text):
digits = m[1:]
if len(digits) not in (3, 4, 6, 8):
continue
if len(digits) in (6, 8) or any(c in "abcdefABCDEF" for c in digits):
out.append(m)
return out
def test_the_module_contains_no_colour_literals_at_all():
"""Belt and braces on the above, and the stronger claim: not just that
tokens are blank, but that no palette hides in a comment or a docstring
waiting to be pasted in. Checks the SOURCE, not the output."""
import pathlib
src = pathlib.Path(roles.__file__).read_text()
hexes = _colour_literals(src)
assert not hexes, f"colour literals in product code: {hexes}"
def test_the_colour_check_does_not_flag_issue_references():
"""Pins the exclusion above, because without it this file fails on its own
citations and the obvious 'fix' is to delete the check."""
assert _colour_literals("see #2275 and #2349") == []
assert _colour_literals("color: #fff") == ["#fff"]
assert _colour_literals("#E8E4D8 on #14171A") == ["#E8E4D8", "#14171A"]
assert _colour_literals("#000000") == ["#000000"]
def test_every_group_is_individually_selectable():
"""Operator's call: one flat list, all skippable. An install that wants
three tokens must be able to get three."""
only_text = roles.starter_tokens(["text"])
assert {r["group_name"] for r in only_text} == {"text"}
assert len(only_text) == 4
def test_empty_selection_yields_nothing_and_is_not_the_same_as_None():
"""`[]` is a real answer — "none of them" — and must not be read as
"unspecified, so give me everything". Getting this backwards would seed 40
rows into a system whose creator explicitly declined."""
assert roles.starter_tokens([]) == []
assert len(roles.starter_tokens(None)) > 30
def test_unknown_group_names_are_ignored_not_fatal():
"""This feeds a checkbox list. A stale name from an older client should not
fail an otherwise-fine creation."""
out = roles.starter_tokens(["text", "not-a-real-group"])
assert {r["group_name"] for r in out} == {"text"}
def test_the_prefix_is_the_installs_choice():
"""`--fs-` is FabledSword's convention, not the product's. Baking it in
would put one family's naming into every install."""
assert all(r["name"].startswith("--ds-") for r in roles.starter_tokens(["text"]))
custom = roles.starter_tokens(["text"], prefix="--acme-")
assert all(r["name"].startswith("--acme-") for r in custom)
assert "--acme-text-primary" in {r["name"] for r in custom}
def test_text_on_action_is_in_the_starter_set():
"""The specific role whose absence produced 76 literals. It is separate
from text-primary on purpose: the surfaces it sits on do not change with
the mode, while the page does — so reusing text-primary there passes in
dark and fails contrast in light (#2275)."""
names = {r["name"] for r in roles.starter_tokens(["text"])}
assert "--ds-text-on-action" in names
assert "--ds-text-primary" in names
def test_names_are_valid_custom_properties():
"""They go straight into a stylesheet; an invalid name is a silent no-op
rather than an error, which is the worst failure mode available."""
valid = re.compile(r"^--[A-Za-z0-9_-]+$")
for row in roles.starter_tokens():
assert valid.match(row["name"]), row["name"]
def test_no_duplicate_names_across_the_whole_set():
"""A design system has a partial-unique index on (system, name); a
duplicate in the starter set would make creation fail at the DB with a
constraint error rather than anything legible."""
names = [r["name"] for r in roles.starter_tokens()]
assert len(names) == len(set(names))
def test_every_role_states_a_purpose():
"""An unfilled role is only useful if it says what belongs there. "Colour
1" is a blank with extra steps."""
for row in roles.starter_tokens():
assert row["purpose"].strip(), row["name"]
def test_describe_groups_matches_what_starter_tokens_produces():
"""The catalogue a UI renders and the rows creation writes must not drift —
a checklist offering a group that seeds nothing is a lie in the UI."""
described = {g["group"]: g["token_count"] for g in roles.describe_groups()}
for group, count in described.items():
assert len(roles.starter_tokens([group])) == count
+3 -2
View File
@@ -284,8 +284,9 @@ def test_a_shorter_hex_does_not_match_inside_a_longer_one():
def test_the_literal_match_is_case_insensitive():
"""Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
check would silently find nothing — the same trap normalize_hex exists for."""
"""A record writes `#FFFFFF` and code writes `#ffffff`. A case-sensitive
check would silently find nothing — the same trap `normalizeColour` in
utils/designDrift.ts exists for on the client side."""
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
assert report["superseded_literals"] == [
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
+55
View File
@@ -0,0 +1,55 @@
"""The /api/design blueprint — this install's UI, not the design-system record.
Modelled on tests/test_routes_design_systems.py. The URL enumeration is
deliberate rather than a pattern match: this blueprint was repointed from
`/expectations` to `/ui-system` (#2419), and the failure worth catching is the
old rule surviving the change — a route nothing serves any more, answering with
whatever the last handler registered on it did.
"""
import inspect
import pytest
def test_design_blueprint_registered():
from scribe.routes.design import design_bp
assert design_bp.name == "design"
assert design_bp.url_prefix == "/api/design"
def test_design_blueprint_registered_in_app():
from scribe.app import create_app
app = create_app()
assert "design" in app.blueprints
def test_the_blueprint_serves_exactly_one_rule():
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.")
}
assert rules == {"/api/design/ui-system"}
def test_the_designation_reader_takes_user_id():
"""The setting is per-user, so the read must be too (rule #78). A
module-level or admin-scoped read would hand one user another's designation."""
from scribe.services import design_systems as svc
assert "user_id" in inspect.signature(svc.ui_design_system).parameters
def test_the_setting_key_is_the_new_one():
"""Named explicitly because the panel silently reading a retired key is the
exact shape of the bug this replaced: a feature that renders, and reports
nothing, forever."""
from scribe.services.design_systems import UI_DESIGN_SYSTEM_SETTING
assert UI_DESIGN_SYSTEM_SETTING == "ui_design_system_id"
def test_the_rulebook_expectation_extractor_is_gone():
"""It was retired with the rulebook it parsed (#2288, #2419). Left importable
it would keep looking like a live path to the next reader."""
with pytest.raises(ModuleNotFoundError):
import scribe.services.design_rulebook_import # noqa: F401
+6 -1
View File
@@ -44,6 +44,11 @@ def test_every_endpoint_is_reachable_on_the_app():
}
assert rules == {
"/api/design-systems",
# Static segment, declared before the <int:...> rule reads it — Quart's
# int converter will not match "starter-roles", so the two cannot
# collide. Worth stating: a static-vs-dynamic sibling on the same prefix
# is exactly where a silently-shadowed route hides.
"/api/design-systems/starter-roles",
"/api/design-systems/<int:design_system_id>",
"/api/design-systems/<int:design_system_id>/resolved",
"/api/design-systems/<int:design_system_id>/stylesheet",
@@ -85,7 +90,7 @@ def test_agent_and_web_surfaces_stay_at_parity():
"resolve_design_system", "update_design_system", "delete_design_system",
"create_design_token", "list_design_tokens", "update_design_token",
"delete_design_token", "set_project_design_system",
"get_design_system_stylesheet",
"get_design_system_stylesheet", "list_starter_role_groups",
):
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
assert callable(getattr(routes, name)), f"REST route missing: {name}"