Compare commits

...
Author SHA1 Message Date
Renovate Bot 5bf55fc488 Add renovate.json 2026-08-04 04:01:43 +00:00
bvandeusen fefae606ed Fix the projects-page pool exhaustion, and cap milestone bars at 10 (#95)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / integration (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 24s
2026-08-02 19:43:00 -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
bvandeusen da6bb815bb Buttons: one definition, aligned to the design system — plus local prior-art recall (#94)
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 20s
2026-08-02 18:50:04 -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
bvandeusen 8288c6e4a7 Design-system delivery, the contrast fix, recall scoping, and backup coverage (#93)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 23s
CI & Build / Python tests (push) Successful in 57s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 17s
2026-07-31 23:21: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 716f227bc7 theme.css generated from the design system (#92)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 21s
2026-07-31 12:45: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 d1d335e293 Formulas — derived tokens that follow their source (#91)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 19s
2026-07-31 09:54:10 -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 7872e7d9ec Drop the rulebook import — a migration, not a product feature (#90)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
2026-07-31 09:49:48 -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 15eae532bd Fix the dead create button on a fresh install, and make Design one surface (#89)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 22s
2026-07-31 08:32:07 -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 f00e9747ad Design systems as records — the stylesheet Scribe holds (#88)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 17s
2026-07-30 23:35:26 -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
bvandeusenandClaude Opus 5 293a14361a fix(db): bind datetimes, not strings, when filtering timestamptz columns
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 26s
Closes #1727 and #2257 — the same bug in two services written months apart.

AppLog.created_at is `timestamp with time zone`. asyncpg binds a Python str as
VARCHAR and Postgres has no `timestamptz >= text` operator, so both of these
raised when Postgres planned the query:

  notifications.check_due_tasks   AppLog.created_at >= today.isoformat()
  logging.get_logs                AppLog.created_at >= <raw request.args str>

#1727 was the worse of the two because a per-user `except Exception` swallowed
it: reminder emails silently never sent, and the only outward trace was an
hourly traceback in the Postgres log. It has been open since 2026-07-19 with the
diagnosis written and the fix never applied. #2257 has no swallowing handler, so
it merely breaks the admin log viewer's date filters outright.

notifications: `utc_day_start(day)` returns midnight UTC as an AWARE datetime.
Deliberately not the bare `date` the original diagnosis suggested — comparing
timestamptz to date does work via an implicit cast, but Postgres resolves that
cast in the SESSION's TimeZone, so the dedup window would drift with a server
setting nobody remembers is load-bearing.

logging: `parse_filter_datetime()` converts the query-string value to an aware
UTC datetime; unparseable input returns None so the filter is skipped rather
than 500ing the viewer. It also fixes a bug the naive fix would have introduced
— `date_to=2026-07-30` parses to midnight, so `<=` would exclude the entire day
the user asked for. Date-only upper bounds now run to 23:59:59.999999, while a
value carrying an explicit time is left as given.

The guard is the point. This class is invisible to ordinary testing: the failure
happens when Postgres plans the query, not when Python builds it, so no unit
test that doesn't execute SQL can see it. tests/test_timestamp_filters.py fails
CI on two shapes —

  1. a local bound to .isoformat() compared against a *_at column   (#1727)
  2. a str-ANNOTATED PARAMETER compared against a *_at column       (#2257)

Shape 2 is the one that matters. Nothing in logging.py looks date-ish, so a
guard built only from #1727's shape finds nothing there — which is exactly how
the second instance survived. Verified by replaying both checks against the
pre-fix files out of git: shape 1 catches `today_str`, shape 2 catches
`date_from`/`date_to`, and the current tree is clean.

Found by grepping for siblings after fixing #1727 — the third instance today of
"the second place nobody checked", after #2245.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 12:53:57 -04:00
bvandeusen 1adf57739d Cross-language prior-art labelling + close the surfaced→pulled loop on get_task (#87)
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 23s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 19s
Closes #2244 and #2245. Prior-art hits in a different language than the file
being written are now labelled and explained rather than surfaced bare, and
get_task records a pull so auto-inject's pull-through stops reading near-zero for
the kind it mostly surfaces.

No migration, no plugin manifest bump — server-side only.
2026-07-30 11:03:57 -04:00
bvandeusenandClaude Opus 5 6ca215d2b6 fix(telemetry): record a pull on get_task, closing the surfaced→pulled loop
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 39s
Closes #2245. note_usage_events recorded `surfaced` for every auto-inject menu
line regardless of kind, but `pulled` only from get_note, get_snippet and the
REST snippet route. get_task recorded nothing.

Auto-inject ranks kind-blind over a corpus that is overwhelmingly tasks and
issues, so tasks are most of what it surfaces. Measured live, "write a function
to debounce a callback in the frontend" returned three tasks and zero snippets —
all three written as surfaced, none able to record a pull.

surfaced and pulled only mean anything as a PAIR; the rate between them is what
#1038 and #2085 gate on. So the gap sat exactly where the volume is, and the
metric would have said "auto-inject surfaces things nobody opens" for its own
dominant kind — an artifact of the instrumentation, not a fact about the feature,
and one that pointed at a plausible-sounding wrong conclusion.

get_note already carried a comment stating this was meant to cover ANY note kind
precisely so tasks wouldn't look like dead weight. get_task is a separate tool in
a separate module and never got the call — sibling drift, invisible because a
missing side effect changes no return value.

Guarded by a rule-#33 contract test that asserts, by source inspection, that
every getter reachable from an auto-inject menu calls record_pulled. Source
inspection because no behavioural test can see a call that isn't there.

Not fixed here: the REST note/task detail routes still record nothing while the
REST snippet route records `rest_snippet`. That asymmetry is real, but a human
reading a note in a browser is arguably not the same event as an agent recalling
one, and collapsing them could skew the signal the other way. Raised as a
question for the retrieval survey instead of decided in passing.

Pre-fix rows under-count task pulls, one-sidedly by kind — treat them as unknown
rather than zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 10:35:23 -04:00
bvandeusenandClaude Opus 5 390846a3d5 fix(write-path): disclose cross-language prior art instead of hiding it
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
Closes #2244. Retrieval matches on concept, and concepts are language-agnostic:
asking about a TypeScript union-find scores 0.72-0.73 against a PYTHON snippet,
comfortably over the 0.68 bar. That is useful — a different-language solution
gives you the shape even when the code isn't reusable — but the menu line said
nothing about it, so the reader either dismissed a good structural reference or
pasted Python into a .ts file.

Worth noting this predates the concept-query change: raw TS code already matched
the Python snippet at 0.73, because the embedder reads identifiers and structure
semantically rather than syntactically. The fail state has been shipping quietly;
#2242 only made it an intended use rather than an accident.

- knowledge._note_to_item projects `language` from the data mirror, same shape as
  the existing verification projection — a plain column read, no body parsing.
- The semantic arm carries language through on the item it builds; it is the arm
  where these arise, since a snippet recorded AT the path you're editing is
  almost never in another language.
- _prior_art_line folds it into the marker: [similar 0.72 · python]. Together
  with the score rather than after the title, because the two jointly are the
  judgement being offered.
- One explanatory line is added to the menu, and only when something on it is
  actually tagged.

Two deliberate calls:

LABEL, DON'T FILTER. A stricter threshold for foreign-language hits would
suppress exactly the shape-borrowing this exists for. They were never the
problem; their being undisclosed was.

ONLY CLAIM A MISMATCH YOU CAN ESTABLISH. _foreign_language returns "" when either
side is unknown — unrecognised extension, or a snippet with no recorded language.
A wrong "· python" is worse than no tag. Same-language hits stay unlabelled, so
the common case keeps a clean line and the preamble stays off the menu entirely.
Operator-typed language names fold through an alias table first (py/python3 →
python, tsx → typescript, c++ → cpp); unrecognised names pass through lowercased,
which still makes an unknown-but-equal pair compare equal.

Trap found while building: _note() in the tests is a MagicMock, so `note.data`
auto-created a truthy mock that would have rendered its repr into a menu line.
Both test helpers now set data = None explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 10:31:56 -04:00
bvandeusen a36837c96a Write-path semantic arm: query snippets by concept, not raw code (#86)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 18s
Implements #2242. The semantic arm now queries with what the code says it is FOR
— declarations plus the first docstring / JSDoc / leading comment — instead of the
raw payload, because snippet documents are prose-forward: 0.823 vs 0.743, with
double the separation from the noise floor.

No doc means no rewrite (a bare identifier measured 0.671, worse than the code),
and the 48-char floor still judges the raw payload before the rewrite.

No migration, no plugin manifest bump — server-side only.
2026-07-30 10:21:19 -04:00
bvandeusenandClaude Opus 5 57781770c3 feat(write-path): query snippets by concept, not by raw code
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 26s
Implements #2242, from the operator's question: would it make more sense to
search by the concept of the snippet than by the code itself?

It would, measurably. A snippet's embedded text is f"{title}\n{body}", and a
snippet's body is composed markdown — When to use / Signature / Location, then
the fenced code — so `when_to_use` appears TWICE in the vector and the document
is prose-forward. The semantic arm was interrogating it with raw code carrying
no prose at all. Measured on the deployed instance against snippet #2222:

  query built from            score   best unrelated   separation
  raw code body               0.743   0.630            0.11
  name + docstring            0.823   0.602            0.22
  hand-written concept prose  0.835   0.583            0.25

A 12-word description beats a near-verbatim reimplementation of the function,
and code-as-query RAISES the noise floor. It's also the cleanest explanation for
the fragment miss recorded on #2223: a short excerpt carries almost no prose to
match a document that is mostly prose.

So build the query from what the code says it's FOR — declarations plus the
first docstring / JSDoc / leading comment block — shaped as "name(params) — what
it does", mirroring a snippet's own title, which is the form that measured 0.823.

Server-side rather than in the hook: no manifest bump, so installed 0.1.20
plugins get this immediately; multi-language parsing in bash would be miserable;
and it's unit-testable here.

Two rules worth calling out, both measured rather than chosen:

- NO DOC, NO REWRITE. A bare identifier is not a concept and scored 0.671 vs the
  code body's 0.743. Separation from noise is identical either way (0.113), but
  the absolute drops under the 0.68 bar, so preferring a bare name would convert
  a comfortable hit into a miss. Undocumented code keeps the raw payload.
- The 48-char floor still judges the RAW payload, before the rewrite. A concept
  query is allowed to be shorter than the floor — that is the point, the best
  queries are short — but a sub-floor edit stays silent even with a docstring.
  Applying the floor after extraction would discard the best queries.

Regex, not a parser: this is on a PreToolUse critical path and an Edit's
new_string is rarely a valid module, so a miss must cost only a fallback. Every
unrecognised language (Vue SFC, config files) degrades to exactly the previous
behaviour.

Telemetry now logs the concept query rather than the code, since retrieval_logs
is what the threshold gets tuned from and the two aren't comparable.

0.68 is left alone: signal rises to 0.82 while noise FALLS to 0.58, so the bar
sits mid-gap instead of near the edge. To be re-measured against the deployed
instance rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 09:58:06 -04:00
107 changed files with 10507 additions and 2047 deletions
+12 -1
View File
@@ -165,7 +165,18 @@ jobs:
# ruff is pre-installed in the ci-python image — no install
# step needed, lint runs in ~2s.
- name: Lint
run: ruff check src/
run: ruff check src/ scripts/
# Design tokens: does the frontend's CSS agree with the stylesheet the
# design system generates? Fails only on an unresolvable var() reference —
# that count is at zero, so this is a ratchet rather than a backlog. The
# literal findings are printed, not gated; hundreds exist and a
# permanently-red job is one nobody reads.
#
# Stdlib only, no install, no network: the source of truth is theme.css,
# which is generated from the design system and committed.
- name: Design token check
run: python3 scripts/check_design_tokens.py --report-literals
test:
name: Python tests
+138
View File
@@ -0,0 +1,138 @@
"""design systems + tokens, and the project pointer
Revision ID: 0072
Revises: 0071
Create Date: 2026-07-30
Makes the design system a first-class record instead of prose in a rulebook: a
named set of tokens with an optional parent, so a family system holds the house
style and an app system holds only what it changes.
`design_systems.parent_id` is the whole model. It replaces both an `always_on`
flag (a family system is one with no parent) and a subscription join table (a
project points at ONE system, and the chain supplies the rest), which is less
schema than the rulebook shape it mirrors.
Two deliberate choices worth stating here rather than leaving to be re-derived:
- **`design_tokens.value_by_mode` is JSONB keyed by mode**, not `value_light` +
`value_dark` columns. In a child system an unset mode means "inherit"; in a
root it would mean "not mode-dependent", and as columns both are NULL and
indistinguishable. As a map, resolution is a dict merge at every level with
no special case for roots — and a third mode (high-contrast, print) is data
rather than a schema change. The cost is that a typo'd mode key is not
rejected by the database. Nothing filters tokens by value in SQL, so the
queryability the columns would have bought is for a query no caller makes.
(Named `value_by_mode` rather than `values`, which is reserved in SQL.)
- **`group_name` is free text, not a CHECK enum.** Groupings are each design
system's own vocabulary; a whitelist would bake one install's kit into the
schema. No CHECK is introduced anywhere in this migration.
`parent_id` and `projects.design_system_id` are both ON DELETE SET NULL. Deleting
a family system must orphan its children into roots that still hold their own
overrides, not cascade away every app system that inherited from it; deleting a
system a project points at must unstyle that project, not delete it.
Downgrade drops both tables and the column. Any design system defined this way
is lost — this is the migration that introduces the concept, so there is no
earlier representation to fall back to.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0072"
down_revision = "0071"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"design_systems",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"owner_user_id", sa.BigInteger(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"parent_id", sa.BigInteger(),
sa.ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
op.create_index(
"ix_design_systems_owner_user_id", "design_systems", ["owner_user_id"]
)
op.create_index("ix_design_systems_parent_id", "design_systems", ["parent_id"])
op.create_table(
"design_tokens",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"design_system_id", sa.BigInteger(),
sa.ForeignKey("design_systems.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
# NOT NULL with a '{}' default: a nullable JSONB column has two empty
# states (SQL NULL and JSON null) and every reader has to test for both.
sa.Column(
"value_by_mode", JSONB,
nullable=False, server_default=sa.text("'{}'::jsonb"),
),
sa.Column("group_name", sa.Text(), nullable=True),
sa.Column("purpose", sa.Text(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
op.create_index(
"ix_design_tokens_design_system_id", "design_tokens", ["design_system_id"]
)
# Partial unique: a name is unique among LIVE tokens in a system. Two live
# rows with the same name are a duplicate definition and the cascade would
# pick between them arbitrarily; a trashed row must not block reusing its
# name.
op.create_index(
"uq_token_per_design_system", "design_tokens", ["design_system_id", "name"],
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
)
op.add_column(
"projects", sa.Column("design_system_id", sa.BigInteger(), nullable=True)
)
op.create_foreign_key(
"fk_projects_design_system_id", "projects", "design_systems",
["design_system_id"], ["id"], ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_projects_design_system_id", "projects", type_="foreignkey")
op.drop_column("projects", "design_system_id")
op.drop_index("uq_token_per_design_system", table_name="design_tokens")
op.drop_index("ix_design_tokens_design_system_id", table_name="design_tokens")
op.drop_table("design_tokens")
op.drop_index("ix_design_systems_parent_id", table_name="design_systems")
op.drop_index("ix_design_systems_owner_user_id", table_name="design_systems")
op.drop_table("design_systems")
@@ -0,0 +1,55 @@
"""design_tokens.supersedes — the literals a token should be used instead of
Revision ID: 0073
Revises: 0072
Create Date: 2026-07-30
Records what a prohibition was actually trying to say.
A design rulebook writes "pure white #FFFFFF is NEVER used as text color". That
sentence has no row in a table of tokens, because a design system stores what
things ARE — which looked like a gap in the model and was really a sentence
written backwards. The positive fact is "text is Parchment", and the useful
record is the mapping from the literal someone would otherwise write to the
token they should write instead.
So `supersedes` is a JSONB array of literal values, e.g. `["#fff", "#ffffff"]`
on a text-on-action token. A finding built from it can say what to write, not
merely what not to.
It must be DECLARED rather than derived. `#fff` and Parchment `#E8E4D8` are
different colours, so no value-matching rule could ever have connected them —
which is exactly why the prohibition felt unrepresentable until it was turned
around.
Consumed by the source lint that reads component CSS, not by the drift panel:
these literals are in the components, which the panel cannot see.
NOT NULL with a `'[]'` default, matching `value_by_mode` — a nullable JSONB
column has two empty states and every reader has to test for both.
Downgrade drops the column; the declarations are lost, which costs the lint its
input and nothing else.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0073"
down_revision = "0072"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"design_tokens",
sa.Column(
"supersedes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")
),
)
def downgrade() -> None:
op.drop_column("design_tokens", "supersedes")
+44
View File
@@ -0,0 +1,44 @@
"""central prose on a design system, and rationale on a token
Revision ID: 0074
Revises: 0073
Create Date: 2026-07-31
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
on the system. Two fields rather than one per category:
design_systems.guidance the narrative a token table cannot hold — aesthetic,
voice and tone, what is deliberately out of scope.
Markdown, free-form.
design_tokens.rationale WHY this token is this value, which is a different
question from `purpose` (what it is FOR). "Success
equals Moss, aligned by design" is a rationale;
"page bg, deepest surface" is a purpose.
Free-form rather than a column per category on purpose. A schema with `voice`,
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
every install (rule #115), and the next install's design system would have three
empty columns and nowhere to put what it actually cares about.
Both nullable: a design system with no prose at all is complete, not a draft.
Downgrade drops both columns and the prose with them.
"""
from alembic import op
import sqlalchemy as sa
revision = "0074"
down_revision = "0073"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("design_systems", sa.Column("guidance", sa.Text(), nullable=True))
op.add_column("design_tokens", sa.Column("rationale", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("design_tokens", "rationale")
op.drop_column("design_systems", "guidance")
+1 -1
View File
@@ -22,7 +22,7 @@ real Postgres), build (docker buildx).
- uv (test + integration jobs run `uv sync --locked`; installed in the
image since the ci-python Dockerfile started pip-installing it)
- docker CLI + buildx (build job pushes the production image to the
Forgejo registry)
Fabled-Git registry)
## Per-job tool installs
+1 -1
View File
@@ -255,7 +255,7 @@ onUnmounted(() => {
z-index: 9999;
padding: 0.4rem 0.75rem;
background: var(--color-primary);
color: #fff;
color: var(--fs-text-on-action);
border-radius: 0 0 4px 4px;
font-size: 0.875rem;
text-decoration: none;
+9
View File
@@ -0,0 +1,9 @@
import { apiGet } from "@/api/client";
import type { ExpectationResponse } from "@/utils/designDrift";
/** Checkable claims from the rulebook this install designated as its design system.
*
* `rulebook_id: null` means none has been designated — the normal state for a
* fresh install, not an error. The caller shows an explanatory empty state. */
export const fetchDesignExpectations = () =>
apiGet<ExpectationResponse>("/api/design/expectations");
+188
View File
@@ -0,0 +1,188 @@
/**
* Design systems — the stylesheet held as records (milestone #254).
*
* A design system is a named set of tokens with an optional parent. A system
* with no parent is a "family"; one with a parent holds ONLY what it changes,
* so "what does this app alter?" is a plain list rather than a diff.
*/
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
export interface DesignSystem {
id: number;
owner_user_id: number;
title: string;
description: string;
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
guidance: string;
parent_id: number | null;
created_at: string | null;
updated_at: string | null;
}
/** A token as STORED — one system's own row for it. */
export interface DesignToken {
id: number;
design_system_id: number;
name: string;
/** Values keyed by mode. `base` applies when no mode is more specific. */
value_by_mode: Record<string, string>;
group_name: string | null;
purpose: string | null;
/** WHY it is this value — distinct from `purpose`, which is what it is FOR. */
rationale: string | null;
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
*
* How a design system records what a prohibition was trying to say: not
* "white is banned" but "write this token instead". Declared rather than
* inferred, because a superseded literal and the token's own value are
* usually different values and nothing could connect them by matching. */
supersedes: string[];
order_index: number;
}
export interface Contribution {
system_id: number;
value: string;
}
/**
* A token after the cascade.
*
* `contributions` is every system that offered a value, per mode, DEEPEST
* FIRST — entry 0 won and the rest were shadowed. `value_by_mode` and
* `origin_by_mode` are the winners, provided so the client never has to derive
* them (and so it cannot derive them differently).
*
* Provenance is per MODE because overriding is: a system can own `base` and
* inherit `dark` at the same time.
*/
export interface ResolvedToken {
name: string;
group_name: string | null;
purpose: string | null;
rationale: string | null;
supersedes: string[];
order_index: number;
value_by_mode: Record<string, string>;
origin_by_mode: Record<string, number>;
contributions: Record<string, Contribution[]>;
}
export const fetchDesignSystems = () =>
apiGet<{ design_systems: DesignSystem[] }>("/api/design-systems");
export const fetchDesignSystem = (id: number) =>
apiGet<DesignSystem>(`/api/design-systems/${id}`);
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
}) => apiPost<DesignSystem>("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
export const updateDesignSystem = (
id: number,
body: {
title?: string;
description?: string;
guidance?: string;
parent_id?: number | null;
},
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
export const deleteDesignSystem = (id: number) =>
apiDelete(`/api/design-systems/${id}`);
/** The EFFECTIVE set: everything inherited, with this system's on top. */
export const fetchResolvedTokens = (id: number) =>
apiGet<{ design_system_id: number; tokens: ResolvedToken[] }>(
`/api/design-systems/${id}/resolved`,
);
/** This system's OWN tokens — its override set. */
export const fetchDesignTokens = (id: number) =>
apiGet<{ tokens: DesignToken[] }>(`/api/design-systems/${id}/tokens`);
export const createDesignToken = (
designSystemId: number,
body: {
name: string;
value_by_mode?: Record<string, string>;
group_name?: string | null;
purpose?: string | null;
rationale?: string | null;
supersedes?: string[];
order_index?: number;
},
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
export const updateDesignToken = (
tokenId: number,
body: Partial<Omit<DesignToken, "id" | "design_system_id">>,
) => apiPatch<DesignToken>(`/api/design-tokens/${tokenId}`, body);
export const deleteDesignToken = (tokenId: number) =>
apiDelete(`/api/design-tokens/${tokenId}`);
/** Point a project at a design system. `null` clears it. */
export const setProjectDesignSystem = (
projectId: number,
designSystemId: number | null,
) =>
apiPut<{ project_id: number; design_system_id: number | null }>(
`/api/projects/${projectId}/design-system`,
{ design_system_id: designSystemId },
);
export interface StylesheetResult {
design_system_id: number;
/** The master sheet: purpose tokens only, no element or class rules. */
css: string;
token_count: number;
/** Tokens the system names but has no value for yet. */
valueless: string[];
/** Values declared under more than one name — alias, or one idea twice. */
duplicates: Record<string, string[]>;
derivation: {
/** Tokens computed from others, mapped to what they're computed from. */
derived: Record<string, string[]>;
/** Formulas pointing at tokens that don't exist — the browser drops these. */
unknown_refs: Record<string, string[]>;
/** Derivation loops, which resolve to nothing for the same reason. */
cycles: string[][];
};
}
/** The master CSS sheet a design system generates.
*
* Purpose tokens only. Components (buttons, tables, input schemes) are
* snippets that reference these names, so a value is stated once and reused
* rather than restated per element. */
export const fetchStylesheet = (id: number) =>
apiGet<StylesheetResult>(`/api/design-systems/${id}/stylesheet`);
export interface SnippetFinding {
snippet_id: number;
title: string;
/** References that resolve against the sheet. */
used: string[];
/** `var(--x)` where the system has no `--x` — renders as nothing at all. */
unknown: string[];
/** Literals the sheet says to stop writing, paired with what to write. */
superseded_literals: { literal: string; use_instead: string }[];
/** Custom properties the snippet mints for itself instead of reusing. */
local_definitions: string[];
}
export interface SnippetCheck {
design_system_id: number;
checked: number;
/** Only snippets with something to act on; clean ones are omitted. */
findings: SnippetFinding[];
}
/** Which recorded snippets disagree with this design system's sheet. */
export const checkSnippets = (id: number) =>
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
+5
View File
@@ -88,6 +88,11 @@ export interface SnippetListItem {
* them, not one of your own. Absent means it's yours. */
shared?: boolean;
owner?: string | null;
/** The recorded language, when one was given. Projected from the `data`
* mirror so lists can show it without parsing the body — and so a prior-art
* hit can be flagged as being in a DIFFERENT language than the file being
* written, which is a shape to adapt rather than code to paste. */
language?: string;
/** Always present from the backend, zero-filled for records with no events. */
usage?: SnippetUsage;
/** Present on the detail record; the list feed carries it when a check has
+193
View File
@@ -0,0 +1,193 @@
/* Shared component styles — the house recipes, in one place.
* ===========================================================================
*
* WHY THIS FILE EXISTS
*
* `.btn-primary` was defined five times, in five scoped stylesheets, and all
* five had drifted: three paddings, three font sizes, three disabled opacities,
* and one view with no disabled style at all (#2273). Nothing detected that,
* because a scoped duplicate is invisible to every tool — it isn't a rule
* violation, isn't a broken reference, and isn't a recorded snippet.
*
* GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or
* scale change moves the buttons rather than stranding a copy that no longer
* matches.
*
* A button is `variant + size`, composed in the template:
* btn-primary a page action
* btn-primary btn-compact a row action
* btn-ghost btn-inline an affordance inside a card
* btn-primary btn-block a form's single submitting action
* Semantic per-view names (.btn-save, .btn-delete-task, …) were the thing that
* drifted, because 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.
*
* MIGRATION NOTE — this file is deliberately safe to land ahead of the removals.
* These are plain selectors (specificity 0,1,0); a Vue `<style scoped>` rule
* compiles to `.btn-primary[data-v-…]` (0,2,0) and therefore WINS. So a view
* still carrying its own copy is unaffected until that copy is deleted, and
* every intermediate state of the migration is coherent.
*/
/* --- the shared shape ---------------------------------------------------- */
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-danger,
.btn-danger-outline {
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
border: none;
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
font-family: var(--fs-font-body);
font-size: var(--fs-size-label); /* 12px */
font-weight: var(--fs-weight-medium); /* 500 — the heaviest the system goes */
line-height: var(--fs-leading-body);
white-space: nowrap;
cursor: pointer;
transition: background var(--fs-dur-fast) var(--fs-ease),
border-color var(--fs-dur-fast) var(--fs-ease),
color var(--fs-dur-fast) var(--fs-ease);
}
/* One rule, so a disabled button can never look enabled in one view and
* disabled in another — which is exactly what ProjectListView shipped, having
* no disabled style at all. */
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled,
.btn-danger-outline:disabled {
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
.btn-primary:focus-visible,
.btn-secondary:focus-visible,
.btn-ghost:focus-visible,
.btn-danger:focus-visible,
.btn-danger-outline:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
/* --- variants ------------------------------------------------------------ */
/* The accent is deliberately ABSENT from every filled variant. Action colours
* are universal across the family so a Save button looks identical in every
* app — the accent is identity, not action. */
.btn-primary {
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.btn-primary:not(:disabled):hover {
background: var(--color-action-primary-hover);
}
.btn-secondary {
background: var(--color-action-secondary);
color: var(--fs-text-on-action);
}
.btn-secondary:not(:disabled):hover {
background: var(--color-action-secondary-hover);
}
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
* are the same token rather than two values that happen to match. Hover moves
* the BORDER, not the text to the accent — SnippetDetailView tinted the label
* with the accent on hover, which the house style reserves for identity and
* active state, not for general chrome. */
.btn-ghost {
background: none;
border: var(--fs-border);
color: var(--color-text);
}
.btn-ghost:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--color-hover);
}
/* Destructive is NOT the error colour: an error is a failure that happened, a
* destructive action is one about to happen. Pair with an icon. */
.btn-danger {
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
}
.btn-danger:not(:disabled):hover {
background: var(--color-action-destructive-hover);
}
/* A bare text button: no fill, no border. The most common shape in the dense
* surfaces — a dismiss, a cancel next to a confirm, a clear-search — where a
* border would draw a box around something that should read as an action on
* the text beside it. Distinct from ghost, which IS a box. */
.btn-text {
background: none;
border: none;
color: var(--color-text-muted);
padding: var(--fs-space-1) var(--fs-space-2);
font-family: var(--fs-font-body);
font-size: var(--fs-size-tiny);
line-height: 1;
cursor: pointer;
transition: color var(--fs-dur-fast) var(--fs-ease);
}
.btn-text:not(:disabled):hover { color: var(--color-text); }
.btn-text:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
/* Destructive, outlined — fills on hover. Already existed independently in
* three views before this sheet, which is what makes it a variant rather than
* a one-off: it is what a delete looks like when it must not shout. */
.btn-danger-outline {
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
}
.btn-danger-outline:not(:disabled):hover {
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
}
/* --- size modifiers ------------------------------------------------------
*
* THREE sizes, because the app genuinely has three. Measured across the ~100
* bespoke button rules before this scale existed, vertical padding fell into
* clusters rather than a spread: ~27 at 0.40.45rem, ~28 at 0.250.3rem, ~23
* at 0.10.15rem. Those are three different components — a page action, a row
* action, and an affordance living inside a card — that happen to share a name
* prefix. Collapsing them to one size would visibly break the card layouts.
*
* A button carries its size modifier; the DEFAULT (no modifier) is the page
* action, which is the one the house style specifies.
*/
/* Row actions: a toolbar, a table row, a list item's controls. */
.btn-compact,
.btn-small, /* pre-existing spellings, kept so no template churns */
.btn-sm {
padding: var(--fs-space-1) var(--fs-space-3); /* 4px 12px */
font-size: var(--fs-size-tiny);
}
/* Inline affordances: a dismiss ×, a confirm tick, an add-chip — things that
* sit INSIDE a line of text or a card and must not disturb its rhythm. Below
* the spacing scale's first step on the vertical axis by necessity: 4px of
* padding on a 11px label already exceeds the line box these live in. */
.btn-inline {
padding: 2px var(--fs-space-1); /* 2px 4px */
font-size: var(--fs-size-tiny);
line-height: 1;
}
/* Full width, for a form's single submitting action — the auth screens. Width
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
.btn-block {
display: block;
width: 100%;
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
because a full-width button
is the page's main action */
font-size: var(--fs-size-body-sm);
}
+98 -183
View File
@@ -34,86 +34,11 @@
gap: 0.5rem;
align-items: center;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
text-decoration: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn-back:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
the software" — not a brand moment. Accent gradient is reserved for
Send / empty-state CTAs. */
.btn-save {
padding: 0.45rem 1.1rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
transition: background 0.15s, opacity 0.15s;
}
.btn-save:hover:not(:disabled) {
background: var(--color-action-primary-hover);
}
.btn-save:disabled {
opacity: 0.55;
cursor: default;
}
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
with a Trash icon at the call site to reinforce intent. */
.btn-delete {
padding: 0.45rem 1rem;
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 500;
}
.btn-delete:hover { background: var(--color-action-destructive-hover); }
.btn-assist-toggle {
margin-left: auto;
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.85rem;
}
.btn-assist-toggle.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.title-input {
padding: 0.4rem 0;
border: none;
border-bottom: 1.5px solid var(--color-border);
border-radius: 0;
font-size: 1.5rem;
font-weight: 500;
font-family: "Fraunces", Georgia, serif;
background: transparent;
color: var(--color-text);
width: 100%;
transition: border-color 0.15s;
}
.title-input:focus {
outline: none;
border-bottom-color: var(--color-primary);
@@ -155,23 +80,6 @@
align-items: center;
gap: 0.4rem;
}
.btn-suggest-tags {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.8rem;
}
.btn-suggest-tags:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-suggest-tags:disabled {
opacity: 0.6;
cursor: wait;
}
.tag-pill {
display: inline-flex;
align-items: center;
@@ -187,29 +95,17 @@
}
.tag-pill:hover:not(:disabled) {
background: var(--color-primary);
color: #fff;
color: var(--fs-text-on-action);
}
.tag-pill.applied {
background: var(--color-success, #2ecc71);
border-color: var(--color-success, #2ecc71);
color: #fff;
color: var(--fs-text-on-action);
cursor: default;
}
.tag-check {
font-size: 0.7rem;
}
.btn-dismiss-tags {
padding: 0.1rem 0.4rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.btn-dismiss-tags:hover {
color: var(--color-text);
}
/* ── Assist panel ── */
.assist-panel {
@@ -237,32 +133,6 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-proofread {
padding: 0.3rem 0.65rem;
font-size: 0.78rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.btn-proofread:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-proofread:disabled {
opacity: 0.5;
cursor: default;
}
.btn-close-assist {
padding: 0.1rem 0.4rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.assist-panel-body {
flex: 1;
min-height: 0;
@@ -342,28 +212,6 @@
display: flex;
gap: 0.5rem;
}
.btn-generate {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-generate:disabled {
opacity: 0.5;
cursor: default;
}
.btn-clear {
padding: 0.4rem 0.9rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* Streaming */
.assist-streaming-label {
@@ -419,14 +267,6 @@
font-weight: 500;
color: var(--color-text-secondary);
}
.btn-toggle-view {
font-size: 0.75rem;
color: var(--color-primary);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.diff-view {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
@@ -475,24 +315,6 @@
display: flex;
gap: 0.5rem;
}
.btn-accept {
padding: 0.4rem 1rem;
background: var(--color-success);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-reject {
padding: 0.4rem 1rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* ── Modal ── */
.modal-overlay {
@@ -537,7 +359,7 @@
}
.modal-btn-danger {
background: var(--color-danger);
color: #fff;
color: var(--fs-text-on-action);
border-color: var(--color-danger);
}
@@ -547,8 +369,8 @@
z-index: 100;
transform: translateX(-50%);
padding: 0.3rem 0.75rem;
background: var(--color-primary);
color: #fff;
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
@@ -640,3 +462,96 @@
padding: 0.5rem 1rem 1rem;
}
}
/* ---------------------------------------------------------------------------
* Editor button aliases.
*
* These names are used across six views, so they alias onto the shared variants
* rather than every call site being rewritten — the class stays the app's, the
* appearance comes from components.css. Same reasoning as .btn-small: a name
* already in the templates is cheaper to point somewhere than to replace.
*
* They are @extend-shaped, which CSS lacks, so each carries the variant's own
* declarations. That is the one duplication this migration cannot remove — but
* it is duplication of a REFERENCE (a var()), not of a value, so a palette
* change still moves everything at once.
* ------------------------------------------------------------------------ */
.btn-accept,
.btn-generate,
.btn-save {
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
}
.btn-accept:not(:disabled):hover,
.btn-generate:not(:disabled):hover,
.btn-save:not(:disabled):hover {
background: var(--color-action-primary-hover);
}
.btn-back,
.btn-clear,
.btn-reject,
.btn-proofread,
.btn-suggest-tags {
background: none;
border: var(--fs-border);
color: var(--color-text);
}
.btn-back:not(:disabled):hover,
.btn-clear:not(:disabled):hover,
.btn-reject:not(:disabled):hover,
.btn-proofread:not(:disabled):hover,
.btn-suggest-tags:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--color-hover);
}
.btn-delete {
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
border: none;
}
.btn-delete:not(:disabled):hover {
background: var(--color-action-destructive-hover);
}
/* Shared geometry for every alias above. */
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
.btn-delete, .btn-dismiss-tags, .btn-proofread, .btn-suggest-tags {
border-radius: var(--fs-radius-md);
font-family: var(--fs-font-body);
font-weight: var(--fs-weight-medium);
cursor: pointer;
white-space: nowrap;
transition: background var(--fs-dur-fast) var(--fs-ease),
border-color var(--fs-dur-fast) var(--fs-ease),
color var(--fs-dur-fast) var(--fs-ease);
}
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
.btn-delete {
padding: var(--fs-space-2) var(--fs-space-4);
font-size: var(--fs-size-label);
}
.btn-proofread, .btn-suggest-tags {
padding: var(--fs-space-1) var(--fs-space-3);
font-size: var(--fs-size-tiny);
}
.btn-dismiss-tags {
background: none;
border: none;
color: var(--color-text-muted);
padding: 2px var(--fs-space-1);
font-size: var(--fs-size-tiny);
line-height: 1;
}
.btn-dismiss-tags:hover { color: var(--color-text); }
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
.btn-delete:disabled, .btn-proofread:disabled, .btn-suggest-tags:disabled,
.btn-dismiss-tags:disabled {
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
+331 -161
View File
@@ -1,149 +1,319 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
/* ==========================================================================
GENERATED FROM THE DESIGN SYSTEM — Scribe (design system 2), which inherits
the FabledSword house style (design system 1).
Do not hand-edit the --fs-* block below. Edit the design system in the app
and regenerate: /design-systems -> Master stylesheet -> Copy.
DARK IS THE BASE LAYER. The kit is dark-mode-first, so :root carries the dark
palette and [data-theme="light"] overrides it. That is the inverse of how this
file used to read, and it is deliberate: the light palette was never specified
by any rule, so it is recorded as a departure rather than as the default.
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
radius, and every derived colour — is stated once, because a value built with
var() resolves where it is USED, not where it is written.
========================================================================== */
:root {
/* Light mode — warm parchment palette */
--color-bg: #F5F1E8;
--color-bg-secondary: #FBF8F0;
--color-bg-card: #FBF8F0;
--color-surface: #EFEAE0;
--color-text: #14171A;
--color-text-secondary: #5A5852;
--color-text-muted: #9A9890;
--color-border: #D9D6CE;
--color-input-border: #D9D6CE;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.12);
--color-tag-text: #5B4A8A;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #4A5D3F;
--color-toast-error: #C04A1F;
--color-status-todo: #3F4651;
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
--color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
--color-status-done: #4A5D3F;
--color-status-done-bg: rgba(74, 93, 63, 0.12);
--color-priority-low: #3D5A6E;
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
--color-priority-medium: #8B6F1E;
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
--color-priority-high: #C04A1F;
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
--color-wikilink: #5B4A8A;
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
--color-overdue: #C04A1F;
--color-code-bg: #EBEDF0;
--color-code-inline-bg: #EBEDF0;
--color-table-stripe: rgba(20, 23, 26, 0.025);
--color-success: #4A5D3F;
--color-warning: #8B6F1E;
--color-input-bar-bg: #EFEAE0;
--color-input-bar-text: #14171A;
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45);
--color-bubble-user-bg: transparent;
--color-bubble-user-border: #D9D6CE;
--color-bubble-user-text: #5A5852;
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
--color-primary-solid: #5B4A8A;
--color-primary-deep: #3F3560;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
--color-primary-faint: rgba(91, 74, 138, 0.08);
--color-primary-tint: rgba(91, 74, 138, 0.12);
--color-primary-wash: rgba(91, 74, 138, 0.20);
/* accent */
--fs-accent: #5B4A8A; /* Scribe's signature colour */
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent); /* Pill, tag and badge backgrounds */
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
--color-action-primary: #4A5D3F;
--color-action-primary-hover: #5A6F4D;
--color-action-secondary: #8B7355;
--color-action-secondary-hover: #A0876A;
--color-action-destructive: #6B2118;
--color-action-destructive-hover: #7E2A1F;
--color-action-ghost-border: #3F4651;
/* action */
--fs-action-primary: #4A5D3F; /* Save, Submit, Confirm */
--fs-action-secondary: #8B7355; /* Non-destructive alternates */
--fs-action-tertiary: var(--fs-border-color); /* Ghost / outline actions */
--fs-action-destructive: var(--fs-destructive);
--fs-action-primary-hover: color-mix(in srgb, var(--fs-action-primary) 88%, white);
--fs-action-secondary-hover: color-mix(in srgb, var(--fs-action-secondary) 88%, white);
--fs-action-destructive-hover: color-mix(in srgb, var(--fs-action-destructive) 88%, white);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
--chat-reading-width: min(1200px, 100%);
--chat-context-sidebar-width: 220px;
/* border */
--fs-border-color: #3F4651; /* Borders, dividers and ghost outlines */
--fs-border: 0.5px solid var(--fs-border-color);
--fs-border-hover: 0.5px solid color-mix(in srgb, var(--fs-text-secondary) 30%, transparent);
--fs-border-active: 2px solid var(--fs-accent); /* Selected card or active tab only */
/* editor */
--fs-wikilink: var(--fs-accent);
/* elevation */
--fs-shadow-1: 0 1px 0 rgba(0,0,0,0.4); /* Hairline lift */
--fs-shadow-2: 0 4px 12px rgba(0,0,0,0.35); /* Dropdowns, popovers */
--fs-shadow-3: 0 16px 40px rgba(0,0,0,0.5); /* Modals */
/* focus */
--fs-focus-ring: 0 0 0 2px var(--fs-accent);
/* font */
--fs-font-display: 'Fraunces', Georgia, serif;
--fs-font-body: 'Inter', system-ui, sans-serif;
--fs-font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
/* icon */
--fs-icon-stroke: 1.5px; /* at 24px */
--fs-icon-stroke-sm: 1px; /* at 16px */
/* layout */
--fs-layout-page-max: 1200px;
--fs-layout-page-pad: 1rem;
--fs-layout-sidebar: 260px;
--fs-layout-header: 52px;
/* motion */
--fs-ease: cubic-bezier(0.2, 0.6, 0.2, 1); /* the one curve */
--fs-dur-fast: 120ms;
--fs-dur-base: 180ms;
--fs-dur-slow: 280ms;
/* priority */
--fs-priority-low: var(--fs-info);
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
--fs-priority-medium: var(--fs-warning);
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
--fs-priority-high: var(--fs-error);
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
/* radius */
--fs-radius-sm: 4px; /* pills, tags, code spans */
--fs-radius-md: 8px; /* buttons, inputs, small cards */
--fs-radius-lg: 12px; /* cards, panels, modals */
--fs-radius-xl: 16px; /* hero containers */
--fs-radius-pill: 9999px;
/* semantic */
--fs-success: var(--fs-action-primary);
--fs-warning: #8B6F1E;
--fs-error: #C04A1F;
--fs-info: #3D5A6E;
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
/* space */
--fs-space-1: 4px;
--fs-space-2: 8px;
--fs-space-3: 12px;
--fs-space-4: 16px;
--fs-space-5: 20px;
--fs-space-6: 24px;
--fs-space-7: 32px;
--fs-space-8: 48px;
--fs-space-9: 64px;
--fs-space-10: 96px;
/* state */
--fs-disabled-opacity: 0.5;
--fs-overlay: rgba(0, 0, 0, 0.65);
/* status */
--fs-status-todo: var(--fs-border-color);
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
--fs-status-in-progress: var(--fs-accent);
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
--fs-status-done: var(--fs-success);
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
--fs-overdue: var(--fs-error);
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
/* surface */
--fs-surface-page: #14171A; /* page bg, deepest surface */
--fs-surface-raised: #1E2228; /* cards, raised elements */
--fs-surface-hover: #2C313A; /* hovered surfaces */
--fs-surface-code: var(--fs-surface-page);
--fs-surface-code-inline: var(--fs-surface-raised);
--fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent);
/* text */
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
--fs-text-secondary: #C2BFB4;
--fs-text-tertiary: #9C9A92;
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
/* type */
--fs-size-display: 40px;
--fs-size-h1: 32px;
--fs-size-h2: 24px;
--fs-size-h3: 18px;
--fs-size-body: 15px;
--fs-size-body-sm: 13px;
--fs-size-label: 12px;
--fs-size-code: 13px;
--fs-size-tiny: 11px; /* the only ALL CAPS, the only non-default tracking */
--fs-weight-regular: 400;
--fs-weight-medium: 500; /* the heaviest the system goes */
--fs-leading-heading: 1.3;
--fs-leading-body: 1.5;
--fs-leading-longform: 1.7;
--fs-tracking-tiny: 0.08em;
}
[data-theme="dark"] {
/* Dark mode — Obsidian / Iron / Pewter */
--color-bg: #14171A;
--color-bg-secondary: #1E2228;
--color-bg-card: #1E2228;
--color-surface: #2C313A;
--color-text: #E8E4D8;
--color-text-secondary: #C2BFB4;
--color-text-muted: #9C9A92;
--color-border: #3F4651;
--color-input-border: #3F4651;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.15);
--color-tag-text: #5B4A8A;
[data-theme="light"] {
/* accent */
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 20%, transparent);
--fs-glow-cta-hover: 0 4px 20px color-mix(in srgb, var(--fs-accent) 55%, transparent);
/* border */
--fs-border-color: #D9D6CE;
/* state */
--fs-overlay: rgba(0, 0, 0, 0.45);
/* surface */
--fs-surface-page: #F5F1E8;
--fs-surface-raised: #FBF8F0;
--fs-surface-hover: #EFEAE0;
--fs-surface-code: #EBEDF0;
--fs-surface-code-inline: #EBEDF0;
/* text */
--fs-text-primary: #14171A;
--fs-text-secondary: #5A5852;
--fs-text-tertiary: #9A9890;
}
/* SUPERSEDES — write the token, not the literal.
* #fff -> --fs-text-on-action
* #ffffff -> --fs-text-on-action
* white -> --fs-text-on-action
* bold -> --fs-weight-medium
* bolder -> --fs-weight-medium
*/
/* ==========================================================================
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
These exist so ~55 components keep working while they migrate to --fs-*
one at a time. Every one is a plain var() reference, which is what lets this
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
--color-bg follows, because the alias resolves at use time.
That is why this file lost 48 of its 60 dark-mode overrides — they were all
restating relationships the aliases now express directly.
Removing this block is a rename sweep across the components, tracked
separately. Nothing new should reference a --color-* name.
========================================================================== */
:root {
/* surfaces */
--color-bg: var(--fs-surface-page);
--color-bg-secondary: var(--fs-surface-raised);
--color-bg-card: var(--fs-surface-raised);
--color-surface: var(--fs-surface-hover);
--color-code-bg: var(--fs-surface-code);
--color-code-inline-bg: var(--fs-surface-code-inline);
--color-table-stripe: var(--fs-table-stripe);
--color-overlay: var(--fs-overlay);
/* text */
--color-text: var(--fs-text-primary);
--color-text-secondary: var(--fs-text-secondary);
--color-text-muted: var(--fs-text-tertiary);
/* lines */
--color-border: var(--fs-border-color);
--color-input-border: var(--fs-border-color);
--focus-ring: var(--fs-focus-ring);
/* brand */
--color-primary: var(--fs-accent);
--color-primary-solid: var(--fs-accent);
--color-primary-deep: var(--fs-accent-deep);
--color-primary-faint: var(--fs-accent-faint);
--color-primary-tint: var(--fs-accent-soft);
--color-primary-wash: var(--fs-accent-wash);
--color-tag-bg: var(--fs-accent-soft);
--color-tag-text: var(--fs-accent);
--color-wikilink: var(--fs-wikilink);
--color-wikilink-bg: var(--fs-accent-soft);
--gradient-cta: var(--fs-gradient-cta);
--glow-cta: var(--fs-glow-cta);
--glow-cta-hover: var(--fs-glow-cta-hover);
/* actions */
--color-action-primary: var(--fs-action-primary);
--color-action-primary-hover: var(--fs-action-primary-hover);
--color-action-secondary: var(--fs-action-secondary);
--color-action-secondary-hover: var(--fs-action-secondary-hover);
--color-action-destructive: var(--fs-action-destructive);
--color-action-destructive-hover: var(--fs-action-destructive-hover);
/* semantic */
--color-success: var(--fs-success);
--color-warning: var(--fs-warning);
--color-danger: var(--fs-error);
--color-overdue: var(--fs-overdue);
--color-toast-success: var(--fs-success);
--color-toast-error: var(--fs-error);
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4A5D3F;
--color-toast-error: #C04A1F;
--color-status-todo: #3F4651;
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
--color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
--color-status-done: #4A5D3F;
--color-status-done-bg: rgba(74, 93, 63, 0.18);
--color-priority-low: #3D5A6E;
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
--color-priority-medium: #8B6F1E;
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
--color-priority-high: #C04A1F;
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
--color-wikilink: #5B4A8A;
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
--color-overdue: #C04A1F;
--color-code-bg: #14171A;
--color-code-inline-bg: #1E2228;
--color-table-stripe: rgba(255, 255, 255, 0.025);
--color-success: #4A5D3F;
--color-warning: #8B6F1E;
--color-input-bar-bg: #1E2228;
--color-input-bar-text: #E8E4D8;
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: transparent;
--color-bubble-user-border: #3F4651;
--color-bubble-user-text: #C2BFB4;
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-primary-solid: #5B4A8A;
--color-primary-deep: #3F3560;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
--color-primary-faint: rgba(91, 74, 138, 0.10);
--color-primary-tint: rgba(91, 74, 138, 0.14);
--color-primary-wash: rgba(91, 74, 138, 0.22);
/* Action color set — identical across themes */
--color-action-primary: #4A5D3F;
--color-action-primary-hover: #5A6F4D;
--color-action-secondary: #8B7355;
--color-action-secondary-hover: #A0876A;
--color-action-destructive: #6B2118;
--color-action-destructive-hover: #7E2A1F;
--color-action-ghost-border: #3F4651;
/* task status + priority */
--color-status-todo: var(--fs-status-todo);
--color-status-todo-bg: var(--fs-status-todo-bg);
--color-status-in-progress: var(--fs-status-in-progress);
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
--color-status-done: var(--fs-status-done);
--color-status-done-bg: var(--fs-status-done-bg);
--color-priority-low: var(--fs-priority-low);
--color-priority-low-bg: var(--fs-priority-low-bg);
--color-priority-medium: var(--fs-priority-medium);
--color-priority-medium-bg: var(--fs-priority-medium-bg);
--color-priority-high: var(--fs-priority-high);
--color-priority-high-bg: var(--fs-priority-high-bg);
/* geometry */
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
--page-max-width: var(--fs-layout-page-max);
--page-padding-x: var(--fs-layout-page-pad);
--sidebar-width: var(--fs-layout-sidebar);
--header-height: var(--fs-layout-header);
/* ------------------------------------------------------------------
Names components reference that were NEVER declared anywhere.
Each of these was reached for with a hardcoded fallback, so the page
rendered — but the fallback was what rendered, always, and several were
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
to a brick that is not the oxblood, --color-status-cancelled to a grey from
no palette in this system.
Wiring them to real tokens is the whole point of the exercise. Expect small
visual shifts exactly where a fallback had drifted; that shift IS the fix.
------------------------------------------------------------------ */
--color-accent: var(--fs-accent);
/* Foreground ON the accent, so it follows the accent's mode-independence,
not the page text's. Pointing this at --fs-text-primary made it invert to
obsidian on light — over a mid-tone accent, well under the AA floor. */
--color-accent-fg: var(--fs-text-on-action);
--color-hover: var(--fs-surface-hover);
--color-bg-hover: var(--fs-surface-hover);
--color-bg-tertiary: var(--fs-surface-hover);
--color-surface-2: var(--fs-surface-hover);
--color-surface-alt: var(--fs-surface-hover);
--color-surface-raised: var(--fs-surface-raised);
--color-input-bg: var(--fs-surface-page);
--color-muted: var(--fs-text-tertiary);
--color-destructive: var(--fs-destructive);
--color-primary-bg: var(--fs-accent-soft);
--color-status-cancelled: var(--fs-status-cancelled);
--font-display: var(--fs-font-display);
--font-mono: var(--fs-font-mono);
}
/* ==========================================================================
Base element styles
========================================================================== */
*,
*::before,
*::after {
@@ -152,36 +322,36 @@
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-family: var(--fs-font-body);
font-feature-settings: "cv11";
line-height: 1.5;
transition: background-color 0.2s, color 0.2s;
line-height: var(--fs-leading-body);
transition: background-color var(--fs-dur-base) var(--fs-ease),
color var(--fs-dur-base) var(--fs-ease);
}
h1, h2 {
font-family: 'Fraunces', Georgia, serif;
font-family: var(--fs-font-display);
font-optical-sizing: auto;
font-weight: 500;
line-height: 1.3;
font-weight: var(--fs-weight-medium);
line-height: var(--fs-leading-heading);
}
h3 {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 500;
line-height: 1.3;
font-family: var(--fs-font-body);
font-weight: var(--fs-weight-medium);
line-height: var(--fs-leading-heading);
}
code, pre, kbd, samp {
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-family: var(--fs-font-mono);
font-feature-settings: "liga", "calt";
}
::selection {
background: rgba(91, 74, 138, 0.3);
color: var(--color-text);
background: var(--fs-accent-wash);
color: var(--fs-text-primary);
}
input:focus-visible,
@@ -190,15 +360,15 @@ select:focus-visible,
button:focus-visible,
a:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
box-shadow: var(--fs-focus-ring);
border-radius: var(--fs-radius-sm);
}
button:not(:disabled):active,
.btn:not(:disabled):active,
[role="button"]:not(:disabled):active {
transform: scale(0.97);
transition: transform 0.08s ease;
transition: transform 0.08s var(--fs-ease);
}
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
@@ -228,11 +398,11 @@ button:not(:disabled):active,
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 9999px;
background: var(--fs-border-color);
border-radius: var(--fs-radius-pill);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: var(--fs-text-tertiary);
}
/* Floating inline assist button (teleported to body, cannot be scoped) */
@@ -240,14 +410,14 @@ button:not(:disabled):active,
position: fixed;
z-index: 150;
transform: translateX(-50%);
background: var(--color-primary);
color: #fff;
background: var(--fs-accent);
color: var(--fs-text-primary);
border: none;
border-radius: 999px;
border-radius: var(--fs-radius-pill);
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
font-size: var(--fs-size-body-sm);
cursor: pointer;
box-shadow: 0 2px 8px var(--color-shadow);
box-shadow: var(--fs-shadow-2);
white-space: nowrap;
}
.inline-assist-btn:hover {
+12 -1
View File
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -64,6 +64,16 @@ router.afterEach(() => {
<Moon v-else :size="16" />
</button>
<!-- Design. An icon rather than a sixth primary nav link: it's a
meta-surface like Trash and Settings, but hiding it entirely would
defeat the point of having somewhere the design system is visible.
Points at the RECORD, not the live-token view — the record is what
you work with; the live view is the check on it, and it's a tab
away. -->
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
<Palette :size="16" />
</router-link>
<!-- Trash link -->
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
<Trash2 :size="16" />
@@ -98,6 +108,7 @@ router.afterEach(() => {
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
/**
* Sub-navigation for the Design surface.
*
* There are two pages here and they are halves of ONE thing: the record that
* decides the styling, and what the browser is actually rendering from it. They
* were briefly two top-level nav entries, which put the read-only diagnostic
* first and buried the editable record under it — backwards, since the record
* is the thing you work with and the live view is the check on it.
*
* A component rather than the same markup pasted into both views: two copies of
* a tab bar diverge the moment a third tab appears, and that is the exact shape
* of duplication this whole surface exists to make visible.
*/
</script>
<template>
<nav class="design-tabs" aria-label="Design views">
<router-link to="/design-systems" class="design-tab">Design system</router-link>
<router-link to="/design" class="design-tab">Live tokens</router-link>
</nav>
</template>
<style scoped>
.design-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.design-tab {
padding: 0.5rem 0.9rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.design-tab:hover {
color: var(--color-text);
}
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
exact matching would drop the highlight on any future child route. */
.design-tab.router-link-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+1 -14
View File
@@ -281,7 +281,7 @@ onMounted(loadVersions);
<div class="history-footer">
<button
class="btn-restore"
class="btn-primary"
:disabled="!selectedVersion?.body"
@click="restore"
>Restore this version</button>
@@ -393,19 +393,6 @@ onMounted(loadVersions);
border-top: 1px solid var(--color-border);
}
.btn-restore {
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
}
.btn-restore:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Pin badges + label rendering ───────────────────────────────────────── */
.pin-badge {
@@ -188,11 +188,11 @@ const markers: Record<DiffLine["type"], string> = {
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
font-weight: 600;
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
background: var(--color-success, #22c55e);
color: #fff;
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
+1 -1
View File
@@ -82,7 +82,7 @@ onUnmounted(() => {
top: -5px;
right: -5px;
background: var(--color-danger, #ef4444);
color: #fff;
color: var(--fs-text-on-action);
font-size: 0.6rem;
font-weight: 700;
min-width: 16px;
+2 -35
View File
@@ -50,7 +50,7 @@ onMounted(() => store.fetchAll())
<span class="notif-panel-title">Notifications</span>
<button
v-if="store.count > 0"
class="btn-mark-all"
class="btn-text"
@click="store.markAll()"
>Mark all read</button>
</header>
@@ -70,7 +70,7 @@ onMounted(() => store.fetchAll())
</p>
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
</div>
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
<button class="btn-text" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
</li>
</ul>
<div v-else class="notif-empty">No unread notifications</div>
@@ -108,21 +108,6 @@ onMounted(() => store.fetchAll())
font-size: 0.9rem;
}
.btn-mark-all {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.78rem;
cursor: pointer;
padding: 0;
}
.btn-mark-all:hover { text-decoration: underline; }
.notif-list {
list-style: none;
padding: 0;
margin: 0;
}
.notif-item {
display: flex;
@@ -148,22 +133,4 @@ onMounted(() => store.fetchAll())
}
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
.btn-notif-close {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 0.1rem 0.25rem;
flex-shrink: 0;
transition: color 0.1s;
}
.btn-notif-close:hover { color: var(--color-text); }
.notif-empty {
padding: 1.5rem;
text-align: center;
color: var(--color-muted);
font-size: 0.88rem;
}
</style>
+1 -1
View File
@@ -90,7 +90,7 @@ function goToPage(page: number) {
}
.page-btn.active {
background: var(--color-primary);
color: #fff;
color: var(--fs-text-on-action);
border-color: var(--color-primary);
}
.ellipsis {
+5 -41
View File
@@ -117,7 +117,7 @@ onMounted(async () => {
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
<header class="share-header">
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
<button class="btn-text" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
</header>
<!-- Add share form -->
@@ -184,7 +184,7 @@ onMounted(async () => {
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
<button class="btn-text" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
</li>
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
</ul>
@@ -231,23 +231,6 @@ onMounted(async () => {
color: var(--color-text);
}
.btn-close {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem;
line-height: 1;
border-radius: 4px;
transition: color 0.15s;
}
.btn-close:hover { color: var(--color-text); }
.share-add {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.share-tabs {
display: flex;
@@ -268,7 +251,7 @@ onMounted(async () => {
.share-tab.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
color: var(--fs-text-on-action);
}
.share-target-form {
@@ -339,11 +322,11 @@ onMounted(async () => {
.btn-add-share {
padding: 0.45rem 1rem;
background: var(--gradient-cta);
color: #fff;
color: var(--fs-text-on-action);
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
font-weight: var(--fs-weight-medium);
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
@@ -394,23 +377,4 @@ onMounted(async () => {
cursor: pointer;
}
.btn-remove-share {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.9rem;
padding: 0.15rem 0.3rem;
border-radius: 4px;
transition: color 0.15s;
flex-shrink: 0;
}
.btn-remove-share:hover { color: var(--color-danger, #ef4444); }
.shares-empty {
color: var(--color-text-muted);
font-size: 0.85rem;
text-align: center;
padding: 0.75rem;
}
</style>
+7 -52
View File
@@ -163,7 +163,7 @@ async function confirmDelete() {
<!-- Toolbar -->
<div class="systems-toolbar">
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
+ System
</button>
<label v-if="archivedSystems.length" class="archived-toggle">
@@ -190,10 +190,10 @@ async function confirmDelete() {
aria-label="System description"
></textarea>
<div class="system-form-actions">
<button type="submit" class="btn-confirm" :disabled="!newName.trim() || creating">
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }}
</button>
<button type="button" class="btn-cancel" @click="cancelCreate">Cancel</button>
<button type="button" class="btn-ghost btn-compact" @click="cancelCreate">Cancel</button>
</div>
</form>
@@ -211,7 +211,7 @@ async function confirmDelete() {
<div v-else-if="!visibleSystems.length" class="systems-empty">
<p class="empty-title">No systems yet</p>
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
<button v-if="!showCreate" class="btn-confirm" @click="openCreate">+ Create a system</button>
<button v-if="!showCreate" class="btn-primary btn-compact" @click="openCreate">+ Create a system</button>
</div>
<!-- List -->
@@ -241,10 +241,10 @@ async function confirmDelete() {
aria-label="System description"
></textarea>
<div class="system-form-actions">
<button type="submit" class="btn-confirm" :disabled="!editName.trim() || savingEdit">
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-cancel" @click="cancelEdit">Cancel</button>
<button type="button" class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
</div>
</form>
</template>
@@ -387,51 +387,6 @@ async function confirmDelete() {
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
.btn-confirm {
padding: 0.35rem 0.8rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-confirm:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.btn-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-cancel {
padding: 0.35rem 0.8rem;
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-cancel:hover { background: var(--color-action-secondary-hover); }
.btn-cancel:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
/* ── List ─────────────────────────────────────────────────────── */
.systems-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.system-card {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0.85rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: border-color 0.12s, box-shadow 0.15s;
}
.system-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
}
.system-card--archived { opacity: 0.6; }
.system-swatch {
@@ -555,6 +510,6 @@ async function confirmDelete() {
font-family: inherit;
}
.modal-btn:hover { background: var(--color-bg); }
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
</style>
+6 -59
View File
@@ -122,8 +122,8 @@ onMounted(loadLogs);
class="log-duration-input"
placeholder="min"
/>
<button class="btn-log-save" @click="saveEdit(log)">Save</button>
<button class="btn-log-cancel" @click="cancelEdit">Cancel</button>
<button class="btn-primary btn-compact" @click="saveEdit(log)">Save</button>
<button class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
</div>
</template>
<template v-else>
@@ -133,8 +133,8 @@ onMounted(loadLogs);
{{ formatDuration(log.duration_minutes) }}
</span>
<div class="log-entry-actions">
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
<button class="btn-log-delete" aria-label="Delete log entry" @click="deleteLog(log)">&times;</button>
<button class="btn-text" @click="startEdit(log)" title="Edit">Edit</button>
<button class="btn-text" aria-label="Delete log entry" @click="deleteLog(log)">&times;</button>
</div>
</div>
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
@@ -162,7 +162,7 @@ onMounted(loadLogs);
/>
</label>
<button
class="btn-log-submit"
class="btn-primary btn-compact"
@click="submitLog"
:disabled="!newContent.trim() || submitting"
>
@@ -217,7 +217,7 @@ onMounted(loadLogs);
.log-duration-badge {
background: var(--color-primary);
color: #fff;
color: var(--fs-text-on-action);
border-radius: 99px;
padding: 0.1rem 0.5rem;
font-size: 0.72rem;
@@ -230,34 +230,7 @@ onMounted(loadLogs);
gap: 0.25rem;
}
.btn-log-edit,
.btn-log-delete {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.8rem;
font-family: inherit;
padding: 0.1rem 0.25rem;
line-height: 1;
}
.btn-log-edit:hover { color: var(--color-primary); }
.btn-log-delete:hover { color: var(--color-danger, #e74c3c); }
.log-content {
font-size: 0.875rem;
}
.log-content :deep(p) { margin: 0; }
.log-add {
border-top: 1px solid var(--color-border);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.log-textarea {
width: 100%;
@@ -307,32 +280,6 @@ onMounted(loadLogs);
border-color: var(--color-primary);
}
.btn-log-submit,
.btn-log-save {
margin-left: auto;
padding: 0.3rem 0.75rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
}
.btn-log-submit:disabled {
opacity: 0.5;
cursor: default;
}
.btn-log-cancel {
padding: 0.3rem 0.6rem;
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
}
</style>
@@ -36,7 +36,7 @@ const toastStore = useToastStore();
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 6px;
color: #fff;
color: var(--fs-text-on-action);
font-size: 0.9rem;
box-shadow: 0 2px 8px var(--color-shadow);
min-width: 200px;
@@ -54,7 +54,7 @@ const toastStore = useToastStore();
padding: 0 0.15rem;
}
.toast-close:hover {
color: #fff;
color: var(--fs-text-on-action);
}
.toast--success {
background: var(--color-toast-success);
@@ -236,12 +236,12 @@ function restore() {
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
.vh-btn-restore {
background: var(--color-primary);
background: var(--color-action-primary);
border: none;
border-radius: var(--radius-sm);
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: #fff;
color: var(--fs-text-on-action);
cursor: pointer;
font-family: inherit;
}
+15 -198
View File
@@ -314,14 +314,14 @@ defineExpose({ reload: loadProjectNotes });
@keydown.enter="createNote"
@keydown.escape="cancelNewNote"
/>
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
<button class="btn-primary btn-inline" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '' : '+' }}
</button>
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
<button class="btn-text" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
</template>
<template v-else>
<span class="rail-title">Notes</span>
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
<button class="btn-ghost btn-inline" @click="startNewNote" title="New note">+ New</button>
</template>
</div>
@@ -333,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
type="search"
aria-label="Search notes"
/>
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
<button v-if="searchQuery" class="btn-text btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
</div>
<div v-if="listLoading" class="rail-state">Loading</div>
@@ -358,12 +358,12 @@ defineExpose({ reload: loadProjectNotes });
</div>
<div class="note-row-actions" @click.stop>
<template v-if="deletingId === note.id">
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
<button class="btn-danger-outline btn-inline" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
{{ pendingDelete === note.id ? '' : 'Delete?' }}
</button>
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
<button class="btn-text" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
</template>
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
<button v-else class="btn-text" title="Delete note" @click="requestDelete(note.id, $event)">
<Trash2 :size="16" />
</button>
</div>
@@ -382,7 +382,7 @@ defineExpose({ reload: loadProjectNotes });
<WordCount :body="noteBody" />
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
<span v-if="saving" class="saving-txt">Saving</span>
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
<button class="btn-primary btn-compact" :disabled="saving || !dirty" @click="saveNote">Save</button>
</div>
</div>
@@ -400,7 +400,7 @@ defineExpose({ reload: loadProjectNotes });
<div class="tag-row">
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
<button
class="btn-suggest-tags"
class="btn-ghost btn-compact"
:disabled="tagSuggestions.suggestingTags.value"
title="Auto-suggest tags from title and body"
@click="tagSuggestions.fetchTagSuggestions()"
@@ -417,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
@click="tagSuggestions.applyTagSuggestion(tag)"
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
<button class="btn-text" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
</div>
<div class="toolbar-row">
@@ -429,8 +429,8 @@ defineExpose({ reload: loadProjectNotes });
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span>
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
<button class="btn-ghost btn-inline" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-text" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
</div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
@@ -484,26 +484,6 @@ defineExpose({ reload: loadProjectNotes });
flex: 1;
}
.btn-new-note {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
}
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
.rail-search {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.rail-search-input {
flex: 1;
@@ -517,17 +497,8 @@ defineExpose({ reload: loadProjectNotes });
.rail-search-input:focus { outline: none; }
.rail-search-input::-webkit-search-cancel-button { display: none; }
.btn-search-clear {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.68rem;
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
}
.btn-search-clear:hover { color: var(--color-text); }
/* Sits inside the search field: no padding, and must not flex. */
.btn-search-clear { padding: 0; flex-shrink: 0; }
.rail-state {
padding: 1rem 0.65rem;
@@ -617,99 +588,7 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
}
.btn-delete {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0.1rem;
border-radius: 3px;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
display: flex;
align-items: center;
}
.note-row:hover .btn-delete { opacity: 1; }
.btn-delete:hover { color: var(--color-action-destructive); }
.btn-confirm-delete {
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 3px;
transition: background 0.15s, color 0.15s;
}
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
.btn-cancel-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.7rem;
cursor: pointer;
padding: 0.1rem;
}
.btn-cancel-delete:hover { color: var(--color-text); }
/* Inline new note */
.new-note-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-primary);
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-size: 0.78rem;
color: var(--color-text);
min-width: 0;
}
.new-note-input:focus { outline: none; }
.btn-confirm {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-size: 0.85rem;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.btn-confirm:disabled { opacity: 0.4; cursor: default; }
.btn-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
padding: 0.1rem;
flex-shrink: 0;
}
.btn-cancel:hover { color: var(--color-text); }
/* ── Right editor pane ── */
.note-editor-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.editor-empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 0.85rem;
}
/* Editor UI */
.panel-header {
@@ -731,23 +610,6 @@ defineExpose({ reload: loadProjectNotes });
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
/* Moss action-primary per Hybrid */
.btn-save {
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 5px;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-save:disabled { opacity: 0.4; cursor: default; }
.note-title-row {
padding: 0.9rem 1.1rem 0.5rem;
flex-shrink: 0;
}
.note-title-input {
width: 100%;
@@ -775,20 +637,7 @@ defineExpose({ reload: loadProjectNotes });
}
.tag-row > :first-child { flex: 1; min-width: 0; }
.btn-suggest-tags {
background: none;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
align-self: center;
}
.btn-suggest-tags:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
.btn-suggest-tags:disabled { opacity: 0.5; cursor: default; }
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
.tag-suggestions {
display: flex;
@@ -817,21 +666,6 @@ defineExpose({ reload: loadProjectNotes });
color: var(--color-primary);
}
.btn-dismiss-suggestions {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
margin-left: auto;
padding: 0.1rem 0.3rem;
}
.btn-dismiss-suggestions:hover { color: var(--color-text); }
.toolbar-row {
padding: 0.3rem 0.6rem;
flex-shrink: 0;
}
.link-suggest-strip {
display: flex;
@@ -865,21 +699,4 @@ defineExpose({ reload: loadProjectNotes });
}
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
.btn-link-all {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.1rem 0.4rem;
font-size: 0.7rem;
color: var(--color-text-muted);
cursor: pointer;
margin-left: 0.1rem;
}
.btn-link-all:hover { border-color: var(--color-primary); color: var(--color-primary); }
.editor-area {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.6rem;
}
</style>
+11 -81
View File
@@ -232,7 +232,7 @@ defineExpose({ reload: loadAll });
placeholder="New task..."
@keydown.enter="addTask"
/>
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
<button class="btn-primary btn-inline btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
</div>
<div v-if="loading" class="state-msg">Loading...</div>
@@ -293,18 +293,18 @@ defineExpose({ reload: loadAll });
<Transition name="detail-fade">
<div v-if="activeTask" class="task-detail">
<div class="detail-header">
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-edit-task" title="Open full editor">Edit </RouterLink>
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit </RouterLink>
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
</span>
<template v-if="deleteConfirmPending">
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
<button class="btn-danger-outline btn-inline btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
<button class="btn-text" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
</template>
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
<button v-else class="btn-text btn-delete-task" title="Delete task" @click="deleteActiveTask">
<Trash2 :size="16" />
</button>
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
<button class="btn-text btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
</div>
<h3 class="detail-title">{{ activeTask.title }}</h3>
@@ -396,17 +396,7 @@ defineExpose({ reload: loadAll });
}
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
.btn-add {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 5px;
padding: 0.28rem 0.55rem;
font-size: 1rem;
cursor: pointer;
line-height: 1;
}
.btn-add:disabled { opacity: 0.4; cursor: default; }
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
.groups-scroll {
flex: 1;
@@ -534,17 +524,7 @@ defineExpose({ reload: loadAll });
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
.btn-edit-task {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.78rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 3px;
text-decoration: none;
flex-shrink: 0;
}
.btn-edit-task { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; }
.detail-body {
@@ -566,51 +546,11 @@ defineExpose({ reload: loadAll });
color: var(--color-text);
}
.btn-delete-task {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
padding: 0.15rem 0.3rem;
border-radius: 3px;
margin-left: 0.25rem;
}
.btn-delete-task { margin-left: 0.25rem; }
.btn-delete-task:hover { color: var(--color-action-destructive); }
.btn-delete-confirm {
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
padding: 0.15rem 0.5rem;
border-radius: 4px;
margin-left: 0.25rem;
transition: background 0.15s, color 0.15s;
}
.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-delete-confirm { margin-left: 0.25rem; }
.btn-delete-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
}
.btn-delete-cancel:hover { color: var(--color-text); }
.detail-title {
padding: 0.75rem 0.75rem 0.25rem;
font-size: 0.95rem;
font-weight: 500;
margin: 0;
color: var(--color-text);
flex-shrink: 0;
}
.detail-meta {
display: flex;
@@ -679,15 +619,5 @@ defineExpose({ reload: loadAll });
}
/* Close detail button */
.btn-close-detail {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.15rem 0.3rem;
border-radius: 3px;
margin-left: 0.2rem;
}
.btn-close-detail:hover { color: var(--color-text); }
.btn-close-detail { margin-left: 0.2rem; }
</style>
+2
View File
@@ -3,6 +3,8 @@ import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/theme.css";
// After theme.css — it consumes the tokens declared there.
import "./assets/components.css";
import "./assets/prose.css";
const app = createApp(App);
+14
View File
@@ -109,6 +109,20 @@ const router = createRouter({
name: "rules",
component: () => import("@/views/RulesView.vue"),
},
{
// Meta-surface, same family as /rules: it describes the app rather than
// holding the operator's records.
path: "/design",
name: "design",
component: () => import("@/views/DesignView.vue"),
},
{
// The editable half of the same surface: /design is what the browser
// renders, /design-systems is the record that ought to decide it.
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
},
{
path: "/tasks",
redirect: "/",
+169
View File
@@ -0,0 +1,169 @@
/**
* Drift comparison — what the rulebook claims vs what the stylesheet does.
*
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
* prose into claims) is server-side in `services/design_system.py`, where pytest
* can assert on it. What's left here is set arithmetic over live token values,
* which is the one thing the browser knows and the server doesn't.
*
* SCOPE, and it is a real limit rather than an omission. This compares the
* rulebook against the TOKENS. It cannot see the third category of drift — a
* literal hardcoded in a component where a token should be referenced (#2275,
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
*
* Catching it needs the component sources, which would mean bundling every SFC
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
* matters: a drift report that silently omits a category invites the reader to
* conclude the category is clean.
*/
import type { DesignToken } from "@/utils/designTokens";
export type ExpectationKind = "token" | "color" | "prohibited_color";
export interface Expectation {
kind: ExpectationKind;
value: string;
rule_id: number;
rule_title: string;
context: string;
}
export interface ExpectationResponse {
rulebook_id: number | null;
expectations: Expectation[];
}
export type FindingStatus = "ok" | "missing" | "violated";
export interface Finding {
expectation: Expectation;
status: FindingStatus;
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
matches: string[];
}
/**
* Normalise a colour for comparison — the client-side twin of
* `normalize_hex` in services/design_system.py.
*
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
* spellings of one colour, and a comparison that misses any of them under-reports
* rather than erroring. The rgb() case is browser-specific and therefore has no
* server-side counterpart, which is exactly why it is handled here.
*/
export function normalizeColour(value: string): string | null {
const raw = value.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) {
digits = digits.split("").map((c) => c + c).join("");
}
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
}
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
if (rgb) {
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
if (parts.length < 3) return null;
const channels = parts.slice(0, 3).map((p) => Number(p));
if (channels.some((n) => !Number.isFinite(n))) return null;
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
const base = `#${channels.map(hexOf).join("")}`;
if (parts.length === 3) return base;
const alpha = Number(parts[3]);
if (!Number.isFinite(alpha) || alpha >= 1) return base;
return `${base}${hexOf(alpha * 255)}`;
}
return null;
}
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
const index = new Map<string, string[]>();
for (const token of tokens) {
const colour = normalizeColour(token.value);
if (!colour) continue;
const names = index.get(colour);
if (names) names.push(token.name);
else index.set(colour, [token.name]);
}
return index;
}
/**
* Compare claims against the live tokens.
*
* A `token` claim asks whether a custom property of that name exists.
* A `color` claim asks whether any token resolves to that value.
* A `prohibited_color` claim INVERTS the test — present is the failure.
*/
export function compareToTokens(
expectations: Expectation[],
tokens: DesignToken[],
): Finding[] {
const names = new Set(tokens.map((t) => t.name));
const colours = colourIndex(tokens);
return expectations.map((expectation) => {
if (expectation.kind === "token") {
const present = names.has(expectation.value);
return {
expectation,
status: present ? "ok" : "missing",
matches: present ? [expectation.value] : [],
};
}
const matches = colours.get(expectation.value) ?? [];
if (expectation.kind === "prohibited_color") {
return {
expectation,
status: matches.length ? "violated" : "ok",
matches,
};
}
return {
expectation,
status: matches.length ? "ok" : "missing",
matches,
};
});
}
export interface DriftSummary {
ok: number;
missing: number;
violated: number;
total: number;
}
export function summarise(findings: Finding[]): DriftSummary {
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
for (const finding of findings) summary[finding.status] += 1;
return summary;
}
/**
* Findings worth leading with.
*
* A panel that opens with every row gets closed and never reopened — the same
* principle the auto-inject menu is built on: a short list that gets read beats
* a complete one that doesn't. Violations first (something is actively wrong),
* then missing (something was never built), and `ok` rows are not "findings" at
* all — they belong behind an expansion.
*/
export function rankFindings(findings: Finding[]): Finding[] {
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
return [...findings].sort((a, b) => {
const byStatus = order[a.status] - order[b.status];
if (byStatus !== 0) return byStatus;
return a.expectation.rule_id - b.expectation.rule_id;
});
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Design-token inventory — what tokens exist, and what they actually resolve to.
*
* Foundation for the design explorer (milestone #251): the gallery renders
* against these, and the drift panel compares them to the design rulebook.
*
* DESIGN NOTE — why this parses NAMES but never VALUES.
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
* its VALUE is not: values contain nested parens, commas inside rgba(),
* `var()` references to other tokens, multi-part shadows, and gradients — and
* `theme.css` has all of those today. So we take the names from the source and
* ask the BROWSER for every value.
*
* That is not just easier, it is more correct. getComputedStyle reports what
* actually won the cascade, resolves `var()` chains, and — critically for this
* milestone — reflects live overrides set on a container, which is exactly what
* the preview surface needs (see #2261). Parsing the source would report what
* the file says rather than what the user is looking at.
*
* It also means this module needs no unit tests to be trustworthy: the only
* logic here is a name regex and a group lookup. The frontend has no test
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
* error-prone half in the browser rather than in our code is deliberate.
*/
import themeCss from "@/assets/theme.css?raw";
export type TokenGroup =
| "color"
| "radius"
| "gradient"
| "glow"
| "focus"
| "layout"
| "other";
export type ThemeMode = "light" | "dark";
export interface DesignToken {
/** Full custom-property name, including the leading `--`. */
name: string;
/** Coarse family, derived from the name prefix. */
group: TokenGroup;
/** Resolved value in the requested context, straight from the browser. */
value: string;
/** True when the declaration appears inside the dark block in source. */
overriddenInDark: boolean;
}
/**
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
*
* The discriminator is the COLON, not the preceding character. A declaration is
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
* actively wrong: an earlier version required the match to follow `{` or `;`,
* which silently dropped every declaration that came after a comment —
* including `--color-bg`, the first and most-used token in the file.
*/
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
/** Comments are stripped first so a commented-out declaration isn't counted. */
const COMMENT = /\/\*[\s\S]*?\*\//g;
/** The dark block's selector, as written in theme.css. */
const DARK_SELECTOR = '[data-theme="dark"]';
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
["--color-", "color"],
["--radius-", "radius"],
["--gradient-", "gradient"],
["--glow-", "glow"],
["--focus-", "focus"],
["--page-", "layout"],
["--sidebar-", "layout"],
["--chat-", "layout"],
];
export function groupFor(name: string): TokenGroup {
for (const [prefix, group] of GROUP_PREFIXES) {
if (name.startsWith(prefix)) return group;
}
return "other";
}
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
export function tokenNames(css: string = themeCss): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
const name = match[1];
if (!seen.has(name)) {
seen.add(name);
out.push(name);
}
}
return out;
}
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
export function darkOverriddenNames(css: string = themeCss): Set<string> {
const bare = css.replace(COMMENT, "");
const start = bare.indexOf(DARK_SELECTOR);
if (start === -1) return new Set();
const open = bare.indexOf("{", start);
const close = bare.indexOf("}", open);
if (open === -1 || close === -1) return new Set();
return new Set(tokenNames(bare.slice(open, close)));
}
/**
* Read the resolved value of every token in `host`'s context.
*
* Pass a container to read the tokens as they apply INSIDE it — which is how
* the preview surface reads a scoped override without disturbing the page.
* Defaults to the document root, i.e. the app-wide values.
*/
export function readTokens(host: Element = document.documentElement): DesignToken[] {
const computed = getComputedStyle(host);
const dark = darkOverriddenNames();
return tokenNames().map((name) => ({
name,
group: groupFor(name),
value: computed.getPropertyValue(name).trim(),
overriddenInDark: dark.has(name),
}));
}
/**
* Read tokens as they would resolve in a given mode, without touching the page.
*
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
* mutated to take a reading.
*
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
* function: light is declared on `:root` while dark is declared on
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
* subtree, but there is no `[data-theme="light"]` block to add the light ones
* back. So reading "light" from inside a dark page returns the dark values —
* the probe has nothing to match.
*
* Concretely: dark-inside-light previews work, light-inside-dark previews do
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
* (milestone #251 step 6) is what makes this symmetric, and until then callers
* should treat a cross-mode read as best-effort.
*/
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
const probe = document.createElement("div");
probe.setAttribute("data-theme", mode);
probe.style.display = "none";
document.body.appendChild(probe);
try {
return readTokens(probe);
} finally {
probe.remove();
}
}
/** Tokens grouped by family, preserving source order within each group. */
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
const out = new Map<TokenGroup, DesignToken[]>();
for (const token of tokens) {
const bucket = out.get(token.group);
if (bucket) bucket.push(token);
else out.set(token.group, [token]);
}
return out;
}
/**
* Tokens declared in the stylesheet that nothing references with `var()`.
*
* Dead tokens are drift too: `--chat-reading-width` and
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
* in the MCP-first pivot, and nothing has referenced them since. Takes the
* corpus of source files to search as an argument so the caller decides what
* "used" means — this module has no opinion about the project layout.
*/
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
const haystack = sources.join("\n");
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
}
File diff suppressed because it is too large Load Diff
+545
View File
@@ -0,0 +1,545 @@
<script setup lang="ts">
/**
* Design explorer — the gallery (milestone #251 step 3).
*
* Renders the design system against the tokens that are actually live, read at
* runtime rather than parsed from source, so what you see here is what the app
* is using right now.
*
* HONESTY RULE, and the reason parts of this page say "not implemented":
* a gallery of hand-written look-alikes drifts from the app within a month and
* then lies — which is the same failure this whole surface exists to catch. So
* every specimen below is either a REAL component imported from the app, or a
* real token read from the browser, or it is explicitly marked as missing.
*
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
* in five `<style scoped>` blocks, all five drifted, and this page reported it
* as a gap because drawing a look-alike would have made it a sixth copy.
* `assets/components.css` is now the single definition (#2273), so the
* specimens below are the app's real classes — they cannot drift from the app
* without drifting the app itself.
*/
import { computed, onMounted, ref } from "vue";
import { fetchDesignExpectations } from "@/api/design";
import DesignTabs from "@/components/DesignTabs.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import TagPill from "@/components/TagPill.vue";
import {
compareToTokens,
rankFindings,
summarise,
type Expectation,
type Finding,
} from "@/utils/designDrift";
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
const tokens = ref<DesignToken[]>([]);
const expectations = ref<Expectation[]>([]);
const designRulebookId = ref<number | null>(null);
const driftLoaded = ref(false);
const showCleanRows = ref(false);
/**
* Read on mount, not at module scope: the values depend on the live cascade,
* which needs the app's stylesheets applied and the theme attribute set.
*/
onMounted(async () => {
tokens.value = readTokens();
try {
const response = await fetchDesignExpectations();
designRulebookId.value = response.rulebook_id;
expectations.value = response.expectations;
} catch {
// The gallery is useful without the panel, so a failed fetch degrades to
// "no drift data" rather than taking the page down with it.
designRulebookId.value = null;
} finally {
driftLoaded.value = true;
}
});
const findings = computed<Finding[]>(() =>
rankFindings(compareToTokens(expectations.value, tokens.value)),
);
const driftSummary = computed(() => summarise(findings.value));
const visibleFindings = computed(() =>
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
);
const grouped = computed(() => groupTokens(tokens.value));
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
const orderedGroups = computed(() =>
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
);
/** A token whose value reads as a colour is worth showing as a swatch. */
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
}
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
const RULEBOOK_BUTTONS = [
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
];
const TYPE_SPECIMENS = [
{ token: "Display", spec: "40 / 500 / Fraunces" },
{ token: "H1", spec: "32 / 500 / Fraunces" },
{ token: "H2", spec: "24 / 500 / Fraunces" },
{ token: "H3", spec: "18 / 500 / Inter" },
{ token: "Body", spec: "15 / 400 / Inter" },
{ token: "Body small", spec: "13 / 400 / Inter" },
{ token: "Label", spec: "12 / 500 / Inter" },
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
];
</script>
<template>
<div class="design-view">
<DesignTabs />
<header class="design-header">
<h1>Live tokens</h1>
<p class="lede">
The system as it actually is. Token values are read from the browser at
runtime, so this page reflects the live cascade rather than what the
stylesheet says. Components shown are the real ones where a piece of
the system has no shared implementation, it is marked missing rather
than mocked up.
</p>
</header>
<!-- Drift: what the rulebook claims vs what the tokens do. -->
<section class="design-section">
<h2>Rulebook drift</h2>
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook</p>
<div v-else-if="designRulebookId === null" class="gap-notice">
<strong>No design rulebook designated.</strong>
<p>
This install hasn't said which rulebook describes its design system, so
there is nothing to check the tokens against. Designate one in
<router-link to="/settings">Settings</router-link> and this panel will
compare every colour and token the rulebook names against what the
stylesheet actually resolves to.
</p>
</div>
<template v-else>
<p class="section-note">
<strong>{{ driftSummary.violated }}</strong> violated ·
<strong>{{ driftSummary.missing }}</strong> missing ·
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
claims in rulebook #{{ designRulebookId }}.
</p>
<div class="gap-notice">
<strong>This compares the rulebook against the TOKENS only.</strong>
<p>
A value hardcoded in a component — where a token should have been
referenced — is invisible here, because the drift isn't in the tokens
at all. Reading it would mean bundling every component's source into
the app. That check belongs in CI and is tracked separately, so treat
a clean panel as "the tokens agree", not "the app agrees".
</p>
</div>
<p v-if="!findings.length" class="muted">
The rulebook names nothing this panel can check. Rules that state values
— colours, token names — produce claims; rules that state judgement
don't, by design.
</p>
<ul v-else class="spec-list">
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
<span class="spec-name">
<span
v-if="finding.expectation.kind !== 'token'"
class="swatch"
:style="{ background: finding.expectation.value }"
aria-hidden="true"
/>
<code>{{ finding.expectation.value }}</code>
</span>
<span class="spec-detail">
rule #{{ finding.expectation.rule_id }} {{ finding.expectation.rule_title }}
<span v-if="finding.matches.length" class="matches">
· {{ finding.matches.join(", ") }}
</span>
</span>
<span class="spec-status" :class="finding.status">
{{ finding.status === "violated" ? "forbidden, but present"
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
</span>
</li>
</ul>
<button
v-if="findings.length && driftSummary.ok"
class="reveal-toggle"
@click="showCleanRows = !showCleanRows"
>
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
</button>
</template>
</section>
<!-- Real components: these are imported, not recreated. -->
<section class="design-section">
<h2>Components</h2>
<p class="section-note">Imported from the app. What you see is what ships.</p>
<div class="specimen">
<span class="specimen-label">Status badge</span>
<div class="specimen-row">
<StatusBadge status="todo" />
<StatusBadge status="in_progress" />
<StatusBadge status="done" />
<StatusBadge status="cancelled" />
</div>
</div>
<div class="specimen">
<span class="specimen-label">Priority badge</span>
<div class="specimen-row">
<PriorityBadge priority="low" />
<PriorityBadge priority="medium" />
<PriorityBadge priority="high" />
<span class="muted">(<code>none</code> renders nothing, by design)</span>
</div>
</div>
<div class="specimen">
<span class="specimen-label">Tag pill</span>
<div class="specimen-row">
<TagPill tag="design-system" />
<TagPill tag="dismissible" dismissible />
</div>
</div>
</section>
<!-- No longer a gap: these are the app's real classes, from the shared
sheet. Nothing here is a look-alike — change components.css and these
specimens change with it, which is the only way this page stays true. -->
<section class="design-section">
<h2>Buttons</h2>
<div class="button-specimens">
<button class="btn-primary">Save</button>
<button class="btn-secondary">Detect</button>
<button class="btn-ghost">Cancel</button>
<button class="btn-danger">Delete</button>
<button class="btn-primary" disabled>Disabled</button>
</div>
<p class="spec-caption">
Three sizes, because the app has three kinds of button: a page action, a
row action, and an affordance that sits inside a card without disturbing
its rhythm.
</p>
<div class="button-specimens">
<button class="btn-primary">Default — page action</button>
<button class="btn-primary btn-compact">Compact — row action</button>
<button class="btn-primary btn-inline">Inline</button>
</div>
<ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status ok">shared</span>
</li>
</ul>
</section>
<!-- Typography: the families load, the scale does not exist as tokens. -->
<section class="design-section">
<h2>Type scale</h2>
<div class="gap-notice">
<strong>Families load; the scale has no tokens.</strong>
<p>
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
scale is not expressed as custom properties, so sizes and weights are
set ad hoc per component. Listed here as specification, not as a live
specimen there is nothing to read.
</p>
</div>
<ul class="spec-list">
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
<span class="spec-name">{{ t.token }}</span>
<span class="spec-detail">{{ t.spec }}</span>
<span class="spec-status missing">no token</span>
</li>
</ul>
</section>
<!-- Tokens: entirely real, read live. -->
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
<ul class="token-list">
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
<span
v-if="isColourish(token.value)"
class="swatch"
:style="{ background: token.value }"
aria-hidden="true"
/>
<span v-else class="swatch swatch-none" aria-hidden="true" />
<code class="token-name">{{ token.name }}</code>
<code class="token-value">{{ token.value || "—" }}</code>
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
mode-aware
</span>
</li>
</ul>
</section>
<p v-if="!tokens.length" class="muted">Reading tokens</p>
</div>
</template>
<style scoped>
.design-view {
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem var(--page-padding-x) 4rem;
}
.design-header {
margin-bottom: 2rem;
}
.lede {
color: var(--color-text-secondary);
max-width: 60ch;
line-height: 1.7;
}
.design-section {
margin-bottom: 2.5rem;
}
.design-section h2 {
margin-bottom: 0.25rem;
}
.token-group-heading {
text-transform: capitalize;
}
.count {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 400;
}
.section-note,
.muted {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 1rem;
}
/* Specimens -------------------------------------------------------------- */
.specimen {
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
}
.specimen-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.specimen-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* Gaps ------------------------------------------------------------------- */
.spec-caption {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
line-height: 1.5;
max-width: 60ch;
}
/* Layout only. The buttons inside style themselves from the shared sheet —
adding any appearance rule here would recreate the copy this section
just stopped being. */
.button-specimens {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--fs-space-3);
margin-bottom: 1rem;
}
.gap-notice {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.gap-notice p {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.6;
max-width: 70ch;
}
.spec-list {
list-style: none;
padding: 0;
margin: 0;
}
.spec-list li {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.spec-name {
min-width: 8rem;
font-weight: 500;
}
.spec-detail {
color: var(--color-text-secondary);
font-size: 0.85rem;
flex: 1;
}
.spec-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
}
.spec-status.missing {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.spec-status.violated {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.ok {
background: var(--color-status-done-bg);
color: var(--color-status-done);
}
.spec-name .swatch {
vertical-align: middle;
margin-right: 0.4rem;
}
.matches {
color: var(--color-text-muted);
}
.reveal-toggle {
margin-top: 0.75rem;
padding: 0.35rem 0.75rem;
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
}
.reveal-toggle:hover {
border-color: var(--color-text-muted);
}
/* Tokens ----------------------------------------------------------------- */
.token-list {
list-style: none;
padding: 0;
margin: 0;
}
.token-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.swatch {
width: 1.25rem;
height: 1.25rem;
flex: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.swatch-none {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
var(--color-border) 3px,
var(--color-border) 4px
);
}
.token-name {
min-width: 16rem;
font-size: 0.8rem;
}
.token-value {
color: var(--color-text-secondary);
font-size: 0.8rem;
flex: 1;
word-break: break-all;
}
.token-flag {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.05rem 0.35rem;
}
@media (max-width: 640px) {
.token-name {
min-width: 0;
}
}
</style>
+1 -19
View File
@@ -49,7 +49,7 @@ async function handleSubmit() {
/>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="submitting">
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
{{ submitting ? "Sending..." : "Send Reset Link" }}
</button>
</form>
@@ -137,24 +137,6 @@ async function handleSubmit() {
.success-msg p {
margin: 0.5rem 0;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
+4 -40
View File
@@ -381,7 +381,7 @@ onUnmounted(() => {
<option value="alpha">Alphabetical</option>
<option value="type">By type</option>
</select>
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
<button class="btn-ghost btn-compact" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
<Share2 :size="16" />
Graph
</button>
@@ -463,14 +463,14 @@ onUnmounted(() => {
<span>Graph</span>
<div style="display:flex;gap:4px;align-items:center">
<button
class="btn-icon-sm"
class="btn-text"
@click="toggleGraphExpand"
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
>
<ChevronLeft v-if="graphExpanded" :size="16" />
<ChevronRight v-else :size="16" />
</button>
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
<button class="btn-text" @click="toggleGraph" title="Close graph">
<X :size="16" />
</button>
</div>
@@ -574,7 +574,7 @@ onUnmounted(() => {
border-radius: 10px;
border: none;
background: var(--gradient-cta);
color: #fff;
color: var(--fs-text-on-action);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
@@ -719,26 +719,6 @@ onUnmounted(() => {
cursor: pointer;
outline: none;
}
.btn-graph {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: transparent;
color: var(--color-muted);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s;
white-space: nowrap;
}
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
.btn-graph.active {
background: var(--color-primary-wash);
border-color: rgba(91, 74, 138, 0.35);
color: var(--color-primary);
}
/* ── Card grid ───────────────────────────────────────────── */
.card-grid {
@@ -956,22 +936,6 @@ onUnmounted(() => {
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
flex-shrink: 0;
}
.btn-icon-sm {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85rem;
transition: color 0.15s;
}
.btn-icon-sm:hover { color: var(--color-text); }
.graph-embed {
flex: 1;
overflow: hidden;
position: relative;
}
/* Override GraphView's 100vh height so it fills the panel instead */
.graph-embed :deep(.graph-page) {
height: 100%;
+2 -34
View File
@@ -84,7 +84,7 @@ function loginWithOAuth() {
<p class="forgot-link">
<router-link to="/forgot-password">Forgot your password?</router-link>
</p>
<button type="submit" class="btn-submit" :disabled="submitting">
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
{{ submitting ? "Signing in..." : "Sign In" }}
</button>
</form>
@@ -98,7 +98,7 @@ function loginWithOAuth() {
<button
v-if="authStore.oauthEnabled"
class="btn-oauth"
class="btn-ghost btn-block"
@click="loginWithOAuth"
>
Login with Authentik
@@ -176,24 +176,6 @@ function loginWithOAuth() {
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.divider {
display: flex;
align-items: center;
@@ -208,20 +190,6 @@ function loginWithOAuth() {
flex: 1;
border-top: 1px solid var(--color-border);
}
.btn-oauth {
width: 100%;
padding: 0.6rem;
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-oauth:hover {
background: var(--color-bg-hover, var(--color-border));
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
+1 -14
View File
@@ -177,7 +177,7 @@ function clearFilters() {
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
<button
v-if="category || search || dateFrom || dateTo"
class="btn-clear"
class="btn-ghost btn-compact"
@click="clearFilters"
>
Clear
@@ -337,19 +337,6 @@ function clearFilters() {
.filter-date {
width: 140px;
}
.btn-clear {
padding: 0.4rem 0.75rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-clear:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
/* Table */
.loading-msg,
+1 -1
View File
@@ -748,7 +748,7 @@ onUnmounted(() => assist.clearSelection());
cursor: pointer;
font-family: inherit;
}
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
.btn-link-all:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
.link-suggest-list {
display: flex;
+4 -75
View File
@@ -194,22 +194,22 @@ async function convertToTask() {
</div>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back"> Notes</router-link>
<router-link to="/notes" class="btn-ghost"> Notes</router-link>
<router-link
:to="`/notes/${store.currentNote.id}/edit`"
class="btn-edit"
class="btn-primary"
>
Edit
</router-link>
<button
v-if="!store.currentNote.is_task"
class="btn-convert"
class="btn-secondary btn-compact"
@click="convertToTask"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
<button class="btn-share" @click="showShare = true">Share</button>
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
</div>
<!-- Breadcrumb: parent project milestone -->
@@ -329,81 +329,10 @@ async function convertToTask() {
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
text-decoration: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn-back:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Edit: Moss action-primary — switching from view to edit is operating
the software, not a brand moment. */
.btn-edit {
display: inline-flex;
align-items: center;
padding: 0.45rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: var(--color-action-primary);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.15s;
}
.btn-edit:hover {
background: var(--color-action-primary-hover);
color: #fff;
}
/* Convert + Share: Bronze action-secondary — alternate paths */
.btn-convert {
margin-left: auto;
padding: 0.3rem 0.75rem;
background: var(--color-action-secondary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s;
}
.btn-convert:hover { background: var(--color-action-secondary-hover); }
.btn-convert:disabled {
opacity: 0.6;
cursor: default;
}
.btn-share {
padding: 0.3rem 0.75rem;
background: var(--color-action-secondary);
border: none;
border-radius: var(--radius-sm);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-share:hover { background: var(--color-action-secondary-hover); }
.note-title {
font-family: "Fraunces", Georgia, serif;
font-size: 2rem;
font-weight: 500;
line-height: 1.2;
margin: 0.25rem 0 0.5rem;
color: var(--color-text);
}
.meta {
display: flex;
+67 -22
View File
@@ -113,6 +113,45 @@ function truncate(text: string | null, max = 120): string {
return text.length > max ? text.slice(0, max) + "..." : text;
}
// A card is a glance, not a report. Roundtable had ~35 milestones and its tile
// ran several viewport-heights tall, which made the grid unreadable (#2391).
const MAX_MILESTONE_BARS = 10;
interface MilestoneBar extends MilestoneSummary {
/** Position in the FULL list, so a bar keeps its colour when another
* milestone is added or finishes. Tying the palette to the visible index
* would recolour the card every time work closed. */
paletteIndex: number;
}
/** Bars to draw per project, plus how many were withheld.
*
* Ordered OPEN WORK FIRST, newest first. Recency alone would be wrong here: a
* long-running project's oldest milestones are usually its finished ones, so
* showing 10 completed bars while hiding the 3 in flight is worse than showing
* nothing. What the card is for is "what is happening", not "what happened".
*
* Computed once per load rather than called from the template — a helper in a
* v-for is re-run on every render, and this one sorts.
*/
const milestoneBars = computed(() => {
const byProject = new Map<number, { bars: MilestoneBar[]; hidden: number }>();
for (const project of projects.value) {
const all = project.summary?.milestone_summary ?? [];
const indexed: MilestoneBar[] = all.map((ms, i) => ({ ...ms, paletteIndex: i }));
const newestFirst = (a: MilestoneBar, b: MilestoneBar) => b.id - a.id;
const ordered = [
...indexed.filter((m) => m.pct < 100).sort(newestFirst),
...indexed.filter((m) => m.pct >= 100).sort(newestFirst),
];
byProject.set(project.id, {
bars: ordered.slice(0, MAX_MILESTONE_BARS),
hidden: Math.max(0, ordered.length - MAX_MILESTONE_BARS),
});
}
return byProject;
});
function overallPct(project: Project): { total: number; pct: number } {
const counts = project.summary?.task_counts;
if (!counts) return { total: 0, pct: 0 };
@@ -185,11 +224,11 @@ function overallPct(project: Project): { total: number; pct: number } {
<!-- Milestone progress bars -->
<div
v-if="project.summary?.milestone_summary?.length"
v-if="milestoneBars.get(project.id)?.bars.length"
class="milestone-bars"
>
<div
v-for="(ms, i) in project.summary.milestone_summary"
v-for="ms in milestoneBars.get(project.id)!.bars"
:key="ms.id"
class="milestone-bar-row"
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
@@ -198,11 +237,23 @@ function overallPct(project: Project): { total: number; pct: number } {
<div class="milestone-bar-track">
<div
class="milestone-bar-fill"
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
:style="{ width: ms.pct + '%', background: milestoneColor(ms.paletteIndex) }"
></div>
</div>
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
</div>
<!-- Say what is withheld. A list that simply stops reads as a
rendering bug; a count reads as a summary. Plain text, not a
link: the whole card already navigates to this project, and a
link nested inside a clickable region is a trap for keyboard
and screen-reader users. -->
<span
v-if="milestoneBars.get(project.id)!.hidden"
class="milestone-more"
>
+{{ milestoneBars.get(project.id)!.hidden }}
{{ milestoneBars.get(project.id)!.hidden === 1 ? 'more milestone' : 'more milestones' }}
</span>
</div>
<div class="card-footer">
@@ -284,20 +335,6 @@ function overallPct(project: Project): { total: number; pct: number } {
/* Moss action-primary per Hybrid — list-view utility action,
not a brand moment. Empty-state .empty-action below keeps accent. */
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-primary:hover {
background: var(--color-action-primary-hover);
}
.filter-tabs {
display: flex;
@@ -340,8 +377,8 @@ function overallPct(project: Project): { total: number; pct: number } {
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
.empty-action:hover { background: var(--color-primary); color: #fff; }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-action-primary); border-radius: var(--radius-sm); color: var(--color-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
.empty-action:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
.skeleton-card {
height: 140px;
@@ -506,6 +543,14 @@ function overallPct(project: Project): { total: number; pct: number } {
text-align: right;
}
/* Deliberately quiet — it is a footnote about what is not shown, not another
row competing with the bars above it. */
.milestone-more {
color: var(--color-text-muted);
font-size: var(--fs-size-tiny);
padding-top: 0.15rem;
}
.card-footer {
margin-top: auto;
}
@@ -592,9 +637,9 @@ function overallPct(project: Project): { total: number; pct: number } {
background: var(--color-bg);
}
.modal-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
background: var(--color-action-primary);
border-color: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
opacity: 0.9;
+60 -155
View File
@@ -9,6 +9,11 @@ import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import {
fetchDesignSystems,
setProjectDesignSystem,
type DesignSystem,
} from "@/api/designSystems";
import {
LayoutGrid,
Clock,
@@ -41,6 +46,7 @@ interface Project {
goal: string | null;
status: "active" | "paused" | "completed" | "archived";
color: string | null;
design_system_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -69,6 +75,12 @@ const toast = useToastStore();
const tasksStore = useTasksStore();
const project = ref<Project | null>(null);
// Design system the project is styled from. Loaded separately because an
// install with none is the ordinary case (rule #115) and the picker simply
// doesn't render — a failed fetch must not take the project page with it.
const designSystems = ref<DesignSystem[]>([]);
const editDesignSystemId = ref<number | null>(null);
const loading = ref(false);
const showStartPlanning = ref(false);
@@ -175,6 +187,7 @@ async function loadProject() {
editDescription.value = data.description ?? "";
editGoal.value = data.goal ?? "";
editStatus.value = data.status;
editDesignSystemId.value = data.design_system_id ?? null;
editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
@@ -336,8 +349,20 @@ onMounted(async () => {
await loadProject();
loadTasks();
loadNotes();
loadDesignSystems();
});
/** Populate the design-system picker. Swallows failure on purpose: with no
* design systems the picker doesn't render at all, which is the ordinary state
* for most installs — so this must never be able to break the project page. */
async function loadDesignSystems() {
try {
designSystems.value = (await fetchDesignSystems()).design_systems;
} catch {
designSystems.value = [];
}
}
watch(projectId, async () => {
await loadProject();
loadTasks();
@@ -345,28 +370,39 @@ watch(projectId, async () => {
});
watch(
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
() => {
if (!project.value) return;
editDirty.value =
editTitle.value !== project.value.title ||
editDescription.value !== (project.value.description ?? "") ||
editGoal.value !== (project.value.goal ?? "") ||
editStatus.value !== project.value.status;
editStatus.value !== project.value.status ||
editDesignSystemId.value !== (project.value.design_system_id ?? null);
}
);
async function saveProject() {
if (!project.value || saving.value) return;
// Bound once rather than re-read: the checks below straddle two awaits, and
// `project.value` is a ref whose narrowing doesn't survive them.
const current = project.value;
if (!current || saving.value) return;
saving.value = true;
try {
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
title: editTitle.value.trim(),
description: editDescription.value.trim() || null,
goal: editGoal.value.trim() || null,
status: editStatus.value,
});
project.value = { ...project.value, ...updated };
// The design-system pointer is its own endpoint (PUT, because clearing it
// is a real outcome rather than an omission), so it saves separately —
// only when it actually changed, to keep the common save at one request.
if (editDesignSystemId.value !== (current.design_system_id ?? null)) {
await setProjectDesignSystem(current.id, editDesignSystemId.value);
updated.design_system_id = editDesignSystemId.value;
}
project.value = { ...current, ...updated };
editDirty.value = false;
toast.show("Project saved");
} catch {
@@ -399,7 +435,7 @@ async function confirmDelete() {
<!-- Nav bar -->
<div class="page-header">
<router-link to="/projects" class="btn-back"> Projects</router-link>
<router-link to="/projects" class="btn-ghost"> Projects</router-link>
<div class="page-header-actions">
<template v-if="showStartPlanning">
<input
@@ -415,7 +451,7 @@ async function confirmDelete() {
>
Create plan
</button>
<button class="btn-share" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
<button class="btn-secondary btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
</template>
<button
v-else-if="project"
@@ -428,7 +464,7 @@ async function confirmDelete() {
<LayoutGrid :size="16" />
Workspace
</router-link>
<button v-if="project && !showStartPlanning" class="btn-share" @click="showShare = true">Share</button>
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
</div>
</div>
@@ -505,7 +541,14 @@ async function confirmDelete() {
<option value="archived">Archived</option>
</select>
</div>
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
<div v-if="designSystems.length" class="edit-field">
<label class="edit-label" for="project-design-system">Design system</label>
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<button class="btn-primary" @click="saveProject" :disabled="!editDirty || saving">
{{ saving ? "Saving..." : "Save Changes" }}
</button>
</aside>
@@ -538,7 +581,7 @@ async function confirmDelete() {
</div>
<template v-else>
<div class="milestone-actions">
<button v-if="!showNewMilestone" class="btn-add-milestone" @click="showNewMilestone = true">
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
+ Milestone
</button>
<div v-else class="new-milestone-row">
@@ -550,10 +593,10 @@ async function confirmDelete() {
@keydown.enter="createMilestone"
@keydown.escape="showNewMilestone = false; newMilestoneTitle = ''"
/>
<button class="btn-ms-confirm" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
<button class="btn-primary btn-compact" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
{{ creatingMilestone ? "..." : "Add" }}
</button>
<button class="btn-ms-cancel" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
<button class="btn-secondary btn-compact" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
</div>
</div>
@@ -813,80 +856,9 @@ async function confirmDelete() {
min-width: 200px;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: border-color 0.15s, color 0.15s;
}
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
the project's "central feature moment" — entering the focused workspace
is a Scribe-flavored action, not a plain operation. */
.btn-workspace {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 1rem;
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); }
/* Share: Bronze action-secondary — alternate path */
.btn-share {
padding: 0.4rem 0.8rem;
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-share:hover { background: var(--color-action-secondary-hover); }
/* Delete project: Oxblood action-destructive ghost — outline form since
the actual confirm modal carries the filled destructive treatment */
.btn-danger-outline {
padding: 0.4rem 0.8rem;
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
/* ── Project identity header ─────────────────────────────────── */
.project-header { margin-bottom: 1rem; }
.title-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.project-title-input {
flex: 1;
font-size: 1.75rem;
@@ -1019,30 +991,6 @@ async function confirmDelete() {
.edit-textarea { resize: vertical; }
/* Save panel: Moss action-primary per Hybrid rule */
.btn-save-panel {
padding: 0.45rem 0.9rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
width: 100%;
transition: background 0.15s;
}
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
/* ── Content area ────────────────────────────────────────────── */
.content-area { display: flex; flex-direction: column; gap: 0.75rem; }
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
}
.tab-btn {
display: inline-flex;
align-items: center;
@@ -1105,49 +1053,6 @@ async function confirmDelete() {
}
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
.btn-ms-confirm {
padding: 0.3rem 0.65rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-ms-cancel {
padding: 0.3rem 0.65rem;
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
/* ── Milestone group ─────────────────────────────────────────── */
.milestone-group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.milestone-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
background: var(--color-bg-secondary);
font-size: 0.85rem;
user-select: none;
border-bottom: 1px solid var(--color-border);
}
.milestone-header.clickable { cursor: pointer; }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
@@ -1181,7 +1086,7 @@ async function confirmDelete() {
cursor: pointer;
border: 1px solid var(--color-border);
}
.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.ms-plan-actions .btn-primary { background: var(--color-action-primary); color: var(--fs-text-on-action); border-color: var(--color-action-primary); }
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
@@ -1350,8 +1255,8 @@ async function confirmDelete() {
line-height: 1;
}
.task-card:hover .task-advance-btn { opacity: 1; }
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
.task-advance-btn:hover { background: var(--color-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: var(--fs-text-on-action); }
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
.priority-dot {
@@ -1432,7 +1337,7 @@ async function confirmDelete() {
font-family: inherit;
}
.modal-btn:hover { background: var(--color-bg); }
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
/* ── Skeleton ────────────────────────────────────────────────── */
+1 -19
View File
@@ -144,7 +144,7 @@ async function handleSubmit() {
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="!canSubmit">
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
{{ submitting ? "Creating Account..." : "Create Account" }}
</button>
</form>
@@ -247,24 +247,6 @@ async function handleSubmit() {
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
+1 -19
View File
@@ -117,7 +117,7 @@ async function handleSubmit() {
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="!canSubmit">
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
{{ submitting ? "Creating account..." : "Create Account" }}
</button>
</form>
@@ -216,24 +216,6 @@ async function handleSubmit() {
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
+1 -19
View File
@@ -88,7 +88,7 @@ async function handleSubmit() {
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="!canSubmit">
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
{{ submitting ? "Resetting..." : "Reset Password" }}
</button>
</form>
@@ -195,24 +195,6 @@ async function handleSubmit() {
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
+61 -241
View File
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
import { listRulebooks } from "@/api/rulebooks";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -31,6 +32,11 @@ const kbWritePathThreshold = ref("0.68");
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
// suggests a merge the operator reviews (services/dedup.py).
const kbDuplicateThreshold = ref("0.82");
// Which rulebook describes this install's design system, for the /design drift
// panel. Empty = none designated, which is the normal state for a fresh install
// rather than a misconfiguration — the panel explains itself when unset.
const designRulebookId = ref("");
const designRulebooks = ref<{ id: number; title: string }[]>([]);
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);
@@ -100,6 +106,9 @@ async function saveKbInject() {
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
kb_writepath_threshold: String(wpT),
kb_duplicate_threshold: String(dupT),
// Empty string DELETES the setting (see routes/settings.py), which is
// exactly right for "no design rulebook" — absent rather than zero.
design_rulebook_id: designRulebookId.value,
});
kbInjectSaved.value = true;
setTimeout(() => (kbInjectSaved.value = false), 2000);
@@ -490,6 +499,14 @@ onMounted(async () => {
if (allSettings.kb_duplicate_threshold !== undefined) {
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
}
designRulebookId.value = allSettings.design_rulebook_id ?? "";
// Best-effort: the picker degrades to "none available" rather than blocking
// the whole settings page if rulebooks can't be listed.
try {
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
} catch {
designRulebooks.value = [];
}
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -1148,7 +1165,7 @@ function formatUserDate(iso: string): string {
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
<button class="btn-primary" @click="saveTimezone" :disabled="savingTimezone">
{{ savingTimezone ? 'Saving…' : 'Save' }}
</button>
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
@@ -1173,7 +1190,7 @@ function formatUserDate(iso: string): string {
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveRetention" :disabled="savingRetention">
<button class="btn-primary" @click="saveRetention" :disabled="savingRetention">
{{ savingRetention ? 'Saving' : 'Save' }}
</button>
<span v-if="retentionSaved" class="saved-msg">Saved!</span>
@@ -1260,6 +1277,22 @@ function formatUserDate(iso: string): string {
location, not by resemblance.
</p>
</div>
<div class="field">
<label for="design-rulebook">Design-system rulebook</label>
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
<option value="">None — don't check for design drift</option>
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
{{ rb.title }}
</option>
</select>
<p class="field-hint">
Which rulebook describes how this app should look. Once set, the
<router-link to="/design">Design</router-link> page compares every colour
and token your rules name against what the stylesheet actually resolves
to, and reports where they disagree. Leave it as None if your rules
don't describe a design system — nothing else depends on this.
</p>
</div>
<div class="field">
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
<input
@@ -1280,7 +1313,7 @@ function formatUserDate(iso: string): string {
</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveKbInject" :disabled="savingKbInject">
<button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject">
{{ savingKbInject ? 'Saving' : 'Save' }}
</button>
<span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
@@ -1328,7 +1361,7 @@ function formatUserDate(iso: string): string {
</div>
<div class="actions">
<button
class="btn-save"
class="btn-primary"
@click="changeEmail"
:disabled="changingEmail || !emailPassword"
>
@@ -1376,7 +1409,7 @@ function formatUserDate(iso: string): string {
</div>
<div class="actions">
<button
class="btn-save"
class="btn-primary"
@click="changePassword"
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
>
@@ -1432,7 +1465,7 @@ function formatUserDate(iso: string): string {
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving' : 'Save' }}</button>
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
@@ -1458,7 +1491,7 @@ function formatUserDate(iso: string): string {
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving' : 'Save' }}</button>
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
@@ -1468,7 +1501,7 @@ function formatUserDate(iso: string): string {
<p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
@@ -1500,7 +1533,7 @@ function formatUserDate(iso: string): string {
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
@@ -1529,7 +1562,7 @@ function formatUserDate(iso: string): string {
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveNotifications" :disabled="savingNotifications">
<button class="btn-primary" @click="saveNotifications" :disabled="savingNotifications">
{{ savingNotifications ? "Saving..." : "Save" }}
</button>
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
@@ -1556,7 +1589,7 @@ function formatUserDate(iso: string): string {
placeholder="Enter a search query..."
@keydown="onSearchKeydown"
/>
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
<button class="btn-primary" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
{{ searchLoading ? "Searching..." : "Search" }}
</button>
</div>
@@ -1858,7 +1891,7 @@ function formatUserDate(iso: string): string {
/>
</div>
<div class="actions">
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
<button class="btn-primary" @click="saveBaseUrl" :disabled="savingBaseUrl">
{{ savingBaseUrl ? "Saving..." : "Save" }}
</button>
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
@@ -1883,7 +1916,7 @@ function formatUserDate(iso: string): string {
/>
</div>
<div class="actions">
<button class="btn-save" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
<button class="btn-primary" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
{{ savingMarketplaceUrl ? "Saving..." : "Save" }}
</button>
<span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
@@ -1913,10 +1946,10 @@ function formatUserDate(iso: string): string {
</select>
</div>
<div class="actions">
<button class="btn-save" @click="saveDbMaintenance" :disabled="savingDbMaint">
<button class="btn-primary" @click="saveDbMaintenance" :disabled="savingDbMaint">
{{ savingDbMaint ? "Saving..." : "Save" }}
</button>
<button class="btn-save btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
<button class="btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
{{ runningDbMaint ? "Running..." : "Run now" }}
</button>
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
@@ -2007,7 +2040,7 @@ function formatUserDate(iso: string): string {
<p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-save" @click="saveSmtp" :disabled="savingSmtp">
<button class="btn-primary" @click="saveSmtp" :disabled="savingSmtp">
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
</button>
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
@@ -2021,7 +2054,7 @@ function formatUserDate(iso: string): string {
placeholder="test@example.com"
class="input"
/>
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
<button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
{{ sendingTest ? "Sending..." : "Send Test" }}
</button>
</div>
@@ -2046,7 +2079,7 @@ function formatUserDate(iso: string): string {
<p class="field-hint">When closed, new users can only be added by an administrator.</p>
</div>
<button
class="btn-toggle"
class="btn-primary btn-toggle"
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
@click="toggleRegistration"
:disabled="toggling"
@@ -2067,7 +2100,7 @@ function formatUserDate(iso: string): string {
required
:disabled="sendingInvite"
/>
<button type="submit" class="btn-save" :disabled="sendingInvite || !inviteEmail.trim()">
<button type="submit" class="btn-primary" :disabled="sendingInvite || !inviteEmail.trim()">
{{ sendingInvite ? "Sending..." : "Send Invite" }}
</button>
</form>
@@ -2089,7 +2122,7 @@ function formatUserDate(iso: string): string {
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button class="btn-delete" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
<button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
</button>
</td>
@@ -2128,13 +2161,13 @@ function formatUserDate(iso: string): string {
<span class="you-label">You</span>
</template>
<template v-else-if="confirmDeleteId === u.id">
<button class="btn-confirm-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">
<button class="btn-danger btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
</template>
<template v-else>
<button class="btn-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
<button class="btn-ghost btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
</template>
</td>
</tr>
@@ -2272,10 +2305,10 @@ function formatUserDate(iso: string): string {
<span v-if="g.description" class="group-desc">{{ g.description }}</span>
</div>
<div class="group-card-actions">
<button class="btn-sm" @click="toggleGroupExpand(g)">
<button class="btn-ghost btn-compact" @click="toggleGroupExpand(g)">
{{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
</button>
<button class="btn-sm btn-danger-sm" @click="deleteGroupConfirm(g)">Delete</button>
<button class="btn-danger-outline btn-compact" @click="deleteGroupConfirm(g)">Delete</button>
</div>
</div>
@@ -2305,7 +2338,7 @@ function formatUserDate(iso: string): string {
<li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
<span class="member-name">{{ m.username }}</span>
<span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
<button class="btn-sm btn-danger-sm" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
<button class="btn-danger-outline btn-compact" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
</li>
<li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
</ul>
@@ -2531,82 +2564,6 @@ function formatUserDate(iso: string): string {
flex-wrap: wrap;
}
/* Save: Moss action-primary per Hybrid */
.btn-save {
padding: 0.4rem 0.9rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-save:disabled { opacity: 0.6; cursor: default; }
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
/* Danger outline (Invalidate sessions, Clear observations, etc.):
Oxblood action-destructive ghost — fills on hover */
.btn-danger-outline {
padding: 0.4rem 0.9rem;
background: none;
color: var(--color-action-destructive);
border: 1px solid var(--color-action-destructive);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--color-action-destructive);
color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
/* Filled destructive: Oxblood action-destructive */
.btn-danger {
padding: 0.4rem 0.9rem;
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: default; }
/* Secondary: Bronze action-secondary — alternate paths (Detect, Test,
Refresh, Add slot, etc.). Outline form for visual lightness. */
.btn-secondary {
padding: 0.4rem 0.9rem;
background: var(--color-action-secondary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
/* DB maintenance last-run summary */
.db-maint-last { margin-top: 1rem; }
.db-maint-last-label {
display: block;
font-size: 0.8rem;
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.db-maint-table-list {
list-style: none;
margin: 0;
@@ -2652,7 +2609,7 @@ function formatUserDate(iso: string): string {
.db-health-table tr.dh-warn td:first-child code { color: var(--color-warning); }
.btn-warn:hover:not(:disabled) {
background: var(--color-warning);
color: #fff;
color: var(--fs-text-on-action);
}
.saved-msg {
@@ -2952,68 +2909,6 @@ function formatUserDate(iso: string): string {
}
.you-label { font-size: 0.8rem; color: var(--color-text-muted); }
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
.btn-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-delete:hover:not(:disabled) { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
.btn-delete:disabled { opacity: 0.4; cursor: default; }
/* Two-stage destructive: Confirm = Oxblood filled, Cancel = Bronze ghost */
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
margin-right: 0.25rem;
transition: background 0.15s;
}
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
.btn-cancel-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
/* Toggle (Open/Close registration, etc.): Open = Moss, Close = Pewter ghost */
.btn-toggle {
padding: 0.45rem 1rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
transition: background 0.15s;
}
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
.btn-toggle-open { background: var(--color-action-primary); color: #fff; }
.btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-toggle-close {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); }
.loading-msg, .empty-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
/* Logs panel */
.stats-section { padding: 1rem 1.25rem; }
@@ -3115,20 +3010,6 @@ function formatUserDate(iso: string): string {
/* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */
.btn-primary {
padding: 0.4rem 0.9rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.input-field {
width: 100%;
@@ -3211,32 +3092,6 @@ function formatUserDate(iso: string): string {
flex-shrink: 0;
}
.btn-sm {
padding: 0.25rem 0.6rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.78rem;
color: var(--color-text-secondary);
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-danger-sm:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
.group-members-panel {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.members-search {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.member-search-wrap {
flex: 1;
@@ -3358,7 +3213,7 @@ function formatUserDate(iso: string): string {
}
.unit-btn.active {
background: var(--color-action-primary);
color: #fff;
color: var(--fs-text-on-action);
}
.unit-btn:hover:not(.active) {
color: var(--color-text);
@@ -3749,21 +3604,6 @@ function formatUserDate(iso: string): string {
text-align: right;
color: var(--color-text-secondary);
}
.btn-remove-slot {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.45rem;
line-height: 1;
transition: color 0.15s, border-color 0.15s;
}
.btn-remove-slot:hover {
color: var(--color-danger, #e05555);
border-color: var(--color-danger, #e05555);
}
.blend-actions {
margin-top: 0.25rem;
}
@@ -3817,24 +3657,4 @@ function formatUserDate(iso: string): string {
color: var(--color-text-muted);
margin-bottom: 0.75rem;
}
.btn-danger-outline {
padding: 0.45rem 1rem;
background: none;
border: 1px solid var(--color-action-destructive);
border-radius: var(--radius-sm);
color: var(--color-action-destructive);
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--color-action-destructive);
color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
@keyframes va-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
</style>
-28
View File
@@ -249,34 +249,6 @@ async function confirmDelete() {
flex-shrink: 0;
}
.btn-ghost {
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-ghost:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-danger {
padding: 0.35rem 0.8rem;
border: none;
background: var(--color-action-destructive, #6B2118);
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
}
.btn-danger:hover {
opacity: 0.9;
}
.when-to-use {
margin: 0.75rem 0 1.25rem;
-31
View File
@@ -572,37 +572,6 @@ function cancel() {
gap: 0.5rem;
margin-top: 0.25rem;
}
.btn-primary {
padding: 0.5rem 1.1rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-action-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-secondary {
padding: 0.5rem 1.1rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.btn-secondary:hover {
background: var(--color-bg);
}
@media (max-width: 600px) {
.field-row,
+5 -39
View File
@@ -543,21 +543,6 @@ function usageTitle(s: SnippetListItem): string {
}
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
white-space: nowrap;
}
.btn-primary:hover {
background: var(--color-action-primary-hover);
}
.search-row {
margin-bottom: 1.25rem;
@@ -669,17 +654,17 @@ function usageTitle(s: SnippetListItem): string {
.empty-action {
display: inline-block;
padding: 0.4rem 1rem;
border: 1px solid var(--color-primary);
border: 1px solid var(--color-action-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
color: var(--color-action-primary);
background: none;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
.empty-action:hover {
background: var(--color-primary);
color: #fff;
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.skeleton-grid,
@@ -870,21 +855,6 @@ function usageTitle(s: SnippetListItem): string {
gap: 0.5rem;
align-items: center;
}
.btn-ghost {
padding: 0.4rem 0.85rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-ghost:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Selected card = 2px accent border per the design system (featured/active). */
.snippet-card.selected {
@@ -931,10 +901,6 @@ function usageTitle(s: SnippetListItem): string {
flex: 1;
min-width: 0;
}
.select-bar .btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
/* Merge modal */
.modal-overlay {
@@ -1021,7 +987,7 @@ function usageTitle(s: SnippetListItem): string {
.modal-btn-primary {
background: var(--color-action-primary);
border-color: var(--color-action-primary);
color: #fff;
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--color-action-primary-hover);
+4 -68
View File
@@ -645,7 +645,7 @@ useEditorGuards(dirty, save);
@focus="onParentFocus"
@blur="hideParentDropdown"
/>
<button v-if="parentId" class="btn-clear-parent" @click="clearParentTask" title="Clear">&times;</button>
<button v-if="parentId" class="btn-text btn-clear-parent" @click="clearParentTask" title="Clear">&times;</button>
</div>
<div v-if="showParentDropdown" class="parent-dropdown">
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
@@ -666,7 +666,7 @@ useEditorGuards(dirty, save);
<div v-if="isEditing" class="subtasks-section">
<div class="subtasks-header">
<span class="subtasks-label">Sub-tasks</span>
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
<button class="btn-text" @click="addingSubTask = !addingSubTask">+ Add</button>
</div>
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
<template v-else>
@@ -686,8 +686,8 @@ useEditorGuards(dirty, save);
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
autofocus
/>
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
<button class="btn-primary btn-compact" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
<button class="btn-ghost btn-compact" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
</div>
</template>
</div>
@@ -921,23 +921,6 @@ useEditorGuards(dirty, save);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.btn-add-subtask {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
font-size: 0.78rem;
font-family: inherit;
padding: 0.1rem 0.2rem;
}
.btn-add-subtask:hover { opacity: 0.8; }
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
.subtask-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
}
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
.subtask-title {
font-size: 0.83rem;
@@ -968,33 +951,6 @@ useEditorGuards(dirty, save);
font-family: inherit;
}
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
.btn-subtask-confirm {
padding: 0.25rem 0.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-subtask-cancel {
padding: 0.25rem 0.5rem;
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
/* Streaming preview */
.stream-label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.stream-preview {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
@@ -1125,24 +1081,4 @@ useEditorGuards(dirty, save);
color: var(--color-primary, #6366f1);
font-style: normal;
}
.btn-reconsolidate {
margin-left: auto;
padding: 0.25rem 0.7rem;
font-size: 0.78rem;
font-style: normal;
color: inherit;
background: transparent;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: 999px;
cursor: pointer;
transition: background 120ms ease;
}
.btn-reconsolidate:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--color-primary, #6366f1);
}
.btn-reconsolidate:disabled {
opacity: 0.5;
cursor: progress;
}
</style>
+5 -82
View File
@@ -263,29 +263,29 @@ const subTaskProgress = computed(() => {
<div class="toolbar">
<router-link
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
class="btn-back"
class="btn-ghost"
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
class="btn-primary"
>
Edit
</router-link>
<button
v-if="advanceLabel"
class="btn-advance"
class="btn-primary"
@click="advanceStatus"
>
{{ advanceLabel }}
</button>
<button
class="btn-convert"
class="btn-secondary btn-compact"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
<button class="btn-share" @click="showShare = true">Share</button>
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
</div>
<!-- Breadcrumb: parent task project milestone -->
@@ -475,83 +475,6 @@ const subTaskProgress = computed(() => {
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
text-decoration: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn-back:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Edit + Advance: Moss action-primary — both are "operating the software"
workflow actions, not brand moments. */
.btn-edit,
.btn-advance {
display: inline-flex;
align-items: center;
padding: 0.45rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: var(--color-action-primary);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.15s;
}
.btn-edit:hover,
.btn-advance:hover {
background: var(--color-action-primary-hover);
color: #fff;
}
/* Convert + Share: Bronze action-secondary — alternate paths */
.btn-convert {
margin-left: auto;
padding: 0.3rem 0.75rem;
background: var(--color-action-secondary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s;
}
.btn-convert:hover { background: var(--color-action-secondary-hover); }
.btn-convert:disabled {
opacity: 0.6;
cursor: default;
}
.btn-share {
padding: 0.3rem 0.75rem;
background: var(--color-action-secondary);
border: none;
border-radius: var(--radius-sm);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-share:hover { background: var(--color-action-secondary-hover); }
.task-title {
font-family: "Fraunces", Georgia, serif;
font-size: 2rem;
font-weight: 500;
line-height: 1.2;
margin: 0.25rem 0 0.5rem;
color: var(--color-text);
}
.meta {
display: flex;
+5 -19
View File
@@ -25,7 +25,7 @@ onMounted(() => store.fetchTrash());
<h1>Trash</h1>
<button
v-if="store.batches.length"
class="btn-empty"
class="btn-ghost btn-compact"
@click="empty"
>Empty trash</button>
</header>
@@ -52,8 +52,8 @@ onMounted(() => store.fetchTrash());
</div>
</div>
<div class="batch-actions">
<button class="btn-restore" @click="store.restore(b.batch_id)">Restore</button>
<button class="btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
<button class="btn-ghost btn-compact btn-restore" @click="store.restore(b.batch_id)">Restore</button>
<button class="btn-ghost btn-compact btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
</div>
</li>
</ul>
@@ -64,25 +64,11 @@ onMounted(() => store.fetchTrash());
.trash-page { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
.trash-header { display: flex; align-items: center; justify-content: space-between; }
.trash-header h1 { font-family: Fraunces, serif; font-style: italic; margin: 0; }
.btn-empty {
background: none; border: 1px solid var(--color-border, #2a2a2e);
color: inherit; border-radius: 6px; padding: 0.4rem 0.8rem; cursor: pointer;
}
.btn-empty:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
.trash-note { opacity: 0.7; font-size: 0.9em; margin: 0.5rem 0 1.5rem; }
.trash-loading, .trash-empty { opacity: 0.6; font-style: italic; padding: 2rem 0; }
.batch-list { list-style: none; padding: 0; margin: 0; }
.batch {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; padding: 0.85rem 1rem; margin-bottom: 0.5rem;
background: var(--color-surface, #18181b); border-radius: 8px;
border-left: 2px solid var(--color-border, #2a2a2e);
}
.batch-summary { font-weight: 500; }
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
.btn-restore:hover { border-color: var(--color-primary, #6366f1); color: var(--color-primary, #6366f1); }
.btn-purge:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
</style>
+8 -92
View File
@@ -167,7 +167,7 @@ function formatDate(iso: string): string {
</p>
</div>
<button
class="btn-toggle"
class="btn-primary btn-toggle"
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
@click="toggleRegistration"
:disabled="toggling"
@@ -190,7 +190,7 @@ function formatDate(iso: string): string {
/>
<button
type="submit"
class="btn-invite"
class="btn-primary"
:disabled="sendingInvite || !inviteEmail.trim()"
>
{{ sendingInvite ? "Sending..." : "Send Invite" }}
@@ -216,7 +216,7 @@ function formatDate(iso: string): string {
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button
class="btn-delete"
class="btn-ghost btn-compact"
@click="revokeInvitation(inv.id)"
:disabled="revokingId !== null"
>
@@ -262,17 +262,17 @@ function formatDate(iso: string): string {
</template>
<template v-else-if="confirmDeleteId === u.id">
<button
class="btn-confirm-delete"
class="btn-danger btn-compact"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
</template>
<template v-else>
<button
class="btn-delete"
class="btn-ghost btn-compact"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
@@ -328,24 +328,6 @@ function formatDate(iso: string): string {
outline: none;
border-color: var(--color-primary);
}
.btn-invite {
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn-invite:disabled {
opacity: 0.6;
cursor: default;
}
.btn-invite:hover:not(:disabled) {
opacity: 0.9;
}
.invite-list {
margin-top: 1rem;
}
@@ -380,26 +362,8 @@ function formatDate(iso: string): string {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.btn-toggle {
padding: 0.45rem 1rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn-toggle:disabled {
opacity: 0.6;
cursor: default;
}
.btn-toggle-open {
background: var(--color-primary);
color: #fff;
}
.btn-toggle-open:hover:not(:disabled) {
opacity: 0.9;
}
/* The one genuine override: 'close registration' must NOT read as the
primary action it sits on. Scoped, so it beats the shared variant. */
.btn-toggle-close {
background: var(--color-bg-secondary);
color: var(--color-text);
@@ -478,54 +442,6 @@ function formatDate(iso: string): string {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.btn-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-delete:hover:not(:disabled) {
border-color: var(--color-danger);
color: var(--color-danger);
}
.btn-delete:disabled {
opacity: 0.4;
cursor: default;
}
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
margin-right: 0.25rem;
}
.btn-confirm-delete:hover:not(:disabled) {
filter: brightness(0.9);
}
.btn-confirm-delete:disabled {
opacity: 0.6;
cursor: default;
}
.btn-cancel-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-cancel-delete:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
@media (max-width: 768px) {
.registration-row {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.20",
"version": "0.1.22",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+101 -18
View File
@@ -48,16 +48,9 @@ case "$file_path" in
exit 0 ;;
esac
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# Guard against an unexpanded ${...} placeholder arriving as a literal.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
# Unconfigured install → silent. Prior-art recall is pure enrichment.
[ -n "$url" ] && [ -n "$token" ] || exit 0
# Snippet locations are recorded repo-relative, so send a repo-relative path —
# an absolute one would simply match nothing.
# an absolute one would simply match nothing. Resolved BEFORE the config gate
# because the local arm below needs the repo root and needs no server at all.
lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true)
[ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
@@ -68,6 +61,84 @@ if [ -n "$repo_root" ]; then
esac
fi
# ---------------------------------------------------------------------------
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
#
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
# of the codebase, so a helper nobody thought to record is invisible to them —
# which 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.
#
# This arm closes that by asking the only question the record cannot answer,
# in the only place that can: the hook already runs on the developer's machine,
# inside the repo, holding the code about to be written. No index, no storage,
# no staleness, and no server — it deliberately runs even on an install that
# has never configured Scribe.
#
# Definition-shaped patterns only. Grepping for bare occurrences would match
# every CALL site and drown the real finding — and a hint that is mostly noise
# is one people learn to skip, which is worse than none.
# ---------------------------------------------------------------------------
local_lines=""
if [ -n "$repo_root" ] && [ -n "$code" ]; then
# kind<TAB>name for each thing this payload DEFINES.
names=$(printf '%s' "$code" | awk '
match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) {
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t);
if (t != "") print "css\t" t; next }
match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
if (t != "") print "sym\t" t; next }
match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
if (t != "") print "sym\t" t; next }
match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) {
t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t);
if (t != "") print "sym\t" t; next }
match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) {
t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t);
sub(/[^A-Za-z0-9_$].*$/, "", t);
if (t != "") print "sym\t" t; next }
' 2>/dev/null | sort -u | head -12) || names=""
while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue
case "$kind" in
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
*) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
esac
# -I skips binaries; :(exclude) drops the file being written, which would
# otherwise always match itself on an Edit.
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
[ -n "$hits" ] || continue
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
done <<< "$names"
fi
local_context=""
if [ -n "$local_lines" ]; then
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
fi
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# Guard against an unexpanded ${...} placeholder arriving as a literal.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
# arm above already ran and may have something to say.
if [ -z "$url" ] || [ -z "$token" ]; then
if [ -n "$local_context" ]; then
jq -n --arg c "$local_context" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
fi
exit 0
fi
# Cap the code sent as the semantic query. The embedder truncates at its own
# token limit well before this, so a bigger slice buys no extra signal — and the
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
@@ -110,20 +181,32 @@ if [ -n "$session_id" ]; then
fi
fi
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
# finding that needed no instance to produce.
body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
[ -n "$body" ] || exit 0
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body=""
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
[ -n "$context" ] || exit 0
# Remember what was surfaced so it isn't shown again this session.
if [ -n "$idfile" ]; then
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
context=""
if [ -n "$body" ]; then
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
# Remember what was surfaced so it isn't shown again this session.
if [ -n "$idfile" ] && [ -n "$context" ]; then
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
fi
fi
# Local first. It answers "this already EXISTS", which is a stronger claim than
# "this resembles something recorded" — and it is the one the recorded arms are
# structurally unable to make.
combined="$local_context"
if [ -n "$context" ]; then
[ -n "$combined" ] && combined="${combined}"$'\n'
combined="${combined}${context}"
fi
[ -n "$combined" ] || exit 0
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
jq -n --arg c "$context" \
jq -n --arg c "$combined" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
exit 0
+28 -6
View File
@@ -64,11 +64,13 @@ Two constraints on *how* that's achieved:
3. **Update over duplicate.** When recording, prefer updating an existing
note/rule/task over creating a new one. Search first; revise what's there.
4. **Plans live in Scribe.** For non-trivial work call `start_planning(project_id,
title)` FIRST — it creates a milestone whose `body` holds the design; each
step is its own task under that milestone (`create_task(milestone_id=...)`),
progress goes in work-logs (`add_task_log`). Read it back with `get_milestone`.
Do not write plans/specs to local `.md` files.
4. **When you plan, plan in Scribe.** Work with an *arc* — several steps toward
one goal — gets a plan, and a plan is a milestone: `start_planning(project_id,
title)` creates one whose `body` holds the design, each step is its own task
under it (`create_task(milestone_id=...)`), progress goes in work-logs
(`add_task_log`). Work without an arc (a fix, a one-file change, a question)
is just a task — don't wrap it in a milestone. Either way, do not write
plans/specs to local `.md` files. See the **writing-plans** skill.
5. **Keep state honest.** Set a task `in_progress` when you start it, `done` the
moment it's complete; log progress as you go.
@@ -102,7 +104,7 @@ shared homes general:
norms that bind *every* project. Cross-project standards only.
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
reusable, *themed* module of general rules that binds only projects that opt
in (e.g. a design system → visual apps). Themed, but still project-agnostic.
in (e.g. a review checklist → every service). Themed, but project-agnostic.
- **Project rule** (`create_project_rule`) — anything specific to one project
(its files, paths, quirks).
@@ -112,6 +114,26 @@ project rule; a standard a category shares → subscribed rulebook; a universal
norm → always-on rulebook. Never put project-specific detail in a shared
rulebook — it leaks to every other project that gets it.
**First ask whether it's a rule at all.** A rule is prose you have to remember
and apply; Scribe's other entities are structure a tool can resolve and check.
Visual standards belong in a **design system**, not a rulebook — a token can be
inherited, resolved per mode, rendered to a stylesheet and diffed against code,
and none of that survives being written as a rule. A repeatable procedure is a
**process**; reusable code is a **snippet**. Reach for a rule when the thing
really is a standing instruction about how to work.
## Building UI: the project's design system binds
`enter_project` returns a `design_system` when the project has one, with the
guidance **chain-merged** — the house style it inherits plus its own departures
from it. Treat it the way you treat a rule.
Before writing a colour, size, radius, weight or duration by hand, reach for a
token: `resolve_design_system(id)` for the values, or
`get_design_system_stylesheet(id)` for the rendered sheet. A literal is a value
stated outside the system, so it can never follow a palette change — and
nothing will tell you it drifted.
## Other Scribe process-skills
This plugin also ships focused process-skills — writing-plans, systematic
+24 -6
View File
@@ -1,6 +1,6 @@
---
name: writing-plans
description: Use before starting any non-trivial or multi-step piece of work — produce a clear plan BEFORE diving in. Triggers when the user asks you to plan, design an approach, scope an effort, or tackle work big enough to need ordered steps. The plan lives in a Scribe milestone (via start_planning), not a local file.
description: Use when a piece of work has an arc — several steps toward one goal, worth tracking as a unit — and you want the approach reviewable before you start. Triggers when the user asks you to plan, design an approach, or scope an effort, or when work is about to sprawl across several steps. Not for single-step work. The plan lives in a Scribe milestone (via start_planning), not a local file.
---
# Writing plans
@@ -9,12 +9,30 @@ A plan is **how** you'll execute a chunk of work — the design plus an ordered
set of steps — written *before* you start, so the approach is reviewable and the
work stays trackable.
## Start the plan in Scribe, not a file
## First decide whether this work wants a plan
For non-trivial work, call **`start_planning(project_id, title)` FIRST** —
before any design or implementation. It creates a **milestone** (the plan
container) seeded with a design template and returns the milestone id plus the
project's applicable rules. The plan lives in that milestone:
A plan lives in a milestone, and **a milestone earns its place when the work has
an arc**: several steps, one shared goal, a beginning and an end worth tracking
as a unit. That is the whole test, and it is a judgment about the *shape* of the
work — not about its size, difficulty, or importance.
Plenty of real work has no arc. A bug fix, a one-file change, a question
answered, a setting changed. For those, a milestone is a container with one
thing in it: the ceremony costs more than it records, and it leaves the project
with milestones that never meant anything. **Use a task instead** — set it
`in_progress`, record what you find with `add_task_log`, set it `done`. That is
a complete, honest record of work that didn't need a plan.
Some projects are milestone-shaped and some are a flat task list. Read the
project you are in rather than imposing a shape on it.
## When it does: start the plan in Scribe, not a file
Call **`start_planning(project_id, title)`** before designing or implementing —
so the milestone exists to write into, rather than being backfilled from work
already done. It creates a **milestone** (the plan container) seeded with a
design template and returns the milestone id plus the project's applicable
rules. The plan lives in that milestone:
- The **design/intent** goes in the milestone `body` — edit it with
`update_milestone(milestone_id, body=...)`.
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Check the frontend's CSS against the tokens its stylesheet declares.
The gap this closes: `services/design_stylesheet.check_code_against_tokens` has
always been able to answer "does this code use the sheet correctly?", but the
only thing ever fed to it was recorded SNIPPETS. The app's own components — where
sixteen unresolvable references were found living quietly (#2319) — 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 component 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 local file, with no network and no credentials.
INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should
be called or which literals are discouraged. Both come from the stylesheet: the
declarations, and the `SUPERSEDES` block the generator emits. Point it at a
different install's sheet and it checks that install's rules.
Two severities, and the split is deliberate:
FAIL an unresolvable `var()` reference. Currently zero, so this is a ratchet
that holds a line already reached rather than a backlog that keeps CI
red. It also cannot false-positive: either the name is declared or it
is not.
REPORT superseded literals and raw colour literals. Hundreds today, so gating
on them would mean a permanently failing job that everyone learns to
ignore — which is worse than no check.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
# A declaration is `--name:`; a reference is `var(--name)` or `var(--name, …)`.
DECLARATION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
SUPERSEDES_LINE = re.compile(r"^\s*\*\s*(\S+)\s*->\s*(--[A-Za-z0-9_-]+)\s*$")
HEX_LITERAL = re.compile(r"#[0-9a-fA-F]{3,8}\b")
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
def declared_tokens(sheet: str) -> set[str]:
"""Every custom property the stylesheet declares.
Anchored on the colon alone. Anchoring on `{` or `;` instead silently drops
every declaration that follows a comment — a mistake made once already, which
lost `--color-bg` and 2 others without erroring.
"""
return set(DECLARATION.findall(sheet))
def superseded_literals(sheet: str) -> dict[str, str]:
"""`{literal: token}` from the generator's SUPERSEDES block, lowercased."""
out: dict[str, str] = {}
for line in sheet.splitlines():
match = SUPERSEDES_LINE.match(line)
if match:
out[match.group(1).lower()] = match.group(2)
return out
def _literal_pattern(literal: str) -> re.Pattern:
"""Match a literal without matching a longer one containing it.
`#fff` must not fire inside `#ffffff`: different colours, and a finding on
the wrong one sends someone to change correct code.
"""
return re.compile(
r"(?<![0-9A-Za-z_#-])" + re.escape(literal) + r"(?![0-9A-Za-z_-])",
re.IGNORECASE,
)
def style_source(path: pathlib.Path) -> str:
"""The CSS in a file — `<style>` blocks for an SFC, the whole of a .css.
Comments are stripped, and that is load-bearing rather than tidy. A comment
EXPLAINING a rule mentions the very literal the rule forbids: this file's own
stylesheet documents why it avoids `#fff`, and the first run of this checker
reported that explanation as a violation. A checker that flags the
documentation of a rule teaches people to stop documenting rules.
"""
text = path.read_text(encoding="utf-8", errors="replace")
css = "\n".join(STYLE_BLOCK.findall(text)) if path.suffix == ".vue" else text
return CSS_COMMENT.sub(" ", css)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
parser.add_argument("--root", default="frontend/src")
parser.add_argument(
"--report-literals", action="store_true",
help="also list raw colour literals (advisory, never fails)",
)
args = parser.parse_args()
sheet_path = pathlib.Path(args.sheet)
if not sheet_path.is_file():
print(f"error: stylesheet not found: {sheet_path}", file=sys.stderr)
return 2
sheet = sheet_path.read_text()
declared = declared_tokens(sheet)
supersedes = superseded_literals(sheet)
print(f"{sheet_path}: {len(declared)} tokens declared, "
f"{len(supersedes)} superseded literals recorded\n")
root = pathlib.Path(args.root)
sources = sorted(
[p for p in root.rglob("*.vue")] + [p for p in root.rglob("*.css")]
)
unresolved: list[tuple[pathlib.Path, str]] = []
superseded_hits: list[tuple[pathlib.Path, str, str]] = []
literal_count = 0
for path in sources:
if path == sheet_path:
continue
css = style_source(path)
if not css.strip():
continue
# A component may legitimately declare a local custom property; a
# reference to it is not unresolved.
local = set(DECLARATION.findall(css))
for name in sorted(set(REFERENCE.findall(css))):
if name not in declared and name not in local:
unresolved.append((path, name))
for literal, token in supersedes.items():
if _literal_pattern(literal).search(css):
superseded_hits.append((path, literal, token))
literal_count += len(HEX_LITERAL.findall(css))
if unresolved:
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
print(" These render as the fallback if given one, or as nothing at all.")
print(" Either way nothing errors, which is why they survive.\n")
for path, name in unresolved:
print(f" {path}: {name}")
print()
else:
print("OK — every var() reference resolves to a declared token.\n")
if superseded_hits:
print(f"REPORT — {len(superseded_hits)} superseded literal(s). "
"The sheet says what to write instead:")
seen: set[tuple[str, str]] = set()
for path, literal, token in superseded_hits:
key = (str(path), literal)
if key in seen:
continue
seen.add(key)
print(f" {path}: {literal} -> {token}")
print()
if args.report_literals:
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
print(" Advisory: a literal is a value stated outside the system, so it "
"cannot follow a palette change.\n")
return 1 if unresolved else 0
if __name__ == "__main__":
sys.exit(main())
+56 -1
View File
@@ -181,13 +181,25 @@ def check_shellcheck() -> None:
# pinning: the bug and the healthy no-results case look identical from outside.
# Pinning it does NOT make the failure visible; it makes sure the fail-open
# behaviour is deliberate rather than accidental.
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
# The prior-art hook's local arm (#2280) fires with no credentials, so the
# silence assertion below needs a name the repo genuinely lacks. Two traps,
# both hit while writing this:
# - `def f` matched real code, so the hook spoke and "silent" was asserting
# the wrong thing;
# - spelling the replacement out in full put `def <name>(` INTO this file,
# so the smoke event defined the very symbol it claimed was absent.
# Concatenating keeps the contiguous string out of the source.
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
SMOKE_EVENTS: dict[str, str] = {
"scribe_autoinject.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
),
"scribe_prior_art.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
"tool_input": {"file_path": "src/x.py", "new_string": "def f():\n pass\n"}}
"tool_input": {"file_path": "src/x.py",
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
@@ -253,6 +265,48 @@ def check_fail_open() -> None:
ok(f"{rel} [{label}]: exit 0, silent")
def check_local_prior_art_needs_no_instance() -> None:
"""The prior-art hook's local arm must answer with no credentials (#2280).
The other arms ask Scribe what was RECORDED. This one asks the repo what
EXISTS, which needs no instance — and that is the whole reason it catches
the case the recorded arms structurally cannot: a helper nobody thought to
record. If it ever silently starts depending on configuration, it stops
covering that case and nothing else would notice.
Paired with the silence assertion in check_fail_open, which uses a symbol
that cannot exist. Together they pin both halves: silent when there is
nothing to say, and speaking when there is — both with no instance at all.
"""
script = HOOKS_DIR / "scribe_prior_art.sh"
if not script.is_file() or not shutil.which("jq"):
skip("prior-art local arm: hook or jq missing")
return
# A definition this repo really does contain, written into a DIFFERENT file
# so the self-match exclusion doesn't suppress it.
event = json.dumps({
"session_id": "smoke", "cwd": ".", "tool_name": "Write",
"tool_input": {
"file_path": "scripts/_probe_not_real.py",
"content": "def check_local_prior_art_needs_no_instance():\n pass\n",
},
})
try:
proc = _run_hook(script, event, {}) # NO credentials, on purpose
except subprocess.TimeoutExpired:
fail("prior-art local arm: hung")
return
if proc.returncode != 0:
fail(f"prior-art local arm: exited {proc.returncode}, must be 0")
elif "already defined" not in proc.stdout:
fail("prior-art local arm: found nothing for a symbol this repo "
"defines, with no credentials — the arm that needs no instance "
"has stopped working, and the recorded arms cannot cover for it")
else:
ok("prior-art local arm: answers with no instance configured")
def _git(*args: str) -> tuple[int, str]:
proc = subprocess.run(
["git", *args], capture_output=True, text=True, cwd=ROOT
@@ -344,6 +398,7 @@ def main() -> int:
check_patterns()
check_shellcheck()
check_fail_open()
check_local_prior_art_needs_no_instance()
if not args.no_version:
check_version_bump(args.base)
+4
View File
@@ -26,6 +26,8 @@ from scribe.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
from scribe.routes.design import design_bp
from scribe.routes.design_systems import design_systems_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
@@ -89,6 +91,8 @@ def create_app() -> Quart:
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
app.register_blueprint(design_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
+36 -18
View File
@@ -33,14 +33,27 @@ What each part is for, and when to reach for it:
- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of
work. The design/intent lives in the milestone `body`; each step is its own
child task (create_task(milestone_id=...)), tracked with status + work-logs —
NOT a checkbox buried in the body. Start one with start_planning when
beginning non-trivial work, before you dive in; read it back with
get_milestone (body + steps). (The old kind=plan task is retired — some
historical plan-tasks still exist and remain readable, but don't create new
ones.)
NOT a checkbox buried in the body. Create one with start_planning when the
work has an arc (same test as a milestone, above) and you want the approach
reviewable before you start; read it back with get_milestone (body + steps).
Work without an arc is a task, not a plan. (The old kind=plan task is retired
— some historical plan-tasks still exist and remain readable, but don't
create new ones.)
- Note: durable free-form knowledge — reference material, decisions, logs of
what happened.
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
- Design system: the visual standards a project's UI is built from — design
tokens (name + value per mode) plus the prose a token table cannot hold
(aesthetic, voice, what is out of scope). Systems INHERIT: a child holds only
what it changes and the chain supplies the rest, so a family's house style and
one app's departures from it are the same structure at two depths. A project
points at one with set_project_design_system, and enter_project then hands it
back with the guidance chain-merged. Treat it as binding for UI work: reach
for a token (resolve_design_system / get_design_system_stylesheet) before
writing a colour, size, radius or duration by hand. Do NOT record a design
system as a rulebook — rules are for behaviour, and tokens kept as prose
cannot be resolved, inherited, rendered to a stylesheet, or checked against
code.
- System: a per-project, reusable, self-describing subsystem/area. Associate any
record (note, task, issue) with it via system_ids so research, build-work, and
fixes for the same area line up, and recurring problem-spots surface. Manage
@@ -143,8 +156,8 @@ right altitude:
subscribe_project_to_rulebook) — a reusable, THEMED module of general
rules that binds only the projects which subscribe. Its rules must make
sense for every project that could subscribe, never one specific project
(e.g. a design-system rulebook: design-specific but project-agnostic — no
rule names a single app).
(e.g. a code-review checklist, or a compliance regime a category of
projects shares — no rule names a single app).
- Project rule (create_project_rule) — anything specific to ONE project.
Both rulebook tiers are SHARED, so their rules stay general; the difference
between them is REACH (all projects vs opt-in by theme), not generality. Rule
@@ -152,6 +165,15 @@ of thumb: names a specific project's files/paths/quirks -> project rule; a
standard a CATEGORY of projects shares -> subscribed rulebook; a universal
norm -> always-on rulebook. Coordinate with the operator on which home fits.
Before writing a rule, check whether another entity already models the thing.
A rule is prose an agent must remember and apply; the other entities are
structure a tool can resolve, render and check. Visual standards are a DESIGN
SYSTEM, not a rulebook — a token can be inherited, resolved per mode, rendered
to a stylesheet and diffed against code, and none of that survives being
written as a rule. A repeatable procedure is a PROCESS. Reusable code is a
SNIPPET. Reach for a rule when the thing genuinely is a standing instruction
about how to work, and nothing else can hold it.
One thing NOT to do: don't bridge Scribe into a session by writing to the
host's native memory. Rules are pull-only, so a fresh session won't reach for
them unless its always-loaded context says to — but the bridge for that is the
@@ -184,17 +206,13 @@ adopting or creating — never do either silently, and never guess a project int
existence. Once a project is in scope, the enter_project handshake and the
host-memory pointer step above both apply.
A plan is a MILESTONE, and Scribe is the canonical home for it. When you begin
non-trivial work, call start_planning(project_id, title) FIRST — before any
brainstorming, design, or plan-writing skill runs. start_planning creates the
milestone, seeds its `body` with the design template, returns the project's
applicable_rules, and gives you the milestone id you'll write into. Put the
design/intent in the milestone body via update_milestone(milestone_id, body=...);
create each step as a child task with create_task(milestone_id=...) and track it
with status + add_task_log — do NOT list steps as checkboxes in the body. Read
the plan back with get_milestone (body + steps). If a habit tells you to save a
plan or spec to a local `.md` file, that's superseded here: the milestone is the
record, not a local file.
When work DOES get a plan, Scribe is the plan's canonical home: it is a
milestone (see the Plan entry above), created with start_planning and written
into with update_milestone + child tasks. If a habit tells you to save a plan or
spec to a local `.md` file, that's superseded here — the milestone is the
record, not a file on disk. Whether a given piece of work wants a plan at all is
a separate question, answered by the arc test above and by the writing-plans
skill; these instructions do not mandate one.
Deletes are recoverable: every delete_* tool moves the entity (and its
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
+3 -1
View File
@@ -5,7 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets, systems, tags, tasks, trash,
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
systems, tags, tasks, trash,
)
@@ -17,6 +18,7 @@ def register_all(mcp) -> None:
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
repos.register(mcp)
+366
View File
@@ -0,0 +1,366 @@
"""Design system + token MCP tools — wrappers over services/design_systems.py.
A design system is a stylesheet held as records: a named set of tokens with an
optional parent, so a family system carries the house style and an app system
carries only what it changes. Precedence by name along the parent chain IS the
CSS cascade, which is why "what does this app alter?" is a plain list rather
than a diff.
Parity with the REST surface is a rule, not a nicety (see
`tests/test_routes_design_systems.py`): an agent and a browser are two callers
of one service.
Sentinels, matching the milestone/task tool conventions:
- title="" / description="" / etc. -> "leave unchanged" on update
- parent_id / design_system_id: 0 = leave unchanged, -1 = clear, positive = set
(three states, because clearing a parent is a real operation and not the
same as omitting the argument)
- order_index=-1 -> "leave unchanged" (0 is a valid order_index)
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
async def create_design_system(
title: str,
description: str = "",
guidance: str = "",
parent_id: int = 0,
) -> dict:
"""Create a design system, optionally inheriting from another.
Args:
title: What this system is — a house style, or one app within it
(required).
description: What it covers and when it applies.
guidance: The narrative a token table cannot hold — aesthetic, voice and
tone, what is deliberately out of scope. Markdown, free-form.
parent_id: Inherit from this system — it holds the defaults this one
overrides. Omit (0) for a top-level "family" system, which is what
a first design system usually is.
"""
uid = current_user_id()
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
guidance=guidance or None,
parent_id=parent_id or None,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
rows = await ds_svc.list_design_systems(uid)
return {"design_systems": [s.to_dict() for s in rows]}
async def get_design_system(design_system_id: int) -> dict:
"""Fetch a design system plus its OWN tokens — i.e. what it changes.
For what it actually resolves to once inheritance is applied, use
`resolve_design_system`. The two answer different questions and a system
that overrides nothing has an empty token list but a full resolved set.
"""
uid = current_user_id()
system = await ds_svc.get_design_system(uid, design_system_id)
if system is None:
raise ValueError(f"design system {design_system_id} not found")
tokens = await ds_svc.list_tokens(uid, design_system_id)
return {
"design_system": system.to_dict(),
"tokens": [t.to_dict() for t in tokens],
}
async def resolve_design_system(design_system_id: int) -> dict:
"""The EFFECTIVE token set — everything inherited, with this system's on top.
Each token carries `origin_by_mode` (which system supplied each mode's
value) and `contributions` (every system that offered one, deepest first —
so entry 0 won and the rest were shadowed). Reach for this when you need to
know what a value actually IS; reach for `get_design_system` when you need
to know what this system CHANGES.
"""
uid = current_user_id()
resolved = await ds_svc.resolve_design_system(uid, design_system_id)
if resolved is None:
raise ValueError(f"design system {design_system_id} not found")
return {
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
}
async def update_design_system(
design_system_id: int,
title: str = "",
description: str = "",
guidance: str = "",
parent_id: int = 0,
) -> dict:
"""Update a design system.
Args:
design_system_id: The system to update.
title: New title, or "" to leave unchanged.
description: New description, or "" to leave unchanged.
guidance: New guidance prose, or "" to leave unchanged.
parent_id: 0 = leave unchanged, -1 = clear (make this a top-level
family system), positive = inherit from that system. A parent that
already inherits from this system is refused — that would be a loop.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if guidance:
fields["guidance"] = guidance
if parent_id:
fields["parent_id"] = None if parent_id == -1 else parent_id
try:
system = await ds_svc.update_design_system(uid, design_system_id, **fields)
except DesignSystemCycle as exc:
raise ValueError(str(exc)) from exc
if system is None:
raise ValueError(f"design system {design_system_id} not found or not writable")
return system.to_dict()
async def delete_design_system(design_system_id: int) -> dict:
"""Soft-delete a design system (recoverable).
Systems that inherited from it become top-level systems keeping their own
tokens — deleting a family does not delete the apps under it.
"""
uid = current_user_id()
if not await ds_svc.delete_design_system(uid, design_system_id):
raise ValueError(f"design system {design_system_id} not found or not writable")
return {"message": f"Design system {design_system_id} deleted."}
async def get_design_system_stylesheet(
design_system_id: int,
root_selector: str = ":root",
) -> dict:
"""The master CSS sheet a design system generates.
Purpose tokens only — this sheet declares what values MEAN and styles no
elements. Components (buttons, tables, input schemes) are SNIPPETS that
reference these names, so a value is stated once and reused rather than
restated per element. Reach for this when you need the tokens a snippet is
allowed to use.
Also returns `valueless` (tokens the system names but has no value for) and
`duplicates` (values declared under more than one name — a deliberate alias,
or one idea recorded twice).
Args:
design_system_id: The system to render.
root_selector: Selector for the base layer. Defaults to `:root`; pass a
container selector to scope the sheet to a preview region.
"""
uid = current_user_id()
result = await ds_svc.stylesheet_for_system(uid, design_system_id, root_selector)
if result is None:
raise ValueError(f"design system {design_system_id} not found")
return result
async def check_snippets_against_design_system(
design_system_id: int,
project_id: int = 0,
) -> dict:
"""Which recorded snippets disagree with a design system's sheet.
Snippets are the component layer — buttons, tables, input schemes — and they
are supposed to use the tags the sheet declares. Three findings per snippet,
each currently silent in the codebase:
unknown `var(--x)` where the system has no `--x`. Renders as
NOTHING: no error, no failing test, just an element
that quietly isn't styled.
superseded_literals a literal the sheet says to stop writing, paired with
the token to write instead.
local_definitions custom properties the snippet mints for itself rather
than using shared ones — the bloat a shared sheet
exists to prevent.
Snippets with nothing to report are omitted. Reach for this before writing
or reviewing component CSS.
Args:
design_system_id: The system whose sheet is authoritative.
project_id: Narrow to one project, or 0 for every project.
"""
uid = current_user_id()
result = await ds_svc.check_snippets_against_system(
uid, design_system_id, project_id
)
if result is None:
raise ValueError(f"design system {design_system_id} not found")
return result
# ── Tokens ──────────────────────────────────────────────────────────────
async def create_design_token(
design_system_id: int,
name: str,
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
rationale: str = "",
supersedes: list | None = None,
order_index: int = 0,
) -> dict:
"""Add a token to a design system.
Args:
design_system_id: The system that owns this token.
name: The custom-property name, e.g. "--surface-page" (required).
Name it for its PURPOSE, not its value: a name like "--obsidian"
or "--button-bg" stops being true the moment the value or the
element changes.
value_by_mode: Values keyed by mode, e.g.
{"base": "#14171a", "light": "#f7f5ef"}. Use "base" for the value
that applies when no mode is more specific; a token that is not
mode-dependent needs only "base". In a system WITH a parent, an
omitted mode is inherited rather than blanked.
group_name: Free-text grouping — "surface", "text", "radius", whatever
this system's own vocabulary is.
purpose: What the token is for, e.g. "page background, deepest
surface".
rationale: WHY it is this value — a different question from purpose.
"Deliberately the same value as the primary action colour" is a
rationale; "page background, deepest surface" is a purpose.
supersedes: Literal values this token should be used INSTEAD OF, e.g.
["#fff", "#ffffff"]. This is how a design system records what a
prohibition was trying to say — not "white is banned" but "write
this token instead". Declare it rather than expecting it to be
inferred: a superseded literal and the token's own value are
usually different values, so nothing can connect them by matching.
order_index: Display position within its group.
"""
uid = current_user_id()
token = await ds_svc.create_token(
uid,
design_system_id=design_system_id,
name=name,
value_by_mode=value_by_mode,
group_name=group_name or None,
purpose=purpose or None,
rationale=rationale or None,
supersedes=supersedes,
order_index=order_index,
)
if token is None:
raise ValueError(f"design system {design_system_id} not found or not writable")
return token.to_dict()
async def list_design_tokens(design_system_id: int) -> dict:
"""A design system's OWN tokens — its override set, not its effective set."""
uid = current_user_id()
rows = await ds_svc.list_tokens(uid, design_system_id)
return {"tokens": [t.to_dict() for t in rows]}
async def update_design_token(
token_id: int,
name: str = "",
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
rationale: str = "",
supersedes: list | None = None,
order_index: int = -1,
) -> dict:
"""Update a token. Empty/None args leave a field unchanged.
`value_by_mode` and `supersedes` REPLACE their whole value rather than
merging into it, so send every entry you want the token to keep. Pass `[]`
to clear `supersedes` entirely.
"""
uid = current_user_id()
fields: dict = {}
if name:
fields["name"] = name
if value_by_mode is not None:
fields["value_by_mode"] = value_by_mode
if group_name:
fields["group_name"] = group_name
if purpose:
fields["purpose"] = purpose
if rationale:
fields["rationale"] = rationale
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
# superseded literal) and would otherwise be unreachable.
if supersedes is not None:
fields["supersedes"] = supersedes
if order_index >= 0:
fields["order_index"] = order_index
token = await ds_svc.update_token(uid, token_id, **fields)
if token is None:
raise ValueError(f"design token {token_id} not found or not writable")
return token.to_dict()
async def delete_design_token(token_id: int) -> dict:
"""Soft-delete a token (recoverable).
In a system with a parent this restores inheritance: the token stops being
overridden here and resolves to the parent's value again.
"""
uid = current_user_id()
if not await ds_svc.delete_token(uid, token_id):
raise ValueError(f"design token {token_id} not found or not writable")
return {"message": f"Design token {token_id} deleted."}
async def set_project_design_system(project_id: int, design_system_id: int = 0) -> dict:
"""Point a project at a design system.
Args:
project_id: The project to style.
design_system_id: The system it uses, or -1 to clear it. Pointing at a
system only requires READ access to it — consuming a design system
is not changing it.
"""
uid = current_user_id()
target = None if design_system_id == -1 else design_system_id
ok = await ds_svc.set_project_design_system(uid, project_id, target)
if not ok:
raise ValueError(
f"project {project_id} not writable, or design system "
f"{design_system_id} not found"
)
return {"project_id": project_id, "design_system_id": target}
def register(mcp) -> None:
for fn in (
create_design_system,
list_design_systems,
get_design_system,
resolve_design_system,
update_design_system,
delete_design_system,
get_design_system_stylesheet,
check_snippets_against_design_system,
create_design_token,
list_design_tokens,
update_design_token,
delete_design_token,
set_project_design_system,
):
mcp.tool(name=fn.__name__)(fn)
+16 -1
View File
@@ -17,6 +17,7 @@ keeps working.
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as design_systems_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
@@ -53,7 +54,13 @@ async def enter_project(project_id: int) -> dict:
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes.
open_tasks, recent_notes, design_system.
`design_system` is null unless the project points at one. When present it
carries the chain-merged guidance (the house style AND this project's
departures from it) plus a summary of the token set — treat it as binding
for any UI you write, and pull the values with resolve_design_system or
get_design_system_stylesheet before reaching for a literal.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
@@ -74,9 +81,17 @@ async def enter_project(project_id: int) -> dict:
uid, is_task=False, project_id=project_id,
sort="updated_at", limit=5,
)
# A project need not have one, and most installs won't — null is ordinary
# here, not a missing prerequisite.
design_system = None
if project.design_system_id:
design_system = await design_systems_svc.design_context(
uid, project.design_system_id,
)
return {
"project": project.to_dict(),
"design_system": design_system,
"milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"],
"project_rules": applicable.get("project_rules", []),
+3 -2
View File
@@ -45,7 +45,8 @@ async def create_rulebook(title: str, description: str = "") -> dict:
Two ways a rulebook reaches projects, set by its always_on flag (toggle via
update_rulebook):
- always_on = true -> binds EVERY one of your projects automatically.
Use for universal cross-project norms (e.g. "FabledSword family").
Use for universal cross-project norms that apply across every
project, not just one.
- always_on = false -> binds only projects that subscribe
(subscribe_project_to_rulebook). Use for a THEMED body of rules a
category of projects shares (e.g. a design system that visual apps
@@ -54,7 +55,7 @@ async def create_rulebook(title: str, description: str = "") -> dict:
to any single project. Project-specific rules go in create_project_rule.
Args:
title: Rulebook name (e.g. "FabledSword family").
title: Rulebook name.
description: Optional short description of what this rulebook covers.
"""
uid = current_user_id()
+15
View File
@@ -27,6 +27,7 @@ from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from scribe.services import task_logs as task_logs_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
async def list_tasks(
@@ -99,6 +100,14 @@ async def get_task(task_id: int) -> dict:
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
data.update(await access_svc.describe_provenance(uid, note))
# Same reasoning as get_note's record_pulled, and this is the tool where it
# matters MOST: auto-inject ranks kind-blind over a corpus that is
# overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without
# this the surfaced→pulled loop was open exactly where the volume is — every
# surfaced task counted as never-pulled because the tool that opens one didn't
# say so, driving auto-inject's measured pull-through toward zero for its own
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_task")
return data
@@ -267,6 +276,12 @@ async def add_task_log(task_id: int, content: str) -> dict:
async def start_planning(project_id: int, title: str) -> dict:
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
Reach for this when the work has an ARC — several steps toward one goal,
worth tracking as a unit. Work without one (a fix, a one-file change, a
question answered) is a task, not a plan: create_task, drive its status, and
record progress with add_task_log. A milestone holding a single step is
ceremony, and it leaves the project with a plan that never meant anything.
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
template (Goal/Approach/Verification) under the given project, and the call
returns it together with the project's applicable Rulebook rules and brief
+1
View File
@@ -43,3 +43,4 @@ from scribe.models.rulebook import ( # noqa: E402, F401
)
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
+158
View File
@@ -0,0 +1,158 @@
"""Design systems — a stylesheet held as records, inherited family -> app.
A DesignSystem is a named set of design tokens with an OPTIONAL parent, and that
single self-FK is the whole model. A system with no parent is a family system; a
system WITH one holds only what it changes. "What does this app alter?" is
therefore `list its tokens` — nothing to compute, nothing to diff — which is why
inheritance won over a flat family-plus-loose-overrides shape.
Resolution walks the chain and lets the deepest system win by token name. That
is the CSS cascade rather than an analogy to it, which is why the storage model
and the stylesheet model come out the same shape.
`parent_id` also replaces two things the rulebook model needs to express the same
idea: an `always_on` flag (a family system is simply one with no parent) and a
subscription join table (a project points at ONE system, and the chain supplies
the rest). Less schema for more structure.
"""
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "design_systems"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
owner_user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
)
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# The narrative a token table cannot hold: aesthetic, voice and tone, what
# is deliberately out of scope. Free-form markdown rather than a column per
# category — a schema with `voice`/`aesthetic`/`scope` columns would bake one
# rulebook's table of contents into every install.
guidance: Mapped[str | None] = mapped_column(Text, nullable=True)
# SET NULL, not CASCADE: deleting a family system must not delete every app
# system that inherited from it. Orphaning turns each child into a root that
# still holds its own overrides — recoverable. A cascade would destroy data
# the operator never asked to touch.
parent_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("design_systems.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
def to_dict(self) -> dict:
return {
"id": self.id,
"owner_user_id": self.owner_user_id,
"title": self.title,
"description": self.description or "",
"guidance": self.guidance or "",
"parent_id": self.parent_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"""One custom property in one system: its name, and its value per mode.
`value_by_mode` is a JSONB map of mode -> value, e.g.
`{"base": "#f7f5ef", "dark": "#14171a"}`. Three reasons it beats a pair of
`value_light` / `value_dark` columns here:
- **Absence means one thing.** In a child system an unset mode means
"inherit"; in a root it would have to mean "not mode-dependent". With
columns those are both NULL and the resolver cannot tell them apart. With
a map, resolution is `{**parent_map, **child_map}` at every level —
one rule, no special case for roots.
- **Per-mode overrides are already real.** A palette rule in this operator's
own kit deepens one accent on light backgrounds for contrast while
leaving the dark value alone. Mode is a second override axis, not a
second column.
- **Nothing filters tokens by value in SQL.** Drift comparison resolves the
set first and compares in the client; the importer diffs in Python. The
queryability columns would buy is for a query no caller makes.
The cost is real — a third mode is data rather than schema, so the DB will
not reject a typo'd mode key. That is the trade taken.
NOT NULL with a `{}` default deliberately: a JSONB column otherwise has two
empty states (SQL NULL and JSON null) and code has to test for both.
"""
__tablename__ = "design_tokens"
__table_args__ = (
# Partial unique: a name is unique among LIVE tokens in a system, so a
# trashed token doesn't block recreating the same name. Two live rows
# named `--fs-obsidian` in one system is a duplicate definition, and the
# cascade would pick between them arbitrarily.
Index(
"uq_token_per_design_system", "design_system_id", "name",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
design_system_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(Text)
# Named for what it holds rather than the bare word `values`, which is
# reserved in SQL — the same reason `group_name` is not `group`.
value_by_mode: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
)
# `group` is a reserved word in SQL; `group_name` throughout — model, column
# and payload — so no layer has to remember which spelling it is on.
# Free text, not a CHECK enum: groupings are the design system's own
# vocabulary, and a whitelist would bake one install's kit into the schema.
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
# WHY this token is this value — a different question from `purpose`, which
# is what it is FOR. "Deliberately the same value as the primary action
# colour" is a rationale; "page background, deepest surface" is a purpose.
# Design guidance carries the first routinely and a token row had nowhere to
# put it.
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
# "#ffffff"] on a text-on-action token.
#
# This is how a design system records the thing a prohibition was trying to
# say. "Pure white is never text" is the shadow of a positive fact — some
# other colour IS the text colour — and a system that stores what things ARE
# has no row for a ban.
# Recording the replacement keeps the check and makes it actionable: a
# finding can name what to write instead of merely objecting.
#
# It has to be DECLARED rather than inferred, because the superseded literal
# and the token's own value are usually different colours entirely. No
# value-matching rule could ever connect them.
#
# Consumed by the source lint (#2277), not by the drift panel: these
# literals live in component CSS, which the panel cannot see and says so.
supersedes: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
)
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
def to_dict(self) -> dict:
return {
"id": self.id,
"design_system_id": self.design_system_id,
"name": self.name,
"value_by_mode": self.value_by_mode or {},
"group_name": self.group_name,
"purpose": self.purpose,
"rationale": self.rationale,
"supersedes": self.supersedes or [],
"order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+8 -1
View File
@@ -1,5 +1,5 @@
import enum
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin
@@ -21,6 +21,12 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
# The design system this project's UI is built from, or NULL. NULL is the
# ordinary state, not a degraded one — most installs have no design system
# at all and nothing may assume one exists.
design_system_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
)
def to_dict(self) -> dict:
return {
@@ -31,6 +37,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"goal": self.goal,
"status": self.status,
"color": self.color,
"design_system_id": self.design_system_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+29
View File
@@ -0,0 +1,29 @@
"""Design-system surface — what the rulebook expects of the stylesheet.
The client owns the other half of the comparison: it reads live token values from
the browser (see `utils/designTokens.ts`), which is the only place they exist
resolved. This endpoint supplies the claims to check them against.
"""
from quart import Blueprint, jsonify
from scribe.auth import get_current_user_id, login_required
from scribe.services import design_rulebook_import as design_svc
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
@design_bp.get("/expectations")
@login_required
async def get_expectations():
"""Checkable claims from the rulebook this install designated as its design system.
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
`rulebook_id: null` is the NORMAL case, not an error — an install that has
not designated a design rulebook has nothing to compare against, and the
client shows an explanatory empty state (rule #115). Distinguishing it from
"designated but empty" is why the id is returned alongside the list.
"""
uid = get_current_user_id()
result = await design_svc.design_expectations(uid)
return jsonify(result.as_dict())
+236
View File
@@ -0,0 +1,236 @@
"""Design system + token REST endpoints (milestone #254 step 4).
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
closer shape.
Two failures this layer has to keep apart, which is why the service raises for
one and returns None for the other:
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
from this system" is a correctable mistake and the caller needs to be told
which one they made.
- None -> 404, covering both "no such system" and "not yours". Conflating
those two IS the intent: distinguishing them would confirm the existence of
records the caller may not see.
"""
from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
def _not_found(what: str = "design system"):
return jsonify({"error": f"{what} not found"}), 404
# ── Design systems ──────────────────────────────────────────────────────
@design_systems_bp.get("/design-systems")
@login_required
async def list_design_systems():
"""The caller's design systems. An empty list is the ordinary state for an
install that has never made one, not an error."""
rows = await ds_svc.list_design_systems(_uid())
return jsonify({"design_systems": [s.to_dict() for s in rows]})
@design_systems_bp.post("/design-systems")
@login_required
async def create_design_system():
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
system = await ds_svc.create_design_system(
user_id=_uid(),
title=title,
description=data.get("description") or None,
guidance=data.get("guidance") or None,
parent_id=data.get("parent_id"),
)
if system is None:
return jsonify({"error": "parent design system not found"}), 404
return jsonify(system.to_dict()), 201
@design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required
async def get_design_system(design_system_id: int):
system = await ds_svc.get_design_system(_uid(), design_system_id)
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
@login_required
async def update_design_system(design_system_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items() if k in ("title", "description", "guidance")
}
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
# which a `if data.get("parent_id")` filter would silently drop.
if "parent_id" in data:
fields["parent_id"] = data["parent_id"]
try:
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
except DesignSystemCycle as exc:
return jsonify({"error": str(exc)}), 400
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
@login_required
async def delete_design_system(design_system_id: int):
if not await ds_svc.delete_design_system(_uid(), design_system_id):
return _not_found()
return "", 204
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
@login_required
async def resolve_design_system(design_system_id: int):
"""The EFFECTIVE token set — everything inherited, with this system's on top.
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
this returns what it ends up being. Both are real questions and answering
only one would make the other a client-side computation.
"""
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
if resolved is None:
return _not_found()
return jsonify({
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
})
@design_systems_bp.get("/design-systems/<int:design_system_id>/stylesheet")
@login_required
async def get_design_system_stylesheet(design_system_id: int):
"""The master CSS sheet this design system generates.
JSON by default (the UI wants the reuse report alongside the CSS); add
`?format=css` for the raw stylesheet as `text/css`, which is what a build
step or a `curl` wants.
`?root=` overrides the base selector — a container-scoped preview cannot use
`:root`, so the generator takes it as a parameter.
"""
root = (request.args.get("root") or ":root").strip() or ":root"
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root)
if result is None:
return _not_found()
if request.args.get("format") == "css":
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
return jsonify(result)
@design_systems_bp.get("/design-systems/<int:design_system_id>/snippet-check")
@login_required
async def check_snippets_against_system(design_system_id: int):
"""Which recorded snippets disagree with this system's sheet.
`?project_id=` narrows to one project; omit it to check every project, which
is usually right — a component recorded elsewhere still has to use the same
tags.
"""
project_id = request.args.get("project_id", type=int) or 0
result = await ds_svc.check_snippets_against_system(
_uid(), design_system_id, project_id
)
if result is None:
return _not_found()
return jsonify(result)
# ── Tokens ──────────────────────────────────────────────────────────────
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
@login_required
async def list_design_tokens(design_system_id: int):
"""This system's OWN tokens — its override set, not its effective set."""
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
return _not_found()
rows = await ds_svc.list_tokens(_uid(), design_system_id)
return jsonify({"tokens": [t.to_dict() for t in rows]})
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
@login_required
async def create_design_token(design_system_id: int):
data = await request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
token = await ds_svc.create_token(
user_id=_uid(),
design_system_id=design_system_id,
name=name,
value_by_mode=data.get("value_by_mode"),
group_name=data.get("group_name") or None,
purpose=data.get("purpose") or None,
rationale=data.get("rationale") or None,
supersedes=data.get("supersedes"),
order_index=data.get("order_index") or 0,
)
if token is None:
return _not_found()
return jsonify(token.to_dict()), 201
@design_systems_bp.patch("/design-tokens/<int:token_id>")
@login_required
async def update_design_token(token_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in (
"name", "value_by_mode", "group_name", "purpose", "rationale",
"supersedes", "order_index",
)
}
token = await ds_svc.update_token(_uid(), token_id, **fields)
if token is None:
return _not_found("design token")
return jsonify(token.to_dict())
@design_systems_bp.delete("/design-tokens/<int:token_id>")
@login_required
async def delete_design_token(token_id: int):
if not await ds_svc.delete_token(_uid(), token_id):
return _not_found("design token")
return "", 204
# ── The project pointer ─────────────────────────────────────────────────
@design_systems_bp.put("/projects/<int:project_id>/design-system")
@login_required
async def set_project_design_system(project_id: int):
"""Point a project at a design system. `{"design_system_id": null}` clears it.
PUT rather than PATCH: this sets one field to exactly what is sent, and
clearing it is a first-class outcome rather than an omission.
"""
data = await request.get_json() or {}
ok = await ds_svc.set_project_design_system(
_uid(), project_id, data.get("design_system_id")
)
if not ok:
return _not_found("project or design system")
return jsonify({"project_id": project_id,
"design_system_id": data.get("design_system_id")})
-11
View File
@@ -1,8 +1,6 @@
import asyncio
import logging
import re
from scribe.services.embeddings import upsert_note_embedding
from quart import Blueprint, jsonify, request
@@ -113,9 +111,6 @@ async def create_note_route():
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
return jsonify(note.to_dict()), 201
@@ -221,9 +216,6 @@ async def update_note_route(note_id: int):
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
@@ -259,9 +251,6 @@ async def patch_note_route(note_id: int):
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
+21 -9
View File
@@ -1,5 +1,4 @@
"""Project management routes."""
import asyncio
import logging
from quart import Blueprint, jsonify, request
@@ -13,6 +12,7 @@ from scribe.services.projects import (
delete_project,
get_project,
get_project_for_user,
get_project_summaries,
get_project_summary,
list_projects_for_user,
update_project,
@@ -31,16 +31,28 @@ async def list_projects_route():
include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
projects = await list_projects_for_user(uid, status=status)
if include_summary:
# Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls
async def _attach(project_dict: dict) -> dict:
# Batched: four queries plus two, in two sessions, for ALL projects.
# This replaced an asyncio.gather over a per-project summary that opened
# its own session and then one more per milestone — ~250 concurrent
# checkouts against a pool of 15, all waiting out the 30s timeout and
# starving every other route on the instance (#2384).
#
# Grouped by OWNER because a shared project's counts belong to its
# owner's records, matching what the per-project path passed.
by_owner: dict[int, list[dict]] = {}
for p in projects:
by_owner.setdefault(p.get("user_id") or uid, []).append(p)
for owner_uid, owned in by_owner.items():
try:
owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict()
summary = await get_project_summary(owner_uid, project_dict["id"])
project_dict["summary"] = summary
summaries = await get_project_summaries(
owner_uid, [p["id"] for p in owned]
)
except Exception:
pass
return project_dict
projects = list(await asyncio.gather(*[_attach(p) for p in projects]))
logger.warning("Project summaries failed", exc_info=True)
continue
for p in owned:
if p["id"] in summaries:
p["summary"] = summaries[p["id"]]
return jsonify({"projects": projects})
-8
View File
@@ -1,4 +1,3 @@
import asyncio
from datetime import date
from quart import Blueprint, jsonify, request
@@ -8,7 +7,6 @@ from scribe.models.note import TaskPriority, TaskStatus
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note
from scribe.services import systems as systems_svc
from scribe.services.embeddings import upsert_note_embedding
from scribe.services.notes import (
create_note,
get_note_for_user,
@@ -149,9 +147,6 @@ async def create_task_route():
)
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task.id, data["system_ids"])
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task.id)]
return jsonify(out), 201
@@ -255,9 +250,6 @@ async def update_task_route(task_id: int):
return not_found("Task")
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task_id, data["system_ids"])
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
return jsonify(out)
+84
View File
@@ -12,11 +12,13 @@ import logging
from sqlalchemy import or_, select
from scribe.models import async_session
from scribe.models.design_system import DesignSystem
from scribe.models.group import GroupMembership
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare
from scribe.models.user import User
from scribe.services.design_cascade import ancestry
logger = logging.getLogger(__name__)
@@ -164,6 +166,88 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
return perm in ("editor", "admin", "owner")
# ---------------------------------------------------------------------------
# Design-system permissions
# ---------------------------------------------------------------------------
async def get_design_system_permission(
user_id: int, design_system_id: int
) -> str | None:
"""Effective permission on a design system, or None.
Two ways in, and the asymmetry between them is the point:
- **Owning it** grants "owner" — full read and write.
- **Reaching it through a project you can see** grants "viewer", and only
ever "viewer". Being an editor on a shared project must NOT confer the
right to rewrite the family system that project inherits from: one
project's collaborator would be editing tokens every other project in
the family resolves through. Editing a design system stays the owner's
act, and it is the same reasoning that keeps a rulebook owner-scoped.
Reachability follows the parent chain UPWARD. Rendering a project's UI means
resolving its whole chain, so read access to a system implies read access to
its ancestors — otherwise a shared project would resolve to a truncated
cascade and silently render with the wrong values.
"""
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
if system.owner_user_id == user_id:
return "owner"
shared_project_ids = select(ProjectShare.project_id).where(
or_(
ProjectShare.shared_with_user_id == user_id,
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
)
)
entry_points = set(
(
await session.execute(
select(Project.design_system_id).where(
Project.design_system_id.is_not(None),
Project.deleted_at.is_(None),
or_(
Project.user_id == user_id,
Project.id.in_(shared_project_ids),
),
)
)
).scalars().all()
)
if not entry_points:
return None
# One narrow query for the whole forest's shape. Design systems are a
# handful of rows per install — a family and one per app — so walking
# from each entry point in memory beats a recursive CTE per check.
parents = dict(
(
await session.execute(
select(DesignSystem.id, DesignSystem.parent_id).where(
DesignSystem.deleted_at.is_(None)
)
)
).all()
)
for entry in entry_points:
if design_system_id in ancestry(entry, parents):
return "viewer"
return None
async def can_read_design_system(user_id: int, design_system_id: int) -> bool:
return (await get_design_system_permission(user_id, design_system_id)) is not None
async def can_write_design_system(user_id: int, design_system_id: int) -> bool:
perm = await get_design_system_permission(user_id, design_system_id)
return perm in ("editor", "admin", "owner")
# ---------------------------------------------------------------------------
# Set-based visibility (for LIST queries)
#
+251 -7
View File
@@ -8,7 +8,10 @@ from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.note_draft import NoteDraft
from scribe.models.note_version import NoteVersion
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.note_usage import NoteUsageEvent
from scribe.models.project import Project
from scribe.models.repo_binding import RepoBinding
from scribe.models.rulebook import (
Rule,
Rulebook,
@@ -18,6 +21,7 @@ from scribe.models.rulebook import (
project_topic_suppressions,
)
from scribe.models.setting import Setting
from scribe.models.system import RecordSystem, System
from scribe.models.task_log import TaskLog
from scribe.models.user import User
@@ -26,17 +30,44 @@ logger = logging.getLogger(__name__)
# Backup format version. v3 (2026-06) added rulebooks/topics/rules + their
# project subscription/suppression join tables. v4 (2026-07) dropped events
# when the calendar surface was retired — old v3 events are skipped on restore.
# v5 (2026-08) added the six tables that had accumulated outside the backup
# entirely (#2293), and the coverage guard that stops the seventh.
# Bump when the serialized schema changes.
BACKUP_VERSION = 4
BACKUP_VERSION = 5
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
# what tests/test_services_backup.py asserts against Base.metadata.
#
# The point is the ABSENCE case. A new table gets a model and a migration, both
# of which fail loudly if wrong, and then silently never gets a backup section:
# no error, no warning, and a restore that reports success. Naming the coverage
# explicitly turns "someone forgot" into a failing test (#2293).
_BACKED_UP = [
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
# v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
# embeddings are derived (regenerated from note bodies); api_keys are sensitive
# credentials; the rest are transient/operational.
# note_embeddings are derived (regenerated from note bodies); api_keys are
# sensitive credentials; retrieval_logs is observational telemetry that nothing
# reads for correctness and that grows per query; the rest are
# transient/operational.
#
# REAL table names, deliberately. This list used to read "embeddings",
# "invitations", "password_resets" — none of which are tables — so it looked
# like coverage while naming nothing the schema could confirm.
_NOT_INCLUDED = [
"groups", "group_memberships", "project_shares", "note_shares",
"api_keys", "embeddings", "app_logs", "notifications", "invitations",
"password_resets", "user_profiles",
"api_keys", "note_embeddings", "app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles",
"retrieval_logs",
]
@@ -60,12 +91,73 @@ def _topic_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
# The v5 sections. Pure row-builders like the join-table helpers above, for the
# same reason: CI has no database, so a serialiser that is a plain function is
# one that can actually be tested.
def _system_rows(rows) -> list[dict]:
return [
{
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
"name": r.name, "description": r.description, "color": r.color,
"status": r.status, "order_index": r.order_index,
}
for r in rows
]
def _record_system_rows(rows) -> list[dict]:
return [{"note_id": r.note_id, "system_id": r.system_id} for r in rows]
def _design_system_rows(rows) -> list[dict]:
return [
{
"id": r.id, "owner_user_id": r.owner_user_id, "title": r.title,
"description": r.description, "guidance": r.guidance,
"parent_id": r.parent_id,
}
for r in rows
]
def _design_token_rows(rows) -> list[dict]:
return [
{
"id": r.id, "design_system_id": r.design_system_id, "name": r.name,
"value_by_mode": r.value_by_mode or {},
"group_name": r.group_name, "purpose": r.purpose,
"rationale": r.rationale, "supersedes": r.supersedes or [],
"order_index": r.order_index,
}
for r in rows
]
def _usage_event_rows(rows) -> list[dict]:
return [
{
"user_id": r.user_id, "note_id": r.note_id, "event": r.event,
"source": r.source,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _repo_binding_rows(rows) -> list[dict]:
return [
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
for r in rows
]
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
async def export_full_backup() -> dict:
"""Export all data as a version-3 JSON backup."""
"""Export all data as a version-5 JSON backup."""
async with async_session() as session:
users = (await session.execute(select(User))).scalars().all()
projects = (await session.execute(select(Project))).scalars().all()
@@ -77,6 +169,18 @@ async def export_full_backup() -> dict:
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
)).scalars().all()
settings = (await session.execute(select(Setting))).scalars().all()
systems = (await session.execute(select(System))).scalars().all()
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
# Parent-first, so a restore can resolve parent_id as it goes rather
# than needing a second pass — the self-FK is the only ordering
# constraint in this payload.
design_systems = (await session.execute(
select(DesignSystem).order_by(DesignSystem.parent_id.nullsfirst(),
DesignSystem.id)
)).scalars().all()
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all()
@@ -244,11 +348,17 @@ async def export_full_backup() -> dict:
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"systems": _system_rows(systems),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
"design_tokens": _design_token_rows(design_tokens),
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
}
async def export_user_backup(user_id: int) -> dict:
"""Export a single user's data as a version-3 JSON backup."""
"""Export a single user's data as a version-5 JSON backup."""
async with async_session() as session:
user = await session.get(User, user_id)
projects = (await session.execute(
@@ -274,6 +384,32 @@ async def export_user_backup(user_id: int) -> dict:
settings = (await session.execute(
select(Setting).where(Setting.user_id == user_id)
)).scalars().all()
systems = (await session.execute(
select(System).where(System.user_id == user_id)
)).scalars().all()
system_ids = [sy.id for sy in systems]
note_ids = [n.id for n in notes]
# Scoped by the user's SYSTEMS, not their notes: a shared note carrying
# this user's system tag belongs in their backup, and a note of theirs
# tagged with someone else's system does not — that row is the other
# user's to keep.
record_systems = (await session.execute(
select(RecordSystem).where(RecordSystem.system_id.in_(system_ids))
)).scalars().all() if system_ids else []
design_systems = (await session.execute(
select(DesignSystem).where(DesignSystem.owner_user_id == user_id)
.order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id)
)).scalars().all()
ds_ids = [d.id for d in design_systems]
design_tokens = (await session.execute(
select(DesignToken).where(DesignToken.design_system_id.in_(ds_ids))
)).scalars().all() if ds_ids else []
usage_events = (await session.execute(
select(NoteUsageEvent).where(NoteUsageEvent.note_id.in_(note_ids))
)).scalars().all() if note_ids else []
repo_bindings = (await session.execute(
select(RepoBinding).where(RepoBinding.user_id == user_id)
)).scalars().all()
rulebooks = (await session.execute(
select(Rulebook).where(Rulebook.owner_user_id == user_id)
)).scalars().all()
@@ -455,6 +591,12 @@ async def export_user_backup(user_id: int) -> dict:
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"systems": _system_rows(systems),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
"design_tokens": _design_token_rows(design_tokens),
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
}
@@ -556,6 +698,8 @@ async def _restore_v2(data: dict) -> dict:
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"rulebook_subscriptions": 0, "rule_suppressions": 0,
"topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
}
async with async_session() as session:
@@ -814,6 +958,106 @@ async def _restore_v2(data: dict) -> dict:
))
stats["topic_suppressions"] += 1
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
# payload restores without them rather than failing on an absent key.
# 15. Systems
system_id_map: dict[int, int] = {}
for sy_data in data.get("systems", []):
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
mapped_pid = project_id_map.get(sy_data.get("project_id", 0))
if mapped_uid is None or mapped_pid is None:
continue
system = System(
user_id=mapped_uid, project_id=mapped_pid,
name=sy_data.get("name", ""),
description=sy_data.get("description"),
color=sy_data.get("color"),
status=sy_data.get("status", "active"),
order_index=sy_data.get("order_index", 0),
)
session.add(system)
await session.flush()
system_id_map[sy_data["id"]] = system.id
stats["systems"] += 1
# 16. Record↔system links
for rs in data.get("record_systems", []):
mapped_nid = note_id_map.get(rs.get("note_id", 0))
mapped_sid = system_id_map.get(rs.get("system_id", 0))
if mapped_nid is None or mapped_sid is None:
continue
session.add(RecordSystem(note_id=mapped_nid, system_id=mapped_sid))
stats["record_systems"] += 1
# 17. Design systems. The export orders these parent-first, so a
# parent's new id is always in the map by the time a child needs it —
# no second pass, and a child whose parent is missing lands as a root
# rather than failing the whole restore.
design_system_id_map: dict[int, int] = {}
for ds_data in data.get("design_systems", []):
mapped_uid = user_id_map.get(ds_data.get("owner_user_id", 0))
if mapped_uid is None:
continue
design = DesignSystem(
owner_user_id=mapped_uid,
title=ds_data.get("title", ""),
description=ds_data.get("description"),
guidance=ds_data.get("guidance"),
parent_id=design_system_id_map.get(ds_data.get("parent_id") or 0),
)
session.add(design)
await session.flush()
design_system_id_map[ds_data["id"]] = design.id
stats["design_systems"] += 1
# 18. Design tokens
for t_data in data.get("design_tokens", []):
mapped_dsid = design_system_id_map.get(t_data.get("design_system_id", 0))
if mapped_dsid is None:
continue
session.add(DesignToken(
design_system_id=mapped_dsid,
name=t_data.get("name", ""),
value_by_mode=t_data.get("value_by_mode") or {},
group_name=t_data.get("group_name"),
purpose=t_data.get("purpose"),
rationale=t_data.get("rationale"),
supersedes=t_data.get("supersedes") or [],
order_index=t_data.get("order_index", 0),
))
stats["design_tokens"] += 1
# 19. Usage events. Kept because pull-through is the evidence base for
# whether recall works at all, and it is only ever accumulated — a
# restore that dropped it would silently reset that measurement to zero
# while everything still looked fine.
for ev in data.get("note_usage_events", []):
mapped_nid = note_id_map.get(ev.get("note_id", 0))
if mapped_nid is None:
continue
session.add(NoteUsageEvent(
user_id=user_id_map.get(ev.get("user_id") or 0),
note_id=mapped_nid,
event=ev.get("event", ""),
source=ev.get("source", ""),
created_at=_dt(ev.get("created_at")),
))
stats["note_usage_events"] += 1
# 20. Repo bindings — small, but losing them means every bound repo
# quietly stops loading its project at session start.
for rb_data in data.get("repo_bindings", []):
mapped_uid = user_id_map.get(rb_data.get("user_id", 0))
mapped_pid = project_id_map.get(rb_data.get("project_id", 0))
if mapped_uid is None or mapped_pid is None:
continue
session.add(RepoBinding(
user_id=mapped_uid, project_id=mapped_pid,
repo_key=rb_data.get("repo_key", ""),
))
stats["repo_bindings"] += 1
await session.commit()
logger.info("Restored v2/v3 backup: %s", stats)
+251
View File
@@ -0,0 +1,251 @@
"""The design-system cascade, as pure functions over already-loaded rows.
Deliberately free of every database and service import — including
`services/access.py`, which needs `ancestry` to answer "can this caller read
this system?" and would otherwise form an import cycle with the service that
needs `access` back. A module that imports nothing can be imported by both.
Being pure is also what makes the cascade rule testable without a database:
these take a plain `{id: parent_id}` map, so a test states the shape of a
hierarchy in one literal instead of building one.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
# The mode key that applies when no more specific one does. Emitted CSS puts it
# on the base selector and every other key on a mode selector, mirroring how a
# stylesheet is actually written: light on `:root`, dark layered over it.
BASE_MODE = "base"
def ancestry(system_id: int, parents: Mapping[int, int | None]) -> list[int]:
"""The inheritance chain from `system_id` up to its root, nearest first.
Includes `system_id` itself at index 0, because resolution wants
deepest-to-shallowest and the system being resolved is the deepest link.
A system missing from `parents` terminates the chain rather than raising: a
parent whose row was soft-deleted or filtered out is a truncated chain, not
a failed request.
The visited-set is defensive, not the primary guard — writes already refuse
to create a cycle (`would_cycle`). It is here because a loop introduced by a
direct DB edit or a future bug must degrade to a truncated chain instead of
spinning forever. Truncation shows up in the result; a hang shows up as an
outage.
"""
chain: list[int] = []
seen: set[int] = set()
current: int | None = system_id
while current is not None and current not in seen:
seen.add(current)
chain.append(current)
current = parents.get(current)
return chain
def would_cycle(
system_id: int,
proposed_parent_id: int | None,
parents: Mapping[int, int | None],
) -> bool:
"""Would making `proposed_parent_id` the parent of `system_id` close a loop?
True when the proposed parent IS the system, or already inherits from it.
The walk goes UP from the proposed parent, which is the cheap direction —
each system has at most one parent, so the chain is a line. Asking the
equivalent downward question ("is the proposed parent among my
descendants?") would mean searching a whole forest for the same answer.
`proposed_parent_id=None` clears the parent and can never cycle.
"""
if proposed_parent_id is None:
return False
if proposed_parent_id == system_id:
return True
return system_id in ancestry(proposed_parent_id, parents)
# ---------------------------------------------------------------------------
# Resolution — flattening a chain into an effective token set
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Contribution:
"""One system's offer for one token in one mode."""
system_id: int
value: str
@dataclass(frozen=True)
class ResolvedToken:
"""A token after the cascade, carrying the whole argument rather than the verdict.
`contributions` holds every system that supplied a value, per mode, DEEPEST
FIRST — so `[0]` is the winner and `[1:]` are what it shadowed. Storing the
contest rather than a winner plus a separate provenance field means there is
nothing to keep in sync: "which system supplied this?" and "what did it
override?" are both reads of the same list, and they cannot disagree.
Provenance is per MODE, not per token, because overriding is. A system that
deepens one accent for light backgrounds while leaving dark alone owns the
base value and inherits the dark one, and a token-level "overridden here"
flag would have to lie about one of them.
"""
name: str
contributions: dict[str, tuple[Contribution, ...]]
group_name: str | None
purpose: str | None
rationale: str | None
supersedes: tuple[str, ...]
order_index: int
@property
def value_by_mode(self) -> dict[str, str]:
"""The effective value for each mode — the winner of each contest."""
return {mode: entries[0].value for mode, entries in self.contributions.items()}
@property
def origin_by_mode(self) -> dict[str, int]:
"""Which system supplied each mode's effective value."""
return {mode: entries[0].system_id for mode, entries in self.contributions.items()}
def value_for(self, mode: str) -> str | None:
"""The value to render in `mode`, falling back to the base mode.
This is the read rule the storage shape implies: a token that is not
mode-dependent carries only `base`, and asking it for "dark" must yield
the base value rather than nothing.
"""
entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE)
return entries[0].value if entries else None
def to_dict(self) -> dict:
"""Payload shape for both surfaces — and it carries the SHADOWED entries.
Serialising only the winner would throw away the provenance at the last
step, which is the one thing this type exists to preserve. `contributions`
is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so
a client renders without re-deriving anything, and cannot derive it
differently.
"""
return {
"name": self.name,
"group_name": self.group_name,
"purpose": self.purpose,
"rationale": self.rationale,
"supersedes": list(self.supersedes),
"order_index": self.order_index,
"value_by_mode": self.value_by_mode,
"origin_by_mode": self.origin_by_mode,
"contributions": {
mode: [
{"system_id": c.system_id, "value": c.value} for c in entries
]
for mode, entries in self.contributions.items()
},
}
def is_overridden_in(self, system_id: int) -> bool:
"""Does `system_id` win any mode of this token AND shadow something?
The distinction the UI needs: a token this system introduced is not an
override, and a token it merely inherits is not either.
"""
return any(
len(entries) > 1 and entries[0].system_id == system_id
for entries in self.contributions.values()
)
def _sort_key(token: ResolvedToken) -> tuple:
# Ungrouped tokens sort last rather than first: a design system that has
# started grouping should read as its groups, with the not-yet-filed
# remainder at the end.
return (token.group_name is None, token.group_name or "", token.order_index, token.name)
def resolve_tokens(
system_id: int,
parents: Mapping[int, int | None],
tokens_by_system: Mapping[int, Sequence],
) -> list[ResolvedToken]:
"""Flatten a system's inheritance chain into its effective token set.
Walks from `system_id` up to the root and applies tokens by name, deepest
winning. That is the CSS cascade — precedence by name along a parent chain —
rather than an analogy to it, which is why the storage model and the
stylesheet model came out the same shape.
`tokens_by_system` maps a system id to its own token rows. Any object with
`.name`, `.value_by_mode`, `.group_name`, `.purpose` and `.order_index` will
do, so a test can state a hierarchy in literals and the service can pass ORM
rows to the same function.
The result includes tokens the system never mentions — inheriting one is
what puts it in the effective set. A system with no tokens of its own
resolves to its parent's set entire, which is the correct answer for an app
that has not departed from the family yet.
Merging is per (name, MODE): a child that supplies only a dark value
overrides only dark and keeps inheriting base. Metadata (`group_name`,
`purpose`, `order_index`) cascades separately by the same deepest-wins rule,
since a child overriding a value routinely leaves the family's description
of what the token is FOR untouched — and inheriting it beats blanking it.
"""
chain = ancestry(system_id, parents)
contributions: dict[str, dict[str, list[Contribution]]] = {}
metadata: dict[str, dict[str, object]] = {}
# Deepest first, so the first contribution seen for a (name, mode) wins and
# every later one is a shadowed ancestor appended behind it.
for depth_system_id in chain:
for token in tokens_by_system.get(depth_system_id) or ():
per_mode = contributions.setdefault(token.name, {})
for mode, value in (token.value_by_mode or {}).items():
per_mode.setdefault(mode, []).append(
Contribution(system_id=depth_system_id, value=value)
)
meta = metadata.setdefault(
token.name,
{
"group_name": None, "purpose": None, "rationale": None,
"supersedes": None, "order_index": None,
},
)
for field in ("group_name", "purpose", "rationale"):
if meta[field] is None:
meta[field] = getattr(token, field, None)
# order_index alone treats 0 as UNSTATED rather than "first",
# because 0 is the column default. Reading it as a real value would
# let any child override drag its token to the top of the group and
# lose the family's ordering — a visible reshuffle in return for a
# change that only touched a colour.
if not meta["order_index"]:
meta["order_index"] = getattr(token, "order_index", 0) or None
# `supersedes` cascades on EMPTINESS, not on None: a child that
# overrides a colour and says nothing about which literals it
# replaces should keep the family's declaration, and an empty list
# is what "said nothing" looks like once the column is NOT NULL.
# A child that states its own list replaces the whole thing.
if not meta["supersedes"]:
meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None
resolved = [
ResolvedToken(
name=name,
contributions={
mode: tuple(entries) for mode, entries in per_mode.items()
},
group_name=metadata[name]["group_name"],
purpose=metadata[name]["purpose"],
rationale=metadata[name]["rationale"],
supersedes=metadata[name]["supersedes"] or (),
order_index=metadata[name]["order_index"] or 0,
)
for name, per_mode in contributions.items()
]
return sorted(resolved, key=_sort_key)
@@ -0,0 +1,232 @@
"""Design-system expectations — turning rulebook prose into checkable claims.
Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
against what the stylesheet and components actually DO. This module owns the
first half: reading a rulebook's rules and extracting the claims that can be
mechanically checked.
WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
is the entire check — and this is the one genuinely fiddly piece of the feature.
Extraction happens here where pytest can assert on it; the comparison itself is
set arithmetic and stays in the browser, where the live token values are.
WHY NOT NLP. Rule statements are prose written for humans, and they should stay
that way — they are read by people far more often than they are parsed. So this
extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
property names a rule mentions. Everything subtler (padding scales, type ramps)
needs a rule author to opt into a structured form, which is deliberately left for
when someone wants it rather than invented up front.
RULE #115. Nothing here assumes a design rulebook exists, or that it is this
operator's. An install designates one; an install that hasn't gets an empty
result and a panel that explains itself.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from scribe.models.rulebook import Rule
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
# Which rulebook describes this install's design system. A plain setting rather
# than a column: no migration, discoverable in the Settings UI (rule #25), and
# honest about being a per-install choice rather than a property of the rulebook.
DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
# A custom-property name as written in prose, including the slash shorthand the
# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
# Negation markers. Checked PER SENTENCE, which is the whole trick — see
# _extract_from_sentence.
_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
@dataclass
class Expectation:
"""One mechanically-checkable claim a rule makes."""
kind: str # "token" | "color" | "prohibited_color"
value: str # "--fs-obsidian" | "#14171a"
rule_id: int
rule_title: str
context: str # the sentence it came from, for showing your work
def as_dict(self) -> dict:
return {
"kind": self.kind,
"value": self.value,
"rule_id": self.rule_id,
"rule_title": self.rule_title,
"context": self.context,
}
@dataclass
class ExpectationSet:
rulebook_id: int | None = None
expectations: list[Expectation] = field(default_factory=list)
def as_dict(self) -> dict:
return {
"rulebook_id": self.rulebook_id,
"expectations": [e.as_dict() for e in self.expectations],
}
def normalize_hex(value: str) -> str | None:
"""Fold a hex colour to a comparable form, or None if it isn't one.
Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
code writes `#fff`, and those must compare equal or the single largest drift
finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
same colour, and silently dropping the alpha would invent equality.
"""
match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
if not match:
return None
digits = match.group(1).lower()
if len(digits) in (3, 4):
digits = "".join(c * 2 for c in digits)
if len(digits) not in (6, 8):
return None
return f"#{digits}"
def expand_token_shorthand(raw: str) -> list[str]:
"""`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
The rulebook writes token families in a slash shorthand, and both forms it
uses expand correctly under one rule: take everything up to and including the
LAST hyphen of the first segment as the prefix, then append each alternative.
--fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
--fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
--fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
A name with no slash is returned as-is.
"""
if "/" not in raw:
return [raw]
head, *rest = raw.split("/")
cut = head.rfind("-")
if cut <= 1: # no hyphen beyond the leading `--`
return [head, *rest]
prefix = head[: cut + 1]
return [head, *[f"{prefix}{part}" for part in rest if part]]
def _is_negated(sentence: str) -> bool:
return any(marker in sentence.lower() for marker in _NEGATIONS)
def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
"""Claims in ONE sentence, with negation scoped to that sentence.
Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
"Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
Pure white #FFFFFF is NEVER used as text color."
Three colours the palette REQUIRES and one it FORBIDS, in one statement.
Detecting negation across the whole statement would mark all four as
forbidden; detecting it per sentence gets all four right.
"""
out: list[Expectation] = []
negated = _is_negated(sentence)
for match in _HEX.finditer(sentence):
value = normalize_hex(match.group(0))
if not value:
continue
out.append(Expectation(
kind="prohibited_color" if negated else "color",
value=value,
rule_id=int(rule.id),
rule_title=rule.title,
context=sentence.strip(),
))
# Token names are not negated in practice — a rule says which tokens should
# exist, never which must not — so they are recorded as expectations
# regardless. If that ever changes, it needs its own kind rather than
# borrowing the colour one.
for match in _TOKEN.finditer(sentence):
for name in expand_token_shorthand(match.group(1)):
out.append(Expectation(
kind="token",
value=name,
rule_id=int(rule.id),
rule_title=rule.title,
context=sentence.strip(),
))
return out
def extract_expectations(rules: list[Rule]) -> list[Expectation]:
"""Every checkable claim across a set of rules, deduped on (kind, value).
First occurrence wins so the reported rule is the one that introduced the
claim, which is usually the most specific place to send a reader.
"""
seen: set[tuple[str, str]] = set()
out: list[Expectation] = []
for rule in rules:
text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""]))
for sentence in _SENTENCE_SPLIT.split(text):
if not sentence.strip():
continue
for expectation in _extract_from_sentence(sentence, rule):
key = (expectation.kind, expectation.value)
if key in seen:
continue
seen.add(key)
out.append(expectation)
return out
async def get_design_rulebook_id(user_id: int) -> int | None:
"""The rulebook this install designated as its design system, if any."""
raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
if not raw:
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
return value if value > 0 else None
async def design_expectations(user_id: int) -> ExpectationSet:
"""Checkable claims from the designated design rulebook.
Returns an empty set when no rulebook is designated — the normal case for
any install but the one that set it up (rule #115). The caller shows an
explanatory empty state rather than treating this as an error.
"""
rulebook_id = await get_design_rulebook_id(user_id)
if rulebook_id is None:
return ExpectationSet()
from scribe.services import rulebooks as rulebooks_svc
try:
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
except Exception:
logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
return ExpectationSet(rulebook_id=rulebook_id)
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
+409
View File
@@ -0,0 +1,409 @@
"""Render a design system's resolved tokens as its master CSS sheet.
Pure, like `design_cascade` and for the same reasons: no database import, so the
rendering rule is testable without one and the preview surface can reuse it.
WHAT THIS SHEET IS, AND DELIBERATELY IS NOT
-------------------------------------------
It declares **purpose tokens only** — custom properties, grouped by what they
mean. It contains no rules for elements or classes: no `.btn-primary`, no
`table`, no `input`.
That is the point rather than a limitation. A sheet that styled every element
would restate the same handful of values once per element and grow with the UI;
a sheet of purpose-named values states each once and is reused. Components —
buttons, tables, input schemes — live as SNIPPETS that reference these names and
carry prose about the idea, which is a surface that already exists and already
has recall, locations, drift checks and merge.
So the division is: this sheet says what the values MEAN; snippets say what
things LOOK LIKE, in terms of those values. A token named after an element
(`--button-bg`) is the smell that the two have been mixed — it multiplies
with every new element, where a purpose name (`--action-primary`) is reused.
SAFETY
------
Values are interpolated into a stylesheet, and design systems are shareable
records (rule #47). A value of `red; } body { display: none` in a system someone
shared with you would otherwise inject CSS into your page. Everything rendered
here is validated or dropped — never escaped-and-hoped.
"""
from __future__ import annotations
import re
from collections.abc import Sequence
BASE_MODE = "base"
# A custom property name, strictly. Anything else is dropped rather than
# sanitised: a name is an identifier, and a "cleaned up" identifier is a
# different token than the one the operator recorded.
_VALID_NAME = re.compile(r"^--[A-Za-z0-9_-]+$")
# Characters that would end the declaration, open a block, start an at-rule, or
# begin a tag. A value containing any of them is not a value.
_UNSAFE_VALUE = re.compile(r"[{};@<>]|\*/|/\*|\n|\r")
def is_valid_token_name(name: str) -> bool:
return bool(_VALID_NAME.match((name or "").strip()))
def safe_value(value: str) -> str | None:
"""A CSS value that cannot escape its declaration, or None.
Rejects rather than strips. A partially-sanitised value is a value the
operator did not write, and silently rendering a different colour than the
record holds is worse than rendering none — the sheet's whole claim is that
it IS the record.
"""
candidate = (value or "").strip()
if not candidate or _UNSAFE_VALUE.search(candidate):
return None
return candidate
def safe_comment(text: str) -> str:
"""Comment text that cannot close its comment or break the line."""
return re.sub(r"\*/|/\*|[\n\r]", " ", (text or "")).strip()
def selector_for_mode(mode: str, root_selector: str = ":root") -> str:
"""Which selector a mode's declarations belong under.
`base` gets the caller's root selector; every other mode gets a bare
attribute selector, matching the convention already in the codebase.
The root selector is a PARAMETER because a container-scoped preview cannot
use `:root` — #251 recorded that mode scoping is one-way (light lives on
`:root`, dark layers over it), so a generator that hardcoded `:root` could
not serve a preview at all.
"""
if mode == BASE_MODE:
return root_selector
safe_mode = re.sub(r"[^A-Za-z0-9_-]", "", mode)
return f'[data-theme="{safe_mode}"]' if safe_mode else root_selector
def _grouped(tokens: Sequence) -> list[tuple[str | None, list]]:
"""Tokens by group, preserving the order they arrive in (already sorted)."""
groups: dict[str | None, list] = {}
for token in tokens:
groups.setdefault(getattr(token, "group_name", None), []).append(token)
return list(groups.items())
def _modes_present(tokens: Sequence) -> list[str]:
"""Every mode any token declares, base first then the rest alphabetically."""
modes = {
mode
for token in tokens
for mode in (getattr(token, "value_by_mode", None) or {})
}
rest = sorted(modes - {BASE_MODE})
return ([BASE_MODE] if BASE_MODE in modes else []) + rest
def render_stylesheet(
tokens: Sequence,
*,
root_selector: str = ":root",
title: str = "",
design_system_id: int | None = None,
) -> str:
"""The master sheet for a resolved token set.
One block per mode. Within a block, only the tokens that declare a value for
that mode — so a mode block is an override layer, exactly as the storage
model has it.
Tokens with no value at all are emitted as COMMENTED-OUT declarations in
their group, not dropped. The rulebook named them, so their absence is a
finding, and a commented line puts that finding where the reader is already
looking.
"""
header = [
"/*",
f" * {safe_comment(title) or 'Design system'} — generated stylesheet",
]
if design_system_id is not None:
header.append(f" * Source: design system {int(design_system_id)}.")
header += [
" *",
" * Purpose tokens only. This sheet declares what values MEAN; it styles",
" * no elements. Buttons, tables and input schemes live as snippets that",
" * reference these names, so each value is stated once and reused rather",
" * than restated per element.",
" *",
" * Generated — edit the design system, not this file.",
" */",
"",
]
lines = list(header)
valueless = [
t for t in tokens
if is_valid_token_name(getattr(t, "name", ""))
and not (getattr(t, "value_by_mode", None) or {})
]
for mode in _modes_present(tokens):
block: list[str] = []
for group, group_tokens in _grouped(tokens):
entries: list[str] = []
for token in group_tokens:
name = (getattr(token, "name", "") or "").strip()
if not is_valid_token_name(name):
continue
raw = (getattr(token, "value_by_mode", None) or {}).get(mode)
if raw is None:
continue
value = safe_value(str(raw))
if value is None:
entries.append(
f" /* {name}: value rejected — not a safe CSS value */"
)
continue
# Purpose first — what the token is FOR is what a reader of the
# stylesheet needs. Rationale is the fallback so a token that
# only carries the why still says something.
purpose = safe_comment(
getattr(token, "purpose", "")
or getattr(token, "rationale", "")
or ""
)
comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else ""
entries.append(f" {name}: {value};{comment}")
if entries:
if group:
block.append(f" /* {safe_comment(group)} */")
block.extend(entries)
block.append("")
# Declared-but-valueless tokens belong to the base layer: they have no
# mode to sit under, and repeating them per mode would triple the noise.
if mode == BASE_MODE and valueless:
block.append(" /* Declared by the design system, no value set yet: */")
block.extend(f" /* {t.name}: ; */" for t in valueless)
block.append("")
if not block:
continue
lines.append(f"{selector_for_mode(mode, root_selector)} {{")
lines.extend(block[:-1] if block[-1] == "" else block)
lines.append("}")
lines.append("")
# A trailing, machine-readable record of what this system says to write
# INSTEAD of a given literal.
#
# The sheet carries its own supersedes declarations so that any consumer has
# them — notably a CI check, which has the component sources but no database.
# Hardcoding the mapping in a checker would bake one install's palette into
# the tool; reading it from the sheet keeps the checker instance-agnostic and
# keeps this file the single source.
replacements = [
(literal, getattr(token, "name", ""))
for token in tokens
for literal in (getattr(token, "supersedes", None) or ())
if is_valid_token_name(getattr(token, "name", ""))
]
if replacements:
lines.append("/* SUPERSEDES — write the token, not the literal.")
for literal, name in replacements:
lines.append(f" * {safe_comment(str(literal))} -> {name}")
lines.append(" */")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def duplicate_values(tokens: Sequence) -> dict[str, list[str]]:
"""Values declared by more than one token, mapped to the names declaring them.
Two names for one value are either a deliberate alias or the same idea
recorded twice — the token-level form of the duplicated-definition shape.
Reported rather than refused: a design system legitimately aligns colours on
purpose (one palette entry defined as equal to another), and a generator
that rejected that
would be wrong about the operator's intent.
Compares the BASE value only. Two tokens agreeing in one mode and diverging
in another are not duplicates of each other — they are a near-miss, which is
a different and less interesting finding.
"""
by_value: dict[str, list[str]] = {}
for token in tokens:
name = getattr(token, "name", "")
base = (getattr(token, "value_by_mode", None) or {}).get(BASE_MODE)
if not name or not base:
continue
by_value.setdefault(str(base).strip().lower(), []).append(name)
return {value: names for value, names in by_value.items() if len(names) > 1}
# ---------------------------------------------------------------------------
# Reading a sheet from the other side: does this code use it correctly?
# ---------------------------------------------------------------------------
#
# "The snippets use the tags from the sheet" is a verifiable relation, and
# nothing checked it before. Three questions, each a different failure:
#
# var(--x) where no --x exists -> renders as NOTHING. No error, no test
# failure, no visual clue beyond the thing
# silently not being styled.
# a superseded literal in code -> the value the sheet said to stop writing,
# and the sheet knows what to write instead.
# --x: declared inside a snippet -> a component minting its own token is the
# bloat a shared sheet exists to prevent.
#
# The first is not hypothetical: `--color-accent` was used throughout a new view
# in this codebase and does not exist.
_VAR_REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
_LOCAL_DEFINITION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
def referenced_tokens(code: str) -> set[str]:
"""Every custom property the code reads through `var()`."""
return set(_VAR_REFERENCE.findall(code or ""))
def defined_tokens(code: str) -> set[str]:
"""Every custom property the code declares itself.
Excludes names it also reads: `--x: var(--x, fallback)` is a redeclaration
of something the sheet owns, which the unknown-reference check already
covers more precisely.
"""
return set(_LOCAL_DEFINITION.findall(code or "")) - referenced_tokens(code or "")
def _literal_pattern(literal: str) -> re.Pattern:
"""Match a literal value without matching a longer one that contains it.
`#fff` must not match inside `#ffffff` — they are different colours, and a
finding that fired on the wrong one would send someone to change code that
was already correct.
"""
escaped = re.escape(literal)
lead = r"(?<![0-9A-Za-z_#-])"
trail = r"(?![0-9A-Za-z_-])" if literal.startswith("#") else r"(?![0-9A-Za-z_-])"
return re.compile(lead + escaped + trail, re.IGNORECASE)
def check_code_against_tokens(code: str, tokens) -> dict:
"""What this code gets wrong about that token set.
`tokens` is any sequence with `.name`, `.value_by_mode` and `.supersedes` —
resolved tokens, or stored ones.
Reports rather than scores. Every finding here has a legitimate exception:
a snippet may target a system it isn't being checked against, and a literal
may be deliberate in a context the token doesn't cover. What it removes is
the SILENCE — all three currently fail with no signal at all.
"""
known = {getattr(t, "name", "") for t in tokens}
referenced = referenced_tokens(code)
superseded: list[dict] = []
for token in tokens:
for literal in getattr(token, "supersedes", None) or ():
if _literal_pattern(str(literal)).search(code or ""):
superseded.append({
"literal": literal,
"use_instead": getattr(token, "name", ""),
})
return {
"used": sorted(referenced & known),
"unknown": sorted(referenced - known),
"superseded_literals": superseded,
"local_definitions": sorted(defined_tokens(code)),
}
# ---------------------------------------------------------------------------
# Derivation — tokens whose value is a formula over other tokens
# ---------------------------------------------------------------------------
#
# `--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)` needs
# NO special storage: it is a value like any other, and the browser resolves the
# `var()` at use time. Change `--fs-accent` and every derived token shifts with
# it, in every mode, from one declaration.
#
# That last part is the real win. A derived token declared once in the base layer
# follows its source through dark mode automatically, because `var()` resolves in
# whatever context it is used rather than where it is written. Storing a computed
# literal instead would need one row per mode AND would silently stop tracking
# the source the moment the source changed.
#
# What derivation DOES need is the check below. A formula pointing at a token
# that does not exist is invalid-at-computed-value-time: the browser drops the
# declaration and the element falls back to inheritance or nothing. Silent, like
# everything else in this family.
def token_dependencies(tokens) -> dict[str, set[str]]:
"""Each token name mapped to the token names its own values reference."""
deps: dict[str, set[str]] = {}
for token in tokens:
name = getattr(token, "name", "")
if not name:
continue
refs: set[str] = set()
for value in (getattr(token, "value_by_mode", None) or {}).values():
refs |= referenced_tokens(str(value))
deps[name] = refs - {name}
return deps
def _find_cycles(deps: dict[str, set[str]]) -> list[list[str]]:
"""Derivation loops, each reported once as the names involved.
CSS degrades a loop to invalid-at-computed-value-time rather than hanging, so
this is about telling the operator, not about protecting the renderer. A
token that quietly resolves to nothing is the failure worth naming.
"""
cycles: list[list[str]] = []
seen_cycles: set[frozenset] = set()
def walk(node: str, path: list[str], visiting: set[str]) -> None:
for dep in sorted(deps.get(node, ())):
if dep in visiting:
loop = path[path.index(dep):]
key = frozenset(loop)
if loop and key not in seen_cycles:
seen_cycles.add(key)
cycles.append(loop)
continue
if dep in deps:
walk(dep, path + [dep], visiting | {dep})
for name in sorted(deps):
walk(name, [name], {name})
return cycles
def derivation_report(tokens) -> dict:
"""Which tokens are formulas, and which of those are broken.
`derived` name -> the tokens it is computed from
`unknown_refs` name -> references that resolve to no token in this system.
The browser drops such a declaration entirely; nothing errors.
`cycles` derivation loops, which resolve to nothing for the same reason
"""
deps = token_dependencies(tokens)
known = set(deps)
derived = {name: sorted(refs) for name, refs in deps.items() if refs}
unknown = {
name: sorted(refs - known)
for name, refs in deps.items()
if refs - known
}
return {
"derived": derived,
"unknown_refs": unknown,
"cycles": _find_cycles(deps),
}
+497
View File
@@ -0,0 +1,497 @@
"""Design system + token persistence, and the guard on the parent chain.
Access goes through `services/access.py` (rule #78), where reaching a system via
a project you can see grants READ but never write — see
`get_design_system_permission` for why that asymmetry exists.
The cascade itself lives in `services/design_cascade.py` as pure functions. This
module is the part that needs a database: loading the shape of the hierarchy,
refusing writes that would break it, and storing tokens.
Nothing here seeds or implies a default system. An install with no design
systems is an ordinary install, and every caller must handle an empty list as
the normal case rather than a missing prerequisite.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.project import Project
from scribe.services import access
from scribe.services.design_stylesheet import (
check_code_against_tokens,
derivation_report,
duplicate_values,
render_stylesheet,
)
from scribe.services.design_cascade import (
ResolvedToken,
ancestry,
resolve_tokens,
would_cycle,
)
logger = logging.getLogger(__name__)
class DesignSystemCycle(ValueError):
"""A parent assignment that would close an inheritance loop.
Raised rather than returned as None because the two outcomes need different
answers: None already means "not found, or not yours", and a caller that
conflated them would show "no such design system" for what is really "that
parent is one of its own descendants". Routes map this to a 400.
"""
async def _parent_map(session, owner_user_id: int) -> dict[int, int | None]:
"""`{id: parent_id}` for one owner's live systems — the hierarchy's shape.
Scoped to the OWNER of the systems, not the caller, and the distinction is
load-bearing on the read path: a caller reading through a shared project
does not own any link in the chain, and a caller-scoped map would hand them
an empty forest and a cascade truncated to one system. They would get a page
that renders with plausible wrong values and no error anywhere.
Owner-scoping is also the constraint on parenting: a chain may only be built
from systems its owner controls, or someone else's delete or re-parent would
silently restyle your app.
"""
rows = (
await session.execute(
select(DesignSystem.id, DesignSystem.parent_id).where(
DesignSystem.owner_user_id == owner_user_id,
DesignSystem.deleted_at.is_(None),
)
)
).all()
return dict(rows)
# --- design systems ---------------------------------------------------------
async def create_design_system(
user_id: int,
title: str,
description: str | None = None,
guidance: str | None = None,
parent_id: int | None = None,
) -> DesignSystem | None:
"""Create a system, with or without a parent.
Returns None when `parent_id` names a system the caller may not write —
which, per the ACL, means one they do not own.
"""
if parent_id is not None and not await access.can_write_design_system(
user_id, parent_id
):
return None
async with async_session() as session:
system = DesignSystem(
owner_user_id=user_id,
title=title.strip(),
description=description,
guidance=guidance,
parent_id=parent_id,
)
session.add(system)
await session.commit()
await session.refresh(system)
return system
async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem | None:
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_read_design_system(user_id, design_system_id):
return None
return system
async def list_design_systems(user_id: int) -> list[DesignSystem]:
"""The caller's own systems, ordered by title. Empty is normal."""
async with async_session() as session:
rows = await session.execute(
select(DesignSystem)
.where(
DesignSystem.owner_user_id == user_id,
DesignSystem.deleted_at.is_(None),
)
.order_by(DesignSystem.title)
)
return list(rows.scalars().all())
async def update_design_system(
user_id: int, design_system_id: int, **fields: object
) -> DesignSystem | None:
"""Partial update. Raises DesignSystemCycle if `parent_id` would loop.
`parent_id` is handled apart from the other fields because None is a
meaningful value for it — "make this a root" — where for every other field
None means "leave alone". Callers signal it by passing the key at all.
"""
if not await access.can_write_design_system(user_id, design_system_id):
return None
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
if "parent_id" in fields:
parent_id = fields.pop("parent_id")
if parent_id is not None:
parent_id = int(parent_id)
if not await access.can_write_design_system(user_id, parent_id):
return None
if would_cycle(
design_system_id,
parent_id,
await _parent_map(session, system.owner_user_id),
):
raise DesignSystemCycle(
f"Design system {design_system_id} cannot inherit from "
f"{parent_id}: that system already inherits from it."
)
system.parent_id = parent_id
for key, value in fields.items():
if key in ("title", "description", "guidance") and value is not None:
setattr(system, key, value)
system.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(system)
return system
async def delete_design_system(user_id: int, design_system_id: int) -> bool:
"""Soft-delete a system. Children survive as roots (the FK is SET NULL)."""
if not await access.can_write_design_system(user_id, design_system_id):
return False
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return False
system.deleted_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- tokens -----------------------------------------------------------------
async def create_token(
user_id: int,
design_system_id: int,
name: str,
value_by_mode: dict | None = None,
group_name: str | None = None,
purpose: str | None = None,
rationale: str | None = None,
supersedes: list | None = None,
order_index: int = 0,
) -> DesignToken | None:
if not await access.can_write_design_system(user_id, design_system_id):
return None
async with async_session() as session:
token = DesignToken(
design_system_id=design_system_id,
name=name.strip(),
# `or {}` and not the argument as given: the column is NOT NULL so
# that absence has exactly one spelling. Passing None here would
# otherwise store JSON null and reintroduce the second empty state.
value_by_mode=value_by_mode or {},
group_name=group_name,
purpose=purpose,
rationale=rationale,
# `or []` for the same reason as value_by_mode above: the column is
# NOT NULL so absence has one spelling, and None would store JSON
# null instead of an empty array.
supersedes=supersedes or [],
order_index=order_index,
)
session.add(token)
await session.commit()
await session.refresh(token)
return token
async def list_tokens(user_id: int, design_system_id: int) -> list[DesignToken]:
"""One system's OWN tokens — its override set, not its effective set.
Resolving the chain is step 2's job; this deliberately answers the narrower
question ("what does this system change?") that the model exists to make
free.
"""
if not await access.can_read_design_system(user_id, design_system_id):
return []
async with async_session() as session:
rows = await session.execute(
select(DesignToken)
.where(
DesignToken.design_system_id == design_system_id,
DesignToken.deleted_at.is_(None),
)
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
)
return list(rows.scalars().all())
async def resolve_design_system(
user_id: int, design_system_id: int
) -> list[ResolvedToken] | None:
"""A system's EFFECTIVE token set — everything it inherits, with its own on top.
None when the caller may not read the system; an empty list when the chain
genuinely holds no tokens, which is an ordinary state for a system that has
just been created.
Two queries regardless of how deep the chain runs: one for the hierarchy's
shape, one for every token in it. The flattening itself is
`design_cascade.resolve_tokens` — pure, so the cascade rule is tested
without a database and this function is only the loading.
"""
if not await access.can_read_design_system(user_id, design_system_id):
return None
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
parents = await _parent_map(session, system.owner_user_id)
chain = ancestry(design_system_id, parents)
rows = (
await session.execute(
select(DesignToken)
.where(
DesignToken.design_system_id.in_(chain),
DesignToken.deleted_at.is_(None),
)
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
)
).scalars().all()
tokens_by_system: dict[int, list[DesignToken]] = {}
for token in rows:
tokens_by_system.setdefault(token.design_system_id, []).append(token)
return resolve_tokens(design_system_id, parents, tokens_by_system)
async def design_context(user_id: int, design_system_id: int) -> dict | None:
"""What a session needs to know about a design system, before it writes UI.
This is the DELIVERY side of a design system, and it exists because storing
one does not make a session aware of it. Rules get pushed into every session
by the plugin's SessionStart hook; a design system had no such channel, so
the standards were reachable only by an agent that already knew to go
looking — which is the same silent failure as a token nobody declares.
Guidance is chain-merged, ANCESTOR-FIRST, and that is the point rather than
a convenience. A child system holds only what it CHANGES, so its own
guidance describes a departure from a house style it never restates. Hand an
agent the leaf alone and it builds against a fragment, with no signal that
the rest exists.
Tokens are summarised, not listed: the count and the group names are enough
to know what the system covers, and the full set is one call away. Sending
a hundred token values into every session start would crowd out the context
it is meant to inform.
None when the caller may not read the system.
"""
tokens = await resolve_design_system(user_id, design_system_id)
if tokens is None:
return None
async with async_session() as session:
system = await session.get(DesignSystem, design_system_id)
if system is None or system.deleted_at is not None:
return None
parents = await _parent_map(session, system.owner_user_id)
chain = ancestry(design_system_id, parents)
rows = (
await session.execute(
select(DesignSystem).where(DesignSystem.id.in_(chain))
)
).scalars().all()
by_id = {s.id: s for s in rows}
return {
"id": system.id,
"title": system.title,
"description": system.description or "",
# Outermost ancestor first, so the reader meets the house style before
# the app's departures from it.
"inherits_from": [
by_id[sid].title for sid in reversed(chain[1:]) if sid in by_id
],
"guidance": [
{
"design_system_id": sid,
"title": by_id[sid].title,
"guidance": (by_id[sid].guidance or "").strip(),
}
for sid in reversed(chain)
if sid in by_id and (by_id[sid].guidance or "").strip()
],
"token_count": len(tokens),
"token_groups": sorted({t.group_name for t in tokens if t.group_name}),
}
async def update_token(
user_id: int, token_id: int, **fields: object
) -> DesignToken | None:
allowed = {
"name", "value_by_mode", "group_name", "purpose", "rationale",
"supersedes", "order_index",
}
async with async_session() as session:
token = await session.get(DesignToken, token_id)
if token is None or token.deleted_at is not None:
return None
if not await access.can_write_design_system(user_id, token.design_system_id):
return None
for key, value in fields.items():
if key in allowed and value is not None:
setattr(token, key, value)
token.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(token)
return token
async def delete_token(user_id: int, token_id: int) -> bool:
async with async_session() as session:
token = await session.get(DesignToken, token_id)
if token is None or token.deleted_at is not None:
return False
if not await access.can_write_design_system(user_id, token.design_system_id):
return False
token.deleted_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- the project pointer ----------------------------------------------------
async def set_project_design_system(
user_id: int, project_id: int, design_system_id: int | None
) -> bool:
"""Point a project at a design system, or at nothing (None clears it).
Needs write on the project and READ on the system: pointing at a system is
consuming it, not changing it, so a system shared with you through another
project is a legitimate choice here.
"""
if not await access.can_write_project(user_id, project_id):
return False
if design_system_id is not None and not await access.can_read_design_system(
user_id, design_system_id
):
return False
async with async_session() as session:
project = await session.get(Project, project_id)
if project is None or project.deleted_at is not None:
return False
project.design_system_id = design_system_id
project.updated_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- the master sheet -------------------------------------------------------
async def stylesheet_for_system(
user_id: int, design_system_id: int, root_selector: str = ":root"
) -> dict | None:
"""The master CSS sheet a design system generates, plus its reuse report.
Purpose tokens only — the sheet styles no elements. See
`services/design_stylesheet.py` for why that split is the design rather than
a shortcut.
Returns None if the caller may not read the system. The `duplicates` half is
advisory: two tokens sharing a value are either a deliberate alias or one
idea recorded twice, and only the operator knows which.
"""
resolved = await resolve_design_system(user_id, design_system_id)
if resolved is None:
return None
system = await get_design_system(user_id, design_system_id)
return {
"design_system_id": design_system_id,
"css": render_stylesheet(
resolved,
root_selector=root_selector,
title=system.title if system else "",
design_system_id=design_system_id,
),
"token_count": len(resolved),
"valueless": [t.name for t in resolved if not t.value_by_mode],
"duplicates": duplicate_values(resolved),
# Formulas: which tokens are computed from others, and which of those
# point at nothing. A broken formula is dropped by the browser without
# any error, so the sheet cannot show it for itself.
"derivation": derivation_report(resolved),
}
async def check_snippets_against_system(
user_id: int, design_system_id: int, project_id: int = 0
) -> dict | None:
"""Which recorded snippets disagree with this design system's sheet.
The relation the operator named — "the snippets use the tags from the sheet"
— turned into a check. For each snippet: `var(--x)` references with no such
token, literals the sheet says to stop writing, and custom properties the
snippet mints for itself instead of using shared ones.
Snippets with nothing to report are omitted entirely. A list of everything
that is fine is a list nobody reads twice.
"""
resolved = await resolve_design_system(user_id, design_system_id)
if resolved is None:
return None
from scribe.services import snippets as snippets_svc
# `list_snippets` returns (rows, total) and caps limit at 100; `project_id`
# must be None — not 0 — to reach across every project, since 0 would filter
# to a project with that id.
rows, _total = await snippets_svc.list_snippets(
user_id=user_id, project_id=project_id or None, limit=100,
)
findings: list[dict] = []
for row in rows:
snippet_id = row.get("id")
if snippet_id is None:
continue
# The list rows carry a preview, not the code. The check has to read the
# whole body or it would report on a truncation.
note = await snippets_svc.get_snippet(user_id=user_id, snippet_id=int(snippet_id))
if note is None:
continue
report = check_code_against_tokens(note.body or "", resolved)
if not (
report["unknown"] or report["superseded_literals"] or report["local_definitions"]
):
continue
findings.append({
"snippet_id": int(snippet_id),
"title": note.title or "",
**report,
})
return {
"design_system_id": design_system_id,
"checked": len(rows),
"findings": findings,
}
+29 -8
View File
@@ -14,7 +14,9 @@ import logging
import math
import os
from sqlalchemy import delete, select
from collections.abc import Sequence
from sqlalchemy import delete, or_, select
from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding
@@ -114,7 +116,8 @@ async def semantic_search_notes(
threshold: float = _SIMILARITY_THRESHOLD,
project_id: int | None = None,
is_task: bool | None = None,
note_type: str | None = None,
note_type: str | Sequence[str] | None = None,
task_kind: str | Sequence[str] | None = None,
orphan_only: bool = False,
scope: str = "own",
) -> list[tuple[float, Note]]:
@@ -123,8 +126,15 @@ async def semantic_search_notes(
Scores are cosine similarities in [-1, 1]; only notes at or above
*threshold* are returned, sorted highest-first.
`note_type` narrows to a single record kind (e.g. "snippet"), for callers
that want prior art rather than everything embedded.
`note_type` narrows to a record kind, or several (e.g. "snippet", or
("snippet", "note")), for callers that want prior art rather than everything
embedded.
`task_kind` restricts TASKS to the given kinds while leaving non-task notes
untouched. That asymmetry is the point: "recorded experience" is issues plus
dev-logs, and those differ on `is_task`, so neither `note_type` nor `is_task`
alone can express it. With `note_type="note", task_kind="issue"` a caller
gets fixed problems and durable notes without the open to-do list.
`scope` ("own" | "browse" | "read", see access.notes_visibility_clause)
decides how far this may see. It exists because this one function serves
@@ -179,11 +189,22 @@ async def semantic_search_notes(
stmt = stmt.where(Note.status.isnot(None))
elif is_task is False:
stmt = stmt.where(Note.status.is_(None))
# Narrow to one kind of record. Composes with is_task rather than
# replacing it — 'snippet' is a non-task note_type, so a caller asking
# for prior art gets snippets and not the dev-log that mentions them.
# Narrow to one kind of record, or several. Composes with is_task
# rather than replacing it — 'snippet' is a non-task note_type, so a
# caller asking for prior art gets snippets and not the dev-log that
# mentions them.
if note_type:
stmt = stmt.where(Note.note_type == note_type)
kinds = [note_type] if isinstance(note_type, str) else list(note_type)
stmt = stmt.where(Note.note_type.in_(kinds))
# Restrict TASKS to certain kinds while leaving notes alone. A note
# has no task_kind that means anything, so a plain `.in_()` would
# drop every dev-log — which is exactly the record a caller asking
# for prior experience wants most.
if task_kind:
tkinds = [task_kind] if isinstance(task_kind, str) else list(task_kind)
stmt = stmt.where(
or_(Note.status.is_(None), Note.task_kind.in_(tkinds))
)
if exclude_ids:
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
stmt = stmt.where(distance <= max_distance).order_by(distance.asc()).limit(limit)
+9
View File
@@ -211,6 +211,15 @@ def _note_to_item(note: Note) -> dict:
# body parsing and stays a plain projection of the column. Omitted entirely
# when unchecked, so "no key" and "never verified" don't become two states
# the client has to tell apart.
# Snippet language, same reasoning as the verdict below: a plain projection of
# the `data` mirror, no body parsing. Needed because a prior-art hit in a
# DIFFERENT language than the file being written is useful as the shape of a
# solution but must not be mistaken for code to paste (#2244) — and the caller
# can only say "different" if the language is on the item.
language = (note.data or {}).get("language") if note.data else None
if language:
item["language"] = language
verdict = (note.data or {}).get("verification") if note.data else None
if verdict and verdict.get("status"):
item["verification"] = {
+40 -6
View File
@@ -89,6 +89,38 @@ async def log_error(
await session.commit()
def parse_filter_datetime(value: str | None, *, end_of_day: bool = False) -> datetime | None:
"""An ISO date/datetime from a query string as an AWARE UTC datetime.
The admin log filters arrive as raw `request.args` strings and used to be
compared straight against `AppLog.created_at`. asyncpg binds a str as
VARCHAR and Postgres has no `timestamptz >= text` operator, so supplying
either date filter raised — the same defect as #1727 in notifications, in a
second place (#2254).
Returns None for anything unparseable: a malformed filter should narrow
nothing rather than 500 the log viewer.
`end_of_day` matters for the upper bound. A bare "2026-07-30" parses to
midnight, so `created_at <= that` would exclude the whole of the day the
user asked for — the one day they most likely wanted. With this flag a
date-only value is pushed to the last microsecond of that day; a value that
already carries a time is left exactly as given.
"""
raw = (value or "").strip()
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if end_of_day and len(raw) == 10: # date-only, no time component
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
async def get_logs(
category: str | None = None,
user_id: int | None = None,
@@ -118,12 +150,14 @@ async def get_logs(
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
if date_from:
query = query.where(AppLog.created_at >= date_from)
count_query = count_query.where(AppLog.created_at >= date_from)
if date_to:
query = query.where(AppLog.created_at <= date_to)
count_query = count_query.where(AppLog.created_at <= date_to)
start = parse_filter_datetime(date_from)
end = parse_filter_datetime(date_to, end_of_day=True)
if start:
query = query.where(AppLog.created_at >= start)
count_query = count_query.where(AppLog.created_at >= start)
if end:
query = query.where(AppLog.created_at <= end)
count_query = count_query.where(AppLog.created_at <= end)
total = (await session.execute(count_query)).scalar() or 0
+75 -19
View File
@@ -156,26 +156,82 @@ async def get_milestone_progress(milestone_id: int) -> dict:
for status, count in rows.fetchall():
status_counts[status] = count
total = sum(status_counts.values())
cancelled = status_counts.get("cancelled", 0)
completed = status_counts.get("done", 0)
# Cancelled tasks are resolved work, not pending — exclude them from the
# percent-complete denominator so a milestone whose only open task was
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
active_total = total - cancelled
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
# Same rule as the batch path, computed in one place so the two cannot
# drift on the cancelled-exclusion.
return _progress_from_counts(status_counts)
return {
"total": total,
"completed": completed,
"pct": pct,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
"cancelled": cancelled,
},
}
def _progress_from_counts(status_counts: dict[str, int]) -> dict:
"""The progress shape, computed from already-fetched counts.
Split out of get_milestone_progress so the batch path can reuse the rule
rather than restate it — the cancelled-exclusion below is easy to get
subtly different in a second copy, and then two screens disagree about
whether a milestone is finished.
"""
total = sum(status_counts.values())
cancelled = status_counts.get("cancelled", 0)
completed = status_counts.get("done", 0)
# Cancelled tasks are resolved work, not pending — excluded from the
# denominator so a milestone whose only open task was cancelled reaches
# 100% instead of stalling.
active_total = total - cancelled
return {
"total": total,
"completed": completed,
"pct": round(completed / active_total * 100, 1) if active_total > 0 else 0.0,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
"cancelled": cancelled,
},
}
async def get_project_milestone_summaries(
user_id: int, project_ids: list[int]
) -> dict[int, list[dict]]:
"""Milestone summaries for MANY projects in two queries total.
The per-project version below is a nested fan-out: one query to list a
project's milestones, then one more per milestone for its progress. Called
for 25 projects concurrently it asked for ~250 pooled connections against a
pool of 15, and every one of them waited out the 30-second checkout timeout
(#2384). This does the same work in two queries and one session.
"""
if not project_ids:
return {}
async with async_session() as session:
milestones = list((await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id.in_(project_ids),
Milestone.deleted_at.is_(None),
).order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
)).scalars().all())
counts: dict[int, dict[str, int]] = {}
if milestones:
rows = await session.execute(
select(Note.milestone_id, Note.status, func.count(Note.id))
.where(
Note.milestone_id.in_([m.id for m in milestones]),
Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.milestone_id, Note.status)
)
for milestone_id, status, count in rows.fetchall():
counts.setdefault(milestone_id, {})[status] = count
out: dict[int, list[dict]] = {pid: [] for pid in project_ids}
for m in milestones:
entry = m.to_dict()
entry.update(_progress_from_counts(counts.get(m.id, {})))
out.setdefault(m.project_id, []).append(entry)
return out
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
+38
View File
@@ -10,6 +10,40 @@ from scribe.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
def embed_note(note) -> None:
"""Refresh a note's embedding, fire-and-forget.
Lives HERE — at the service, not the route — so every caller gets it by
construction. Previously each REST route made this call itself and the MCP
tools did not, so a record created through MCP stayed out of semantic search
and auto-inject until the next restart's backfill ran (#2056). That is
invisible on an instance that redeploys constantly and permanent on one that
doesn't, which is the worst shape a bug can have: it only appears where
nobody is looking.
Uses `note.user_id` — the OWNER — rather than the caller. Embeddings belong
to the record, and a collaborator editing a shared note must refresh the
owner's row rather than mint a second one under their own id.
Import is lazy so importing this module doesn't pull in the embedding model;
exceptions are swallowed because a record that saved must not fail on its
index refresh. No running loop (unit tests, scripts) is an ordinary case,
not an error.
"""
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if not text:
return
try:
import asyncio
from scribe.services.embeddings import upsert_note_embedding
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
except RuntimeError:
pass # no running loop — a sync caller, not a failure
except Exception: # noqa: BLE001 - never let indexing break a write
logger.exception("embedding refresh failed for note %s", note.id)
def _normalize_tags(tags: list[str]) -> list[str]:
"""Lowercase, strip, deduplicate, and drop empty tags."""
seen: set[str] = set()
@@ -115,6 +149,8 @@ async def create_note(
await session.commit()
await session.refresh(note)
embed_note(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
@@ -329,6 +365,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
from scribe.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title, old_tags)
embed_note(note)
if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
+22 -4
View File
@@ -3,7 +3,7 @@
import asyncio
import json
import logging
from datetime import date, datetime, timezone
from datetime import date, datetime, time, timezone
from sqlalchemy import func, select, text
@@ -149,13 +149,25 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
def utc_day_start(day: date) -> datetime:
"""Midnight UTC on `day`, as an AWARE datetime — the reminder dedup window.
Deliberately a `datetime` and not the bare `date` (#1727). Comparing a
`timestamptz` column against a `date` does work, via an implicit cast — but
Postgres resolves that cast in the SESSION's TimeZone, so the window would
move with a server setting nobody remembers is load-bearing. An aware UTC
datetime says what it means and compares the same way everywhere.
"""
return datetime.combine(day, time.min, tzinfo=timezone.utc)
async def check_due_tasks() -> None:
"""Check for tasks due today and send reminder emails."""
if not await is_smtp_configured():
return
today = date.today()
today_str = today.isoformat()
window_start = utc_day_start(today)
async with async_session() as session:
# Find tasks due today or overdue, not done
@@ -188,12 +200,18 @@ async def check_due_tasks() -> None:
if not email:
continue
# Dedup: check if we already sent a reminder today
# Dedup: check if we already sent a reminder today.
# `window_start` is an aware datetime, NOT `today.isoformat()`.
# asyncpg binds a str as VARCHAR and Postgres has no
# `timestamptz >= text` operator, so this raised on every run
# that got this far — swallowed by the per-user `except` below,
# which is why the only symptom was reminders silently never
# sending plus an hourly traceback in the DB log (#1727).
dedup_result = await session.execute(
select(func.count(AppLog.id)).where(
AppLog.action == "task_reminder",
AppLog.user_id == user_id,
AppLog.created_at >= today_str,
AppLog.created_at >= window_start,
)
)
if (dedup_result.scalar() or 0) > 0:
+389 -15
View File
@@ -22,6 +22,7 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rulebook import RulebookTopic
from scribe.services import design_systems as design_systems_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
@@ -98,6 +99,69 @@ WRITEPATH_DEFAULT_THRESHOLD = 0.68
# scale. It also saves a pointless embedding round-trip on trivial edits.
WRITEPATH_MIN_CODE_CHARS = 48
# --- concept extraction for the semantic arm's query (#2242) ------------------
# A snippet's embedded text is f"{title}\n{body}", and for a snippet that body is
# composed markdown: **When to use:**, **Signature:**, **Location:**, then the
# fenced code. So `when_to_use` — the description of what the thing is FOR —
# appears twice in the vector, and the document is prose-forward.
#
# The arm used to query it with raw code and no prose at all. Measured on the
# deployed instance against snippet #2222, same corpus:
# query built from score best unrelated separation
# raw code body 0.743 0.630 0.11
# name + docstring 0.823 0.602 0.22
# hand-written concept prose 0.835 0.583 0.25
# A 12-word description beats a near-verbatim reimplementation of the function,
# and code-as-query RAISES the noise floor. It is also the cleanest explanation
# for the fragment miss recorded on #2223: a short code excerpt has almost no
# prose to match against a document that is mostly prose.
#
# So we send the concept instead — and shape it like a snippet's own title,
# "{name} — {when_to_use}", because that is the form the 0.823 measurement used.
# Undocumented code yields little, and a Vue SFC or a config file yields nothing;
# those fall back to the raw payload and behave exactly as before. This raises
# the ceiling for documented helpers rather than fixing every case.
# Declaration forms, one pattern per shape, every pattern exposing (name, params)
# so composition doesn't have to care which matched. Deliberately regex and not a
# real parser: this runs on a PreToolUse hook's critical path, the payload is
# frequently a FRAGMENT that no parser would accept (an Edit's new_string is
# rarely a valid module), and a miss costs only a fallback to today's behaviour.
_CONCEPT_DECL_PATTERNS = (
# python: def / async def, and class with optional bases
re.compile(r"^[ \t]*(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
re.compile(r"^[ \t]*class[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))?", re.M),
# js/ts: function decl, and the const-arrow form that dominates modern code
re.compile(r"^[ \t]*(?:export[ \t]+)?(?:default[ \t]+)?(?:async[ \t]+)?function[ \t]+([A-Za-z_$][\w$]*)[ \t]*(\([^)]*\))", re.M),
re.compile(r"^[ \t]*(?:export[ \t]+)?(?:const|let|var)[ \t]+([A-Za-z_$][\w$]*)[ \t]*=[ \t]*(?:async[ \t]*)?(\([^)]*\))[ \t]*=>", re.M),
# rust / go
re.compile(r"^[ \t]*(?:pub[ \t]+)?fn[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
re.compile(r"^[ \t]*func[ \t]+(?:\([^)]*\)[ \t]*)?([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
# posix shell: name() {
re.compile(r"^[ \t]*([A-Za-z_]\w*)[ \t]*(\(\))[ \t]*\{", re.M),
)
# Doc forms, tried in order. The Python pattern also matches a triple-quoted
# string that isn't a docstring — accepted: a stray literal is still text about
# what the code does far more often than it's misleading, and the cost is a
# slightly worse query rather than a wrong answer.
_CONCEPT_PY_DOC = re.compile(r'("""|\'\'\')(.*?)\1', re.S)
_CONCEPT_JSDOC = re.compile(r"/\*\*(.*?)\*/", re.S)
_CONCEPT_LEADING_COMMENT = re.compile(r"\A(?:[ \t]*(?://|#)[^\n]*\n?)+")
# A shebang is a comment to the regex above but says nothing about what the code
# DOES, and it would otherwise open the doc with "/usr/bin/env bash".
_CONCEPT_SHEBANG = re.compile(r"\A#![^\n]*\n")
_CONCEPT_COMMENT_MARKER = re.compile(r"^[ \t]*(?://+|#+!?)[ \t]?", re.M)
_CONCEPT_JSDOC_STAR = re.compile(r"^[ \t]*\*+[ \t]?", re.M)
# Cap the doc so a long module docstring can't drown out the declaration, and cap
# declarations so a 40-function Write doesn't turn into a wall of signatures.
_CONCEPT_MAX_DOC_CHARS = 400
_CONCEPT_MAX_DECLS = 4
# Below this much substance the "concept" is too thin to be a better query than
# the code itself (e.g. all we found was `f()`), so we keep the raw payload.
_CONCEPT_MIN_CHARS = 16
# Margin gate: drop any hit more than this far below the top hit's score, so a
# single strong match doesn't drag in a wall of barely-passing neighbours.
_AUTOINJECT_BAND = 0.10
@@ -226,6 +290,70 @@ def _record_kind(note) -> str:
return note.note_type or "note"
_REUSE_KINDS = ("snippet", "process")
async def _reserve_slot_for_reuse(
user_id: int,
query: str,
kept: list,
cfg: dict,
*,
project_id: int | None,
exclude_ids: set[int],
) -> list:
"""Guarantee the reuse-shaped kinds one slot, if one clears threshold (#2246).
Ranking by raw cosine is blind to what KIND of record answers what kind of
ask, and the corpus makes that fatal rather than merely imperfect: Scribe's
project records are *about software work*, so a task titled "surface snippets
before the agent writes code" is a near-perfect lexical match for "write a
function…" while being useless as an answer to it. Measured live, a prompt
asking for a helper returned three records about BUILDING the retrieval
system and zero snippets.
The bias is structural and gets WORSE as the project record grows — which is
the direction Scribe is supposed to grow. Snippets are ~0.5% of the corpus
here; no threshold tuning fixes a 200:1 ratio.
So the reserved hit is deliberately NOT held to the margin band. The band
measures distance from the top overall score, and that top score is the very
thing snippets lose to. It still has to clear the configured threshold, so a
weak snippet cannot buy the slot — silence stays the default.
"""
if any(_record_kind(n) in _REUSE_KINDS for _s, n in kept):
return kept # reuse already represented; nothing to do
top_k = cfg["top_k"]
reuse = await semantic_search_notes(
user_id, query,
limit=1,
threshold=cfg["threshold"],
project_id=project_id,
exclude_ids=exclude_ids | {int(n.id) for _s, n in kept},
note_type=_REUSE_KINDS,
scope="browse",
)
# Verify the kind rather than trusting the query that asked for it, and
# dedup on top of exclude_ids. This slot exists FOR reuse kinds — a slot
# silently spent on something else is worse than no slot, because the line
# is indistinguishable from one that earned its place on score.
kept_ids = {int(n.id) for _s, n in kept}
fresh = [
(s, n) for s, n in reuse
if _record_kind(n) in _REUSE_KINDS and int(n.id) not in kept_ids
][:1]
if not fresh:
return kept
# Take the LAST slot, never the first: the strongest overall hit is still the
# best answer to the prompt, and displacing it would trade one blindness for
# another.
if len(kept) >= top_k:
return kept[:top_k - 1] + fresh
return (kept + fresh)[:top_k]
async def build_autoinject_hint(
user_id: int,
query: str,
@@ -277,6 +405,10 @@ async def build_autoinject_hint(
# Margin gate: keep only hits close to the strongest one.
top_score = hits[0][0]
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
kept = await _reserve_slot_for_reuse(
user_id, q, kept, cfg, project_id=(project_id or None),
exclude_ids=set(exclude_ids or []),
)
# A collaborator's note can reach this menu via a shared project, and the
# operator never asked for it — so say whose it is. Unattributed, it reads as
@@ -328,15 +460,169 @@ async def build_autoinject_hint(
# this exact file" is a stronger claim than "this resembles something".
def _prior_art_line(item: dict, marker: str, owner: str | None) -> str:
"""One menu line: `- #12 [here] "title"`, attributed when it isn't yours."""
def _prior_art_line(item: dict, marker: str, owner: str | None, foreign_lang: str = "") -> str:
"""One menu line: `- #12 [here] "title"`, attributed when it isn't yours.
A foreign language is folded into the marker (`[similar 0.72 · python]`)
rather than appended after the title, so the reader sees it while still
reading the score — the two together are the judgement being offered.
"""
title = (item.get("title") or "(untitled)").replace("\n", " ").strip()
line = f"> - #{item['id']} [{marker}] \"{title}\""
mark = f"{marker} · {foreign_lang}" if foreign_lang else marker
line = f"> - #{item['id']} [{mark}] \"{title}\""
if owner:
line += f" — shared by {owner}, treat as a suggestion"
return line
# --- cross-language prior art (#2244) ----------------------------------------
# Retrieval is concept-shaped now, and concepts are language-agnostic: a query
# about a TypeScript union-find matches a PYTHON snippet at 0.72-0.73, comfortably
# over the bar. That is a feature — the operator's framing is "borrow the shape of
# the solution even when the code isn't directly reusable" — but only if the line
# SAYS so. An unlabelled Python hit offered while writing TypeScript either gets
# dismissed as irrelevant or, worse, pasted into a .ts file. Measured note: this
# cross-language matching predates concept queries; it was always happening, just
# never disclosed.
#
# Deliberately NOT gated behind a stricter threshold for foreign-language hits: a
# higher bar would suppress exactly the shape-borrowing this is for. Label, don't
# filter.
_LANG_BY_EXT = {
"py": "python", "pyi": "python",
"ts": "typescript", "tsx": "typescript", "mts": "typescript", "cts": "typescript",
"js": "javascript", "jsx": "javascript", "mjs": "javascript", "cjs": "javascript",
"vue": "vue", "svelte": "svelte",
"go": "go", "rs": "rust", "rb": "ruby", "php": "php",
"java": "java", "kt": "kotlin", "kts": "kotlin", "scala": "scala",
"c": "c", "h": "c", "cc": "cpp", "cpp": "cpp", "cxx": "cpp", "hpp": "cpp",
"cs": "csharp", "swift": "swift", "m": "objectivec", "mm": "objectivec",
"sh": "shell", "bash": "shell", "zsh": "shell", "fish": "shell",
"sql": "sql", "css": "css", "scss": "scss", "less": "less",
"html": "html", "htm": "html", "yml": "yaml", "yaml": "yaml",
"toml": "toml", "ini": "ini", "dockerfile": "dockerfile",
"ex": "elixir", "exs": "elixir", "erl": "erlang", "hs": "haskell",
"lua": "lua", "pl": "perl", "r": "r", "dart": "dart", "zig": "zig",
}
# `language` on a snippet is operator-typed free text, so fold the spellings that
# mean the same thing before comparing. Anything unrecognised passes through
# lowercased — an unknown-but-equal pair still compares equal, which is the only
# thing this needs to get right.
_LANG_ALIASES = {
"py": "python", "python3": "python",
"ts": "typescript", "tsx": "typescript",
"js": "javascript", "jsx": "javascript", "node": "javascript",
"sh": "shell", "bash": "shell", "zsh": "shell", "shell-script": "shell",
"c++": "cpp", "cplusplus": "cpp", "c#": "csharp", "objective-c": "objectivec",
"golang": "go", "rs": "rust", "rb": "ruby", "yml": "yaml",
"postgres": "sql", "postgresql": "sql", "psql": "sql",
"vuejs": "vue", "vue3": "vue",
}
def _canonical_language(name: str) -> str:
"""Fold a free-text language name to a comparable token ("" if absent)."""
token = (name or "").strip().lower()
return _LANG_ALIASES.get(token, token)
def _language_for_path(path: str) -> str:
"""The language implied by a file path's extension ("" when unknown)."""
tail = (path or "").rsplit("/", 1)[-1].lower()
if tail.startswith("dockerfile"):
return "dockerfile"
if "." not in tail:
return ""
return _LANG_BY_EXT.get(tail.rsplit(".", 1)[-1], "")
def _foreign_language(item: dict, target: str) -> str:
"""The item's language when it DIFFERS from the target file's, else "".
Returns "" whenever either side is unknown: we can only claim a mismatch we
can actually establish, and a wrong "· python" tag is worse than no tag.
Same-language hits stay unlabelled so the common case keeps a clean line.
"""
if not target:
return ""
theirs = _canonical_language(item.get("language") or "")
if not theirs or theirs == target:
return ""
return theirs
def _concept_doc(code: str) -> str:
"""The first doc-ish prose in `code`: docstring, else JSDoc, else leading comments."""
m = _CONCEPT_PY_DOC.search(code)
if m:
return _collapse(m.group(2))
m = _CONCEPT_JSDOC.search(code)
if m:
return _collapse(_CONCEPT_JSDOC_STAR.sub("", m.group(1)))
# Only a comment block at the very TOP counts. A comment further down is
# usually about one line of the implementation, not about the whole thing.
m = _CONCEPT_LEADING_COMMENT.match(_CONCEPT_SHEBANG.sub("", code))
if m:
return _collapse(_CONCEPT_COMMENT_MARKER.sub("", m.group(0)))
return ""
def _collapse(text: str) -> str:
"""One line, single-spaced, length-capped — embedder input, not display text."""
return " ".join((text or "").split())[:_CONCEPT_MAX_DOC_CHARS].strip()
def concept_query(code: str) -> str:
"""Rewrite a write payload as a CONCEPT query, or "" to keep the raw payload.
Returns something shaped like a snippet's own title — "name(params) — what it
does" — because that is the form that measured best against the prose-forward
snippet documents (#2242; see the table at _CONCEPT_DECL_PATTERNS).
Returns "" rather than raising or guessing whenever there's nothing worth
sending: no declarations and no doc, or a result too thin to beat the code it
would replace. The caller treats "" as "use the payload as-is", so every
unhandled language degrades to exactly the previous behaviour.
"""
if not code or not code.strip():
return ""
decls: list[str] = []
for pattern in _CONCEPT_DECL_PATTERNS:
for match in pattern.finditer(code):
name, params = match.group(1), match.group(2) or ""
label = f"{name}{params}".strip()
if label and label not in decls:
decls.append(label)
if len(decls) >= _CONCEPT_MAX_DECLS:
break
if len(decls) >= _CONCEPT_MAX_DECLS:
break
doc = _concept_doc(code)
# NO DOC, NO REWRITE. An identifier alone is not a concept, and it measured
# WORSE than the code it would replace: `collapse_into_clusters(edges)` scored
# 0.671 against #2222 where the full code body scored 0.743. Separation from
# the noise floor is identical (0.113 either way), but the absolute value
# drops below the 0.68 bar — so preferring a bare name would convert a
# comfortable hit into a miss. Undocumented code keeps the raw payload.
if not doc:
return ""
head = ", ".join(decls)
query = f"{head}{doc}" if head else doc
# Guard against a doc so terse it says nothing ("# TODO", "/** x */").
if len("".join(query.split())) < _CONCEPT_MIN_CHARS:
return ""
return query
async def get_writepath_config(user_id: int) -> dict:
"""Write-path trigger settings: its own `enabled` and `threshold`, auto-inject's top_k.
@@ -451,6 +737,14 @@ async def build_write_path_hint(
# let a deeply-nested one-liner through on padding alone.
if len("".join(query.split())) < WRITEPATH_MIN_CODE_CHARS:
query = ""
# ORDER MATTERS: the floor above judges the RAW payload, this rewrites it.
# Snippet documents are prose-forward, so a concept query out-scores the code
# itself by a wide margin (#2242). The rewritten query is allowed to be
# short — "slugify(t) — turn text into a url slug" is a fine query at 38
# chars, and it only exists because the raw payload already cleared the
# floor. Applying the floor after this would throw away the best queries.
if query:
query = concept_query(query) or query
if remaining > 0 and query:
t0 = time.perf_counter()
hits = await semantic_search_notes(
@@ -459,7 +753,20 @@ async def build_write_path_hint(
threshold=cfg["threshold"],
project_id=scope_project,
exclude_ids=seen,
note_type="snippet",
# Snippets AND recorded experience (#2246). This arm was
# snippets-only, which is auto-inject's mistake inverted: an issue
# saying "we tried this and it deadlocked", or a dev-log recording
# how a problem was solved, is prior art for the code about to be
# written — arguably better prior art than a resembling helper,
# because it says what NOT to do.
#
# `task_kind="issue"` keeps the open to-do list out. A task titled
# "add debouncing to the search box" resembles the code being
# written and answers nothing; an ISSUE is corrective work with a
# root cause in it, and a non-task note is durable knowledge. Both
# earned their place; a todo did not.
note_type=("snippet", "note"),
task_kind="issue",
# Same reasoning as auto-inject: nobody asked for this, so it takes
# the browse scope and never surfaces a one-to-one direct share.
scope="browse",
@@ -467,7 +774,10 @@ async def build_write_path_hint(
record_retrieval(
user_id=user_id, source="write_path", query=query,
threshold=cfg["threshold"], limit=remaining,
project_id=scope_project, is_task=False, results=hits,
# is_task is None, not False: this arm now returns issues too, and
# recording it as a notes-only retrieval would misdescribe the
# candidate set the threshold is being tuned against.
project_id=scope_project, is_task=None, results=hits,
duration_ms=(time.perf_counter() - t0) * 1000.0,
)
if hits:
@@ -475,9 +785,23 @@ async def build_write_path_hint(
for score, note in hits:
if score < top_score - _AUTOINJECT_BAND:
continue
# Name the kind unless it's a snippet — the menu's default and
# the header's default reading. An issue or a dev-log offered
# here is a different KIND of claim ("this was already tried")
# and an unlabelled line would be read as "here is code to
# reuse", which is the opposite of what it says.
kind = _record_kind(note)
scored.append((
f"similar {score:.2f}",
{"id": int(note.id), "title": note.title, "user_id": note.user_id},
f"similar {score:.2f}" if kind == "snippet"
else f"similar {score:.2f} · {kind}",
{
"id": int(note.id), "title": note.title, "user_id": note.user_id,
# Carried so the line can disclose a cross-language hit
# (#2244). The semantic arm is where these actually arise —
# a snippet recorded at the path you're editing is almost
# never in another language, but a concept match easily is.
"language": (note.data or {}).get("language") if note.data else None,
},
))
menu = (placed + scored)[:top_k]
@@ -489,19 +813,38 @@ async def build_write_path_hint(
if it.get("user_id") is not None and int(it["user_id"]) != user_id
})
lines = [
f"> Prior art already recorded in Scribe for `{path}` — open one with "
"`get_snippet(id)` and reuse it rather than writing a fresh one-off "
"(titles only; shown once per session):",
]
note_ids: list[int] = []
target_lang = _language_for_path(path)
rendered: list[tuple[dict, str, str | None, str]] = []
for marker, item in menu:
note_ids.append(int(item["id"]))
owner_id = item.get("user_id")
owner = None
if owner_id is not None and int(owner_id) != user_id:
owner = owners.get(int(owner_id)) or "another user"
lines.append(_prior_art_line(item, marker, owner))
rendered.append((item, marker, owner, _foreign_language(item, target_lang)))
lines = [
f"> Prior art already recorded in Scribe for `{path}` — open one with "
"`get_snippet(id)` for a snippet, `get_task(id)` for an issue, "
"`get_note(id)` otherwise. Reuse a snippet rather than writing a fresh "
"one-off; read an issue before repeating what it records "
"(titles only; shown once per session):",
]
# Say what a language tag MEANS, and only when one is actually on the menu.
# Without this the reader has to infer why "· python" is attached to a hit on
# a .ts file, and the two ways of guessing wrong are both bad: dismiss it as
# irrelevant, or paste Python into TypeScript. Retrieval matches on concept,
# so these are genuinely useful — as the SHAPE of a solution, not as code.
if any(lang for _i, _m, _o, lang in rendered):
lines.append(
"> A tagged language means that snippet is in a DIFFERENT language "
"than this file — it matched on what it does, so treat it as the "
"shape of a solution to adapt, not code to copy."
)
note_ids: list[int] = []
for item, marker, owner, foreign_lang in rendered:
note_ids.append(int(item["id"]))
lines.append(_prior_art_line(item, marker, owner, foreign_lang))
# Split by arm, which is the whole reason this table exists. The place arm
# carries no score and so has no home in retrieval_logs; before #2085 a
@@ -588,6 +931,37 @@ async def build_session_context(
f"Goal: {goal[:200]}" if goal else "",
f"Open todo tasks: {open_count}",
]
# A design system binds the same way a rule does, and until this
# existed it had no push channel — the standards were reachable only
# by an agent that already knew to look for them. Summary only: the
# token VALUES are a tool call away, and pasting a hundred of them
# into every session would crowd out the context they inform.
if project.design_system_id:
design = await design_systems_svc.design_context(
user_id, project.design_system_id,
)
if design:
inherits = (
" (inherits " + " ".join(design["inherits_from"]) + ")"
if design["inherits_from"] else ""
)
groups = ", ".join(design["token_groups"])
lines += [
"",
f"## Design system: {design['title']} "
f"(id {design['id']}){inherits}",
f"{design['token_count']} tokens"
+ (f" across {groups}" if groups else "")
+ ". This project's UI is built from these, not from "
"literals — reach for a token before writing a colour, "
"size, radius or duration by hand.",
f"Values: `resolve_design_system({design['id']})` · "
f"stylesheet: `get_design_system_stylesheet({design['id']})` "
f"· the prose (aesthetic, voice, where the accent may "
f"appear): `enter_project` returns it, or "
f"`get_design_system({design['id']})`.",
]
elif unbound_repo:
lines += [
"",
+80
View File
@@ -127,6 +127,86 @@ async def delete_project(user_id: int, project_id: int) -> bool:
return True
async def get_project_summaries(
user_id: int, project_ids: list[int]
) -> dict[int, dict]:
"""Summaries for MANY projects — four queries and one session, total.
Replaces an `asyncio.gather` over the per-project version below, which was
a nested fan-out: each project opened its own session for three queries,
then called the milestone summary, which opened one more per milestone. For
25 projects that asked for roughly 250 pooled connections at once against a
pool of 15 (SQLAlchemy's default 5 + 10 overflow), so most of them sat out
the 30-second checkout timeout and everything else on the instance queued
behind them — including unrelated routes, which is why /api/settings
returned 500 while /api/projects took 30.9s (#2384).
The comment it replaced 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: the browser had at least been
serialising those calls.
"""
if not project_ids:
return {}
async with async_session() as session:
task_rows = await session.execute(
select(Note.project_id, Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id.in_(project_ids),
Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.project_id, Note.status)
)
task_counts: dict[int, dict[str, int]] = {}
for project_id, status, count in task_rows.fetchall():
task_counts.setdefault(project_id, {})[status] = count
note_rows = await session.execute(
select(Note.project_id, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id.in_(project_ids),
Note.status.is_(None),
Note.deleted_at.is_(None),
)
.group_by(Note.project_id)
)
note_counts = {pid: count for pid, count in note_rows.fetchall()}
# Deliberately NOT filtered by deleted_at, matching the per-project
# version: "last activity" includes trashing something.
activity_rows = await session.execute(
select(Note.project_id, func.max(Note.updated_at))
.where(Note.user_id == user_id, Note.project_id.in_(project_ids))
.group_by(Note.project_id)
)
last_activity = {pid: ts for pid, ts in activity_rows.fetchall()}
from scribe.services.milestones import get_project_milestone_summaries
milestones = await get_project_milestone_summaries(user_id, project_ids)
return {
pid: {
# All three lifecycle keys present so consumers can sum without
# `?? 0` guards — the frontend declares them required, and
# `undefined + N` renders as NaN.
"task_counts": {
"todo": 0, "in_progress": 0, "done": 0,
**task_counts.get(pid, {}),
},
"note_count": note_counts.get(pid, 0),
"last_activity": (
last_activity[pid].isoformat() if last_activity.get(pid) else None
),
"milestone_summary": milestones.get(pid, []),
}
for pid in project_ids
}
async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity."""
async with async_session() as session:
-26
View File
@@ -28,7 +28,6 @@ came from.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import re
@@ -52,25 +51,6 @@ SNIPPET_TAG = "snippet"
UNSET: object = object()
def _embed_snippet(note) -> None:
"""Fire-and-forget embedding refresh for a snippet.
A snippet's whole value is *immediate* recall — it must join the semantic /
auto-inject pool the moment it's recorded, not wait for the startup backfill.
Unlike a plain note (embedded at the REST-route boundary only, so its MCP
create path defers to restart-backfill), a snippet is recorded primarily via
MCP, so we embed here in the service — covering BOTH the MCP tool and the
REST route by construction. Mirrors the route pattern: fire-and-forget,
text = title + body. Import lazily so the pure serialize/parse helpers can be
imported without pulling in the embedding model.
"""
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if not text:
return
from scribe.services.embeddings import upsert_note_embedding
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
# --- serialize: structured fields -> note (title/body/tags) ------------------
def compose_title(name: str, when_to_use: str = "") -> str:
@@ -640,7 +620,6 @@ async def create_snippet(
language=language, code=code, locations=locations,
),
)
_embed_snippet(note)
return note
@@ -801,9 +780,6 @@ async def update_snippet(
# As the OWNER: update_note is owner-scoped, so a shared editor's own id
# would find nothing. The write was authorised by can_write_note above.
updated = await notes_svc.update_note(note.user_id, snippet_id, **fields)
if updated is not None:
# Title/body changed → refresh the embedding so recall reflects the edit.
_embed_snippet(updated)
return updated
@@ -1025,7 +1001,6 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
if batch is not None:
merged_ids.append(s.id)
_embed_snippet(updated)
return updated, merged_ids
@@ -1134,5 +1109,4 @@ async def unmerge_snippet(user_id: int, survivor_id: int, source_id: int):
)
if updated is None:
return None
_embed_snippet(updated)
return updated, restored
+175
View File
@@ -0,0 +1,175 @@
"""The CI-side token check (#2277).
The checker is stdlib-only and lives in scripts/ so CI can run it without an
install, so these tests import it by path rather than as a package.
Two properties matter more than the parsing: it must not fire on a COMMENT that
discusses a rule, and it must not fire on a longer literal that merely contains a
shorter one. Both would make the report untrustworthy, and an untrustworthy
report is worse than none — people stop reading it and the check stops working
while still passing.
"""
import importlib.util
import pathlib
import pytest
_PATH = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "check_design_tokens.py"
_spec = importlib.util.spec_from_file_location("check_design_tokens", _PATH)
check = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(check)
SHEET = """
:root {
/* surface */
--fs-surface-page: #14171A; /* page bg */
--fs-text-primary: #E8E4D8;
}
/* SUPERSEDES — write the token, not the literal.
* #fff -> --fs-text-primary
* #ffffff -> --fs-text-primary
* bold -> --fs-weight-medium
*/
"""
# --- reading the sheet ------------------------------------------------------
def test_declarations_after_a_comment_are_not_lost():
"""Anchored on the colon alone. Anchoring on `{` or `;` silently drops every
declaration that follows a comment — a mistake made once already in this
codebase, which lost three tokens without erroring."""
assert check.declared_tokens(SHEET) == {"--fs-surface-page", "--fs-text-primary"}
def test_the_supersedes_block_is_read_from_the_sheet_not_hardcoded():
"""Rule #115: the checker must know nothing about any install's palette.
Everything it enforces comes out of the stylesheet it is pointed at."""
assert check.superseded_literals(SHEET) == {
"#fff": "--fs-text-primary",
"#ffffff": "--fs-text-primary",
"bold": "--fs-weight-medium",
}
def test_a_sheet_with_no_supersedes_block_yields_nothing():
assert check.superseded_literals(":root { --a: 1px; }") == {}
# --- the literal matcher ----------------------------------------------------
def test_a_short_hex_does_not_match_inside_a_longer_one():
"""`#fff` firing on `#ffffff` would send someone to change correct code."""
assert check._literal_pattern("#fff").search("color: #ffffff;") is None
assert check._literal_pattern("#fff").search("color: #fff;") is not None
def test_the_match_is_case_insensitive():
assert check._literal_pattern("#ffffff").search("color: #FFFFFF;") is not None
def test_a_keyword_does_not_match_inside_a_longer_word():
"""`bold` must not fire on `font-weight: bolder` or a class named
`.bold-label` — the boundary is what keeps the report readable."""
assert check._literal_pattern("bold").search("font-weight: bolder;") is None
assert check._literal_pattern("bold").search(".bold-label { }") is None
assert check._literal_pattern("bold").search("font-weight: bold;") is not None
# --- reading a component ----------------------------------------------------
def test_only_the_style_block_of_an_sfc_is_read(tmp_path):
"""A hex in a template attribute or a script string is not a stylesheet
violation, and reporting it would bury the ones that are."""
sfc = tmp_path / "X.vue"
sfc.write_text(
'<template><div data-x="#fff">white</div></template>\n'
'<script setup>const c = "#fff";</script>\n'
'<style scoped>.a { color: var(--fs-text-primary); }</style>\n'
)
css = check.style_source(sfc)
assert "--fs-text-primary" in css
assert "#fff" not in css
def test_a_comment_explaining_a_rule_is_not_a_violation(tmp_path):
"""LOAD-BEARING, and it fired on the first real run. A comment documenting
why a literal is avoided necessarily contains that literal — this codebase's
own stylesheet says so about `#fff`. A checker that flags the documentation
of a rule teaches people to stop documenting rules."""
sfc = tmp_path / "Y.vue"
sfc.write_text(
"<style scoped>\n"
"/* Deliberately NOT #fff — pure white is never text. */\n"
".a { color: var(--fs-text-primary); }\n"
"</style>\n"
)
css = check.style_source(sfc)
assert check._literal_pattern("#fff").search(css) is None
def test_a_plain_css_file_is_read_whole(tmp_path):
css_file = tmp_path / "shared.css"
css_file.write_text(".a { color: #fff; }")
assert "#fff" in check.style_source(css_file)
# --- the gate ---------------------------------------------------------------
def _run(tmp_path, sheet: str, component: str, monkeypatch, capsys):
(tmp_path / "assets").mkdir(parents=True, exist_ok=True)
sheet_path = tmp_path / "assets" / "theme.css"
sheet_path.write_text(sheet)
(tmp_path / "C.vue").write_text(f"<style>{component}</style>")
monkeypatch.setattr(
"sys.argv",
["check", "--sheet", str(sheet_path), "--root", str(tmp_path)],
)
code = check.main()
return code, capsys.readouterr().out
def test_an_unresolvable_reference_fails_the_build(tmp_path, monkeypatch, capsys):
"""The one hard gate. It is safe to gate on because the count is zero today —
a ratchet holding a line already reached, not a backlog that keeps CI red."""
code, out = _run(tmp_path, SHEET, ".a { color: var(--nope); }", monkeypatch, capsys)
assert code == 1
assert "--nope" in out
def test_a_resolvable_reference_passes(tmp_path, monkeypatch, capsys):
code, out = _run(
tmp_path, SHEET, ".a { color: var(--fs-text-primary); }", monkeypatch, capsys
)
assert code == 0
assert "every var() reference resolves" in out
def test_a_locally_declared_property_is_not_unresolved(tmp_path, monkeypatch, capsys):
"""A component may legitimately define its own custom property for local use —
a keyframe variable, a per-instance override. Only a reference to a name that
exists NOWHERE is broken."""
code, _ = _run(
tmp_path, SHEET, ".a { --local: 4px; padding: var(--local); }", monkeypatch, capsys
)
assert code == 0
def test_a_superseded_literal_reports_but_does_not_fail(tmp_path, monkeypatch, capsys):
"""Hundreds exist. Gating would make a permanently-red job, which is a check
nobody reads — worse than no check at all."""
code, out = _run(tmp_path, SHEET, ".a { color: #fff; }", monkeypatch, capsys)
assert code == 0
assert "#fff -> --fs-text-primary" in out
def test_a_missing_stylesheet_is_an_error_not_a_pass(tmp_path, monkeypatch, capsys):
"""If the sheet moves, the check must fail loudly rather than silently
passing with zero tokens to compare against — which would look identical to
a clean run."""
monkeypatch.setattr(
"sys.argv", ["check", "--sheet", str(tmp_path / "gone.css"), "--root", str(tmp_path)]
)
assert check.main() == 2
+420
View File
@@ -0,0 +1,420 @@
"""The parent chain: walking it, and refusing to close it (milestone #254 step 1).
Pure functions over a `{id: parent_id}` literal, so a whole hierarchy is one line
of setup and no database is involved. That is the reason the cascade lives in its
own import-free module — see services/design_cascade.py.
"""
from types import SimpleNamespace
from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
# --- ancestry ---------------------------------------------------------------
def test_ancestry_returns_the_chain_deepest_first():
"""Deepest first because that is the order resolution consumes it in: the
system being resolved wins over its parent, which wins over the root."""
parents = {1: None, 2: 1, 3: 2}
assert ancestry(3, parents) == [3, 2, 1]
def test_a_root_is_its_own_whole_chain():
"""A family system has no parent, and that is an ordinary state — not an
incomplete one. It must resolve to exactly itself."""
assert ancestry(1, {1: None}) == [1]
def test_a_missing_parent_truncates_rather_than_raising():
"""A parent that was soft-deleted (or filtered out of the caller's scope) is
a shorter chain, not a failed request. The alternative — raising — would make
one deleted system break every descendant's rendering."""
assert ancestry(3, {3: 2}) == [3, 2]
def test_a_system_absent_from_the_map_still_yields_itself():
assert ancestry(9, {}) == [9]
def test_ancestry_terminates_on_a_cycle_instead_of_hanging():
"""LOAD-BEARING, and the reason a visited-set exists even though writes are
guarded. A loop introduced by a direct DB edit or a future bug must degrade
to a truncated chain: truncation shows up in the result, a hang shows up as
an outage. Every id appears exactly once."""
parents = {1: 3, 2: 1, 3: 2}
chain = ancestry(1, parents)
assert chain == [1, 3, 2]
assert len(chain) == len(set(chain))
def test_ancestry_terminates_on_a_self_parent():
assert ancestry(1, {1: 1}) == [1]
# --- would_cycle ------------------------------------------------------------
def test_clearing_the_parent_never_cycles():
"""None means "make this a root", which is always safe."""
assert would_cycle(2, None, {1: None, 2: 1}) is False
def test_a_system_cannot_be_its_own_parent():
assert would_cycle(1, 1, {1: None}) is True
def test_an_ordinary_reparent_is_allowed():
"""family <- app is the shape the whole model exists for; it must not trip
the guard."""
assert would_cycle(2, 1, {1: None, 2: None}) is False
def test_a_direct_swap_is_refused():
"""A -> B, then B -> A. The two-system case, and the one a UI produces first
because both systems are on screen together."""
parents = {1: None, 2: 1}
assert would_cycle(1, 2, parents) is True
def test_an_indirect_loop_is_refused():
"""Three deep: root <- mid <- leaf, then root's parent set to leaf. Catching
this is what makes the check a chain walk rather than a parent comparison."""
parents = {1: None, 2: 1, 3: 2}
assert would_cycle(1, 3, parents) is True
def test_reparenting_onto_a_sibling_subtree_is_allowed():
"""Two branches off one root. Moving one under the other is legitimate — the
guard must refuse loops, not reorganisation."""
parents = {1: None, 2: 1, 3: 1}
assert would_cycle(3, 2, parents) is False
def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
"""If a cycle somehow already exists, the guard still has to answer rather
than spin — the write path is exactly where such a hierarchy gets repaired."""
parents = {1: 2, 2: 1, 3: None}
assert would_cycle(3, 1, parents) is False
assert would_cycle(1, 2, parents) is True
# --- resolution -------------------------------------------------------------
#
# The cascade proper (milestone #254 step 2). Hierarchies are stated as literals
# because resolve_tokens is pure and duck-typed — which is the whole reason it
# lives here rather than inside the service.
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
supersedes=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
)
# A family (1) and an app inheriting from it (2) — the shape the model exists for.
FAMILY, APP = 1, 2
PARENTS = {FAMILY: None, APP: FAMILY}
def _by_name(tokens):
return {t.name: t for t in tokens}
def test_a_system_with_no_tokens_of_its_own_inherits_the_whole_family_set():
"""The correct answer for an app that has not departed from the family yet —
and the state every app system starts in."""
resolved = resolve_tokens(
APP, PARENTS,
{FAMILY: [_token("--fs-obsidian", {"base": "#14171a"})], APP: []},
)
assert [t.name for t in resolved] == ["--fs-obsidian"]
assert resolved[0].value_by_mode == {"base": "#14171a"}
assert resolved[0].origin_by_mode == {"base": FAMILY}
def test_the_deepest_system_wins_and_says_what_it_overrode():
"""Provenance is the point of the whole model: the effective set is just a
list without it, and "where does this app depart from the family?" becomes a
diff someone has to compute."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
},
))
accent = resolved["--fs-accent"]
assert accent.value_by_mode == {"base": "#5b4a8a"}
assert accent.origin_by_mode == {"base": APP}
# The shadowed ancestor is still there, behind the winner.
assert [c.system_id for c in accent.contributions["base"]] == [APP, FAMILY]
assert accent.contributions["base"][1].value == "#6b2118"
def test_overriding_one_mode_leaves_the_others_inherited():
"""LOAD-BEARING, and the argument that decided the storage shape. An accent
deepened for light backgrounds while dark is left alone must own `base` and
still inherit `dark` — which is why merging is per (name, MODE) and why the
value column is a map rather than a pair of columns."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#34a877", "dark": "#34a877"})],
APP: [_token("--fs-accent", {"base": "#15803d"})],
},
))
accent = resolved["--fs-accent"]
assert accent.value_by_mode == {"base": "#15803d", "dark": "#34a877"}
assert accent.origin_by_mode == {"base": APP, "dark": FAMILY}
def test_a_token_only_the_app_defines_is_not_an_override():
"""Introducing a token and overriding one are different acts, and a UI that
labelled both "overridden here" would misdescribe the first."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{FAMILY: [], APP: [_token("--fs-editor-caret", {"base": "#5b4a8a"})]},
))
caret = resolved["--fs-editor-caret"]
assert caret.origin_by_mode == {"base": APP}
assert caret.is_overridden_in(APP) is False
def test_is_overridden_in_is_true_only_for_the_system_that_shadowed():
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
},
))
accent = resolved["--fs-accent"]
assert accent.is_overridden_in(APP) is True
assert accent.is_overridden_in(FAMILY) is False
def test_three_levels_stack_nearest_first():
"""A chain deeper than family->app, to prove the walk isn't a single hop."""
parents = {1: None, 2: 1, 3: 2}
resolved = _by_name(resolve_tokens(
3, parents,
{
1: [_token("--fs-bg", {"base": "a"})],
2: [_token("--fs-bg", {"base": "b"})],
3: [_token("--fs-bg", {"base": "c"})],
},
))
bg = resolved["--fs-bg"]
assert bg.value_by_mode == {"base": "c"}
assert [c.value for c in bg.contributions["base"]] == ["c", "b", "a"]
def test_resolving_the_family_itself_ignores_its_children():
"""Inheritance runs one way. A family system resolved on its own must not
pick up an app's overrides — otherwise every app would restyle the house."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
},
))
assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"}
def test_value_for_falls_back_to_the_base_mode():
"""A token that isn't mode-dependent carries only `base`, and asking it for
"dark" must yield that rather than nothing — the read rule the storage shape
implies."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
))
radius = resolved["--fs-radius-md"]
assert radius.value_for("dark") == "8px"
assert radius.value_for("base") == "8px"
def test_value_for_prefers_an_explicit_mode_over_the_fallback():
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [_token("--fs-bg", {"base": "#f7f5ef", "dark": "#14171a"})]},
))
assert resolved["--fs-bg"].value_for("dark") == "#14171a"
def test_metadata_is_inherited_when_the_override_leaves_it_blank():
"""A child overriding a colour routinely says nothing about what the token is
FOR. Inheriting the family's description beats blanking it — the override was
about the value, not the meaning."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token(
"--fs-obsidian", {"base": "#14171a"},
group_name="surface", purpose="page bg, deepest surface",
)],
APP: [_token("--fs-obsidian", {"base": "#101317"})],
},
))
obsidian = resolved["--fs-obsidian"]
assert obsidian.group_name == "surface"
assert obsidian.purpose == "page bg, deepest surface"
assert obsidian.value_by_mode == {"base": "#101317"} # the value still won
def test_an_override_that_states_metadata_wins_it_too():
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-x", {"base": "a"}, purpose="family says")],
APP: [_token("--fs-x", {"base": "b"}, purpose="app says")],
},
))
assert resolved["--fs-x"].purpose == "app says"
def test_an_override_at_default_order_keeps_the_familys_position():
"""order_index 0 is the column DEFAULT, so it reads as unstated. Treating it
as "first" would let a colour-only override drag its token to the top of the
group — a visible reshuffle nobody asked for."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-x", {"base": "a"}, order_index=7)],
APP: [_token("--fs-x", {"base": "b"})],
},
))
assert resolved["--fs-x"].order_index == 7
def test_the_effective_set_is_ordered_by_group_then_position_with_ungrouped_last():
resolved = resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [
_token("--fs-z", {"base": "1"}), # ungrouped
_token("--fs-b", {"base": "2"}, group_name="text", order_index=1),
_token("--fs-a", {"base": "3"}, group_name="surface", order_index=2),
_token("--fs-c", {"base": "4"}, group_name="surface", order_index=1),
]},
)
assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"]
def test_resolution_terminates_on_a_corrupt_hierarchy():
"""Resolution inherits ancestry's visited-set. A loop reaching this function
must produce a truncated set, not a hung request — the defensive half of the
cycle guard, exercised end to end."""
parents = {1: 2, 2: 1}
resolved = _by_name(resolve_tokens(
1, parents,
{1: [_token("--fs-a", {"base": "one"})], 2: [_token("--fs-b", {"base": "two"})]},
))
assert set(resolved) == {"--fs-a", "--fs-b"}
# Each system contributes exactly once, not endlessly.
assert len(resolved["--fs-a"].contributions["base"]) == 1
# --- supersedes -------------------------------------------------------------
#
# The declaration that replaces a prohibition. A design system stores what things
# ARE, so "pure white is never text" has no row — but "write this token instead
# of #fff" does, and it is the same fact stated forwards.
def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
"""LOAD-BEARING. A child overriding a colour says nothing about which
literals it replaces, and blanking the family's declaration there would
silently disarm the check for every app that customises the token."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "#f0ece0"})],
},
))
text = resolved["--fs-text"]
assert text.supersedes == ("#fff", "#ffffff")
assert text.value_by_mode == {"base": "#f0ece0"} # the value still overrode
def test_an_override_that_states_its_own_supersedes_replaces_the_list():
"""Whole-list replacement, not a merge — an app that means "only #fff" must
be able to say so without inheriting entries it deliberately dropped."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])],
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])],
},
))
assert resolved["--fs-text"].supersedes == ("#fff",)
def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
"""Most tokens replace nothing. That has to be an empty sequence rather than
None, so no caller has to test for two kinds of nothing."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
))
assert resolved["--fs-radius-md"].supersedes == ()
def test_supersedes_survives_serialisation_as_a_list():
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
))
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
def test_the_superseded_literal_need_not_match_the_tokens_own_value():
"""The whole reason this is DECLARED rather than derived. `#fff` and
Parchment are different colours, so a value-matching rule could never have
connected them — which is why the prohibition looked unrepresentable until
it was turned around."""
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS,
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
))
text = resolved["--fs-text"]
assert text.value_by_mode["base"] not in text.supersedes
def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
"""Duck-typed input: a caller passing rows from before the column existed
must not crash the cascade."""
legacy = SimpleNamespace(
name="--fs-x", value_by_mode={"base": "a"},
group_name=None, purpose=None, order_index=0,
)
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
assert resolved["--fs-x"].supersedes == ()
# --- rationale --------------------------------------------------------------
def test_rationale_cascades_like_purpose_and_is_a_different_question():
"""`purpose` is what the token is FOR; `rationale` is why it is this value.
Rules carry the second routinely ("Success = Moss, aligned by design") and a
token row had nowhere to put it until now."""
resolved = _by_name(resolve_tokens(
APP, PARENTS,
{
FAMILY: [SimpleNamespace(
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
group_name="semantic", purpose="success states",
rationale="equals Moss, aligned by design",
order_index=0, supersedes=[],
)],
APP: [_token("--fs-success", {"base": "#3f5236"})],
},
))
token = resolved["--fs-success"]
assert token.rationale == "equals Moss, aligned by design"
assert token.purpose == "success states"
assert token.value_by_mode == {"base": "#3f5236"} # the value still overrode
def test_a_token_without_a_rationale_resolves_to_none():
resolved = _by_name(resolve_tokens(
FAMILY, PARENTS, {FAMILY: [_token("--fs-x", {"base": "1px"})]},
))
assert resolved["--fs-x"].rationale is None
+161
View File
@@ -0,0 +1,161 @@
"""Rulebook prose → checkable claims (milestone #251 step 2).
This is the piece of the design explorer that most needed to be testable, which
is why it lives in Python at all: the frontend has no test runner, so the fiddly
extraction happens server-side and the browser only does set arithmetic over it.
Rule text below is representative of a real design rulebook rather than copied
from this operator's — rule #115: the product must work for an install that has
none of their data, and a test that only passes against their exact wording would
be testing the instance, not the parser.
"""
from types import SimpleNamespace
from scribe.services.design_rulebook_import import (
expand_token_shorthand,
extract_expectations,
normalize_hex,
)
def _rule(rule_id, title, statement, how_to_apply=None):
return SimpleNamespace(
id=rule_id, title=title, statement=statement, how_to_apply=how_to_apply
)
# --- hex normalisation -------------------------------------------------------
def test_normalize_hex_makes_shorthand_and_case_comparable():
"""LOAD-BEARING. The rulebook writes `#FFFFFF` and components write `#fff`.
If those don't compare equal, the single largest drift finding — 67 hardcoded
white text colours (#2275) — reads as zero findings."""
assert normalize_hex("#fff") == normalize_hex("#FFFFFF") == "#ffffff"
assert normalize_hex("#E8E4D8") == "#e8e4d8"
assert normalize_hex("#14171a") == "#14171a"
def test_normalize_hex_keeps_alpha_rather_than_inventing_equality():
"""`#fff` and `#ffff` are different colours. Dropping the alpha to make them
match would manufacture agreement that isn't there."""
assert normalize_hex("#ffff") == "#ffffffff"
assert normalize_hex("#fff") != normalize_hex("#ffff")
def test_normalize_hex_rejects_non_colours():
for junk in ("", " ", "not-a-colour", "#", "#gg", "#12345"):
assert normalize_hex(junk) is None
# --- the slash shorthand -----------------------------------------------------
def test_expand_token_shorthand_handles_every_form_a_rulebook_uses():
"""One rule expands all three shapes: take everything up to and including the
LAST hyphen of the first segment as the prefix."""
assert expand_token_shorthand("--fs-radius-sm/md/lg/xl") == [
"--fs-radius-sm", "--fs-radius-md", "--fs-radius-lg", "--fs-radius-xl",
]
# Prefix is just `--fs-` here, and the same rule finds it.
assert expand_token_shorthand("--fs-obsidian/iron/slate/pewter") == [
"--fs-obsidian", "--fs-iron", "--fs-slate", "--fs-pewter",
]
assert expand_token_shorthand("--fs-dur-fast/base/slow") == [
"--fs-dur-fast", "--fs-dur-base", "--fs-dur-slow",
]
def test_expand_token_shorthand_passes_plain_names_through():
assert expand_token_shorthand("--fs-ease") == ["--fs-ease"]
# --- extraction --------------------------------------------------------------
def test_negation_is_scoped_to_the_sentence_not_the_rule():
"""THE trick that makes prohibition detection usable.
A single rule routinely states what the palette REQUIRES and what it FORBIDS
in consecutive sentences. Detecting negation across the whole statement would
mark the required colours as forbidden too — inverting the finding rather
than missing it, which is worse.
"""
rule = _rule(
52, "Text palette",
"Text tokens: Parchment #E8E4D8 (primary), Vellum #C2BFB4 (secondary), "
"Ash #9C9A92 (tertiary). Pure white #FFFFFF is NEVER used as text color.",
)
found = extract_expectations([rule])
required = {e.value for e in found if e.kind == "color"}
forbidden = {e.value for e in found if e.kind == "prohibited_color"}
assert required == {"#e8e4d8", "#c2bfb4", "#9c9a92"}
assert forbidden == {"#ffffff"}
assert not (required & forbidden)
def test_token_names_are_extracted_and_expanded():
rule = _rule(
72, "CSS custom properties",
"Expose the system as custom properties on :root — surfaces "
"(--fs-obsidian/iron/slate/pewter), radius (--fs-radius-sm/md/lg/xl), "
"and motion (--fs-ease).",
)
names = {e.value for e in extract_expectations([rule]) if e.kind == "token"}
assert "--fs-obsidian" in names and "--fs-pewter" in names
assert "--fs-radius-xl" in names
assert "--fs-ease" in names
assert len(names) == 9
def test_how_to_apply_is_read_as_well_as_the_statement():
"""Rulebooks routinely put the concrete values in how_to_apply and keep the
statement declarative, so ignoring it would miss the checkable half."""
rule = _rule(
56, "Per-app accent", "Each app owns exactly one accent.",
how_to_apply='[data-app="scribe"] #5B4A8A, [data-app="minstrel"] #4A6B5C.',
)
colours = {e.value for e in extract_expectations([rule]) if e.kind == "color"}
assert colours == {"#5b4a8a", "#4a6b5c"}
def test_claims_are_deduped_across_rules_keeping_the_first_source():
"""A colour named by several rules is one expectation, attributed to the rule
that introduced it — usually the most specific place to send a reader."""
rules = [
_rule(51, "Surfaces", "Obsidian #14171A is the page background."),
_rule(99, "Elsewhere", "Obsidian #14171A again, mentioned in passing."),
]
found = [e for e in extract_expectations(rules) if e.kind == "color"]
assert len(found) == 1
assert found[0].rule_id == 51
def test_prose_with_nothing_checkable_yields_nothing():
"""Most rules are judgement, not specification. They must contribute no
findings rather than a shrug — a panel that reports unparseable rules as
problems would be unusable."""
rule = _rule(
68, "Voice and tone",
"Voice is understated: plain language for anything functional, flavour "
"only where the user is waiting or failing. Be brief.",
)
assert extract_expectations([rule]) == []
def test_every_expectation_carries_the_sentence_it_came_from():
"""The panel has to show its working — "the rulebook says X" is only
actionable if you can see where, and in what context.
Asserts the context is the SENTENCE, not the whole statement: a rule that
states a requirement and a prohibition in consecutive sentences would
otherwise attribute both to the same undifferentiated blob of prose.
"""
rule = _rule(63, "Radius", "Radius: Small 4px. Pure white #FFFFFF is never used.")
found = extract_expectations([rule])
assert len(found) == 1
only = found[0]
assert only.kind == "prohibited_color"
assert only.rule_id == 63
assert only.rule_title == "Radius"
assert only.context == "Pure white #FFFFFF is never used."
assert "Radius: Small 4px" not in only.context
+435
View File
@@ -0,0 +1,435 @@
"""Rendering a design system as its master CSS sheet.
The generator is pure, so a whole sheet is one literal of tokens in and a string
out. Two things here are load-bearing rather than cosmetic: what the sheet
deliberately does NOT contain, and the fact that a value cannot escape its
declaration.
"""
from types import SimpleNamespace
from scribe.services.design_stylesheet import (
check_code_against_tokens,
derivation_report,
duplicate_values,
is_valid_token_name,
render_stylesheet,
safe_comment,
safe_value,
selector_for_mode,
)
def _token(name, value_by_mode, group_name=None, purpose=None):
return SimpleNamespace(
name=name, value_by_mode=value_by_mode,
group_name=group_name, purpose=purpose,
)
# --- safety -----------------------------------------------------------------
#
# Design systems are shareable records. A value that can close its declaration
# can inject arbitrary CSS into the page of anyone the system was shared with,
# which makes this a real boundary rather than tidiness.
def test_a_value_that_would_escape_its_declaration_is_refused():
"""`red; } body { display: none` is the whole attack: end the declaration,
close the block, open your own."""
assert safe_value("red; } body { display: none") is None
assert safe_value("#fff}") is None
assert safe_value("#fff;") is None
def test_at_rules_and_tags_are_refused():
assert safe_value("@import url(evil.css)") is None
assert safe_value("</style><script>") is None
def test_comment_delimiters_in_a_value_are_refused():
"""A value is not rendered inside a comment, but `/*` would comment out
every declaration after it — silently blanking the rest of the sheet."""
assert safe_value("red /* ") is None
assert safe_value("*/ red") is None
def test_a_newline_in_a_value_is_refused():
assert safe_value("red\n color: blue") is None
def test_ordinary_values_survive_untouched():
for value in ("#14171a", "8px", "cubic-bezier(0.2, 0.6, 0.2, 1)",
"0 4px 12px rgba(0,0,0,0.35)", "color-mix(in srgb, red 15%, transparent)"):
assert safe_value(value) == value
def test_a_rejected_value_is_dropped_not_cleaned_up():
"""Rejecting beats stripping. A partially-sanitised value is one the operator
never wrote, and the sheet's entire claim is that it IS the record — quietly
rendering a different colour would break that claim invisibly."""
css = render_stylesheet([_token("--fs-x", {"base": "red; } body { color: blue"})])
assert "body" not in css
assert "value rejected" in css
def test_comment_text_cannot_close_its_comment():
"""`purpose` is operator prose rendered into a comment — `*/` in it would
end the comment and spill the rest into the stylesheet as code."""
assert "*/" not in safe_comment("ends the comment */ then color: red")
def test_a_malformed_token_name_is_dropped():
"""A name is an identifier. A 'cleaned up' identifier is a different token
than the one recorded, so it is dropped rather than repaired."""
assert is_valid_token_name("--fs-obsidian")
assert not is_valid_token_name("--fs obsidian")
assert not is_valid_token_name("color: red")
assert not is_valid_token_name("fs-obsidian") # no leading --
css = render_stylesheet([_token("--bad name", {"base": "red"})])
assert "bad name" not in css
def test_a_mode_name_cannot_break_out_of_its_selector():
assert selector_for_mode('dark"] body {') == '[data-theme="darkbody"]'
# --- what the sheet is ------------------------------------------------------
def test_the_sheet_declares_properties_and_styles_no_elements():
"""THE shape decision. A sheet that styled elements would restate the same
handful of values once per element and grow with the UI. Purpose tokens are
stated once and reused; components are snippets that reference them."""
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
_token("--fs-moss", {"base": "#4a5d3f"}, group_name="action"),
])
assert "--fs-obsidian: #14171a;" in css
# No element or class rules — the sheet has exactly one block here, and
# every declaration in it is a custom property.
assert css.count("{") == 1
declarations = [
line.strip() for line in css.splitlines()
if ":" in line and line.strip().endswith(";")
]
assert declarations and all(d.startswith("--") for d in declarations)
def test_the_header_says_what_the_sheet_is_for():
"""A generated file with no explanation gets hand-edited, and then it has
diverged from the record it claims to be."""
css = render_stylesheet([_token("--fs-x", {"base": "1px"})], title="FabledSword")
assert "FabledSword" in css
assert "Generated" in css
assert "snippets" in css
# --- modes ------------------------------------------------------------------
def test_base_goes_on_the_root_selector_and_other_modes_layer_over_it():
"""Matches the convention already in the codebase, and the one-way scoping
#251 recorded: light on `:root`, dark layered on an attribute selector."""
css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
])
assert ":root {" in css
assert '[data-theme="dark"] {' in css
assert css.index(":root {") < css.index('[data-theme="dark"] {')
def test_a_mode_block_contains_only_what_that_mode_declares():
"""A mode block is an OVERRIDE layer, exactly as the storage model has it.
Repeating every token in every block would make the sheet claim each mode
redefines the whole system."""
css = render_stylesheet([
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
_token("--fs-radius-md", {"base": "8px"}),
])
dark_block = css.split('[data-theme="dark"] {')[1]
assert "--fs-bg" in dark_block
assert "--fs-radius-md" not in dark_block
def test_the_root_selector_is_caller_chosen():
"""A container-scoped preview cannot use `:root`. A generator that hardcoded
it could not serve the preview surface at all."""
css = render_stylesheet(
[_token("--fs-x", {"base": "1px"})], root_selector="[data-preview]"
)
assert "[data-preview] {" in css
assert ":root {" not in css
# --- grouping and honesty ---------------------------------------------------
def test_tokens_are_grouped_by_purpose_with_the_group_named():
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
_token("--fs-radius-md", {"base": "8px"}, group_name="radius"),
])
assert "/* surface */" in css
assert "/* radius */" in css
def test_a_purpose_becomes_an_inline_comment_on_the_base_layer_only():
"""Repeating the same prose in every mode block is noise: the token means
the same thing in dark mode."""
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a", "dark": "#000000"},
purpose="page bg, deepest surface"),
])
assert css.count("page bg, deepest surface") == 1
def test_a_declared_token_with_no_value_appears_as_a_comment_not_a_silence():
"""The rulebook named it, so its absence is a FINDING. A commented line puts
that finding where the reader is already looking; dropping it would make the
sheet look complete."""
css = render_stylesheet([
_token("--fs-obsidian", {"base": "#14171a"}),
_token("--fs-radius-sm", {}),
])
assert "--fs-radius-sm" in css
assert "no value set yet" in css
# Commented, so it cannot be mistaken for a live declaration.
assert " --fs-radius-sm:" not in css
def test_an_empty_system_renders_a_sheet_with_no_blocks_rather_than_failing():
"""A system with no tokens is an ordinary state (rule #115), including one
that was just created."""
css = render_stylesheet([])
assert "{" not in css
assert "Generated" in css
# --- the reuse report -------------------------------------------------------
def test_two_tokens_sharing_a_value_are_reported_not_refused():
"""The operator's constraint, made checkable: reuse consistent values rather
than restating them. But a design system legitimately aligns colours on
purpose — "Success = Moss, by design" — so this reports and lets a human
decide which it is."""
dupes = duplicate_values([
_token("--fs-moss", {"base": "#4A5D3F"}),
_token("--fs-success", {"base": "#4a5d3f"}),
_token("--fs-obsidian", {"base": "#14171a"}),
])
assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
def test_tokens_that_agree_in_one_mode_but_differ_in_another_are_not_duplicates():
"""A near-miss is a different, weaker finding, and calling it a duplicate
would send someone to merge two tokens that genuinely diverge."""
assert duplicate_values([
_token("--fs-a", {"base": "#fff", "dark": "#000"}),
_token("--fs-b", {"base": "#fff", "dark": "#111"}),
]) == {"#fff": ["--fs-a", "--fs-b"]}
def test_valueless_tokens_never_count_as_duplicates_of_each_other():
"""Otherwise every unfilled token would collide with every other one and the
report would be nothing but noise on a fresh import."""
assert duplicate_values([_token("--fs-a", {}), _token("--fs-b", {})]) == {}
# --- reading the sheet from the other side ----------------------------------
#
# "The snippets use the tags from the sheet" made checkable. All three findings
# below are currently SILENT in this codebase — no error, no failing test.
def _tok(name, base=None, supersedes=()):
return SimpleNamespace(
name=name,
value_by_mode={"base": base} if base else {},
supersedes=list(supersedes),
group_name=None, purpose=None,
)
SHEET = [
_tok("--fs-obsidian", "#14171a"),
_tok("--fs-parchment", "#e8e4d8", supersedes=["#fff", "#ffffff"]),
]
def test_a_reference_to_a_token_that_does_not_exist_is_reported():
"""THE bug this exists for. `var(--color-accent)` where no such token exists
renders as nothing at all — invisible focus rings, unstyled elements, no
error and no failing test. It happened in this codebase this session."""
report = check_code_against_tokens("outline: 2px solid var(--color-accent);", SHEET)
assert report["unknown"] == ["--color-accent"]
assert report["used"] == []
def test_a_reference_that_resolves_is_reported_as_used_not_as_a_finding():
report = check_code_against_tokens("background: var(--fs-obsidian);", SHEET)
assert report["used"] == ["--fs-obsidian"]
assert report["unknown"] == []
def test_a_superseded_literal_is_found_and_names_its_replacement():
"""The reframe paying off end to end: the finding says what to WRITE, not
merely what is wrong. That is only possible because the token declared it."""
report = check_code_against_tokens("color: #fff;", SHEET)
assert report["superseded_literals"] == [
{"literal": "#fff", "use_instead": "--fs-parchment"}
]
def test_a_shorter_hex_does_not_match_inside_a_longer_one():
"""`#fff` must not fire on `#ffffff` — different colours, and a finding on
the wrong one sends someone to change code that was already correct."""
report = check_code_against_tokens("color: #ffffffee;", SHEET)
assert [f["literal"] for f in report["superseded_literals"]] == []
def test_the_literal_match_is_case_insensitive():
"""Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
check would silently find nothing — the same trap normalize_hex exists for."""
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
assert report["superseded_literals"] == [
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
]
def test_a_token_defined_and_read_locally_is_reported_once_not_twice():
"""A snippet minting its own custom property is the bloat a shared sheet
exists to prevent — but when it also reads it back, that is ONE fact. The
unknown-reference check owns it, because "--btn-bg does not exist in the
sheet" is the more precise statement of the same problem."""
report = check_code_against_tokens(".btn { --btn-bg: #333; background: var(--btn-bg); }", SHEET)
assert report["unknown"] == ["--btn-bg"]
assert report["local_definitions"] == []
def test_a_local_definition_never_read_back_is_still_reported():
report = check_code_against_tokens(".btn { --btn-bg: #333; }", SHEET)
assert report["local_definitions"] == ["--btn-bg"]
def test_a_declaration_that_is_not_a_custom_property_is_not_mistaken_for_one():
report = check_code_against_tokens(".btn { color: red; background: blue; }", SHEET)
assert report["local_definitions"] == []
def test_clean_code_reports_nothing_to_act_on():
report = check_code_against_tokens(
".btn { background: var(--fs-obsidian); color: var(--fs-parchment); }", SHEET
)
assert report["unknown"] == []
assert report["superseded_literals"] == []
assert report["local_definitions"] == []
assert report["used"] == ["--fs-obsidian", "--fs-parchment"]
def test_the_inline_comment_falls_back_to_rationale_when_there_is_no_purpose():
"""A token carrying only the why still says something in the sheet, rather
than rendering bare because the other field happened to be empty."""
token = SimpleNamespace(
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
group_name=None, purpose=None, rationale="equals Moss, by design",
)
assert "equals Moss, by design" in render_stylesheet([token])
def test_purpose_wins_over_rationale_in_the_comment():
"""What a token is FOR is what a reader of the stylesheet needs first."""
token = SimpleNamespace(
name="--fs-x", value_by_mode={"base": "1px"},
group_name=None, purpose="hairline borders", rationale="because thin",
)
css = render_stylesheet([token])
assert "hairline borders" in css
assert "because thin" not in css
# --- derivation -------------------------------------------------------------
#
# A formula needs no special storage: `color-mix(..., var(--fs-accent) 15%, ...)`
# is a value, and the browser resolves it live. What it needs is a check, because
# a formula pointing at a missing token is dropped silently.
def _dtok(name, base):
return SimpleNamespace(
name=name, value_by_mode={"base": base},
group_name=None, purpose=None, supersedes=[],
)
ACCENT = "color-mix(in srgb, var(--fs-accent) 15%, transparent)"
def test_a_formula_survives_the_value_sanitiser_untouched():
"""LOAD-BEARING for the whole approach. If `color-mix(... var(...) ...)` were
rejected as unsafe, derivation would need a storage shape of its own."""
assert safe_value(ACCENT) == ACCENT
assert ACCENT in render_stylesheet([_dtok("--fs-accent-soft", ACCENT)])
def test_a_derived_token_reports_what_it_is_computed_from():
report = derivation_report([
_dtok("--fs-accent", "#5b4a8a"),
_dtok("--fs-accent-soft", ACCENT),
])
assert report["derived"] == {"--fs-accent-soft": ["--fs-accent"]}
assert report["unknown_refs"] == {}
def test_a_formula_pointing_at_a_token_that_does_not_exist_is_reported():
"""The browser drops the whole declaration — invalid at computed-value time —
and nothing errors. Exactly the failure mode this system exists to end."""
report = derivation_report([_dtok("--fs-accent-soft", ACCENT)])
assert report["unknown_refs"] == {"--fs-accent-soft": ["--fs-accent"]}
def test_a_plain_value_is_not_reported_as_derived():
report = derivation_report([_dtok("--fs-obsidian", "#14171a")])
assert report["derived"] == {}
def test_a_derivation_loop_is_reported_once():
"""CSS resolves a loop to nothing rather than hanging, so this is about
telling the operator — but a token that quietly resolves to nothing is
precisely the thing worth being told."""
report = derivation_report([
_dtok("--fs-a", "var(--fs-b)"),
_dtok("--fs-b", "var(--fs-a)"),
])
assert len(report["cycles"]) == 1
assert set(report["cycles"][0]) == {"--fs-a", "--fs-b"}
def test_a_chain_of_derivations_is_not_a_cycle():
"""a <- b <- c is ordinary and must not trip the loop check."""
report = derivation_report([
_dtok("--fs-a", "#000"),
_dtok("--fs-b", "var(--fs-a)"),
_dtok("--fs-c", "var(--fs-b)"),
])
assert report["cycles"] == []
assert report["derived"] == {"--fs-b": ["--fs-a"], "--fs-c": ["--fs-b"]}
def test_a_token_referencing_itself_is_not_treated_as_a_dependency():
"""`--fs-x: var(--fs-x, fallback)` is a self-reference with a fallback, not a
derivation — counting it would report every such token as a one-node loop."""
report = derivation_report([_dtok("--fs-x", "var(--fs-x, 8px)")])
assert report["cycles"] == []
assert report["derived"] == {}
def test_a_derived_token_needs_only_a_base_value_to_follow_every_mode():
"""The reason formulas beat computed literals. One declaration in the base
layer tracks its source through dark mode too, because `var()` resolves where
it is USED, not where it is written — so there is nothing to re-derive when a
colour changes."""
css = render_stylesheet([
SimpleNamespace(
name="--fs-accent", value_by_mode={"base": "#5b4a8a", "dark": "#7a68b0"},
group_name=None, purpose=None,
),
_dtok("--fs-accent-soft", ACCENT),
])
dark_block = css.split('[data-theme="dark"] {')[1]
assert "--fs-accent:" in dark_block
assert "--fs-accent-soft" not in dark_block # stated once, follows anyway
+212
View File
@@ -0,0 +1,212 @@
"""MCP design-system tools — the sentinel translations, mostly.
The tools are thin wrappers, so the only logic worth testing is where the MCP
calling convention meets the service's: an agent cannot omit an argument, so
"leave unchanged", "clear" and "set" have to be encoded in the value. Getting
that mapping wrong is silent — the call succeeds and changes the wrong thing.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.services.design_systems import DesignSystemCycle
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_system():
s = MagicMock()
s.to_dict.return_value = {"id": 1, "title": "FabledSword", "parent_id": None}
return s
def _fake_token():
t = MagicMock()
t.to_dict.return_value = {"id": 9, "name": "--fs-obsidian"}
return t
# --- create -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_creating_without_a_parent_passes_none_not_zero():
"""0 is the "omitted" sentinel, and it must not reach the service as a
system id — there is no system 0, so the create would fail an ACL check for
a record that cannot exist."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="FabledSword")
assert svc.create_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_creating_with_a_parent_passes_it_through():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import create_design_system
await create_design_system(title="Scribe", parent_id=4)
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
@pytest.mark.asyncio
async def test_create_raises_when_the_parent_is_not_writable():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=None)
from scribe.mcp.tools.design_systems import create_design_system
with pytest.raises(ValueError):
await create_design_system(title="Scribe", parent_id=4)
# --- the three-state parent -------------------------------------------------
@pytest.mark.asyncio
async def test_update_with_parent_id_zero_leaves_the_parent_alone():
"""The common case — renaming a system must not silently re-root it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, title="Renamed")
fields = svc.update_design_system.await_args.kwargs
assert "parent_id" not in fields
assert fields["title"] == "Renamed"
@pytest.mark.asyncio
async def test_update_with_parent_id_minus_one_clears_it():
"""-1 means "make this a family system". It has to arrive at the service as
None, which is the value the service reads as "become a root" — where
omitting the key means "leave alone"."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=-1)
assert svc.update_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_update_with_a_positive_parent_id_sets_it():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_system())
from scribe.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=4)
assert svc.update_design_system.await_args.kwargs["parent_id"] == 4
@pytest.mark.asyncio
async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found():
"""The service raises so this layer can keep the two apart. An agent told
"not found" would retry the same call; one told what the loop is can fix it."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(
side_effect=DesignSystemCycle("2 already inherits from 1")
)
from scribe.mcp.tools.design_systems import update_design_system
with pytest.raises(ValueError, match="already inherits"):
await update_design_system(design_system_id=1, parent_id=2)
# --- tokens -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_token_treats_order_index_minus_one_as_unchanged():
"""0 is a VALID order_index, so it cannot double as the omitted sentinel —
the same reason the systems tools use -1."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="page bg")
fields = svc.update_token.await_args.kwargs
assert "order_index" not in fields
assert fields["purpose"] == "page bg"
@pytest.mark.asyncio
async def test_update_token_accepts_order_index_zero():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, order_index=0)
assert svc.update_token.await_args.kwargs["order_index"] == 0
@pytest.mark.asyncio
async def test_update_token_can_set_an_empty_value_map():
"""`value_by_mode={}` is meaningful — it strips every mode from a token. The
guard is `is not None`, not truthiness, or that edit would be unreachable."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, value_by_mode={})
assert svc.update_token.await_args.kwargs["value_by_mode"] == {}
# --- the project pointer ----------------------------------------------------
@pytest.mark.asyncio
async def test_clearing_a_projects_design_system_passes_none():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.set_project_design_system = AsyncMock(return_value=True)
from scribe.mcp.tools.design_systems import set_project_design_system
result = await set_project_design_system(project_id=2, design_system_id=-1)
assert svc.set_project_design_system.await_args.args[2] is None
assert result["design_system_id"] is None
@pytest.mark.asyncio
async def test_resolve_returns_serialised_tokens_with_their_provenance():
"""The payload has to carry the shadowed entries, not just the winner —
dropping them at the serialisation boundary would discard the one thing
resolution was built to preserve."""
from scribe.services.design_cascade import resolve_tokens
class _T:
def __init__(self, name, value_by_mode):
self.name, self.value_by_mode = name, value_by_mode
self.group_name = self.purpose = None
self.order_index = 0
resolved = resolve_tokens(
2, {1: None, 2: 1},
{1: [_T("--fs-accent", {"base": "#6b2118"})],
2: [_T("--fs-accent", {"base": "#5b4a8a"})]},
)
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.resolve_design_system = AsyncMock(return_value=resolved)
from scribe.mcp.tools.design_systems import resolve_design_system
payload = await resolve_design_system(design_system_id=2)
token = payload["tokens"][0]
assert token["value_by_mode"] == {"base": "#5b4a8a"}
assert token["origin_by_mode"] == {"base": 2}
assert token["contributions"]["base"] == [
{"system_id": 2, "value": "#5b4a8a"},
{"system_id": 1, "value": "#6b2118"},
]
@pytest.mark.asyncio
async def test_update_token_can_clear_supersedes_with_an_empty_list():
"""`[]` means "this token replaces nothing after all" — a real edit. Guarded
on `is not None` so it isn't swallowed as "unchanged", the same trap #2077
recorded for update_snippet."""
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, supersedes=[])
assert svc.update_token.await_args.kwargs["supersedes"] == []
@pytest.mark.asyncio
async def test_update_token_leaves_supersedes_alone_when_omitted():
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from scribe.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="text on action surfaces")
assert "supersedes" not in svc.update_token.await_args.kwargs
+44 -1
View File
@@ -17,12 +17,16 @@ def _bind_user():
_user_id_ctx.reset(token)
def _fake_project(**overrides) -> MagicMock:
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
p = MagicMock()
base = {"id": 1, "title": "P", "description": "", "goal": "",
"status": "active", "color": None}
base.update(overrides)
p.to_dict.return_value = base
# Explicit, because a bare MagicMock hands back a truthy auto-attribute —
# which would route every project in this file through the design-system
# branch and out to a real database.
p.design_system_id = design_system_id
return p
@@ -176,6 +180,45 @@ async def test_enter_project_composes_full_context():
assert out["open_tasks"][0]["id"] == 100
assert out["open_tasks"][0]["status"] == "in_progress"
assert out["recent_notes"][0]["id"] == 200
# No design system on this project -> the key is present and null, not
# absent. A caller that has to distinguish "no key" from "no system" will
# eventually get it wrong.
assert out["design_system"] is None
@pytest.mark.asyncio
async def test_enter_project_hands_back_the_design_system_when_the_project_has_one():
"""The handshake is where an agent learns what binds it, and a design
system binds the same way a rule does. Before this it was reachable only by
an agent that already knew to call resolve_design_system — so the standards
were present in the store and absent from the work."""
p = _fake_project(id=5, design_system_id=9)
design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}],
"token_count": 95, "token_groups": ["surface"],
"inherits_from": ["House"], "description": ""}
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []}),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]),
), patch(
"scribe.mcp.tools.projects.notes_svc.list_notes",
AsyncMock(side_effect=[([], 0), ([], 0)]),
), patch(
"scribe.mcp.tools.projects.design_systems_svc.design_context",
AsyncMock(return_value=design),
) as ctx:
out = await enter_project(project_id=5)
assert out["design_system"]["token_count"] == 95
assert out["design_system"]["inherits_from"] == ["House"]
assert ctx.await_args.args == (7, 9) # caller's id, the project's system
@pytest.mark.asyncio
+40
View File
@@ -22,6 +22,12 @@ def _note(nid, title, user_id=1):
n = MagicMock()
n.id, n.title, n.user_id = nid, title, user_id
n.note_type = "snippet"
# See the same note in test_write_path_trigger: an auto-created mock on
# `.data` is truthy and would leak into a rendered menu line (#2244).
n.data = None
# And on `.is_task`, which the kind marker reads FIRST — truthy there makes
# every one of these snippets read as a task (#2246).
n.is_task, n.task_kind = False, "work"
return n
@@ -183,3 +189,37 @@ async def test_auto_inject_records_what_survived_the_margin_gate():
assert surfaced.call_args.kwargs["note_ids"] == [1]
assert surfaced.call_args.kwargs["source"] == "auto_inject"
def test_every_getter_that_can_be_surfaced_also_records_a_pull():
"""Rule #33 contract check — and the one that would have caught #2245.
`surfaced` and `pulled` only mean something as a PAIR: the rate between them
is what #1038 and #2085 gate on. That pair is only closed if the tool which
OPENS a record reports it. `get_note` did, `get_snippet` did, `get_task` did
NOT — and auto-inject ranks kind-blind over a corpus that is overwhelmingly
tasks and issues, so the gap sat exactly where the volume is: every surfaced
task counted as never-pulled, dragging measured pull-through toward zero for
the menu's own dominant kind.
Asserted by source inspection rather than by calling the tools, because the
failure is a MISSING call — which no behavioural test of the tool's return
value can see.
"""
import inspect
from scribe.mcp.tools import notes as notes_tools
from scribe.mcp.tools import snippets as snippet_tools
from scribe.mcp.tools import tasks as task_tools
getters = (
(notes_tools, "get_note"),
(task_tools, "get_task"),
(snippet_tools, "get_snippet"),
)
for module, name in getters:
src = inspect.getsource(getattr(module, name))
assert "record_pulled(" in src, (
f"{name} can be surfaced in an auto-inject menu but records no pull — "
"its pull-through rate will read as zero regardless of real usage"
)
+118
View File
@@ -0,0 +1,118 @@
"""Structural tests for the design-systems blueprint + the parity guard.
Modelled on tests/test_routes_snippets.py. The enumerations below are
deliberate: relaxing one to a pattern would stop it catching the thing it was
written for, which is a capability landing on one surface and not the other.
"""
import inspect
def test_design_systems_blueprint_registered():
from scribe.routes.design_systems import design_systems_bp
assert design_systems_bp.name == "design_systems"
assert design_systems_bp.url_prefix == "/api"
def test_design_systems_blueprint_registered_in_app():
from scribe.app import create_app
app = create_app()
assert "design_systems" in app.blueprints
def test_route_handlers_callable():
from scribe.routes import design_systems as routes
for name in (
"list_design_systems", "create_design_system", "get_design_system",
"update_design_system", "delete_design_system", "resolve_design_system",
"get_design_system_stylesheet",
"check_snippets_against_system",
"list_design_tokens", "create_design_token",
"update_design_token", "delete_design_token", "set_project_design_system",
):
assert callable(getattr(routes, name))
def test_every_endpoint_is_reachable_on_the_app():
"""The handlers existing is not the same as them being routed. This catches
a decorator that was copied without its path changing — two handlers on one
rule, where the second silently never runs."""
from scribe.app import create_app
app = create_app()
rules = {
str(r.rule) for r in app.url_map.iter_rules()
if r.endpoint.startswith("design_systems.")
}
assert rules == {
"/api/design-systems",
"/api/design-systems/<int:design_system_id>",
"/api/design-systems/<int:design_system_id>/resolved",
"/api/design-systems/<int:design_system_id>/stylesheet",
"/api/design-systems/<int:design_system_id>/snippet-check",
"/api/design-systems/<int:design_system_id>/tokens",
"/api/design-tokens/<int:token_id>",
"/api/projects/<int:project_id>/design-system",
}
def test_service_functions_take_user_id():
"""Routes must call the services with user_id — verify the contract (#33)."""
from scribe.services import design_systems as svc
for fn_name in (
"create_design_system", "list_design_systems", "get_design_system",
"update_design_system", "delete_design_system", "resolve_design_system",
"create_token", "list_tokens", "update_token", "delete_token",
"set_project_design_system",
"stylesheet_for_system", "check_snippets_against_system",
):
fn = getattr(svc, fn_name)
assert callable(fn)
assert "user_id" in inspect.signature(fn).parameters
def test_agent_and_web_surfaces_stay_at_parity():
"""The MCP tools and the REST routes are two callers of one service; a
capability on one has to exist on the other (rule #33).
This guard exists because the snippet surfaces drifted apart once — MCP had
no delete, the web side had no near-duplicate gate — and neither failed
anything until someone went looking.
"""
from scribe.mcp.tools import design_systems as tools
from scribe.routes import design_systems as routes
for name in (
"create_design_system", "list_design_systems", "get_design_system",
"resolve_design_system", "update_design_system", "delete_design_system",
"create_design_token", "list_design_tokens", "update_design_token",
"delete_design_token", "set_project_design_system",
"get_design_system_stylesheet",
):
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
assert callable(getattr(routes, name)), f"REST route missing: {name}"
# The snippet check is the one verb whose handler names differ between the
# surfaces (the tool says what it checks AGAINST; the route is already under
# the system), so the loop above can't pair it. It still has to exist on both.
assert callable(tools.check_snippets_against_design_system)
assert callable(routes.check_snippets_against_system)
def test_every_mcp_tool_in_the_module_is_registered():
"""A tool written but never registered is invisible to an agent, and nothing
else in the codebase would notice."""
from scribe.mcp.tools import design_systems as tools
registered = []
class _Recorder:
def tool(self, name):
registered.append(name)
return lambda fn: fn
tools.register(_Recorder())
public = {
name for name, obj in vars(tools).items()
if inspect.iscoroutinefunction(obj) and not name.startswith("_")
}
assert set(registered) == public
+37
View File
@@ -132,3 +132,40 @@ def test_clauses_are_pure_builders(clause_fn):
import inspect
assert not inspect.iscoroutinefunction(clause_fn)
assert _sql(clause_fn(7)) # builds without touching a database
# --- design systems ----------------------------------------------------------
#
# A design system is reachable two ways: you own it, or you can see a project
# that inherits from it. The gap between what those two grant is the invariant
# worth guarding — see get_design_system_permission.
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permission, readable, writable",
[
("owner", True, True),
# Project-derived. Being an EDITOR on a shared project must not confer
# the right to rewrite the family system that project inherits from —
# that would let one project's collaborator restyle every other project
# in the family.
("viewer", True, False),
(None, False, False),
],
)
async def test_reaching_a_design_system_via_a_project_reads_but_never_writes(
permission, readable, writable
):
from unittest.mock import AsyncMock, patch
from scribe.services.access import (
can_read_design_system,
can_write_design_system,
)
with patch(
"scribe.services.access.get_design_system_permission",
AsyncMock(return_value=permission),
):
assert await can_read_design_system(1, 3) is readable
assert await can_write_design_system(1, 3) is writable
+48 -8
View File
@@ -13,16 +13,54 @@ import pytest
from scribe.services import backup
def test_backup_version_is_v4():
assert backup.BACKUP_VERSION == 4
def test_backup_version_is_v5():
assert backup.BACKUP_VERSION == 5
def test_not_included_lists_the_known_gaps():
# The deferred tables must be surfaced explicitly, not silently dropped.
for table in ("groups", "project_shares", "note_shares", "api_keys", "embeddings"):
for table in ("groups", "project_shares", "note_shares", "api_keys",
"note_embeddings", "retrieval_logs"):
assert table in backup._NOT_INCLUDED
def test_every_table_is_either_backed_up_or_explicitly_excluded():
"""THE GUARD (#2293), and the only shape of test that catches an ABSENCE.
A new table gets a model and a migration — both fail loudly if wrong — and
then silently never gets a backup section. No error, no warning, and a
restore that reports success. That is how `systems`, `record_systems`,
`note_usage_events`, `design_systems`, `design_tokens` and `repo_bindings`
all went missing, over five migrations, with nothing to notice.
Extending the export fixes today. THIS fixes the next one: adding a table
now fails here until someone either backs it up or states in
`_NOT_INCLUDED` that it shouldn't be. Either is fine; silence is not.
"""
from scribe.models import Base
schema = set(Base.metadata.tables)
accounted = set(backup._BACKED_UP) | set(backup._NOT_INCLUDED)
unaccounted = schema - accounted
assert not unaccounted, (
f"{len(unaccounted)} table(s) are neither backed up nor explicitly "
f"excluded: {sorted(unaccounted)}. Add each to backup._BACKED_UP (and "
f"give it an export + restore section) or to backup._NOT_INCLUDED with "
f"a reason in the comment above it."
)
# And the reverse: a name in either list that no longer exists is a lie the
# guard would otherwise keep telling. This half is what caught "embeddings",
# "invitations" and "password_resets" — three entries that named nothing.
phantom = accounted - schema
assert not phantom, (
f"backup lists table(s) that are not in the schema: {sorted(phantom)}. "
f"Renamed or dropped — fix the list rather than leaving it to read as "
f"coverage."
)
def test_join_table_row_helpers_are_pure():
subs = [SimpleNamespace(project_id=1, rulebook_id=2)]
rsup = [SimpleNamespace(project_id=1, rule_id=9)]
@@ -57,16 +95,18 @@ class _CM:
@pytest.mark.asyncio
async def test_export_full_backup_contains_v3_sections():
async def test_export_full_backup_contains_every_declared_section():
with patch("scribe.services.backup.async_session", lambda: _CM()):
out = await backup.export_full_backup()
assert out["version"] == 4
assert out["version"] == backup.BACKUP_VERSION
assert out["scope"] == "full"
assert "api_keys" in out["_not_included"]
# The sections v2 silently dropped must now be present (empty here).
# The sections v2 silently dropped, plus the six v5 added (empty here).
for key in ("rulebooks", "rulebook_topics", "rules",
"rulebook_subscriptions", "rule_suppressions",
"topic_suppressions"):
assert key in out, f"missing v3 section: {key}"
"topic_suppressions",
"systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings"):
assert key in out, f"missing export section: {key}"
assert out[key] == []
+414
View File
@@ -0,0 +1,414 @@
"""ACL gating and field handling for services/design_systems.py (unit, mocked).
Mirrors tests/test_services_systems.py — same mocked-session shape, because the
question is the same one: does the service refuse before it touches a row?
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.design_systems import DesignSystemCycle
def _make_mock_session():
s = AsyncMock()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
s.add = MagicMock()
s.commit = AsyncMock()
s.refresh = AsyncMock()
return s
# --- creating ---------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_with_a_parent_is_denied_without_write_on_that_parent():
"""Parenting to someone else's system would let their delete or re-parent
silently restyle your app, so the chain may only be built from systems you
can write — which, per the ACL, means ones you own."""
with patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import create_design_system
result = await create_design_system(user_id=1, title="Scribe", parent_id=7)
assert result is None
@pytest.mark.asyncio
async def test_create_without_a_parent_never_consults_the_parent_acl():
"""A family system has no parent, and creating one must not be gated on a
permission check for a system that does not exist."""
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(
title=obj.title, parent_id=obj.parent_id
)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=False)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_design_system
await create_design_system(user_id=1, title=" FabledSword ")
assert captured["title"] == "FabledSword" # stripped
assert captured["parent_id"] is None
acc.can_write_design_system.assert_not_awaited()
# --- the cycle guard, through the service -----------------------------------
@pytest.mark.asyncio
async def test_reparenting_into_a_loop_raises_rather_than_returning_none():
"""None already means "not found, or not yours". A caller that conflated the
two would report "no such design system" for what is really "that parent is
one of its own descendants", so the cycle gets its own exception type."""
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=MagicMock(deleted_at=None, parent_id=None))
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={1: None, 2: 1})):
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import update_design_system
with pytest.raises(DesignSystemCycle):
await update_design_system(user_id=1, design_system_id=1, parent_id=2)
mock_session.commit.assert_not_awaited() # refused BEFORE the write
@pytest.mark.asyncio
async def test_clearing_the_parent_is_allowed_and_is_not_read_as_no_change():
"""`parent_id=None` means "make this a root" — the one field where None is a
value rather than "leave alone", which is why it is handled apart from the
others."""
system = MagicMock(deleted_at=None, parent_id=5)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=system)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={1: 5, 5: None})):
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import update_design_system
await update_design_system(user_id=1, design_system_id=1, parent_id=None)
assert system.parent_id is None
# --- tokens -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_token_denied_without_write_on_the_system():
with patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import create_token
result = await create_token(user_id=1, design_system_id=3, name="--fs-obsidian")
assert result is None
@pytest.mark.asyncio
async def test_token_values_default_to_an_empty_map_not_json_null():
"""The column is NOT NULL so that absence has exactly ONE spelling. Passing
None straight through would store JSON null and hand every reader back the
second empty state the schema was shaped to remove."""
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(value_by_mode=obj.value_by_mode)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_token
await create_token(
user_id=1, design_system_id=3, name="--fs-radius-md", value_by_mode=None
)
assert captured["value_by_mode"] == {}
@pytest.mark.asyncio
async def test_list_tokens_denied_returns_empty():
with patch("scribe.services.design_systems.access") as acc:
acc.can_read_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import list_tokens
assert await list_tokens(user_id=1, design_system_id=3) == []
# --- the project pointer ----------------------------------------------------
@pytest.mark.asyncio
async def test_pointing_a_project_at_a_system_needs_write_on_the_project():
with patch("scribe.services.design_systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=False)
acc.can_read_design_system = AsyncMock(return_value=True)
from scribe.services.design_systems import set_project_design_system
assert await set_project_design_system(1, project_id=5, design_system_id=3) is False
@pytest.mark.asyncio
async def test_pointing_a_project_needs_only_READ_on_the_system():
"""Consuming a design system is not changing it, so a system reachable
through another project is a legitimate choice here. Requiring write would
make a shared family style unusable by the people it was shared with."""
project = MagicMock(deleted_at=None, design_system_id=None)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=project)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=True)
acc.can_read_design_system = AsyncMock(return_value=True)
acc.can_write_design_system = AsyncMock(return_value=False)
mock_cls.return_value = mock_session
from scribe.services.design_systems import set_project_design_system
assert await set_project_design_system(1, project_id=5, design_system_id=3) is True
assert project.design_system_id == 3
@pytest.mark.asyncio
async def test_clearing_a_projects_design_system_skips_the_system_acl():
"""Un-styling a project must not require permission on the system it is
letting go of — including one that has since been deleted."""
project = MagicMock(deleted_at=None, design_system_id=3)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=project)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=True)
acc.can_read_design_system = AsyncMock(return_value=False)
mock_cls.return_value = mock_session
from scribe.services.design_systems import set_project_design_system
assert await set_project_design_system(1, project_id=5, design_system_id=None) is True
assert project.design_system_id is None
acc.can_read_design_system.assert_not_awaited()
# --- resolution (step 2) ----------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_denied_returns_none_not_an_empty_set():
"""None and [] mean different things here: "you may not see this" versus
"this chain genuinely holds no tokens". A caller that got [] for a denial
would render an empty design system as though it were real."""
with patch("scribe.services.design_systems.access") as acc:
acc.can_read_design_system = AsyncMock(return_value=False)
from scribe.services.design_systems import resolve_design_system
assert await resolve_design_system(user_id=1, design_system_id=3) is None
@pytest.mark.asyncio
async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller():
"""LOAD-BEARING for shared projects. A caller reading through a project
shared with them owns no link in the chain, so a caller-scoped parent map
returns an empty forest and the cascade truncates to one system — a page
that renders with plausible wrong values and no error anywhere.
The ACL already grants read on the whole chain (reachability propagates
upward); this is the loading side keeping that promise.
"""
owner, caller = 42, 7
system = MagicMock(deleted_at=None, owner_user_id=owner)
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=system)
mock_session.execute = AsyncMock(
return_value=MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: [])))
)
parent_map = AsyncMock(return_value={3: None})
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems._parent_map", parent_map), \
patch("scribe.services.design_systems.access") as acc:
acc.can_read_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import resolve_design_system
result = await resolve_design_system(user_id=caller, design_system_id=3)
assert result == [] # empty chain, not None
assert parent_map.await_args.args[1] == owner # NOT `caller`
# --- supersedes (step 6's reframe) ------------------------------------------
@pytest.mark.asyncio
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
"""Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_token
await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
assert captured["supersedes"] == []
@pytest.mark.asyncio
async def test_create_token_records_the_literals_it_replaces():
mock_session = _make_mock_session()
captured = {}
mock_session.add = MagicMock(
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
)
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems.access") as acc:
acc.can_write_design_system = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.design_systems import create_token
await create_token(
user_id=1, design_system_id=3, name="--fs-text-on-action",
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
)
assert captured["supersedes"] == ["#fff", "#ffffff"]
# --- the master sheet -------------------------------------------------------
@pytest.mark.asyncio
async def test_stylesheet_denied_returns_none():
with patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=None)):
from scribe.services.design_systems import stylesheet_for_system
assert await stylesheet_for_system(1, 3) is None
@pytest.mark.asyncio
async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
"""The CSS alone hides two things a reviewer needs: which tokens are still
valueless, and which values are declared twice. Both ride alongside rather
than being left for the reader to derive from the text."""
from scribe.services.design_cascade import Contribution, ResolvedToken
def _resolved(name, base=None):
return ResolvedToken(
name=name,
contributions=(
{"base": (Contribution(system_id=1, value=base),)} if base else {}
),
group_name=None, purpose=None, rationale=None,
supersedes=(), order_index=0,
)
system = MagicMock()
system.title = "FabledSword"
with patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=[
_resolved("--fs-moss", "#4a5d3f"),
_resolved("--fs-success", "#4a5d3f"),
_resolved("--fs-radius-sm"),
])), \
patch("scribe.services.design_systems.get_design_system",
AsyncMock(return_value=system)):
from scribe.services.design_systems import stylesheet_for_system
result = await stylesheet_for_system(1, 3)
assert result["token_count"] == 3
assert result["valueless"] == ["--fs-radius-sm"]
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
assert "--fs-moss: #4a5d3f;" in result["css"]
assert "FabledSword" in result["css"]
# --- design_context: the delivery side --------------------------------------
def _ctx_token(name: str, group: str | None):
from scribe.services.design_cascade import ResolvedToken
return ResolvedToken(
name=name, contributions={}, group_name=group,
purpose=None, rationale=None, supersedes=(), order_index=0,
)
@pytest.mark.asyncio
async def test_design_context_merges_guidance_ANCESTOR_FIRST():
"""LOAD-BEARING. A child system holds only what it CHANGES, so its own
guidance describes a departure from a house style it never restates. An
agent handed the leaf alone builds against a fragment with no signal that
the rest exists — which is exactly the failure retiring the design rulebook
would otherwise have caused.
"""
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
description="the house", guidance="Dark-mode-first.")
family.title = "House"
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
description="one app", guidance="Accent on the wordmark.")
app.title = "App"
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=app)
mock_session.execute = AsyncMock(return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
))
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={3: 1, 1: None})), \
patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=[
_ctx_token("--a", "surface"), _ctx_token("--b", "type"),
_ctx_token("--c", "surface"), _ctx_token("--d", None),
])):
mock_cls.return_value = mock_session
from scribe.services.design_systems import design_context
out = await design_context(user_id=42, design_system_id=3)
assert [g["title"] for g in out["guidance"]] == ["House", "App"]
assert out["inherits_from"] == ["House"]
assert out["token_count"] == 4
# Group names deduped and sorted; an ungrouped token contributes nothing.
assert out["token_groups"] == ["surface", "type"]
@pytest.mark.asyncio
async def test_design_context_denied_returns_none():
"""It rides on resolve_design_system's ACL rather than re-deriving one —
a second permission path is a second thing to get wrong."""
with patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=None)):
from scribe.services.design_systems import design_context
assert await design_context(user_id=1, design_system_id=3) is None
@pytest.mark.asyncio
async def test_design_context_omits_systems_with_no_guidance():
"""A system that overrides one token and says nothing about it should not
contribute an empty section — a heading with nothing under it reads as
missing content rather than as an absence of content."""
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
description="", guidance="Dark-mode-first.")
family.title = "House"
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
description="", guidance=" ")
app.title = "App"
mock_session = _make_mock_session()
mock_session.get = AsyncMock(return_value=app)
mock_session.execute = AsyncMock(return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
))
with patch("scribe.services.design_systems.async_session") as mock_cls, \
patch("scribe.services.design_systems._parent_map",
AsyncMock(return_value={3: 1, 1: None})), \
patch("scribe.services.design_systems.resolve_design_system",
AsyncMock(return_value=[])):
mock_cls.return_value = mock_session
from scribe.services.design_systems import design_context
out = await design_context(user_id=42, design_system_id=3)
assert [g["title"] for g in out["guidance"]] == ["House"]
assert out["token_count"] == 0
+64
View File
@@ -0,0 +1,64 @@
"""services/notes.py — the inline embedding moved here from the routes (#2056).
Why it moved: embedding was triggered at each REST route and nowhere else, so a
record created through MCP stayed out of semantic search and auto-inject until
the next restart's backfill. Putting it in the service means every caller — REST,
MCP, recurrence, snippets — gets it by construction rather than by remembering.
These test the helper directly. The point of the change is that there is now ONE
place to test.
"""
from scribe.services import notes as notes_svc
# --- inline embedding (#2056) -----------------------------------------------
def test_embed_note_uses_the_OWNER_not_the_caller():
"""LOAD-BEARING for shared records. An embedding belongs to the record; a
collaborator editing a shared note must refresh the owner's row rather than
mint a second one under their own id. The routes this replaced passed the
caller's uid on one path and the owner's on another — exactly the kind of
inconsistency that moving it to one place removes."""
from unittest.mock import MagicMock, patch
note = MagicMock(id=5, user_id=42, title="T", body="B")
with patch("scribe.services.embeddings.upsert_note_embedding") as upsert, \
patch("asyncio.create_task") as create_task:
notes_svc.embed_note(note)
assert create_task.called
upsert.assert_called_once()
assert upsert.call_args.args[0] == 5
assert upsert.call_args.args[1] == 42 # owner, never the caller
assert upsert.call_args.args[2] == "T\nB"
def test_embed_note_skips_a_record_with_no_text():
"""An empty embedding is worse than none — it is a row that matches nothing
and hides the fact that the record was never indexed."""
from unittest.mock import MagicMock, patch
note = MagicMock(id=5, user_id=42, title="", body="")
with patch("asyncio.create_task") as create_task:
notes_svc.embed_note(note)
assert not create_task.called
def test_embed_note_without_a_running_loop_is_not_an_error():
"""Unit tests and scripts call create_note with no event loop. That must be
an ordinary case: a write that succeeded cannot be failed by its index
refresh."""
from unittest.mock import MagicMock, patch
note = MagicMock(id=5, user_id=42, title="T", body="B")
with patch("asyncio.create_task", side_effect=RuntimeError("no running loop")):
notes_svc.embed_note(note) # must not raise
def test_embed_note_swallows_an_indexing_failure():
"""Same reason, wider net: the embedding model being unavailable must not
turn a successful save into a 500."""
from unittest.mock import MagicMock, patch
note = MagicMock(id=5, user_id=42, title="T", body="B")
with patch("asyncio.create_task", side_effect=ValueError("model gone")):
notes_svc.embed_note(note) # must not raise

Some files were not shown because too many files have changed in this diff Show More