Eleven commits. Three threads, and the last one reframes the other two.
The design system becomes a tool rather than a mirror (#274)
/design read this install's own bundled theme.css and the browser's cascade, 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 clincher: Scribe's drift was already checked the correct way. check_design_tokens.py runs in CI against a sheet path it knows nothing about, using the same engine as the snippet check. The view was redundant even for the one project it could see.
Retired/design, DesignTabs, routes/design.py, the ui_design_system_id setting, and both browser-reading utils.
Preview, from the record.TokenPreview resolves every value 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. Specimens are chosen by value shape, never by name, so a system calling its spacing --gap-N gets identical treatment to one using --fs-space-N.
Project → Design tab.check_snippets_against_system has taken a project_id since it was written and the route always read ?project_id=; nothing on the frontend ever passed one, so the capability shipped unreachable. Now it reports no such token, defines its own, write the token.
The bug that justified the whole rework
The record view drew a swatch wherever a value looked colour-ish — and that matched color-mix(. So a derived token in any system was rendered by the page, resolving var(--accent) against Scribe. Every system you inspected wore Scribe's palette in its derived rows. It looked right, which is why it went unnoticed.
A literal gets written when there is no role to reach for. So a system is created with its roles already named — ~40 across 10 skippable groups, names and purposes only, never values. A default palette would be one install's taste shipped as product.
184 var() fallbacks removed — every one unreachable. The accent had been hand-written 16 times as rgba(91, 74, 138, …); now derived.
Also
Plugin 0.1.23: every session reports which plugin version is executing (#2220), keylessly.
Design moves into primary navigation. A design system is a record you author, not a setting.
Migration
0075 deletes two retired settings keys — design_rulebook_id and ui_design_system_id. Both named a design source for the running install; a project already carries its own pointer. Neither key has run anywhere, so nothing in a live database changes state unexpectedly.
After deploying
Scribe's own design surface is at project 2 → Design. There is no top-level page for it any more, which is the point.
The tab will read "no snippets recorded" for most projects at first — the honest state, and deliberately worded apart from "checked and clean". The check only sees code Scribe actually holds.
Verification
CI green on 8087ba4 — typecheck, lint + design-token check, unit, integration, plugin hooks, image build.
Eleven commits. Three threads, and the last one reframes the other two.
## The design system becomes a tool rather than a mirror (#274)
`/design` read this install's own bundled `theme.css` and the browser's cascade, 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 clincher: Scribe's drift was **already** checked the correct way. `check_design_tokens.py` runs in CI against a sheet path it knows nothing about, using the same engine as the snippet check. The view was redundant even for the one project it could see.
- **Retired** `/design`, `DesignTabs`, `routes/design.py`, the `ui_design_system_id` setting, and both browser-reading utils.
- **Preview, from the record.** `TokenPreview` resolves every value 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. Specimens are chosen by value shape, never by name, so a system calling its spacing `--gap-N` gets identical treatment to one using `--fs-space-N`.
- **Project → Design tab.** `check_snippets_against_system` has taken a `project_id` since it was written and the route always read `?project_id=`; nothing on the frontend ever passed one, so the capability shipped unreachable. Now it reports `no such token`, `defines its own`, `write the token`.
### The bug that justified the whole rework
The record view drew a swatch wherever a value looked colour-ish — and that matched `color-mix(`. So a derived token in **any** system was rendered by the page, resolving `var(--accent)` against Scribe. Every system you inspected wore Scribe's palette in its derived rows. It looked right, which is why it went unnoticed.
## Design starter roles (#2349)
A literal gets written when there is no role to reach for. So a system is created with its roles already named — ~40 across 10 skippable groups, names and purposes only, **never values**. A default palette would be one install's taste shipped as product.
## Theme literals (#2277)
184 `var()` fallbacks removed — every one unreachable. The accent had been hand-written 16 times as `rgba(91, 74, 138, …)`; now derived.
## Also
- Plugin **0.1.23**: every session reports which plugin version is executing (#2220), keylessly.
- **Design** moves into primary navigation. A design system is a record you author, not a setting.
## Migration
**0075** deletes two retired settings keys — `design_rulebook_id` and `ui_design_system_id`. Both named a design source for the running install; a project already carries its own pointer. Neither key has run anywhere, so nothing in a live database changes state unexpectedly.
## After deploying
Scribe's own design surface is at **project 2 → Design**. There is no top-level page for it any more, which is the point.
The tab will read *"no snippets recorded"* for most projects at first — the honest state, and deliberately worded apart from *"checked and clean"*. The check only sees code Scribe actually holds.
## Verification
CI green on `8087ba4` — typecheck, lint + design-token check, unit, integration, plugin hooks, image build.
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
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
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
#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
`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
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
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
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
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
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
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Eleven commits. Three threads, and the last one reframes the other two.
The design system becomes a tool rather than a mirror (#274)
/designread this install's own bundledtheme.cssand the browser's cascade, 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 clincher: Scribe's drift was already checked the correct way.
check_design_tokens.pyruns in CI against a sheet path it knows nothing about, using the same engine as the snippet check. The view was redundant even for the one project it could see./design,DesignTabs,routes/design.py, theui_design_system_idsetting, and both browser-reading utils.TokenPreviewresolves every value 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. Specimens are chosen by value shape, never by name, so a system calling its spacing--gap-Ngets identical treatment to one using--fs-space-N.check_snippets_against_systemhas taken aproject_idsince it was written and the route always read?project_id=; nothing on the frontend ever passed one, so the capability shipped unreachable. Now it reportsno such token,defines its own,write the token.The bug that justified the whole rework
The record view drew a swatch wherever a value looked colour-ish — and that matched
color-mix(. So a derived token in any system was rendered by the page, resolvingvar(--accent)against Scribe. Every system you inspected wore Scribe's palette in its derived rows. It looked right, which is why it went unnoticed.Design starter roles (#2349)
A literal gets written when there is no role to reach for. So a system is created with its roles already named — ~40 across 10 skippable groups, names and purposes only, never values. A default palette would be one install's taste shipped as product.
Theme literals (#2277)
184
var()fallbacks removed — every one unreachable. The accent had been hand-written 16 times asrgba(91, 74, 138, …); now derived.Also
Migration
0075 deletes two retired settings keys —
design_rulebook_idandui_design_system_id. Both named a design source for the running install; a project already carries its own pointer. Neither key has run anywhere, so nothing in a live database changes state unexpectedly.After deploying
Scribe's own design surface is at project 2 → Design. There is no top-level page for it any more, which is the point.
The tab will read "no snippets recorded" for most projects at first — the honest state, and deliberately worded apart from "checked and clean". The check only sees code Scribe actually holds.
Verification
CI green on
8087ba4— typecheck, lint + design-token check, unit, integration, plugin hooks, image build.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