Commit Graph
1107 Commits
Author SHA1 Message Date
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
bvandeusenandClaude Opus 5 5795fa908a feat(projects): cap milestone bars at 10, open work first
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 39s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 1m47s
CI & Build / Python tests (push) Successful in 2m25s
CI & Build / Build & push image (push) Successful in 1m31s
Roundtable's card rendered ~35 milestone bars and ran several viewport-heights
tall, so one tile dwarfed the grid and stopped being scannable — which is the
whole job of a card (#2391).

Now 10 bars, ordered OPEN WORK FIRST and newest first within each group, with
"+25 more milestones" beneath.

Ordering by recency alone would have been wrong, and the operator's call was to
lead with open work: a long-running project's oldest milestones are usually its
finished ones, so the ten most recent could easily have been ten completed bars
while the three in flight were the ones hidden. A card answers "what is
happening", not "what happened".

Three details that are the actual work:

- The palette index is captured from the FULL list before slicing. Colour keyed
  to visible position would have recoloured every bar on the card each time a
  milestone closed or was added.
- Computed once per load into a Map rather than called from the template. A
  helper invoked inside v-for re-runs on every render, and this one sorts.
- The overflow notice is plain text, not a link. The whole card already
  navigates to the project, and a link nested inside a clickable region is a
  trap for keyboard and screen-reader users.

Saying the count matters more than the cap: a list that simply stops reads as a
rendering bug, while a count reads as a summary.

Payload is unchanged — the API still returns every milestone. Capping
server-side would also need the total to travel with it, or the "+N" has
nothing to count from; not worth it while the response is two queries (#2384).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-02 19:38:48 -04:00
bvandeusenandClaude Opus 5 be3a0ffaf9 fix(projects): batch the summary queries — the fan-out was exhausting the pool
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 43s
CI & Build / integration (push) Successful in 2m33s
CI & Build / Python tests (push) Successful in 3m1s
CI & Build / Build & push image (push) Successful in 44s
Reported live: Projects and Snippets showed skeletons that never resolved,
/knowledge worked intermittently. The logs named it exactly:

    QueuePool limit of size 5 overflow 10 reached, connection timed out, 30.00
    GET /api/settings  500  30584.0ms
    GET /api/projects  200  30882.9ms

/api/projects was not hanging — it was waiting out the 30-second checkout
timeout and then returning 200 with summaries silently missing, because
_attach swallowed the TimeoutError. Nobody waits 31 seconds, so it read as a
hang.

THE SHAPE: routes/projects.py ran asyncio.gather over every project. Each
_attach called get_project_summary, which opened its own session for three
queries and then called get_project_milestone_summary — which opened one more
session PER MILESTONE. So 25 projects asked for roughly 250 concurrent
checkouts against a pool of 15 (SQLAlchemy's default 5 + 10 overflow).

That is why unrelated routes failed too. Snippets and /knowledge were never
broken; they queued behind the burst and inherited its timeout. /api/settings
returning 500 while /api/projects returned 200 is the same cause wearing two
faces.

The comment above the gather said "one backend pass instead of N+1 frontend
calls". It did remove the N+1 from the network — and recreated it against the
connection pool, where it is worse, because the browser had at least been
serialising those calls.

Now: get_project_summaries() does all projects in four queries and one session,
and get_project_milestone_summaries() does all milestones in two. Two sessions
total for the whole page, independent of how many projects exist.

The progress calculation is extracted to _progress_from_counts and shared by
both the batch and single paths, so the cancelled-exclusion rule cannot drift
into two versions that disagree about whether a milestone is finished.

Tests assert the SESSION COUNT, not just the values. An implementation that
returned identical output while opening a session per project would pass a
correctness test and reproduce the outage.

Deliberately NOT done: raising pool_size. It would move the cliff rather than
remove it, and this endpoint now needs two connections regardless of scale.

Closes #2384.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-02 19:09:36 -04:00
bvandeusenandClaude Opus 5 5f8b824523 refactor(ui): the remaining views migrate; 2 dead classes, 2 off-palette hovers
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Python tests (push) Successful in 2m13s
CI & Build / Build & push image (push) Successful in 42s
Twelve more files onto the shared buttons. What the pass turned up:

DEAD, verified not merely unnamed:
- .btn-reconsolidate (TaskEditorView). A comment eleven hundred lines up in the
  same file says the feature was removed in Phase 8. The CSS outlived it.
- .btn-remove-slot (SettingsView), style rules only, no template anywhere.

OFF-PALETTE, the #2319 shape: TrashView's restore and purge hovers used
`var(--color-primary, #6366f1)` and `var(--color-danger, #ef4444)` — Tailwind
indigo and Tailwind red, from no palette in this system. The fallback is what
renders if the token is ever absent, and it renders something plausible
forever. Now the action and destructive colours, no fallback.

A REAL BREAKAGE MY OWN CHECK COULD NOT SEE, worth recording. Deleting a rule
whose selector was part of a comma-separated group left the leading selectors
behind:

    .btn-log-edit,
    <nothing>
    .log-textarea { … }

which silently swallows the next rule. Brace counting passed — there are no
braces in a dangling fragment. Found by scanning for selector lines ending in
`,` not followed by another selector; three instances across two files, one of
them interleaved with comments so the first sweep missed it. The sweep is now
part of the verification, not a one-off.

Kept bespoke, deliberately: .btn-pin/.btn-unpin (pill-shaped history badges),
.btn-add-share and .btn-new-note (gradient CTAs — brand moments, which the
house style does sanction), .btn-icon/.btn-bell (icon buttons, a different
component), .btn-add-system/.btn-add-milestone (dashed "add" affordances).
These are not drift; they are other things wearing a btn- prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:53:13 -04:00
bvandeusenandClaude Opus 5 3fc693443e refactor(ui): the settings family migrates; two colour bugs and a dead class
CI & Build / Python lint (push) Successful in 7s
CI & Build / Plugin hooks (push) Successful in 36s
CI & Build / TypeScript typecheck (push) Successful in 49s
CI & Build / integration (push) Successful in 2m16s
CI & Build / Python tests (push) Successful in 2m51s
CI & Build / Build & push image (push) Successful in 1m15s
SettingsView and UserManagementView had near-identical button vocabularies —
btn-delete, btn-cancel-delete, btn-confirm-delete, btn-toggle/-open/-close —
defined separately in each. Parallel duplication (#2278's shape), and it had
already diverged twice:

- .btn-confirm-delete used --color-danger in UserManagement and
  --color-action-destructive in Settings. Those are different colours on
  purpose: the house style keeps error (something went wrong) distinct from
  destructive (something is about to). A delete confirmation is destructive.
  UserManagement was showing the error colour for a button nothing had failed
  in yet.

- .btn-remove-slot's hover reached for --color-danger for the same reason, and
  is the same correction. It turned out to be dead anyway — style rules only,
  no template reference anywhere in the app — so it is gone.

.btn-danger-outline was defined TWICE inside SettingsView, at 0.4rem 0.9rem and
0.45rem 1rem. One file, one class, two geometries, ~1200 lines apart. That is
the clearest single argument for this whole task that I have found: the drift
does not need two files, only enough distance that nobody sees both at once.

The registration toggle keeps .btn-toggle-close, and only that. It is bound
dynamically (:class="registrationOpen ? … : …"), so a name-based scan reads it
as unused — checked before deleting. .btn-toggle-open went, because btn-primary
now says the same thing; the close state stays because it must NOT read as the
primary action it sits on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:48:35 -04:00
bvandeusenandClaude Opus 5 3d6931b838 refactor(ui): editor-shared buttons alias onto the variants; 3 dead classes go
CI & Build / Python lint (push) Successful in 7s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m51s
CI & Build / Build & push image (push) Canceled after 38s
editor-shared.css defined thirteen button rules with hand-written geometry.
Ten are now thin aliases onto the shared variants — same class names, because
these are used across six views and pointing a name somewhere is cheaper than
rewriting every call site (the .btn-small precedent).

Three were DEAD: .btn-assist-toggle, .btn-close-assist and .btn-toggle-view had
no template reference and no dynamic binding anywhere in the app. Verified
before deleting rather than assumed from the name — a class with no user is
indistinguishable from one bound dynamically until you look.

Named honestly in the file: CSS has no @extend, so each alias carries the
variant's declarations rather than inheriting them. That is duplication this
migration cannot remove. But it is duplication of a REFERENCE — var(--color-
action-primary) — not of a value, so a palette change still moves everything at
once, which is the property that actually mattered.

Also gone: eight hardcoded geometries (0.4rem 1rem, 0.85rem, and so on) that
now come from --fs-space and --fs-size tokens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:46:01 -04:00
bvandeusenandClaude Opus 5 c7cf07824a refactor(ui): the two workspace panels migrate onto the shared buttons
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Canceled after 1m35s
CI & Build / integration (push) Canceled after 1m35s
CI & Build / Build & push image (push) Canceled after 0s
Eighteen bespoke button rules across the two densest components, replaced by
composition in the template. Net -279 lines.

These files are where the size taxonomy earns itself: almost every button here
is an inline affordance — a dismiss ×, a confirm tick, an add-chip — sitting
inside a card or a line of text. Forcing them to the standard 8/16px would have
broken the layouts, which is why the previous commit measured the clusters
before assuming a button is a button.

What the migration left behind is the useful signal. Each residual rule is now
one line stating only what the shared classes genuinely cannot:

  .btn-add            { font-size: 1rem; }      a '+' glyph, not a label
  .btn-search-clear   { padding: 0; flex-shrink: 0; }   sits in the field
  .btn-suggest-tags   { flex-shrink: 0; align-self: center; }
  .btn-delete-task    { margin-left: 0.25rem; }

Four residuals were deleted rather than kept, because the shared sheet already
said the same thing: a disabled opacity, two hover colours, and a danger-outline
hover fill. Keeping them would have recreated the drift in miniature.

Two accent hovers went with them. .btn-suggest-tags tinted its border and label
with the accent on hover, which is the same house-style violation corrected in
f491b6d — it survived that pass because it was a hover, not a fill.

.btn-tag-suggestion and .btn-chip-link stay bespoke, deliberately. They are
tag-shaped rather than button-shaped, and the house style does put the accent on
tags — so they are not drift, they are a different component wearing a btn-
prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:44:21 -04:00
bvandeusenandClaude Opus 5 67fdf7c55b refactor(ui): auth views migrate onto the shared buttons; two variants added
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Python tests (push) Successful in 2m4s
CI & Build / Build & push image (push) Canceled after 0s
The five auth views each defined .btn-submit identically — full-width, filled,
0.6rem — and LoginView additionally defined .btn-oauth. Those rules are now
gone entirely rather than tokenised: the template composes `btn-primary
btn-block` and `btn-ghost btn-block`, and there is nothing left per-file to
drift.

That is the difference between this and the earlier chunks. Consolidating the
core four moved geometry into one place but left every semantic name defining
its own; this removes the definition.

Two variants added, both earned rather than invented:

- .btn-text — no fill, no border. The most common shape in the dense surfaces
  (dismiss, cancel-beside-confirm, clear-search) where a border would draw a
  box around something that should read as an action on the adjacent text.
  Distinct from ghost, which IS a box.
- .btn-danger-outline — already existed independently in three views before
  this sheet, which is what makes it a variant and not a one-off. It is what a
  delete looks like when it must not shout.

.btn-block composes with a variant rather than being one, because width is
orthogonal to appearance.

Also corrected the sheet's own header, which claimed "no template changes" —
true when it was written, false as of this commit. It now states the actual
model: a button is variant + size, composed in the template. Semantic per-view
names are named as the thing that drifted, and why: a name says what a button
is FOR and nothing about what it should look like, so two buttons doing the
same job in two views had no reason to match, and didn't.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:42:14 -04:00
bvandeusenandClaude Opus 5 37616682f0 feat(ui): three button sizes, because the app has three kinds of button
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 45s
Measured before deciding: across the ~100 bespoke button rules, vertical
padding does not spread — it clusters. ~27 at 0.4–0.45rem, ~28 at 0.25–0.3rem,
~23 at 0.1–0.15rem. Those are three different components that happen to share a
name prefix: a page action, a row action, and an affordance that lives inside a
card.

Collapsing them to the single size the shared sheet had would have visibly
broken every card layout, which is why the one-off migration stopped here for a
decision rather than proceeding on the assumption that a button is a button.

  default        8px 16px   page action — what the house style specifies
  .btn-compact   4px 12px   toolbar, table row, list item controls
  .btn-inline    2px 4px    dismiss ×, confirm tick, add-chip

.btn-small and .btn-sm already sat at the compact step, so they are kept as
aliases for it — no template churn, and the two spellings stop being a third
thing that might drift.

.btn-inline is deliberately below the spacing scale's first step on the
vertical axis: 4px of padding on an 11px label already exceeds the line box
these sit in. Stated in the file so it reads as a measured exception rather
than someone ignoring the scale.

DesignView renders all three as real specimens. A size scale described in prose
is one nobody can check; rendered from the actual classes, it cannot claim
something the app does not do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:21:44 -04:00
bvandeusenandClaude Opus 5 97b93bcaea refactor(ui): buttons stop using weights the system doesn't have
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 36s
Eleven buttons set font-weight 600. The house style permits exactly two
weights, 400 and 500, and says so explicitly — 600 and 700 are not part of the
system. Every auth Submit, plus Invite, Toggle, Confirm-delete, Add-share,
the OAuth button and the assist Reject.

Now var(--fs-weight-medium), which is 500. Buttons get very slightly lighter.

Small on its own, but it is the third kind of drift the same five auth views
have now produced: geometry that differed per file, an accent fill the style
forbids, and a weight the system does not define. None of the three was a
deliberate choice — each is what happens when a button is written by copying
the nearest existing one.

Weight declarations only. No geometry, no colour, no templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:19:16 -04:00
bvandeusenandClaude Opus 5 f491b6d7b9 refactor(ui): action buttons stop wearing the accent
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 47s
21 buttons filled with --color-primary, which resolves to Scribe's violet
accent: every auth Submit (Login, Register, Invite, Forgot, Reset), plus
Invite, Add, Confirm, Restore, Generate, Log-save, Subtask-confirm,
Toggle-open, the modal primary, the version-restore, the inline-assist button,
the milestone-plan actions, the task-advance hover, and both empty-state CTAs.

The house style is explicit that the accent never appears on an action button:
action colours are universal across the family precisely so a Save button looks
identical in every app, while the accent carries identity. Doing both makes the
accent mean two things and neither clearly.

Operator's call, and the reasoning is worth keeping: the violet-on-Scribe-
actions treatment was a deliberate early choice to give the web UI its own
personality, made when much more of the app was user-facing. That is no longer
true, so consistency is now worth more than the distinction it was buying.

Found in two passes, which is the part worth noting. The first scan looked for
`.btn-*` and found 13. Seven more were the same thing under different names —
.modal-btn-primary, .vh-btn-restore, .inline-assist-btn, .empty-action,
.task-advance-btn — plus .ms-plan-actions .btn-primary, a compound override
flagged in the previous commit. Searching by naming convention finds what was
named consistently, which is never the whole set.

Deliberately NOT changed: progress-bar fills, active tab / page / selection
states, tag-pill hover, the duration badge, the skip link, the assist pulse.
Those are identity and active-state, which is exactly where the accent belongs.
After this the accent appears only there, which is what makes it read as
identity rather than as decoration.

Colour swaps only — 25 lines changed, no geometry, no structure, no templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:13:58 -04:00
bvandeusenandClaude Opus 5 1a959b1db0 refactor(ui): one button definition, aligned to the design system
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 39s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 1m12s
`.btn-primary` was defined five times in five scoped stylesheets and all five
had drifted: three paddings, three font sizes, three disabled opacities — and
ProjectListView had no disabled style at all, so a disabled button there looked
enabled. Nothing detected any of it. A scoped duplicate is not a rule
violation, not a broken reference, and not a recorded snippet, so no existing
check could see it (#2273).

assets/components.css is now the single definition of the core four —
primary, secondary, ghost, danger — plus the small modifier, in design-system
tokens throughout. Operator's call to align to the system rather than to the
majority of current values, so buttons move to the 8px radius and 12px label
the system specifies, from the app's 4px/14.4px.

Class names are unchanged, so there are no template edits: the existing surface
is repurposed, not rebuilt.

STAGING PROPERTY that makes this safe to land ahead of the rest: a Vue
`<style scoped>` rule compiles to `.btn-primary[data-v-…]` (specificity 0,2,0)
and beats a plain global selector (0,1,0). So the shared sheet changes nothing
for a view still carrying its own copy, and every intermediate state of the
remaining migration is coherent rather than half-applied.

Two divergences corrected on the way, both worth naming:

- SnippetEditorView's `.btn-secondary` was a GHOST in disguise — outline
  styling under the secondary name, while the house style says secondary is
  filled bronze. It now looks like what it is called.
- SnippetDetailView and SnippetListView tinted a ghost button's label with the
  ACCENT on hover. The house style reserves the accent for identity and active
  state, never general chrome.

DesignView reported "no shared button exists" as an honest gap and declined to
draw a look-alike. That gap is closed, so it now renders the app's real
classes — the specimens cannot drift from the app without drifting the app.

Net -177 lines. Follows: the ~20 semantic one-offs (.btn-save, .btn-delete,
.btn-toggle …) and the compound overrides in ProjectView, one of which puts the
accent on a primary action button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 19:52:35 -04:00
bvandeusenandClaude Opus 5 6d01788326 test(plugin): pin both halves of the local prior-art arm
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 28s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 21s
CI caught the previous commit: the fail-open contract asserts a hook stays
SILENT with no working instance, and the new local arm deliberately speaks
there. The invariant is right and the hook is right — the smoke event was
wrong. It used `def f`, which this repo really does define, so the hook found
something and "silent" was asserting the wrong thing.

Fixed by asserting the two properties separately:

- SILENT for a symbol that genuinely does not exist.
- SPEAKING, with NO credentials, for one that does. That is the point of the
  arm — the other arms ask Scribe what was RECORDED; this one asks the repo
  what EXISTS, which needs no instance. Were it to start depending on
  configuration it would stop covering the case it was built for, and only
  this assertion would notice.

Second trap, hit while fixing the first: spelling the absent symbol out in full
wrote `def <name>(` into check_plugin.py, so the smoke event DEFINED the very
symbol it claimed was missing, and the hook found it again. The name is now
assembled from fragments so the contiguous string never appears in the source.

scribe_prior_art.sh joins scribe_session_context.sh as a hook that legitimately
produces output without credentials — for the same reason, that it carries
something needing neither network nor config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 23:49:10 -04:00
bvandeusenandClaude Opus 5 17d59fa3e0 feat(prior-art): ask the repo, not just the record, before writing a definition
CI & Build / Plugin hooks (push) Failing after 6s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 21s
Scribe has never read a line of the codebase. Every Drafter surface recalls
from the RECORD — things someone deliberately recorded — so a helper nobody
thought to record is invisible to all of them. That is how `.btn-primary` came
to be defined four times, in four scoped stylesheets, already diverged: it was
never a snippet, so no threshold and no query rewrite could ever have surfaced
it (#2280).

The write-path hook already runs on the developer's machine, inside the repo,
holding the code about to be written. It can simply look. No index, no storage,
no staleness story, no server round-trip.

Verified against this repo with Scribe unconfigured:

    .btn-primary is already defined in 4 other file(s):
      DesignSystemsView.vue ProjectListView.vue SettingsView.vue
      SnippetEditorView.vue

Three properties it needs, all checked by hand:

- DEFINITION-shaped patterns only. Grepping bare occurrences would match every
  call site and bury the real finding, and a hint that is mostly noise is one
  people learn to skip — worse than none. A payload containing only calls to
  embed_note() stays silent; one containing `def embed_note` does not.
- The target file is excluded, so editing the file that already defines
  something doesn't report it against itself.
- It runs when Scribe is UNCONFIGURED, and a failed request no longer discards
  it. The remote arms answer "what was recorded"; this one answers "what
  exists", and that question needs no instance to produce an answer. `curl ||
  exit 0` became `curl || true` for the same reason.

Plugin 0.1.21 -> 0.1.22.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 23:43:58 -04:00
bvandeusen 6a6a388ecd docs: Fabled-Git, not Forgejo, in ci-requirements
The instance has run Gitea since the migration. Prose only — no workflow or
path change. Scribe issue #2272.
2026-07-31 23:42:25 -04:00
bvandeusenandClaude Opus 5 2cc9e1380e test(backup): assert the version constant, not a copy of it
CI & Build / integration (push) Successful in 21s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 41s
The export test pinned `out["version"] == 4`, so bumping BACKUP_VERSION broke a
test that was only ever checking the payload carries the version — which it
still did. Asserts against backup.BACKUP_VERSION now, and covers the six v5
sections alongside the v3 ones.

The guard itself passed on the first run: every table in Base.metadata was
accounted for, in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 23:13:16 -04:00
bvandeusenandClaude Opus 5 84541f392b fix(backup): six tables were silently absent, and nothing would catch a seventh
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / Python tests (push) Failing after 38s
CI & Build / Build & push image (push) Skipped
services/backup.py enumerated its tables as hand-maintained literals with
nothing tying them to the schema. Tables added since that list was last
extended were absent from every backup — no error, no warning, and a restore
that reports success.

Missing: systems, record_systems (0065), note_usage_events (0071),
design_systems, design_tokens (0072), and repo_bindings — which the issue
itself had not spotted, found only by diffing the model tablenames against the
two lists instead of trusting either.

_NOT_INCLUDED was worse than incomplete: it named "embeddings", "invitations"
and "password_resets", none of which are tables. It read as coverage while
naming nothing the schema could confirm. Now real names, plus retrieval_logs —
observational telemetry that grows per query and that nothing reads for
correctness.

THE DELIVERABLE IS THE GUARD, not the six sections. Extending a list fixes
today and changes nothing about the next table; a new one now fails a test
until someone either backs it up or states that it shouldn't be. It checks
both directions — an unaccounted table, and a listed name that no longer
exists, which is what the three phantom entries above would have tripped.

Design systems need ordering care: parent_id is a self-FK. The export orders
parent-first (parent_id NULLS FIRST, then id — a parent always has the smaller
id), so restore resolves each parent from the map as it goes, with no second
pass. A child whose parent is missing lands as a root rather than failing the
whole restore.

Usage events are kept because pull-through is the evidence base for whether
recall works, and it only ever accumulates — a restore that dropped it would
reset that measurement to zero while everything still looked fine.

BACKUP_VERSION 4 -> 5. Every new restore section is data.get()-guarded, so
v2/v3/v4 payloads restore unchanged.

Closes #2293.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 23:09:25 -04:00
bvandeusenandClaude Opus 5 da2383b079 fix(retrieval): verify the reserved slot's kind instead of trusting the query
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 34s
CI caught six failures on f8522fb. Five were fixtures; one was a real
assumption.

THE REAL ONE: _reserve_slot_for_reuse trusted that a query filtered by
note_type could only return reuse kinds. It now checks _record_kind on the way
in. That slot exists FOR snippets and processes — one silently spent on
something else is worse than no slot at all, because the resulting line is
indistinguishable from one that earned its place on score.

THE FIXTURES, all the same shape: MagicMock notes with is_task left to
auto-create. It is truthy, and _record_kind reads task-ness FIRST — so every
mock snippet in three test modules was rendering as "task". Two of those
fixtures already carried a comment explaining this exact hazard about `.data`;
the same reasoning applies to `.is_task` and nobody had needed it until the
menu started naming kinds.

One assertion was genuinely stale rather than broken: test_write_path_trigger
pinned note_type == "snippet", which was the behaviour the widening replaced.
Updated to the new contract, including the task_kind="issue" filter that keeps
the open to-do list out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 23:01:31 -04:00
bvandeusenandClaude Opus 5 f8522fb28f fix(retrieval): give reuse a slot, and let experience reach the write path
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Failing after 35s
CI & Build / Build & push image (push) Skipped
Two mirror-image scoping mistakes, neither deliberate (#2246).

AUTO-INJECT let every kind compete on raw cosine. That is fatal rather than
merely imperfect here, because Scribe's project records are ABOUT software
work: a task titled "surface snippets before the agent writes code" is a
near-perfect lexical match for "write a function…" while answering none of it.
Measured live, a prompt asking for a helper returned three records about
BUILDING the retrieval system and zero snippets. Snippets are ~0.5% of the
corpus, and the ratio worsens as the project record grows — which is the
direction Scribe is meant to grow, so no threshold tuning fixes it.

Now the best snippet or process takes the LAST slot when none won on score.
Deliberately NOT held to the margin band: that band measures distance from the
top overall score, and the top score is the very thing snippets lose to. It
still must clear the configured threshold, so a weak snippet cannot buy the
slot — silence stays the default. Skipped entirely when reuse already won,
so the fix is invisible in the case it isn't needed.

WRITE-PATH was snippets-only — the same mistake inverted. An issue recording
"we tried this and it deadlocked" could never reach the moment that code was
about to be written, though it is arguably the better prior art: it says what
NOT to do. Widened to snippets plus recorded experience.

That needed a filter the search layer couldn't express. "Experience" is issues
plus dev-logs, which differ on is_task, so neither note_type nor is_task alone
covers it. semantic_search_notes gains task_kind, which restricts TASKS to the
given kinds while leaving non-task notes untouched — so note_type=("snippet",
"note") + task_kind="issue" yields snippets, fixed problems and durable notes,
without the open to-do list. note_type now accepts a sequence too.

Non-snippet hits are labelled with their kind, because an unlabelled issue on
that menu reads as "here is code to reuse", the opposite of what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:56:31 -04:00
bvandeusenandClaude Opus 5 5c51e29f26 fix(embeddings): embed in the service, so every caller gets it (#2056)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 29s
A note or task created through MCP was not semantically searchable until the
next restart's backfill ran. Embedding fired at the five REST route handlers
and nowhere else; the MCP tools call the service directly, so they skipped it.

The shape of this bug is the reason to care: it is invisible on an instance
that redeploys constantly (this one does, per rule 46) and permanent on one
that doesn't. Rule 115 — the product has to stand up for the install that
restarts twice a year, not just for the one that restarts hourly.

Moved to services/notes.embed_note(), called from create_note and update_note,
and deleted from all five routes. Every caller — REST, MCP, recurrence,
snippets — now gets it by construction rather than by remembering.

Two things fall out of having one implementation instead of six:

- It uses note.user_id, the OWNER. The routes were inconsistent: some passed
  the caller's uid, some the owner's. On a shared record the caller's id mints
  a second embedding row that nothing reads.
- services/snippets.py's _embed_snippet existed only because snippets are
  created via MCP and the routes couldn't cover them. Every one of its four
  call sites goes through notes_svc, so the helper and its four calls are gone,
  along with the eight test patches that existed to neutralise it.

RuntimeError (no running loop — unit tests, scripts) and any indexing failure
are both swallowed: a write that succeeded must not be failed by its index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:46:57 -04:00
bvandeusenandClaude Opus 5 4c9a637507 fix(theme): text on a filled colour needs its own token — the old one inverts
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 41s
76 hardcoded `color: #fff` now resolve to --fs-text-on-action, a new token
that is parchment in BOTH modes.

The design system said they should supersede to --fs-text-primary, on the
recorded reasoning that "there is no 'text on action' colour, there is just the
text colour." That is true on dark and wrong on light. --fs-text-primary
inverts to #14171A; the surfaces underneath it do not invert at all — every one
of these 76 sits on an action colour, a semantic colour, the accent, or the CTA
gradient, all of which hold a single value across modes.

Sweeping as recorded would have put obsidian text on moss green: roughly 2.4:1,
against a house style whose stated floor is WCAG AA. It would have looked
correct to me, because I checked it in the mode where it was correct.

--color-accent-fg had the same defect independently and is repointed too.

The token check now reports zero superseded literals, down from 30 files, and
raw colour literals drop 246 -> 169.

Closes #2275.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:40:58 -04:00
bvandeusenandClaude Opus 5 731ca284c3 feat(design-systems): give a design system a way to reach the session
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 31s
Storing a design system never made a session aware of one. Rules get pushed
into every session by the SessionStart hook and returned by enter_project; a
design system had neither, so its standards were reachable only by an agent
that already knew to call resolve_design_system — the same silent failure as a
token nobody declares.

That gap was invisible while the operator's visual standards also lived in a
rulebook. Retiring that rulebook (which is what this unblocks) would have
deleted design guidance from every session with nothing to say so.

- services/design_systems.design_context() — the delivery side. Guidance is
  chain-merged ANCESTOR-FIRST: a child system holds only what it CHANGES, so
  its own guidance describes a departure from a house style it never restates,
  and the leaf alone is a fragment. Tokens are summarised (count + group
  names), not listed — a hundred declarations would crowd out the context they
  are meant to inform.
- enter_project returns `design_system`, null when the project has none.
- The SessionStart context gains a Design system block with pointers to the
  values, alongside the always-on rules.
- server.py's entity list gains Design system, including the negative: do NOT
  record one as a rulebook, because a token kept as prose cannot be resolved,
  inherited, rendered or checked.
- The rulebook-tier passage used "a design-system rulebook" as its worked
  example of a subscribed rulebook — it now teaches the opposite, plus a new
  "is this a rule at all?" test pointing at design systems, processes and
  snippets.
- using-scribe gains a section on building UI against the project's system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:10:09 -04:00
bvandeusenandClaude Opus 5 1e139d0d18 chore(plugin): bump to 0.1.21 for the planning-guidance edits
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 21s
The two skills changed in 6eedb0f are shipped plugin content, and the
installer compares manifest versions to decide whether to refresh the cache
that actually executes. Without the bump the edits reach the repo and stop
there (#2209) — which is exactly the silent no-op the check exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 20:43:01 -04:00
bvandeusenandClaude Opus 5 6eedb0f6b9 fix(instructions): stop mandating a milestone for every non-trivial task
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 26s
CI & Build / Build & push image (push) Successful in 28s
Four product surfaces told the agent to call start_planning FIRST for any
"non-trivial" work, while a fifth — the milestone bullet four lines up in the
same file — had the criterion right: use one when the work has an arc. The
loudest surface won, so sessions wrapped bug fixes and one-file changes in
milestones that never meant anything.

Mandating one project shape is what rule #115 forbids: some projects are
milestone-shaped, others are a flat task list and always will be.

Now the arc test is stated ONCE in full, in writing-plans, along with what to
do when there is no arc (a task, driven by status and work-logs). The other
surfaces name it and defer:

- writing-plans/SKILL.md gains a "first decide whether this work wants a plan"
  section; its frontmatter trigger is the arc, not "non-trivial"
- using-scribe reflex #4 points at the skill instead of restating it
- server.py's Plan bullet adopts the milestone bullet's own criterion
- server.py's planning paragraph drops from 11 lines to 6: it keeps the claim
  MCP instructions should make (a plan's HOME is a milestone, not a local .md)
  and drops the how, which the skill carries
- start_planning's docstring gains the when

Also removed "call start_planning FIRST — before any brainstorming, design, or
plan-writing skill runs." That was the server asserting priority over the skill
layer. Tools describe what they do; skills decide when they apply.

The structural point outlasts the wording: a surface that restates a rule is a
surface that will eventually contradict it, and nothing checks prose against
prose.

Closes #2322.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 20:39:20 -04:00
bvandeusen 378a4b8f99 feat(ci): check the app's own components against the tokens, not just snippets
CI & Build / Python lint (push) Successful in 7s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 41s
Closes the gap the theme.css repoint exposed (#2319, part of #2277).

`check_code_against_tokens` could always answer "does this code use the sheet
correctly?" — it was only ever fed recorded SNIPPETS. The app's own components,
where sixteen unresolvable references were living quietly, were checked by
nothing at all.

That was structural rather than an oversight: the drift panel runs in the browser
and cannot read source files, and the server has no repo access. CI is the only
place holding both the sources and the ability to run the check — and it only
became cheap once theme.css became a generated artifact, so the source of truth
is a committed file with no network and no credentials.

**The sheet now carries its own SUPERSEDES block.** That is what keeps the
checker instance-agnostic (rule #115): it knows nothing about any palette, and
reads both the declarations and the discouraged literals out of whatever
stylesheet it is pointed at. Hardcoding "#fff means use the text token" would
have baked one install's kit into the tool.

Two severities, split on whether the count is already zero:

  FAIL   an unresolvable var() reference — zero today, so this is a ratchet
         holding a line already reached. It cannot false-positive either: the
         name is declared or it is not.
  REPORT superseded literals (32 files) and raw colour literals (246). Gating
         those means a permanently-red job, and a check nobody reads is worse
         than no check.

**Comments are stripped before scanning, and that fired on the first real run.**
A comment explaining why a literal is avoided necessarily contains that literal —
DesignSystemsView's stylesheet documents exactly that about `#fff`, and the
checker reported the explanation as a violation. A checker that flags the
documentation of a rule teaches people to stop documenting rules.

Also narrowed `--fs-weight-medium`'s supersedes to the keywords. `600` and `700`
are real violations of the two-weight rule, but a bare number matches too much to
find by literal scan — `z-index: 600` is not a font weight. That needs a
property-aware check, which is a different tool.

Verified end to end: the generator's output parses back through the checker's
reader, so the two halves cannot drift into disagreeing about the format.
2026-07-31 14:52:06 -04:00
bvandeusen 67a529a38e feat(theme): repoint theme.css at the design system, and find what wasn't captured
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 42s
theme.css is now generated from design system 2 (Scribe, inheriting FabledSword)
plus a compatibility alias layer, so the record decides the styling rather than
describing it after the fact.

**Dark is now the base layer.** The kit is dark-mode-first, so `:root` carries
the dark palette and `[data-theme="light"]` overrides it — the inverse of how
this file read before. `useTheme` already sets the attribute explicitly to
"light" or "dark", so the flip needed no JS change. It also closes the one-way
scoping gap #251 recorded: there IS a `[data-theme="light"]` block now, so a
container can add light as well as dark.

**60 dark overrides became 12.** The other 48 were restating relationships the
derivations now express: `--color-bg` follows `--fs-surface-page` because an
alias resolves at use time, so it needs stating once rather than per mode.

## What the audit found, which is the actual deliverable

**12 dead tokens, removed.** Declared in both modes, referenced by nothing:
seven from the removed chat subsystem (bubbles, input bar), plus `--glow-soft`,
`--color-action-ghost-border`, `--radius-pill` and two chat widths. 17% of the
file was styling a feature that no longer exists.

**16 names referenced but NEVER declared** — not by this change, not by the file
before it. Fourteen carried hardcoded fallbacks, so pages rendered and nothing
ever failed, but the fallback was what rendered, every time. Several were off
the palette entirely:

  --color-primary-bg      fell back to rgba(99,102,241,0.15) — an indigo
  --color-destructive     fell back to #b85a4a — not the oxblood
  --color-status-cancelled fell back to #6b7280 — a grey from no palette here
  --color-muted           fell back to #888

All 16 now resolve to real tokens. Expect small visual shifts exactly where a
fallback had drifted; the shift is the fix.

**Two tokens the app needed and never had**: `--fs-status-cancelled` (Scribe has
had a cancelled task status since the lifecycle was built and never had a colour
for it) and `--fs-layout-header` (referenced with a 52px fallback, so 52px was
always the real value — just not one anybody could look up).

## The system grew to cover what the app improvised

Per the operator: the kit wasn't growing with the app, and this is the result.
Recorded as tokens with GAP RECORDED FROM PRACTICE in their rationale — action
hover states, disabled opacity, the modal scrim, both code backgrounds, the
table stripe, the CTA gradient and glows, the accent-deep and accent-wash tints,
and the layout dimensions.

Scribe's own system gained its domain semantics — task status, priority, overdue,
wikilink — all DERIVED from family colours, so twelve rows of duplicated hex
became twelve formulas and zero new values. Priority maps onto the semantic
ladder deliberately: low is info, medium is warning, high is error.

95 tokens resolved, none valueless, 34 derived, no broken references, no cycles.
2026-07-31 12:42:15 -04:00
bvandeusen 473280e690 chore(design-systems): stop teaching one install's kit in product copy
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 41s
The operator's check: this must be a system for managing design systems, not one
with the FabledSword family built into it.

No LOGIC was coupled — the audit found zero behavioural dependencies. But every
docstring example, every UI placeholder and several comments named this install's
palette, so a stranger creating their first design system was shown
"FabledSword" as the expected shape and `--fs-obsidian` as the expected token.
Examples teach, and these taught the wrong thing.

Placeholders now describe the SHAPE ("Your house style", "--surface-page")
rather than naming one instance's contents, and the token-name placeholder now
says the thing worth saying: name it for its purpose, because `--obsidian` and
`--button-bg` both stop being true the moment the value or the element changes.

Not fixed here, and it is the one real coupling left: DesignView.vue hardcodes
rule 65's button variants and rule 60's type scale as literal arrays, so a
stranger's Design page would display this family's specs. Those arrays exist
because there was no design system to read from — which there now is. They go
when the panel is repointed (#2295), not before.

Scribe's own stylesheet comments ("Moss action-primary per Hybrid") are left
alone: that is the app CONSUMING the family style, which is what dogfooding
looks like, not the tool assuming it.
2026-07-31 10:18:53 -04:00
bvandeusen 1fde646c60 feat(design-systems): formulas — derived tokens that follow their source
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 42s
Operator: "build in a way to support formulas like this so that the colors shift
as expected and have less to clean up when testing color changes."

The storage needed no change at all, which is the good news. A formula is just a
value:

    --fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)

It passes the value sanitiser untouched (verified, and now pinned by a test —
had `color-mix(... var(...) ...)` been rejected as unsafe, derivation would have
needed a storage shape of its own), and the browser resolves the `var()` at use
time. Change `--fs-accent` and everything derived from it shifts.

**One declaration covers every mode**, and that is the "less to clean up" part.
A derived token written once in the base layer follows its source through dark
mode automatically, because `var()` resolves where it is USED rather than where
it is written. A stored computed literal would need a row per mode and would
silently stop tracking the source the moment the source changed — the whole
problem this avoids.

What derivation DID need is the check. A formula pointing at a token that does
not exist is invalid-at-computed-value-time: the browser drops the declaration
outright and the token has no value. No error, no warning, nothing in the
toolchain notices — the same family as `--color-accent`, `_parent_map`, and the
scripted edit whose anchor matched nothing.

So `derivation_report` returns three things alongside the sheet: which tokens are
computed and from what, which formulas point at nothing, and which derive from
each other in a loop. CSS resolves a loop to nothing rather than hanging, so the
cycle check is about telling the operator, not protecting the renderer — but a
token that quietly resolves to nothing is exactly what is worth being told.

A self-reference with a fallback (`var(--fs-x, 8px)`) is deliberately not a
dependency; counting it would report every such token as a one-node loop.

The UI leads with broken formulas, then loops, then the healthy derived set —
the first two are unambiguously wrong, where a duplicate value is a judgement
call.
2026-07-31 09:51:54 -04:00
bvandeusen 23a385e2db revert(design-systems): drop the rulebook import — a migration, not a feature
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Operator's call, and it corrects a scope error rather than a bug:

  "this is a path for a user to go from a rulebook to a design system. we don't
   need to build this path in the app itself ... you should be the one that does
   the import ... going forward no one else should have to do such a migration."

Right. Nobody starting from a design system will ever go rulebook -> system, so
the whole path was permanent product code serving a single act on one install.
Rule #22: remove it, don't flag it off. Gone from the service, the REST route,
the MCP tool, the UI panel, the API client and its tests.

There is a second consequence I had missed, and it is the better argument. The
parser was WORSE at this than doing it by hand. `propose_tokens` leaves radius
steps and type sizes valueless because "Small 4px" is not a hex and nothing here
parses it — a limitation I documented carefully and shipped anyway. But that
limitation only exists because the importer had to run unattended. Done as work
rather than as a feature, those values are just read and written, and the result
is a complete design system instead of one with a dozen blanks and a count
explaining them.

Scaffolding built around my own absence from the loop, when I am the loop.

KEPT: `extract_expectations` and `design_expectations` in
services/design_rulebook_import.py. The live drift panel still reads them until
it is repointed at a resolved design system (#2295), and removing them now would
take the /design page's only content with it. They go with that change, not this
one.
2026-07-31 09:46:12 -04:00
bvandeusen 8eef9e7845 fix(design-systems): the empty state's create button did nothing, and one surface
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 47s
Two reports, one root cause each.

**The dead button.** "Create the first one" set `showCreate = true`, but the
create form lived inside `<div v-else class="ds-body">` — the sibling branch of
the empty state. The two are mutually exclusive, so on a fresh install the flag
flipped and nothing rendered. The first action a new install can take was the
one that didn't work, which is a poor way to honour "an install with zero design
systems is the ordinary state".

The first system now gets its own form outside the list layout, and it drops the
parent picker entirely: there is nothing to inherit from yet, so it says so
instead of offering an empty select.

**Two surfaces, the wrong one first.** /design and /design-systems are halves of
one thing — the record that decides the styling, and what the browser renders
from it — and I had added them as two separate nav entries with the read-only
diagnostic listed first. Backwards: the record is what you work with; the live
view is the check on it.

Now one nav entry pointing at the record, with a shared tab bar joining the two.
The explorer is renamed "Live tokens", which is what it actually shows.

The tab bar is a component rather than the same markup in both views. Two copies
diverge the moment a third tab appears — and a design surface that ships
duplicated markup would be arguing against itself.
2026-07-31 08:29:37 -04:00
bvandeusen 15d2e0c682 fix(design-systems): declare tokenRationale — the ref its usages referenced
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 34s
Broke the typecheck on 0f80b79. The scripted edit that added the three
`tokenRationale` usages and the one that declared the ref were separate
replacements, and only the declaration's anchor was wrong — so three usages
landed against a name that did not exist.

The declaration's replacement had no assertion on it while its neighbours did.
An anchor that matches nothing is a no-op, and a no-op looks exactly like
success.
2026-07-30 21:56:02 -04:00
bvandeusen 0f80b790c7 feat(design-systems): central prose — guidance on the system, rationale on tokens
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have
to live as one offs, there's a central system for managing it."

Two fields, both free-form:

  design_systems.guidance   the narrative a token table cannot hold — aesthetic,
                            voice and tone, what is deliberately out of scope.
  design_tokens.rationale   WHY a token is this value, which is a different
                            question from `purpose` (what it is FOR). "Success
                            equals Moss, aligned by design" is a rationale;
                            "page bg, deepest surface" is a purpose. Rules carry
                            the first routinely and a token row had nowhere to
                            put it.

Free-form rather than a column per category, deliberately. A schema with
`voice`, `aesthetic` and `scope` columns would bake one rulebook's table of
contents into every install (rule #115), leaving the next install three empty
columns and nowhere for what it actually cares about. Both nullable: a design
system with no prose at all is complete, not a draft.

`rationale` cascades like `purpose` — deepest non-empty wins — so an app
overriding a colour keeps the family's reasoning rather than blanking it. Same
argument as `supersedes`: the override was about the value, not the meaning.

In the generated sheet the inline comment prefers `purpose` and falls back to
`rationale`, so a token carrying only the why still says something instead of
rendering bare.
2026-07-30 21:52:16 -04:00
bvandeusen 46d88f9e7e feat(design-systems): check the components against the sheet they claim to use
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 35s
"The snippets use the tags from the sheet" was a relation nobody could verify.
Now it is three checks, and all three currently fail SILENTLY in this codebase:

  unknown             `var(--x)` where the system declares no `--x`. Renders as
                      nothing at all — no error, no failing test, no visual clue
                      beyond the element quietly not being styled.
  superseded literals a value the sheet said to stop writing, paired with the
                      token to write instead. Only possible because `supersedes`
                      is declared rather than inferred.
  local definitions   custom properties a snippet mints for itself instead of
                      reusing the sheet's — the bloat a shared sheet exists to
                      prevent, where a value stops being reused and starts being
                      restated per component.

The first is not hypothetical. Writing DesignSystemsView.vue earlier in this
same session I used `--color-accent` throughout; it does not exist, and nothing
in the toolchain noticed. This check is the thing that would have.

A token that is both defined and read locally is reported ONCE, as an unknown
reference — "--btn-bg does not exist in the sheet" is the more precise statement
of the same problem, and reporting both would double-count one fact.

Literal matching is boundary-aware and case-insensitive: `#fff` must not fire
inside `#ffffff` (different colours, and a finding on the wrong one sends
someone to change correct code), while `#FFFFFF` in a rulebook has to match
`#ffffff` in a stylesheet — the same trap `normalize_hex` exists for.

Snippets with nothing to report are omitted entirely. A list of everything that
is fine is a list nobody reads twice — the same principle the auto-inject menu
and the drift panel are both built on.

Two integration mistakes fixed while wiring it: `list_snippets` returns
`(rows, total)` and caps its limit at 100, and `get_snippet` returns a Note
model rather than a dict. The list rows carry a preview, not the code, so the
check reads each full body — checking the preview would have reported on a
truncation.
2026-07-30 21:47:47 -04:00
bvandeusen b0a7d9e89b feat(design-systems): the master sheet — purpose tokens, not per-element values
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 34s
Operator's new requirement (#2299, architecture in #2296): a design system does
not just hold tokens, it generates and manages a master CSS sheet. That settles
the milestone's open "authority mechanism" question — the record is
authoritative because the stylesheet comes out of it.

**The sheet is shaped by purpose and styles no elements.** It declares custom
properties, grouped by what they mean, and contains no `.btn-primary`, no
`table`, no `input`. That is the design, not a shortcut: a sheet that styled
elements would restate the same handful of values once per element and grow with
the UI, where purpose-named values are stated once and reused. Components live
as SNIPPETS that reference these names — a surface that already exists and
already carries prose, locations, drift checks, merge and write-path recall.

A token named after an element (`--fs-button-bg`) is the smell that the two have
been mixed; a purpose name (`--fs-action-primary`) is reused across all of them.

Alongside the CSS the endpoint returns what the text cannot say for itself:
which tokens are still valueless, and which VALUES are declared under more than
one name. The second is the operator's "reuse consistent values" constraint made
checkable — and it reports rather than refuses, because a design system
legitimately aligns colours on purpose ("Success = Moss, by design") and only a
human knows which case it is.

Mode maps to selector the way the codebase already does it: base on the root
selector, every other mode layered on `[data-theme="…"]`. The root selector is a
PARAMETER — #251 recorded that a container-scoped preview cannot use `:root`, so
hardcoding it would have made the generator useless to the preview surface.

A token the rulebook names but states no value for is emitted as a commented-out
declaration IN ITS GROUP rather than dropped. Its absence is the finding, and a
comment puts that finding where the reader already is.

Values are validated, not escaped, and this is a real boundary rather than
tidiness: design systems are shareable records (rule #47), so `red; } body {
display: none` in a system shared with you would otherwise inject CSS into your
page. A value containing `{ } ; @ < >`, a comment delimiter or a newline is
REFUSED and rendered as a comment saying so — rejecting beats stripping, since a
partially-sanitised value is one the operator never wrote and the sheet's whole
claim is that it is the record.

Not in scope, and deliberately: serving this as the app's actual stylesheet.
Generating and exposing a sheet is reversible; swapping theme.css for a
generated one is not, and it should be an explicit call rather than a side
effect.
2026-07-30 21:42:08 -04:00
bvandeusen 4dc57f8ab2 feat(design-systems): import a design system out of a rulebook's prose
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 3 (#2288). Reuses #251's prose extractor as the reader and
adds the part that makes it an import rather than a list of claims.

**The join is the whole trick.** A rulebook states a design system in two places
and neither half is a token: one rule names the colours ("Obsidian #14171A (page
bg, deepest surface)"), another names the custom properties
(`--fs-obsidian/iron/slate`). The import pairs them on the word — `--fs-obsidian`
ends with `obsidian` — which is the only reason it produces something usable
instead of seventy empty names. The parenthetical becomes the token's purpose,
which is the field a bare hex could never carry.

**Prohibitions arrive as replacements, per the operator's reframe.** Rule 52
declares Parchment and forbids pure white in one breath, so the import emits
"write --fs-parchment instead of #ffffff" — the same fact stated forwards. It
attaches to the FIRST token that rule supplied a value for, not to every token
of that rule, because claiming Vellum is also the replacement for white would be
putting words in the rulebook's mouth.

**A token the rulebook names but states no readable value for is still
proposed, with an empty value.** Radius steps and type sizes are prose ("Small
4px") and nothing here parses them; inventing a parse per shape would be
guessing. The name is real and the value needs a human, so the proposal says
exactly that — and the UI leads with the COUNT of those, because an import that
hid them would look more complete than it is.

Preview is the default on both surfaces and in the UI. An import is a proposal:
rulebooks are written aspirationally and some of what they describe was never
built, so every entry carries the rule id and the sentence it came from and a
reviewer can check the claim rather than trust it.

Existing token names are never overwritten. A value already in the record was
put there deliberately — most likely correcting this importer — so a re-run
fills gaps and lists the rest as skipped, which also makes it safe to repeat.

Colours the rulebook names but never exposes as a custom property produce no
token: it never asked for one, and inventing a name would put something in the
record no rule sanctions.
2026-07-30 21:23:32 -04:00
bvandeusen 3da40abcb8 feat(design-systems): declare what to write instead, rather than what not to
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 6, first half (#2295) — and this reframes the task rather
than answering it. The operator's call:

  "in this case we should declare what should be used in place of pure white,
   it's not a prohibition it's what should be used in its place."

None of the three options on the table (a constraints record / the panel reads
both sources / negative token rows) was right, because all three kept the
prohibition as a KIND OF THING. It isn't one. "Pure white is never text" is the
shadow cast by a positive fact — text is Parchment — and a design system that
stores what things ARE has no row for a ban because it never needed one.

So `design_tokens` gains `supersedes`: the literal values this token should be
written instead of. `--color-text-on-action` supersedes `#fff` / `#ffffff`. Same
fact as the rule, stated forwards, and now actionable — a finding can say what
to write rather than only objecting.

It has to be DECLARED, not derived, and that is the crux: `#fff` and Parchment
`#E8E4D8` are different colours, so no value-matching check could ever have
connected them. That mismatch is precisely why the prohibition looked
unrepresentable until it was turned around.

`supersedes` cascades on EMPTINESS rather than on None. A child overriding a
colour says nothing about which literals it replaces, and blanking the family's
declaration there would silently disarm the check for every app that customises
the token — while a child that states its own list replaces it wholesale.

Two things this deliberately does NOT do:

  - It does not feed the drift panel. Superseded literals live in component CSS,
    which `designDrift.ts` cannot see and already documents as a blind spot.
    This is input for the source lint (#2277). Declaring it with nothing
    consuming it yet is honest; wiring it to a panel that cannot check it would
    not be.
  - It does not remove the panel's `prohibited_color` arm yet — that happens
    when the panel is repointed at a resolved system, which needs #2288 first.

The declaration also exposes a missing token: most of the 67 hardcoded
`color: #fff` (#2275) are text on a coloured action button, and the system has
no token for that role at all. Every view hardcodes it. Declaring the token that
was never there is the first real output of the operator's framing.
2026-07-30 21:16:45 -04:00
bvandeusen 78489308b8 feat(design-systems): link /design to its editable half
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 40s
The two pages are halves of one surface — what the browser renders and the
record that should decide it — and only one direction was linked. Missed in
0937b17 because the patch that added it silently didn't apply; the commit went
out without it.
2026-07-30 17:17:59 -04:00
bvandeusen 0937b1761e feat(design-systems): the editing surface — overrides, effective set, provenance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 15s
CI & Build / Python tests (push) Canceled after 15s
CI & Build / integration (push) Canceled after 15s
CI & Build / Build & push image (push) Canceled after 0s
Milestone #254 step 5 (#2294). /design-systems is the editable half of the
surface /design already showed: that page is what the browser renders, this one
is the record that ought to decide it. Each links to the other.

The layout follows the model rather than decorating it. Two token lists, and
they are deliberately different questions:

  Overrides  — the system's own rows. Short by design, and EMPTY is the correct
               state for an app that hasn't departed from its family yet, so
               that empty state says so rather than looking unfinished.
  Effective  — what it resolves to with inheritance applied, each row labelled
               with where its value came from.

Provenance renders PER MODE when the modes disagree. A system can own `base` and
inherit `dark` at once — that is the case the value column is a map for — and a
single badge per row would have to lie about one of them. Rows whose modes agree
(the common case) keep the single badge.

"Defined here" and "overridden here" are distinct labels. Introducing a token
and shadowing an ancestor's are different acts, and `is_overridden_in` is
already false for the first.

The parent picker filters out the selected system's descendants. The server
refuses those anyway with a message naming the loop — but a refusal you cannot
trigger beats a refusal explained well. Cycles that arrive some other way still
render a truncated chain rather than freezing the tab: the client keeps the same
defensive visited-set the server has.

Three drift bugs caught while writing the styles, all of the shape this
milestone exists to surface:

  - `--color-accent` does not exist. I had used it for every focus ring and
    active border; it would have rendered as nothing at all, silently. The
    brand token is `--color-primary`.
  - focus rings are ALREADY global in theme.css (`button:focus-visible` et al).
    My per-element rules would have overridden the house ring with a different
    one — the exact "bypassed abstraction" shape from #253.
  - every existing `.btn-primary` copy uses `color: #fff`, which is rule 52's
    prohibition and 67 live violations (#2275). This one uses Parchment and
    says why in a comment, rather than becoming the 68th.

Also wires the project pointer into ProjectView's details panel, hidden entirely
when no design systems exist (rule #115 — that is the ordinary state, not a
degraded one) and saved through its own PUT, since clearing it is a real outcome
rather than an omission.
2026-07-30 17:17:43 -04:00
bvandeusen 143b968c5d feat(design-systems): REST + MCP surfaces, at parity
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 26s
Milestone #254 step 4 (#2290). Eleven capabilities, both surfaces, one service.

Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do not nest under /api/projects/ the way systems do —
routes/rulebooks.py was the closer shape. The one exception is the project
pointer, which is genuinely about a project: PUT /api/projects/<id>/design-system,
PUT rather than PATCH because clearing it is a first-class outcome and not an
omission.

`/resolved` and `/tokens` are deliberately separate endpoints. One answers "what
does this system CHANGE", the other "what does it end up BEING", and a system
that overrides nothing has an empty token list and a full resolved set. Shipping
only one would have made the other a client-side computation of exactly the kind
the record model exists to remove.

ResolvedToken.to_dict carries the SHADOWED contributions, not just the winner.
Dropping them at the serialisation boundary would have discarded the one thing
step 2 was built to preserve, and it would have been invisible — the payload
still looks complete.

Three sentinel translations on the MCP side, each tested, because an agent
cannot omit an argument and a wrong mapping here is silent:

  - parent_id: 0 = unchanged, -1 = clear (become a family system), positive =
    set. Renaming a system must not silently re-root it.
  - order_index: -1 = unchanged, since 0 is a valid position.
  - value_by_mode: guarded on `is not None`, not truthiness, so `{}` can strip
    every mode from a token instead of being unreachable.

DesignSystemCycle maps to 400 on REST and to a ValueError carrying the message
on MCP — kept apart from 404 throughout. An agent told "not found" retries the
same call; one told what the loop is can fix it.

Two structural guards beyond the parity list: every endpoint must be reachable
on the app (catching a decorator copied without its path, where the second
handler silently never runs), and every public coroutine in the tools module
must be registered (a tool written but never registered is invisible to an
agent, and nothing else would notice).
2026-07-30 17:09:25 -04:00
bvandeusen 839d6902ad feat(design-systems): resolve the chain, and keep the argument not the verdict
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 29s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
Milestone #254 step 2 (#2287). `resolve_tokens` flattens a system's inheritance
chain into its effective token set — walk to the root, deepest wins by token
name. Pure and duck-typed, so a test states a whole hierarchy in literals and
the service hands the same function ORM rows.

**Provenance is stored as the contest, not the winner.** A ResolvedToken carries
every system that offered a value, per mode, deepest first — `[0]` won and
`[1:]` are what it shadowed. "Which system supplied this?" and "what did it
override?" are then two reads of one list and cannot disagree, where a winner
plus a separate provenance field would be two things to keep in step.

**Merging is per (name, MODE), and that is the storage decision paying off.** A
system that deepens one accent for light backgrounds while leaving dark alone
owns `base` and still inherits `dark`. A token-level "overridden here" flag
would have to lie about one of them, and the two-column shape could not have
represented it at all.

Metadata cascades separately by the same deepest-wins rule, with one exception:
`order_index` treats 0 as UNSTATED rather than "first", because 0 is the column
default. Reading it as a real value would let a colour-only override drag its
token to the top of its group — a visible reshuffle in return for a change that
touched nothing structural.

One fix to step 1 while wiring this up: `_parent_map` is now scoped to the
SYSTEM'S OWNER rather than the caller. A caller reading through a shared project
owns no link in the chain, so the caller-scoped version would have handed them
an empty forest and truncated the cascade to a single system — a page rendering
with plausible wrong values and no error anywhere. The ACL already grants read
along the whole chain; this is the loading side keeping that promise, and it now
has a test naming the shared-project case.

`BASE_MODE` moves from the model to the cascade module, where it belongs: it is
a resolution rule, not a storage fact, and design_cascade.py deliberately
imports nothing so both access.py and the service can depend on it.
2026-07-30 17:02:31 -04:00
bvandeusen 03b3998585 feat(design-systems): the model, the parent chain, and the guard on it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python tests (push) Successful in 42s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Build & push image (push) Successful in 27s
Milestone #254 step 1 (#2286). A design system becomes a record Scribe holds
rather than prose in a rulebook: a named set of tokens with an OPTIONAL parent,
so a family system carries the house style and an app system carries only what
it changes. Answering "what does this app alter?" is then `list its tokens` —
nothing to compute.

`parent_id` is the whole model. It replaces both an `always_on` flag (a family
system is one with no parent) and a subscription join table (a project points at
ONE system; the chain supplies the rest) — less schema than the rulebook shape
it mirrors.

Two decisions the task left open, settled here:

- **Token values are JSONB keyed by mode**, not `value_light`/`value_dark`
  columns. The deciding argument was not flexibility, it was ambiguity: in a
  child system an unset mode means "inherit", in a root it means "not
  mode-dependent", and as columns both are NULL and the resolver cannot tell
  them apart. As a map, resolution is `{**parent, **child}` at every level with
  no special case for roots. Against it: queryability — but nothing filters
  tokens by value in SQL, so that buys a query no caller makes.
- **`group_name` is free text, no CHECK enum.** Groupings are each design
  system's own vocabulary; a whitelist would bake one install's kit into the
  schema. No CHECK is introduced anywhere, so rule #36 does not fire.

The cascade lives in `services/design_cascade.py` as pure functions over a
`{id: parent_id}` map, importing nothing — which is what lets both the service
and `access.py` use it without a cycle, and lets a test state a whole hierarchy
in one literal. Cycles are refused on WRITE by walking up from the proposed
parent (the cheap direction), and survived on READ by a visited-set, because a
loop from a direct DB edit must truncate rather than hang.

ACL (rule #78) is deliberately asymmetric: owning a system grants write,
reaching one through a project you can see grants READ ONLY. An editor on a
shared project must not be able to rewrite the family system every other project
in that family resolves through.

Also renames `services/design_system.py` -> `design_rulebook_import.py`. It is
the #251 prose extractor, whose role is already scheduled to become a one-shot
importer (#2288), and leaving it one character away from the new
`design_systems.py` was a trap for every later session.

Rule #115 throughout: nothing seeds a system or implies a default. An install
with zero design systems is ordinary, not degraded.
2026-07-30 16:54:41 -04:00
bvandeusenandClaude Opus 5 4ca3ab02c4 feat(design-explorer): the drift panel — rulebook says X, tokens say Y
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 40s
Milestone #251 step 5 (#2262), plus the Settings control that makes it reachable.

The comparison is deliberately thin — set arithmetic over live token values,
which is the one thing the browser knows and the server doesn't. The hard half
(prose to claims) already lives in Python where pytest can assert on it.

Three claim kinds, and the third inverts the test: 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 FAILS when present.

normalizeColour is the client-side twin of normalize_hex and has one job the
server cannot do: getComputedStyle reports colours as rgb()/rgba() regardless of
how they were authored. So one colour has three spellings in play — #FFFFFF in
the rulebook, #fff in the stylesheet, rgb(255,255,255) from the browser — and a
comparison that misses any of them under-reports silently rather than erroring.

THE PANEL STATES ITS OWN BLIND SPOT, which matters more than it sounds. This
compares the rulebook against TOKENS. A literal hardcoded in a component, where
a token should have been referenced, is invisible to it — the drift isn't in the
tokens at all (#2275: 67 hardcoded whites against a rule forbidding pure white).
Reading those would mean bundling every SFC's source into the app; the check
belongs in CI and is tracked at #2277. A drift report that silently omitted a
whole category would invite the reader to conclude the category is clean, so the
panel says so in the panel rather than in a comment nobody reads.

Findings are ranked violated → missing → ok, and `ok` rows are hidden behind a
toggle. Same principle the auto-inject menu is built on: a short list that gets
read beats a complete one that doesn't.

Settings gains a rulebook picker. "None" is a first-class choice, not an unset
error — most installs have no rulebook describing their design system, and
saving empty DELETES the setting rather than storing a zero. The panel's empty
state points at Settings and Settings points back at the panel, so neither is a
dead end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 15:32:29 -04:00
bvandeusenandClaude Opus 5 d3ee24f239 feat(design-explorer): rulebook binding + prose→claims extraction
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 27s
Milestone #251 step 2 (#2259). The half of the drift panel that needed to be
testable, which is why it is Python: the frontend has no test runner, so the
fiddly extraction lives server-side and the browser only does set arithmetic
over live token values.

BINDING. A per-user setting `design_rulebook_id` names the rulebook that
describes this install's design system. A 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. No rulebook
designated returns an empty set with rulebook_id: null — the NORMAL case for any
install but the one that set it up (rule #115), which the client renders as an
explanatory empty state rather than an error. The id comes back alongside the
list so "not designated" and "designated but empty" stay distinguishable.

EXTRACTION. No NLP. Rule statements are prose written for humans and should stay
that way, so this takes only what is unambiguous in any prose — the hex colours
and custom-property names a rule mentions. Anything subtler needs a rule author
to opt into a structured form, deliberately left for when someone wants it.

Three things earn their complexity:

- SENTENCE-SCOPED NEGATION. A rule routinely states what the palette requires and
  what it forbids in consecutive sentences ("Parchment #E8E4D8 …, Vellum #C2BFB4
  …. Pure white #FFFFFF is NEVER used."). Detecting negation across the whole
  statement would mark the required colours as forbidden — inverting the finding
  rather than missing it, which is worse. Per sentence, all four come out right.

- HEX NORMALISATION is load-bearing, not tidiness. The rulebook writes #FFFFFF
  and components write #fff; if those don't compare equal the largest drift
  finding in the codebase — 67 hardcoded white text colours (#2275) — reads as
  zero. Alpha forms keep their alpha, since #fff and #ffff are different colours
  and collapsing them would manufacture equality.

- SLASH SHORTHAND. Rulebooks write token families as --fs-radius-sm/md/lg/xl and
  --fs-obsidian/iron/slate/pewter. Both expand under one rule — prefix is
  everything up to and including the LAST hyphen of the first segment — which
  also handles --fs-dur-fast/base/slow. Verified against the real rule text: 18
  tokens from three different shorthand shapes.

how_to_apply is read alongside statement, because rulebooks routinely keep the
statement declarative and put the concrete values in how_to_apply; ignoring it
would miss the checkable half.

Claims dedupe on (kind, value), first source winning, so a colour named by
several rules is one expectation attributed to the rule that introduced it.
Prose with nothing checkable yields nothing — most rules are judgement, not
specification, and a panel that reported unparseable rules as problems would be
unusable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 15:25:36 -04:00
bvandeusenandClaude Opus 5 3c0192d749 feat(design-explorer): the gallery, including what isn't there
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 40s
Milestone #251 step 3 (#2260). New /design view, reachable from a Palette icon
beside Trash and Settings — a meta-surface like /rules, so an icon rather than a
sixth primary nav link, but not hidden either, since somewhere the design system
is visible is the entire point.

Renders three things, and refuses to render a fourth:

- REAL components, imported not recreated: StatusBadge, PriorityBadge, TagPill.
- REAL tokens, read at runtime via readTokens() so the page shows the live
  cascade rather than what the stylesheet claims. Grouped, swatched where the
  value is a colour, flagged where the token is mode-aware.
- Rule 65's four button variants and rule 60's type scale, listed as SPEC and
  marked missing.

That last part is the point of the step rather than a shortfall of it. Step 3's
premise was "render the real components, not copies — a gallery of look-alikes
drifts from the app within a month and then lies." Buttons have no shared
implementation to import: .btn-primary is defined four separate times in four
<style scoped> blocks, and 30 of 54 SFCs carry their own button CSS (#2273).
Drawing a button here would have made this page the fifth copy — committing the
exact drift the surface exists to catch. Same for the type scale: the three
families load (rule 59) but rule 60's sizes and weights are not tokens, so there
is nothing to read and a rendered specimen would be invented.

So the gallery reports them as gaps. A design system nobody can point at is a
design system that isn't there, and saying so is more useful than a page that
looks complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 14:41:06 -04:00
bvandeusenandClaude Opus 5 61e6e38419 feat(design-explorer): token inventory — what exists and what it resolves to
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 31s
Milestone #251 step 1 (#2258). Foundation for the gallery and the drift panel.

Parses NAMES from theme.css and asks the BROWSER for every value. That split is
deliberate. Extracting `--foo` is a trivial regex; extracting its value is not —
theme.css has nested parens, commas inside rgba(), var() chains, multi-part
shadows and gradients. getComputedStyle already resolves all of it, reports what
actually won the cascade, and — the reason that matters here — reflects live
overrides set on a container, which is exactly what the preview surface needs
(#2261). Parsing values would report what the file says rather than what the
user is looking at.

It also keeps the error-prone half out of our code, which matters because the
frontend has no test runner: `vue-tsc --noEmit` is the entire check. Logic that
can't be unit-tested should be logic that can't be very wrong.

readTokens(host) takes an element, so the same function reads app-wide values
from :root and scoped values from inside a preview container.

A BUG CAUGHT BEFORE SHIPPING, worth recording because the first version looked
obviously right: the declaration regex originally required the match to follow
`{` or `;`, to avoid matching var() uses. That silently dropped every
declaration preceded by a COMMENT — including --color-bg, the first and
most-used token in the file. 67 of 70 tokens found, no error, no warning.

The anchor was never needed. A declaration is `--name:` and a reference is
`var(--name)` or `var(--name,` — the colon alone discriminates. Comments are
stripped first so commented-out declarations aren't counted. Verified against
the real stylesheet: 70 unique tokens, 60 dark-overridden, 10 light-only, every
group resolving, zero var()-only false positives.

Also records a constraint discovered while building, which shapes step 6: light
is declared on :root and dark on [data-theme="dark"], so an attribute selector
can ADD dark to a subtree but nothing can add light back. Dark-inside-light
previews work; light-inside-dark previews cannot, until a [data-theme="light"]
block exists. readTokensForMode documents this rather than pretending otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 13:48:23 -04:00