Compare commits

...
11 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 8087ba4db0 feat(design): a project reports drift in its own recorded components
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 43s
The check has taken a project id since it was written — check_snippets_against_
system(user_id, design_system_id, project_id=0), and the route has always read
?project_id=. Nothing on the frontend ever passed one and no project-side
surface existed, so the capability shipped and stayed unreachable.

A Design tab on the project, beside Systems and Rules, reporting three things
per snippet:

  no such token     var(--x) the system doesn't declare. Renders as NOTHING —
                    no error, no failing test, just an element quietly unstyled.
                    Leads for that reason.
  defines its own   a component minting a custom property instead of reaching
                    for the shared one. This is the DRY finding and the reason
                    the surface exists: the codebase re-solving a solved
                    problem, one component at a time, visible only when someone
                    changes the shared value and half the components don't move.
  write the token   a literal the sheet says to stop writing, paired with what
                    to write instead.

Three empty states, kept distinct, because collapsing them is how a check comes
to sit dead: no design system bound, no snippets recorded (nothing was
checked), and checked-and-clean. The last one says how many were checked.

Bound to the SAVED pointer rather than the sidebar picker's draft, so an
unsaved change can't make the tab report against a system the project isn't
using.

Scope is recorded code, per the operator: snippets are what Scribe holds, and a
repository's own sources are checked where they live, by that project's CI.

Step 3 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 10:41:49 -04:00
bvandeusenandClaude Opus 5 7b0984579d feat(design): preview any design system, resolved from the record
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 44s
The record view listed values as text and drew a swatch only where the value
looked like a colour. Two problems, one cause: a derived value such as
color-mix(in srgb, var(--accent) 15%, transparent) was drawn by resolving
--accent against THIS app, so previewing another project's system showed
Scribe's palette. It looked right, which is why nobody noticed.

TokenPreview draws the system from its own record. Every value is resolved on
an offscreen probe carrying only that system's declarations, so a system whose
app this browser has never loaded renders in its own colours — which is the
difference between a tool and a mirror.

Specimens are chosen by value SHAPE, never by name: colours become swatches,
lengths become rules drawn to scale, gradients and shadows get a surface, font
stacks are set in themselves. Nothing matches --fs-space-* or any other
convention, because the convention belongs to the install (rule #115) — a
system that calls its spacing --gap-N gets the same treatment. Translucent
values sit on a checkerboard, or a 15% tint over a solid card reads as opaque
and shows the wrong colour.

Modes come from the system, not from the app: a system declaring base and
light offers both, independent of the theme this page is in.

The provenance list keeps its swatches only for self-contained colours — the
ones needing no resolution, which it can therefore draw honestly. Everything
with a var() inside is left to the preview built for it.

Step 2 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 10:39:45 -04:00
bvandeusenandClaude Opus 5 dcd4efcea0 refactor(design): retire /design — a surface that could only inspect itself
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 40s
The design surface is for the projects an install tracks. /design read the
running app's own stylesheet — names out of a bundled theme.css, values out of
getComputedStyle(document.documentElement) — so it could only ever describe the
instance serving the page. Scribe is one project among the projects Scribe
tracks; it gets no view hardcoded into every install.

The mechanism that makes this a tool rather than a mirror already existed and
already covers Scribe: scripts/check_design_tokens.py runs in CI against a
sheet path it knows nothing about, using check_code_against_tokens — the same
engine behind check_snippets_against_system. /design was redundant even here.

Removed: DesignView, DesignTabs (nothing left to tab between), api/design.ts,
routes/design.py and its blueprint, the /design route, ui_design_system() and
its setting, and the Settings picker that designated "this app's UI".

utils/designTokens.ts and utils/designDrift.ts go with it — between them they
were the browser-reading half. What survives is utils/designValues.ts, which
works on a record rather than a document: valueForMode, modesPresent, and
resolveDeclared.

resolveDeclared gained real isolation in the move. Custom properties inherit
and `all: initial` does not reset them, so a probe sitting in this page would
resolve any reference a record leaves undeclared against the SURROUNDING app's
tokens — previewing another project's system would quietly borrow this one's
palette wherever that system was incomplete, and a token already reported under
unknown_refs would render as though it were fine. Undeclared references are now
blanked on the probe first, so they resolve to nothing, which is what the record
says they are.

Migration 0075 absorbs ui_design_system_id alongside design_rulebook_id rather
than an 0076 undoing it: 0075 has not run anywhere, since dev is unmerged and
deploys come from main. Both keys named a design source for the running
install, and a project already carries its own pointer.

This retires the agreement panel shipped yesterday. It asked whether the sheet
was actually loaded and applied — the one question a record cannot answer about
itself — but only ever about the app you are already inside. Nothing replaces
it; recorded in #2430 rather than quietly dropped.

Step 1 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 10:35:52 -04:00
bvandeusenandClaude Opus 5 4d2be27935 feat(nav): Design is primary navigation, not a utility icon
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 52s
It sat in the right-hand icon cluster with Trash and Settings, filed as a
meta-surface. That was true when /design was a read-only gallery and false
since: a design system is a record you author, with its own table, inheritance,
sharing under the same ACL, and MCP tools. It is the content of the
applications being built, which is the same rule that puts Snippets and
Rulebooks in the bar.

The pill bar is absolutely centred, so a sixth link doesn't push the brand and
the utility cluster aside — it overlaps them. Added a 1150px breakpoint that
drops the wordmark (the logo says the same thing and is still the link home)
and tightens the link padding, rather than leaving Design out of the bar to
avoid the collision.

Mobile menu moves Design above the divider with the other content links, so
both layouts sort it the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 08:26:43 -04:00
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
62 changed files with 1679 additions and 1645 deletions
@@ -0,0 +1,47 @@
"""retire the two settings that designated a design source for the app itself
Revision ID: 0075
Revises: 0074
Create Date: 2026-08-03
Two keys, retired for the same reason a week apart, so they go in one change
rather than one migration each:
design_rulebook_id which rulebook described how this app should look
ui_design_system_id which design system this app's own UI was built from
Both named a design source for THE RUNNING INSTALL. The design surface is for
the projects an install tracks, and a project already carries its own pointer
(`projects.design_system_id`) — so an install-wide designation had nothing left
to mean. `ui_design_system_id` was introduced by this same migration's first
draft and never reached a deployed database; it is listed here rather than
undone by an 0076 that would reverse a change nobody ran.
Deleting settings rows by key is safe in a way dropping a column is not — the
table is free-form key/value, so an install that never designated one simply has
no row to delete.
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
lie: the pointer lives on the project now, and always did for anyone who set it
there.
"""
from alembic import op
import sqlalchemy as sa
revision = "0075"
down_revision = "0074"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM settings "
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
)
)
def downgrade() -> None:
pass
+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;
}
-9
View File
@@ -1,9 +0,0 @@
import { apiGet } from "@/api/client";
import type { ExpectationResponse } from "@/utils/designDrift";
/** Checkable claims from the rulebook this install designated as its design system.
*
* `rulebook_id: null` means none has been designated — the normal state for a
* fresh install, not an error. The caller shows an explanatory empty state. */
export const fetchDesignExpectations = () =>
apiGet<ExpectationResponse>("/api/design/expectations");
+28 -3
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. */
@@ -183,6 +200,14 @@ export interface SnippetCheck {
findings: SnippetFinding[];
}
/** Which recorded snippets disagree with this design system's sheet. */
export const checkSnippets = (id: number) =>
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
/** Which recorded snippets disagree with this design system's sheet.
*
* `projectId` narrows to the snippets one project owns — which is how a
* project asks about its OWN code. Omit it to check every project, which is
* the right default from the system's side: a component recorded elsewhere
* still has to use the same tags. */
export const checkSnippets = (id: number, projectId?: number) =>
apiGet<SnippetCheck>(
`/api/design-systems/${id}/snippet-check`
+ (projectId ? `?project_id=${projectId}` : ""),
);
+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);
}
+27 -15
View File
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -50,6 +50,12 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<!-- A design system is a RECORD you author, not a setting. It sat in
the utility cluster with Trash and Settings while /design was a
read-only gallery, and stayed there after it became a record type
with its own table, sharing and MCP tools. Content, by the same
rule that puts Snippets and Rulebooks here. -->
<router-link to="/design-systems" class="nav-link">Design</router-link>
</div>
</div>
@@ -64,16 +70,6 @@ router.afterEach(() => {
<Moon v-else :size="16" />
</button>
<!-- Design. An icon rather than a sixth primary nav link: it's a
meta-surface like Trash and Settings, but hiding it entirely would
defeat the point of having somewhere the design system is visible.
Points at the RECORD, not the live-token view — the record is what
you work with; the live view is the check on it, and it's a tab
away. -->
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
<Palette :size="16" />
</router-link>
<!-- Trash link -->
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
<Trash2 :size="16" />
@@ -106,9 +102,9 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div>
@@ -129,7 +125,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 +193,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 */
@@ -346,6 +342,22 @@ router.afterEach(() => {
margin-top: 0.25rem;
}
/* The pill bar is absolutely centred, so when the header runs out of room it
OVERLAPS the brand and the utility cluster rather than pushing them — nothing
wraps, it just collides. Six primary links reach that point sooner than five
did, so reclaim the width here instead of leaving one out of the bar.
The wordmark goes first: the logo beside it says the same thing and is still
the link home. */
@media (max-width: 1150px) {
.brand-text {
display: none;
}
.nav-link {
padding: 0.3rem 0.5rem;
font-size: 0.78rem;
}
}
@media (max-width: 768px) {
.nav-center {
display: none;
-51
View File
@@ -1,51 +0,0 @@
<script setup lang="ts">
/**
* Sub-navigation for the Design surface.
*
* There are two pages here and they are halves of ONE thing: the record that
* decides the styling, and what the browser is actually rendering from it. They
* were briefly two top-level nav entries, which put the read-only diagnostic
* first and buried the editable record under it — backwards, since the record
* is the thing you work with and the live view is the check on it.
*
* A component rather than the same markup pasted into both views: two copies of
* a tab bar diverge the moment a third tab appears, and that is the exact shape
* of duplication this whole surface exists to make visible.
*/
</script>
<template>
<nav class="design-tabs" aria-label="Design views">
<router-link to="/design-systems" class="design-tab">Design system</router-link>
<router-link to="/design" class="design-tab">Live tokens</router-link>
</nav>
</template>
<style scoped>
.design-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.design-tab {
padding: 0.5rem 0.9rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.design-tab:hover {
color: var(--color-text);
}
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
exact matching would drop the highlight on any future child route. */
.design-tab.router-link-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+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;
@@ -0,0 +1,234 @@
<script setup lang="ts">
/**
* A project's own code, checked against the design system it is bound to (#2432).
*
* This is what the design surface is FOR: a project's recorded components
* measured against the sheet they are supposed to use. The check itself is not
* new — `check_snippets_against_system` has taken a project id since it was
* written, and the route has always read `?project_id=`. Nothing on this side
* ever passed one, so the capability shipped and stayed unreachable.
*
* The finding that matters most is the quiet one. `local_definitions` is a
* snippet minting its own custom property instead of reaching for the shared
* one — the codebase re-solving a solved problem, one component at a time.
* Nothing breaks, no test fails, and the duplication only becomes visible when
* someone changes the shared value and half the components don't move.
*
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
* snippets — because that is the code Scribe holds. A repository's own sources
* are checked where they live, by that project's CI.
*/
import { onMounted, ref, watch } from "vue";
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
const check = ref<SnippetCheck | null>(null);
const loading = ref(false);
const failed = ref(false);
async function run() {
check.value = null;
failed.value = false;
if (props.designSystemId === null) return;
loading.value = true;
try {
check.value = await checkSnippets(props.designSystemId, props.projectId);
} catch {
// Said out loud rather than rendered as an empty result. "Couldn't check"
// and "nothing to report" look identical if you let them, and that is how
// a check comes to sit dead without anyone noticing (#2419).
failed.value = true;
} finally {
loading.value = false;
}
}
onMounted(run);
watch(() => [props.projectId, props.designSystemId], run);
</script>
<template>
<div class="pdt">
<div v-if="designSystemId === null" class="pdt-note">
<strong>No design system for this project.</strong>
<p>
Bind one in the sidebar and this tab reports where the project's recorded
components disagree with it — references to tokens the system doesn't
have, literals it says to stop writing, and properties a component mints
for itself instead of reusing.
</p>
</div>
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets</p>
<div v-else-if="failed" class="pdt-note">
<strong>The check couldn't run.</strong>
<p>Nothing was compared — this is a failure, not a clean result.</p>
</div>
<template v-else-if="check">
<p v-if="!check.checked" class="pdt-muted">
This project has no recorded snippets, so nothing was checked. Record the
components you reuse and they get measured against the sheet.
</p>
<p v-else-if="!check.findings.length" class="pdt-clean">
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
every reference resolves, and none mints a property of its own.
</p>
<template v-else>
<p class="pdt-summary">
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
</p>
<ul class="pdt-list">
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
{{ f.title || "Untitled snippet" }}
</router-link>
<!-- Renders as nothing at all: no error, no failing test, just an
element that quietly isn't styled. Leads for that reason. -->
<div v-if="f.unknown.length" class="pdt-row">
<span class="pdt-tag unknown">no such token</span>
<span class="pdt-detail">
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
</span>
</div>
<div v-if="f.local_definitions.length" class="pdt-row">
<span class="pdt-tag local">defines its own</span>
<span class="pdt-detail">
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
</span>
</div>
<div v-if="f.superseded_literals.length" class="pdt-row">
<span class="pdt-tag superseded">write the token</span>
<span class="pdt-detail">
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
<code>{{ s.literal }}</code> <code>{{ s.use_instead }}</code>
</span>
</span>
</div>
</li>
</ul>
</template>
</template>
</div>
</template>
<style scoped>
.pdt {
padding: var(--fs-space-2) 0;
}
.pdt-note {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning);
border-radius: var(--fs-radius-sm);
padding: var(--fs-space-3) var(--fs-space-4);
}
.pdt-note p {
margin: var(--fs-space-2) 0 0;
color: var(--color-text-secondary);
font-size: var(--fs-size-body-sm);
line-height: var(--fs-leading-body);
max-width: 70ch;
}
.pdt-muted,
.pdt-clean,
.pdt-summary {
color: var(--color-text-muted);
font-size: var(--fs-size-body-sm);
margin: 0 0 var(--fs-space-3);
max-width: 70ch;
}
.pdt-clean {
color: var(--color-status-done);
}
.pdt-summary {
color: var(--color-text-secondary);
}
.pdt-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fs-space-3);
}
.pdt-finding {
border: 1px solid var(--color-border);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
min-width: 0;
}
.pdt-title {
display: block;
font-weight: var(--fs-weight-medium);
color: var(--color-text);
text-decoration: none;
margin-bottom: var(--fs-space-2);
}
.pdt-title:hover { color: var(--color-primary-solid); }
.pdt-row {
display: flex;
align-items: baseline;
gap: var(--fs-space-2);
flex-wrap: wrap;
padding: 0.15rem 0;
min-width: 0;
}
.pdt-tag {
font-size: var(--fs-size-tiny);
text-transform: uppercase;
letter-spacing: var(--fs-tracking-tiny);
padding: 0.1rem 0.45rem;
border-radius: var(--fs-radius-sm);
white-space: nowrap;
flex: none;
}
.pdt-tag.unknown {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.pdt-tag.local {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.pdt-tag.superseded {
background: var(--color-surface);
color: var(--color-text-muted);
}
.pdt-detail {
display: flex;
flex-wrap: wrap;
gap: var(--fs-space-2);
font-size: var(--fs-size-code);
color: var(--color-text-secondary);
min-width: 0;
}
.pdt-swap {
white-space: nowrap;
}
</style>
+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 */
+298
View File
@@ -0,0 +1,298 @@
<script setup lang="ts">
/**
* A design system's tokens, drawn rather than listed (#2431).
*
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
* Every value is resolved on an offscreen probe carrying only this system's
* declarations (`resolveDeclared`), never read from the page. So a token like
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
* accent, not the accent of the app you happen to be looking at. Previewing
* another project's palette from here is the point; a preview that quietly
* borrows the host app's values would be worse than no preview, because it
* would look right.
*
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
* A colour is drawn as a swatch, a length as a rule of that length, a font
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
* naming convention, because the convention is the install's (rule #115) — a
* system that calls its spacing `--gap-N` gets the same treatment.
*
* A token with no value for the chosen mode is shown as undecided rather than
* skipped. A named role awaiting a decision is information; a gap in a grid
* is not.
*/
import { computed, ref, watch } from "vue";
import type { ResolvedToken } from "@/api/designSystems";
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
const props = defineProps<{ tokens: ResolvedToken[] }>();
const modes = computed(() => modesPresent(props.tokens));
const mode = ref(BASE_MODE);
/** Values as the browser would compute them, for the chosen mode. */
const rendered = ref<Map<string, string>>(new Map());
function recompute() {
const declared = new Map<string, string>();
for (const token of props.tokens) {
const value = valueForMode(token.value_by_mode, mode.value);
if (value) declared.set(token.name, value);
}
rendered.value = resolveDeclared(declared);
}
watch(
[() => props.tokens, mode],
() => {
// Keep the selection only while it still exists — switching systems can
// drop a mode, and a stale one would silently render as base.
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
recompute();
},
{ immediate: true, deep: false },
);
type Shape = "colour" | "surface" | "length" | "font" | "plain";
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
const GRADIENT = /gradient\(/;
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
/** A stack of family names: commas, no functions, no digits. */
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
function shapeOf(value: string): Shape {
const v = value.trim();
if (!v) return "plain";
if (COLOUR.test(v)) return "colour";
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
if (LENGTH.test(v)) return "length";
if (FONT_STACK.test(v)) return "font";
return "plain";
}
interface Specimen {
name: string;
declared: string;
rendered: string;
shape: Shape;
purpose: string | null;
/** True when `var()` substitution changed the value — worth showing on hover. */
substituted: boolean;
}
const groups = computed(() => {
const out = new Map<string, Specimen[]>();
for (const token of props.tokens) {
const declared = valueForMode(token.value_by_mode, mode.value);
const value = rendered.value.get(token.name) ?? "";
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
bucket.push({
name: token.name,
declared,
rendered: value,
shape: shapeOf(value),
purpose: token.purpose,
substituted: Boolean(declared) && value !== declared,
});
out.set(token.group_name ?? "ungrouped", bucket);
}
return [...out.entries()];
});
/**
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
* are visibly different — but a stray `100vw` can't stretch the row.
*/
function ruleWidth(value: string): string {
return `min(${value}, 12rem)`;
}
</script>
<template>
<div class="tp">
<div v-if="modes.length > 1" class="tp-modes">
<button
v-for="m in modes"
:key="m"
class="tp-mode"
:class="{ active: m === mode }"
@click="mode = m"
>{{ m }}</button>
<span class="tp-modes-note">
The system's own modes — independent of the theme this app is in.
</span>
</div>
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
<h3 class="tp-group-heading">{{ group }}</h3>
<ul class="tp-grid">
<li v-for="s in specimens" :key="s.name" class="tp-item">
<div
class="tp-specimen"
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
>
<span
v-if="s.shape === 'colour'"
class="tp-swatch"
:style="{ background: s.rendered }"
/>
<span
v-else-if="s.shape === 'surface'"
class="tp-surface"
:style="s.rendered.includes('gradient(')
? { background: s.rendered }
: { boxShadow: s.rendered }"
/>
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
</span>
<span
v-else-if="s.shape === 'font'"
class="tp-font"
:style="{ fontFamily: s.rendered }"
>Ag</span>
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
<span v-else class="tp-plain">{{ s.rendered }}</span>
</div>
<code class="tp-name">{{ s.name }}</code>
<span class="tp-value">{{ s.declared || "" }}</span>
<span v-if="s.purpose" class="tp-purpose">{{ s.purpose }}</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.tp-modes {
display: flex;
align-items: center;
gap: var(--fs-space-2);
flex-wrap: wrap;
margin-bottom: var(--fs-space-4);
}
.tp-mode {
padding: 0.2rem 0.6rem;
font: inherit;
font-size: var(--fs-size-body-sm);
color: var(--color-text-secondary);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--fs-radius-sm);
cursor: pointer;
}
.tp-mode:hover { color: var(--color-text); }
.tp-mode.active {
color: var(--color-primary-solid);
border-color: var(--color-primary);
background: var(--color-primary-faint);
}
.tp-modes-note {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
}
.tp-group { margin-bottom: var(--fs-space-5); }
.tp-group-heading {
text-transform: capitalize;
font-size: var(--fs-size-label);
color: var(--color-text-secondary);
margin-bottom: var(--fs-space-2);
}
.tp-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--fs-space-3);
}
.tp-item {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid. */
.tp-specimen {
height: 2.75rem;
display: flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--fs-radius-sm);
padding: 0 var(--fs-space-2);
overflow: hidden;
/* Checks show through anything translucent — a 15% tint over a solid card
would otherwise look opaque and read as the wrong colour. */
background:
repeating-conic-gradient(var(--color-surface) 0% 25%, var(--color-bg) 0% 50%)
0 0 / 12px 12px;
}
.tp-swatch,
.tp-surface {
width: 100%;
height: 1.75rem;
border-radius: calc(var(--fs-radius-sm) - 1px);
}
.tp-surface { background: var(--color-surface); }
.tp-rule-wrap {
width: 100%;
display: flex;
align-items: center;
}
.tp-rule {
height: 0.5rem;
min-width: 1px;
background: var(--color-primary-solid);
border-radius: 999px;
}
.tp-font {
font-size: 1.4rem;
color: var(--color-text);
line-height: 1;
}
.tp-plain {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-code);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tp-undecided {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
font-style: italic;
}
.tp-name {
font-size: var(--fs-size-body-sm);
color: var(--color-text);
word-break: break-all;
}
.tp-value,
.tp-purpose {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
word-break: break-word;
}
</style>
@@ -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; }
+5 -9
View File
@@ -110,15 +110,11 @@ const router = createRouter({
component: () => import("@/views/RulesView.vue"),
},
{
// Meta-surface, same family as /rules: it describes the app rather than
// holding the operator's records.
path: "/design",
name: "design",
component: () => import("@/views/DesignView.vue"),
},
{
// The editable half of the same surface: /design is what the browser
// renders, /design-systems is the record that ought to decide it.
// The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the
// running app's own stylesheet out of the browser; it could only ever
// inspect the instance it was served from, which made it a mirror rather
// than a tool (#274).
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
-169
View File
@@ -1,169 +0,0 @@
/**
* Drift comparison — what the rulebook claims vs what the stylesheet does.
*
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
* prose into claims) is server-side in `services/design_system.py`, where pytest
* can assert on it. What's left here is set arithmetic over live token values,
* which is the one thing the browser knows and the server doesn't.
*
* SCOPE, and it is a real limit rather than an omission. This compares the
* rulebook against the TOKENS. It cannot see the third category of drift — a
* literal hardcoded in a component where a token should be referenced (#2275,
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
*
* Catching it needs the component sources, which would mean bundling every SFC
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
* matters: a drift report that silently omits a category invites the reader to
* conclude the category is clean.
*/
import type { DesignToken } from "@/utils/designTokens";
export type ExpectationKind = "token" | "color" | "prohibited_color";
export interface Expectation {
kind: ExpectationKind;
value: string;
rule_id: number;
rule_title: string;
context: string;
}
export interface ExpectationResponse {
rulebook_id: number | null;
expectations: Expectation[];
}
export type FindingStatus = "ok" | "missing" | "violated";
export interface Finding {
expectation: Expectation;
status: FindingStatus;
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
matches: string[];
}
/**
* Normalise a colour for comparison — the client-side twin of
* `normalize_hex` in services/design_system.py.
*
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
* spellings of one colour, and a comparison that misses any of them under-reports
* rather than erroring. The rgb() case is browser-specific and therefore has no
* server-side counterpart, which is exactly why it is handled here.
*/
export function normalizeColour(value: string): string | null {
const raw = value.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) {
digits = digits.split("").map((c) => c + c).join("");
}
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
}
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
if (rgb) {
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
if (parts.length < 3) return null;
const channels = parts.slice(0, 3).map((p) => Number(p));
if (channels.some((n) => !Number.isFinite(n))) return null;
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
const base = `#${channels.map(hexOf).join("")}`;
if (parts.length === 3) return base;
const alpha = Number(parts[3]);
if (!Number.isFinite(alpha) || alpha >= 1) return base;
return `${base}${hexOf(alpha * 255)}`;
}
return null;
}
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
const index = new Map<string, string[]>();
for (const token of tokens) {
const colour = normalizeColour(token.value);
if (!colour) continue;
const names = index.get(colour);
if (names) names.push(token.name);
else index.set(colour, [token.name]);
}
return index;
}
/**
* Compare claims against the live tokens.
*
* A `token` claim asks whether a custom property of that name exists.
* A `color` claim asks whether any token resolves to that value.
* A `prohibited_color` claim INVERTS the test — present is the failure.
*/
export function compareToTokens(
expectations: Expectation[],
tokens: DesignToken[],
): Finding[] {
const names = new Set(tokens.map((t) => t.name));
const colours = colourIndex(tokens);
return expectations.map((expectation) => {
if (expectation.kind === "token") {
const present = names.has(expectation.value);
return {
expectation,
status: present ? "ok" : "missing",
matches: present ? [expectation.value] : [],
};
}
const matches = colours.get(expectation.value) ?? [];
if (expectation.kind === "prohibited_color") {
return {
expectation,
status: matches.length ? "violated" : "ok",
matches,
};
}
return {
expectation,
status: matches.length ? "ok" : "missing",
matches,
};
});
}
export interface DriftSummary {
ok: number;
missing: number;
violated: number;
total: number;
}
export function summarise(findings: Finding[]): DriftSummary {
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
for (const finding of findings) summary[finding.status] += 1;
return summary;
}
/**
* Findings worth leading with.
*
* A panel that opens with every row gets closed and never reopened — the same
* principle the auto-inject menu is built on: a short list that gets read beats
* a complete one that doesn't. Violations first (something is actively wrong),
* then missing (something was never built), and `ok` rows are not "findings" at
* all — they belong behind an expansion.
*/
export function rankFindings(findings: Finding[]): Finding[] {
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
return [...findings].sort((a, b) => {
const byStatus = order[a.status] - order[b.status];
if (byStatus !== 0) return byStatus;
return a.expectation.rule_id - b.expectation.rule_id;
});
}
-181
View File
@@ -1,181 +0,0 @@
/**
* Design-token inventory — what tokens exist, and what they actually resolve to.
*
* Foundation for the design explorer (milestone #251): the gallery renders
* against these, and the drift panel compares them to the design rulebook.
*
* DESIGN NOTE — why this parses NAMES but never VALUES.
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
* its VALUE is not: values contain nested parens, commas inside rgba(),
* `var()` references to other tokens, multi-part shadows, and gradients — and
* `theme.css` has all of those today. So we take the names from the source and
* ask the BROWSER for every value.
*
* That is not just easier, it is more correct. getComputedStyle reports what
* actually won the cascade, resolves `var()` chains, and — critically for this
* milestone — reflects live overrides set on a container, which is exactly what
* the preview surface needs (see #2261). Parsing the source would report what
* the file says rather than what the user is looking at.
*
* It also means this module needs no unit tests to be trustworthy: the only
* logic here is a name regex and a group lookup. The frontend has no test
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
* error-prone half in the browser rather than in our code is deliberate.
*/
import themeCss from "@/assets/theme.css?raw";
export type TokenGroup =
| "color"
| "radius"
| "gradient"
| "glow"
| "focus"
| "layout"
| "other";
export type ThemeMode = "light" | "dark";
export interface DesignToken {
/** Full custom-property name, including the leading `--`. */
name: string;
/** Coarse family, derived from the name prefix. */
group: TokenGroup;
/** Resolved value in the requested context, straight from the browser. */
value: string;
/** True when the declaration appears inside the dark block in source. */
overriddenInDark: boolean;
}
/**
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
*
* The discriminator is the COLON, not the preceding character. A declaration is
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
* actively wrong: an earlier version required the match to follow `{` or `;`,
* which silently dropped every declaration that came after a comment —
* including `--color-bg`, the first and most-used token in the file.
*/
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
/** Comments are stripped first so a commented-out declaration isn't counted. */
const COMMENT = /\/\*[\s\S]*?\*\//g;
/** The dark block's selector, as written in theme.css. */
const DARK_SELECTOR = '[data-theme="dark"]';
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
["--color-", "color"],
["--radius-", "radius"],
["--gradient-", "gradient"],
["--glow-", "glow"],
["--focus-", "focus"],
["--page-", "layout"],
["--sidebar-", "layout"],
["--chat-", "layout"],
];
export function groupFor(name: string): TokenGroup {
for (const [prefix, group] of GROUP_PREFIXES) {
if (name.startsWith(prefix)) return group;
}
return "other";
}
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
export function tokenNames(css: string = themeCss): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
const name = match[1];
if (!seen.has(name)) {
seen.add(name);
out.push(name);
}
}
return out;
}
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
export function darkOverriddenNames(css: string = themeCss): Set<string> {
const bare = css.replace(COMMENT, "");
const start = bare.indexOf(DARK_SELECTOR);
if (start === -1) return new Set();
const open = bare.indexOf("{", start);
const close = bare.indexOf("}", open);
if (open === -1 || close === -1) return new Set();
return new Set(tokenNames(bare.slice(open, close)));
}
/**
* Read the resolved value of every token in `host`'s context.
*
* Pass a container to read the tokens as they apply INSIDE it — which is how
* the preview surface reads a scoped override without disturbing the page.
* Defaults to the document root, i.e. the app-wide values.
*/
export function readTokens(host: Element = document.documentElement): DesignToken[] {
const computed = getComputedStyle(host);
const dark = darkOverriddenNames();
return tokenNames().map((name) => ({
name,
group: groupFor(name),
value: computed.getPropertyValue(name).trim(),
overriddenInDark: dark.has(name),
}));
}
/**
* Read tokens as they would resolve in a given mode, without touching the page.
*
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
* mutated to take a reading.
*
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
* function: light is declared on `:root` while dark is declared on
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
* subtree, but there is no `[data-theme="light"]` block to add the light ones
* back. So reading "light" from inside a dark page returns the dark values —
* the probe has nothing to match.
*
* Concretely: dark-inside-light previews work, light-inside-dark previews do
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
* (milestone #251 step 6) is what makes this symmetric, and until then callers
* should treat a cross-mode read as best-effort.
*/
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
const probe = document.createElement("div");
probe.setAttribute("data-theme", mode);
probe.style.display = "none";
document.body.appendChild(probe);
try {
return readTokens(probe);
} finally {
probe.remove();
}
}
/** Tokens grouped by family, preserving source order within each group. */
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
const out = new Map<TokenGroup, DesignToken[]>();
for (const token of tokens) {
const bucket = out.get(token.group);
if (bucket) bucket.push(token);
else out.set(token.group, [token]);
}
return out;
}
/**
* Tokens declared in the stylesheet that nothing references with `var()`.
*
* Dead tokens are drift too: `--chat-reading-width` and
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
* in the MCP-first pivot, and nothing has referenced them since. Takes the
* corpus of source files to search as an argument so the caller decides what
* "used" means — this module has no opinion about the project layout.
*/
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
const haystack = sources.join("\n");
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Turning a design system's RECORDED values into ones you can look at.
*
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
* running app's own stylesheet — names out of a bundled `theme.css`, values out
* of `getComputedStyle(document.documentElement)`. That could only ever describe
* the install serving the page, and the design surface is for the projects an
* install TRACKS (#274). What is left here works on any system's record,
* including one for an app this browser has never loaded.
*
* Nothing in this module reads the document's own tokens or mutates the page.
*/
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
export const BASE_MODE = "base";
/**
* Which declared value applies in `mode`.
*
* 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 generated 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] ?? "";
}
/** Every mode any token in the set declares, base first then the rest by name. */
export function modesPresent(
tokens: { value_by_mode: Record<string, string> }[],
): string[] {
const modes = new Set<string>();
for (const token of tokens) {
for (const [mode, value] of Object.entries(token.value_by_mode)) {
if (value) modes.add(mode);
}
}
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
}
/**
* Resolve declared values the way a browser would, without applying them.
*
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
* as text that is a string; shown as a swatch it needs `var()` substituted and
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
* offscreen probe and read them back — the substitution is done by the
* implementation that would do it for real.
*
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
* sitting in this page would resolve any reference the record leaves undeclared
* against the surrounding app's own tokens. Previewing another project's system
* would then quietly borrow this one's palette wherever that system was
* incomplete, and a token the record already knows is broken (it shows up under
* `unknown_refs`) would render as though it were fine.
*
* So every name referenced but not declared is blanked on the probe first. It
* resolves to nothing, which is what the record says it is.
*/
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
const probe = document.createElement("div");
probe.style.display = "none";
for (const value of declared.values()) {
for (const match of value.matchAll(VAR_REFERENCE)) {
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
}
}
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();
}
}
+60 -12
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
/**
* Design systems — editing the stylesheet Scribe holds (milestone #254 step 5).
* Design systems — the stylesheets this install RECORDS, for the projects it
* tracks (milestone #254 step 5).
*
* Sibling of /design, which shows the system as the BROWSER has it. This page
* shows it as the RECORD has it, which is the half you can change.
* It had a sibling, `/design`, which showed the system as the BROWSER had it
* names out of a bundled stylesheet, values out of `getComputedStyle`. That
* could only ever describe the install serving the page, so it was a mirror
* rather than a tool and was retired (#274). Everything here works on a system
* whose app this browser has never loaded.
*
* The layout follows the model rather than decorating it. A system with a
* parent holds only what it changes, so this page has two lists and they are
@@ -39,9 +43,10 @@ import {
type SnippetCheck,
type StylesheetResult,
} from "@/api/designSystems";
import DesignTabs from "@/components/DesignTabs.vue";
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import StarterRolePicker from "@/components/StarterRolePicker.vue";
import TokenPreview from "@/components/TokenPreview.vue";
const toast = useToastStore();
@@ -148,6 +153,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 +167,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}`);
@@ -479,14 +493,28 @@ watch(selectedId, () => {
snippetCheck.value = null;
});
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
/**
* A colour this row can draw HONESTLY — self-contained, no `var()` inside.
*
* The provenance list below shows values as the record states them, and a
* `var()` reference states nothing on its own: rendering
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves
* `--accent` against THIS app, so a system that isn't the one Scribe runs on
* would be drawn in Scribe's palette. It looked right, which is why it went
* unnoticed (#274).
*
* The preview above resolves values properly, on a probe carrying only that
* system's declarations. So this list draws only what needs no resolving, and
* leaves the rest to the surface built for it.
*/
function isSelfContainedColour(value: string): boolean {
const v = value.trim();
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(v) && !v.includes("var(");
}
</script>
<template>
<div class="ds-view">
<DesignTabs />
<header class="ds-header">
<h1>Design systems</h1>
@@ -540,6 +568,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 +633,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>
@@ -750,7 +786,7 @@ function isColourish(value: string): boolean {
<ul class="dupe-list">
<li v-for="[value, names] in duplicateEntries" :key="value">
<span
v-if="isColourish(value)" class="swatch"
v-if="isSelfContainedColour(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<code>{{ value }}</code> — {{ names.join(", ") }}
@@ -908,7 +944,7 @@ function isColourish(value: string): boolean {
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
<span
v-if="isColourish(row.value)" class="swatch"
v-if="isSelfContainedColour(row.value)" class="swatch"
:style="{ background: row.value }" aria-hidden="true"
/>
<button
@@ -946,7 +982,7 @@ function isColourish(value: string): boolean {
<span class="token-values">
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
<span
v-if="isColourish(value)" class="swatch"
v-if="isSelfContainedColour(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<span class="mode-name">{{ mode }}</span>
@@ -969,6 +1005,18 @@ function isColourish(value: string): boolean {
</ul>
</section>
<!-- What it looks like. Drawn from the record on an isolated probe,
so this is THIS system's palette even when the app around it is
running a different one. -->
<section v-if="resolved.length" class="ds-section">
<h2>Preview</h2>
<p class="section-note">
{{ selected.title }} as it would render — resolved from the record,
not from the stylesheet this app happens to be running.
</p>
<TokenPreview :tokens="resolved" />
</section>
<!-- Effective set -->
<section class="ds-section">
<h2>Effective tokens</h2>
@@ -1002,7 +1050,7 @@ function isColourish(value: string): boolean {
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
v-if="isSelfContainedColour(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
@@ -1023,7 +1071,7 @@ function isColourish(value: string): boolean {
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
<span class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
v-if="isSelfContainedColour(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
-545
View File
@@ -1,545 +0,0 @@
<script setup lang="ts">
/**
* Design explorer — the gallery (milestone #251 step 3).
*
* Renders the design system against the tokens that are actually live, read at
* runtime rather than parsed from source, so what you see here is what the app
* is using right now.
*
* HONESTY RULE, and the reason parts of this page say "not implemented":
* a gallery of hand-written look-alikes drifts from the app within a month and
* then lies — which is the same failure this whole surface exists to catch. So
* every specimen below is either a REAL component imported from the app, or a
* real token read from the browser, or it is explicitly marked as missing.
*
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
* in five `<style scoped>` blocks, all five drifted, and this page reported it
* as a gap because drawing a look-alike would have made it a sixth copy.
* `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.
*/
import { computed, onMounted, ref } from "vue";
import { fetchDesignExpectations } from "@/api/design";
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 {
compareToTokens,
rankFindings,
summarise,
type Expectation,
type Finding,
} from "@/utils/designDrift";
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
const tokens = ref<DesignToken[]>([]);
const expectations = ref<Expectation[]>([]);
const designRulebookId = ref<number | null>(null);
const driftLoaded = ref(false);
const showCleanRows = ref(false);
/**
* Read on mount, not at module scope: the values depend on the live cascade,
* which needs the app's stylesheets applied and the theme attribute set.
*/
onMounted(async () => {
tokens.value = readTokens();
try {
const response = await fetchDesignExpectations();
designRulebookId.value = response.rulebook_id;
expectations.value = response.expectations;
} catch {
// The gallery is useful without the panel, so a failed fetch degrades to
// "no drift data" rather than taking the page down with it.
designRulebookId.value = null;
} finally {
driftLoaded.value = true;
}
});
const findings = computed<Finding[]>(() =>
rankFindings(compareToTokens(expectations.value, tokens.value)),
);
const driftSummary = computed(() => summarise(findings.value));
const visibleFindings = computed(() =>
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
);
const grouped = computed(() => groupTokens(tokens.value));
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
const orderedGroups = computed(() =>
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
);
/** A token whose value reads as a colour is worth showing as a swatch. */
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
}
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
const RULEBOOK_BUTTONS = [
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
];
const TYPE_SPECIMENS = [
{ token: "Display", spec: "40 / 500 / Fraunces" },
{ token: "H1", spec: "32 / 500 / Fraunces" },
{ token: "H2", spec: "24 / 500 / Fraunces" },
{ token: "H3", spec: "18 / 500 / Inter" },
{ token: "Body", spec: "15 / 400 / Inter" },
{ token: "Body small", spec: "13 / 400 / Inter" },
{ token: "Label", spec: "12 / 500 / Inter" },
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
];
</script>
<template>
<div class="design-view">
<DesignTabs />
<header class="design-header">
<h1>Live tokens</h1>
<p class="lede">
The system as it actually is. Token values are read from the browser at
runtime, so this page reflects the live cascade rather than what the
stylesheet says. Components shown are the real ones where a piece of
the system has no shared implementation, it is marked missing rather
than mocked up.
</p>
</header>
<!-- Drift: what the rulebook claims vs what the tokens do. -->
<section class="design-section">
<h2>Rulebook drift</h2>
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook</p>
<div v-else-if="designRulebookId === null" class="gap-notice">
<strong>No design rulebook designated.</strong>
<p>
This install hasn't said which rulebook describes its design system, so
there is nothing to check the tokens against. Designate one in
<router-link to="/settings">Settings</router-link> and this panel will
compare every colour and token the rulebook names against what the
stylesheet actually resolves to.
</p>
</div>
<template v-else>
<p class="section-note">
<strong>{{ driftSummary.violated }}</strong> violated ·
<strong>{{ driftSummary.missing }}</strong> missing ·
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
claims in rulebook #{{ designRulebookId }}.
</p>
<div class="gap-notice">
<strong>This compares the rulebook against the TOKENS only.</strong>
<p>
A value hardcoded in a component — where a token should have been
referenced — is invisible here, because the drift isn't in the tokens
at all. Reading it would mean bundling every component's source into
the app. That check belongs in CI and is tracked separately, so treat
a clean panel as "the tokens agree", not "the app agrees".
</p>
</div>
<p v-if="!findings.length" class="muted">
The rulebook names nothing this panel can check. Rules that state values
— colours, token names — produce claims; rules that state judgement
don't, by design.
</p>
<ul v-else class="spec-list">
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
<span class="spec-name">
<span
v-if="finding.expectation.kind !== 'token'"
class="swatch"
:style="{ background: finding.expectation.value }"
aria-hidden="true"
/>
<code>{{ finding.expectation.value }}</code>
</span>
<span class="spec-detail">
rule #{{ finding.expectation.rule_id }} {{ finding.expectation.rule_title }}
<span v-if="finding.matches.length" class="matches">
· {{ finding.matches.join(", ") }}
</span>
</span>
<span class="spec-status" :class="finding.status">
{{ finding.status === "violated" ? "forbidden, but present"
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
</span>
</li>
</ul>
<button
v-if="findings.length && driftSummary.ok"
class="reveal-toggle"
@click="showCleanRows = !showCleanRows"
>
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
</button>
</template>
</section>
<!-- Real components: these are imported, not recreated. -->
<section class="design-section">
<h2>Components</h2>
<p class="section-note">Imported from the app. What you see is what ships.</p>
<div class="specimen">
<span class="specimen-label">Status badge</span>
<div class="specimen-row">
<StatusBadge status="todo" />
<StatusBadge status="in_progress" />
<StatusBadge status="done" />
<StatusBadge status="cancelled" />
</div>
</div>
<div class="specimen">
<span class="specimen-label">Priority badge</span>
<div class="specimen-row">
<PriorityBadge priority="low" />
<PriorityBadge priority="medium" />
<PriorityBadge priority="high" />
<span class="muted">(<code>none</code> renders nothing, by design)</span>
</div>
</div>
<div class="specimen">
<span class="specimen-label">Tag pill</span>
<div class="specimen-row">
<TagPill tag="design-system" />
<TagPill tag="dismissible" dismissible />
</div>
</div>
</section>
<!-- No longer a gap: these are the app's real classes, from the shared
sheet. Nothing here is a look-alike — change components.css and these
specimens change with it, which is the only way this page stays true. -->
<section class="design-section">
<h2>Buttons</h2>
<div class="button-specimens">
<button class="btn-primary">Save</button>
<button class="btn-secondary">Detect</button>
<button class="btn-ghost">Cancel</button>
<button class="btn-danger">Delete</button>
<button class="btn-primary" disabled>Disabled</button>
</div>
<p class="spec-caption">
Three sizes, because the app has three kinds of button: a page action, a
row action, and an affordance that sits inside a card without disturbing
its rhythm.
</p>
<div class="button-specimens">
<button class="btn-primary">Default — page action</button>
<button class="btn-primary btn-compact">Compact — row action</button>
<button class="btn-primary btn-inline">Inline</button>
</div>
<ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status ok">shared</span>
</li>
</ul>
</section>
<!-- Typography: the families load, the scale does not exist as tokens. -->
<section class="design-section">
<h2>Type scale</h2>
<div class="gap-notice">
<strong>Families load; the scale has no tokens.</strong>
<p>
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
scale is not expressed as custom properties, so sizes and weights are
set ad hoc per component. Listed here as specification, not as a live
specimen there is nothing to read.
</p>
</div>
<ul class="spec-list">
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
<span class="spec-name">{{ t.token }}</span>
<span class="spec-detail">{{ t.spec }}</span>
<span class="spec-status missing">no token</span>
</li>
</ul>
</section>
<!-- Tokens: entirely real, read live. -->
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
<ul class="token-list">
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
<span
v-if="isColourish(token.value)"
class="swatch"
:style="{ background: token.value }"
aria-hidden="true"
/>
<span v-else class="swatch swatch-none" aria-hidden="true" />
<code class="token-name">{{ token.name }}</code>
<code class="token-value">{{ token.value || "—" }}</code>
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
mode-aware
</span>
</li>
</ul>
</section>
<p v-if="!tokens.length" class="muted">Reading tokens</p>
</div>
</template>
<style scoped>
.design-view {
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem var(--page-padding-x) 4rem;
}
.design-header {
margin-bottom: 2rem;
}
.lede {
color: var(--color-text-secondary);
max-width: 60ch;
line-height: 1.7;
}
.design-section {
margin-bottom: 2.5rem;
}
.design-section h2 {
margin-bottom: 0.25rem;
}
.token-group-heading {
text-transform: capitalize;
}
.count {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 400;
}
.section-note,
.muted {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 1rem;
}
/* Specimens -------------------------------------------------------------- */
.specimen {
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
}
.specimen-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.specimen-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* Gaps ------------------------------------------------------------------- */
.spec-caption {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
line-height: 1.5;
max-width: 60ch;
}
/* Layout only. The buttons inside style themselves from the shared sheet —
adding any appearance rule here would recreate the copy this section
just stopped being. */
.button-specimens {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--fs-space-3);
margin-bottom: 1rem;
}
.gap-notice {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.gap-notice p {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.6;
max-width: 70ch;
}
.spec-list {
list-style: none;
padding: 0;
margin: 0;
}
.spec-list li {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.spec-name {
min-width: 8rem;
font-weight: 500;
}
.spec-detail {
color: var(--color-text-secondary);
font-size: 0.85rem;
flex: 1;
}
.spec-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
}
.spec-status.missing {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.spec-status.violated {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.ok {
background: var(--color-status-done-bg);
color: var(--color-status-done);
}
.spec-name .swatch {
vertical-align: middle;
margin-right: 0.4rem;
}
.matches {
color: var(--color-text-muted);
}
.reveal-toggle {
margin-top: 0.75rem;
padding: 0.35rem 0.75rem;
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
}
.reveal-toggle:hover {
border-color: var(--color-text-muted);
}
/* Tokens ----------------------------------------------------------------- */
.token-list {
list-style: none;
padding: 0;
margin: 0;
}
.token-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.swatch {
width: 1.25rem;
height: 1.25rem;
flex: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.swatch-none {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
var(--color-border) 3px,
var(--color-border) 4px
);
}
.token-name {
min-width: 16rem;
font-size: 0.8rem;
}
.token-value {
color: var(--color-text-secondary);
font-size: 0.8rem;
flex: 1;
word-break: break-all;
}
.token-flag {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.05rem 0.35rem;
}
@media (max-width: 640px) {
.token-name {
min-width: 0;
}
}
</style>
+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;
+27 -13
View File
@@ -7,6 +7,7 @@ import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import {
@@ -108,7 +109,7 @@ async function confirmStartPlanning() {
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
@@ -570,6 +571,9 @@ async function confirmDelete() {
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
Rules
</button>
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
Design
</button>
</div>
<!-- Tasks tab milestone-grouped kanban -->
@@ -784,6 +788,16 @@ async function confirmDelete() {
<!-- Rules tab -->
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
<!-- Design tab: this project's recorded components against its sheet.
Bound to the SAVED pointer rather than the picker's draft value,
so an unsaved change in the sidebar can't make the tab report on
a system this project isn't using. -->
<ProjectDesignTab
v-if="activeTab === 'design'"
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
</div>
</div>
</template>
@@ -847,9 +861,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 +946,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 +1080,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 +1147,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 +1182,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 +1244,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 +1270,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 +1279,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 +1324,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;
+17 -44
View File
@@ -4,7 +4,6 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
import { listRulebooks } from "@/api/rulebooks";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -32,11 +31,6 @@ const kbWritePathThreshold = ref("0.68");
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
// suggests a merge the operator reviews (services/dedup.py).
const kbDuplicateThreshold = ref("0.82");
// Which rulebook describes this install's design system, for the /design drift
// panel. Empty = none designated, which is the normal state for a fresh install
// rather than a misconfiguration — the panel explains itself when unset.
const designRulebookId = ref("");
const designRulebooks = ref<{ id: number; title: string }[]>([]);
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);
@@ -106,9 +100,6 @@ async function saveKbInject() {
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
kb_writepath_threshold: String(wpT),
kb_duplicate_threshold: String(dupT),
// Empty string DELETES the setting (see routes/settings.py), which is
// exactly right for "no design rulebook" — absent rather than zero.
design_rulebook_id: designRulebookId.value,
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -499,14 +490,6 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
}
designRulebookId.value = allSettings.design_rulebook_id ?? "";
// Best-effort: the picker degrades to "none available" rather than blocking
// the whole settings page if rulebooks can't be listed.
try {
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
} catch {
designRulebooks.value = [];
}
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -1277,22 +1260,12 @@ function formatUserDate(iso: string): string {
location, not by resemblance.
</p>
</div>
<div class="field">
<label for="design-rulebook">Design-system rulebook</label>
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
<option value="">None — don't check for design drift</option>
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
{{ rb.title }}
</option>
</select>
<p class="field-hint">
Which rulebook describes how this app should look. Once set, the
<router-link to="/design">Design</router-link> page compares every colour
and token your rules name against what the stylesheet actually resolves
to, and reports where they disagree. Leave it as None if your rules
don't describe a design system — nothing else depends on this.
</p>
</div>
<!-- A design system belongs to a PROJECT, and the picker for it lives on
the project. There was a setting here that designated the system
this install's own interface was built from; it only ever described
the app you were already looking at, which is not what the feature
is for (#274). -->
<div class="field">
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
<input
@@ -2404,7 +2377,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 +2762,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 +3021,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 +3139,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 +3251,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 +3369,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 +3443,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 +3484,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)
-2
View File
@@ -26,7 +26,6 @@ from scribe.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
from scribe.routes.design import design_bp
from scribe.routes.design_systems import design_systems_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
@@ -91,7 +90,6 @@ def create_app() -> Quart:
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
app.register_blueprint(design_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
+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,
-29
View File
@@ -1,29 +0,0 @@
"""Design-system surface — what the rulebook expects of the stylesheet.
The client owns the other half of the comparison: it reads live token values from
the browser (see `utils/designTokens.ts`), which is the only place they exist
resolved. This endpoint supplies the claims to check them against.
"""
from quart import Blueprint, jsonify
from scribe.auth import get_current_user_id, login_required
from scribe.services import design_rulebook_import as design_svc
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
@design_bp.get("/expectations")
@login_required
async def get_expectations():
"""Checkable claims from the rulebook this install designated as its design system.
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
`rulebook_id: null` is the NORMAL case, not an error — an install that has
not designated a design rulebook has nothing to compare against, and the
client shows an explanatory empty state (rule #115). Distinguishing it from
"designated but empty" is why the id is returned alongside the list.
"""
uid = get_current_user_id()
result = await design_svc.design_expectations(uid)
return jsonify(result.as_dict())
+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()
]
+21
View File
@@ -27,6 +27,10 @@ from scribe.services.design_stylesheet import (
duplicate_values,
render_stylesheet,
)
from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX,
starter_tokens,
)
from scribe.services.design_cascade import (
ResolvedToken,
ancestry,
@@ -79,11 +83,23 @@ async def create_design_system(
description: str | None = None,
guidance: str | None = None,
parent_id: int | None = None,
starter_role_groups: list[str] | None = None,
token_prefix: str = DEFAULT_TOKEN_PREFIX,
) -> DesignSystem | None:
"""Create a system, with or without a parent.
Returns None when `parent_id` names a system the caller may not write —
which, per the ACL, means one they do not own.
`starter_role_groups` seeds the system with named, VALUELESS token roles
(#2349) — the moment a role is missing is the moment a literal gets written
instead, so the cheapest time to name them is now. Pass a list of group
names to choose, `[]` for none, or None for none.
None and `[]` deliberately mean the same thing here, unlike in
`starter_tokens` where None means "all": creation must not seed 40 rows
into a system whose caller never asked. Opting in is the caller's job, and
the UI's default of everything-checked lives in the UI.
"""
if parent_id is not None and not await access.can_write_design_system(
user_id, parent_id
@@ -100,6 +116,11 @@ async def create_design_system(
session.add(system)
await session.commit()
await session.refresh(system)
if starter_role_groups:
for row in starter_tokens(starter_role_groups, prefix=token_prefix):
session.add(DesignToken(design_system_id=system.id, **row))
await session.commit()
return system
-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"}
+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}"