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
74 changed files with 2534 additions and 1920 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
+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;
+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;
}
+14 -2
View File
@@ -132,9 +132,10 @@
--fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent);
/* text */
--fs-text-primary: #E8E4D8; /* also the text colour on filled action buttons */
--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;
@@ -178,6 +179,14 @@
--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.
@@ -282,7 +291,10 @@
visual shifts exactly where a fallback had drifted; that shift IS the fix.
------------------------------------------------------------------ */
--color-accent: var(--fs-accent);
--color-accent-fg: var(--fs-text-primary);
/* 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);
+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);
-46
View File
@@ -1262,52 +1262,6 @@ textarea.input {
/* Buttons ---------------------------------------------------------------- */
.btn-primary,
.btn-ghost,
.btn-danger {
border-radius: var(--radius-sm);
padding: 0.45rem 0.9rem;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
border: 1px solid transparent;
}
/* Background matches the house convention (--color-action-primary, as in
ProjectListView and four others). The text colour deliberately does NOT:
every existing copy uses `color: #fff`, which is 67 live violations of the
rule that pure white is never text (#2275). Parchment is what the rulebook
actually specifies. */
.btn-primary {
background: var(--color-action-primary);
color: #E8E4D8;
}
.btn-primary:not(:disabled):hover {
background: var(--color-action-primary-hover);
}
.btn-ghost {
background: none;
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.btn-danger {
background: var(--color-action-destructive);
color: #E8E4D8;
}
.btn-small {
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
}
.btn-primary:disabled,
.btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-actions {
+45 -17
View File
@@ -12,11 +12,12 @@
* 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 are the case where that bites. Rule 65 specifies four variants, but
* `.btn-primary` is defined four separate times in four `<style scoped>` blocks
* and 30 of 54 SFCs carry their own button CSS (#2273). There is no shared
* button to import, so rendering one here would just make this a fifth copy.
* It is reported as a gap instead.
* 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";
@@ -225,25 +226,33 @@ const TYPE_SPECIMENS = [
</div>
</section>
<!-- The honest gap. -->
<!-- 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="gap-notice">
<strong>Not implemented as a shared component.</strong>
<p>
Rule 65 specifies four variants. In practice <code>.btn-primary</code> is
defined four separate times in four <code>&lt;style scoped&gt;</code>
blocks, and 30 of 54 single-file components carry their own button CSS.
There is no shared button to render here, and drawing one would make
this page a fifth copy of it the exact drift this surface exists to
catch. Tracked as issue #2273.
</p>
<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 missing">missing</span>
<span class="spec-status ok">shared</span>
</li>
</ul>
</section>
@@ -361,6 +370,25 @@ const TYPE_SPECIMENS = [
/* 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);
+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;
+12 -150
View File
@@ -435,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
@@ -451,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"
@@ -464,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>
@@ -548,7 +548,7 @@ async function confirmDelete() {
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
<button class="btn-primary" @click="saveProject" :disabled="!editDirty || saving">
{{ saving ? "Saving..." : "Save Changes" }}
</button>
</aside>
@@ -581,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">
@@ -593,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>
@@ -856,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;
@@ -1062,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;
@@ -1148,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)); }
@@ -1224,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); }
@@ -1393,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 {
@@ -1475,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;
+28 -241
View File
@@ -1165,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>
@@ -1190,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>
@@ -1313,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>
@@ -1361,7 +1361,7 @@ function formatUserDate(iso: string): string {
</div>
<div class="actions">
<button
class="btn-save"
class="btn-primary"
@click="changeEmail"
:disabled="changingEmail || !emailPassword"
>
@@ -1409,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"
>
@@ -1465,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>
@@ -1491,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>
@@ -1501,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>
@@ -1533,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>
@@ -1562,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>
@@ -1589,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>
@@ -1891,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>
@@ -1916,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>
@@ -1946,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>
@@ -2040,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>
@@ -2054,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>
@@ -2079,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"
@@ -2100,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>
@@ -2122,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>
@@ -2161,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>
@@ -2305,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>
@@ -2338,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>
@@ -2564,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;
@@ -2685,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 {
@@ -2985,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; }
@@ -3148,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%;
@@ -3244,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;
@@ -3391,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);
@@ -3782,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;
}
@@ -3850,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)
+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
+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", []),
+6
View File
@@ -276,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
-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)
+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)
+21
View File
@@ -194,6 +194,27 @@ def render_stylesheet(
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"
+62
View File
@@ -282,6 +282,68 @@ async def resolve_design_system(
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:
+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)
+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)
+129 -4
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
@@ -289,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,
@@ -340,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
@@ -684,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",
@@ -692,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:
@@ -700,8 +785,15 @@ 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}",
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
@@ -732,7 +824,9 @@ async def build_write_path_hint(
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 "
"`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.
@@ -837,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
+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
+3
View File
@@ -25,6 +25,9 @@ def _note(nid, title, user_id=1):
# 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
+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] == []
+91
View File
@@ -321,3 +321,94 @@ async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
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
+241 -1
View File
@@ -19,6 +19,9 @@ def _note(nid, title, user_id=1, note_type="note", is_task=False, task_kind="wor
# auto-MagicMock is truthy, so every line would read as another user's task.
n.user_id = user_id
n.note_type, n.is_task, n.task_kind = note_type, is_task, task_kind
# The write-path menu reads note.data for a language tag; an auto-mock there
# is truthy and renders its repr into the marker.
n.data = None
return n
@@ -130,7 +133,11 @@ async def test_build_session_context_renders_titles_grouped_by_topic():
@pytest.mark.asyncio
async def test_build_session_context_includes_project_when_scoped():
project = MagicMock(id=2, title="FabledScribe", goal="ship it")
# design_system_id explicitly None: a bare MagicMock would hand back a truthy
# auto-attribute and send this through the design branch, which is the
# opposite of what this test is about.
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
design_system_id=None)
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
patch("scribe.services.plugin_context._topic_titles",
@@ -145,6 +152,68 @@ async def test_build_session_context_includes_project_when_scoped():
assert out["project"] == {"id": 2, "title": "FabledScribe"}
assert "## Active project: FabledScribe (id 2)" in out["context"]
assert "Open todo tasks: 4" in out["context"]
# No design system on the project -> no design block at all. An install with
# none is the ordinary case, not a degraded one.
assert "## Design system" not in out["context"]
@pytest.mark.asyncio
async def test_build_session_context_pushes_the_projects_design_system():
"""The gap this closes: a design system had no push channel, so its
standards reached a session only if the agent already knew to go looking —
the same silent failure as a token nobody declares."""
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
design = {
"id": 9, "title": "App kit", "description": "",
"inherits_from": ["House"],
"guidance": [], "token_count": 95,
"token_groups": ["accent", "surface", "type"],
}
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
patch("scribe.services.plugin_context._topic_titles",
AsyncMock(return_value={1: "git-workflow"})), \
patch("scribe.services.plugin_context.projects_svc.get_project",
AsyncMock(return_value=project)), \
patch("scribe.services.plugin_context.notes_svc.list_notes",
AsyncMock(return_value=([], 0))), \
patch("scribe.services.plugin_context.design_systems_svc.design_context",
AsyncMock(return_value=design)):
from scribe.services.plugin_context import build_session_context
out = await build_session_context(user_id=7, project_id=2)
ctx = out["context"]
assert "## Design system: App kit (id 9) (inherits House)" in ctx
assert "95 tokens across accent, surface, type" in ctx
# A pointer to the values, never the values themselves — a hundred token
# declarations would crowd out the context they are meant to inform. The
# summary carries counts and group NAMES only, which is why design_context
# returns those rather than the resolved tokens.
assert "resolve_design_system(9)" in ctx
assert "get_design_system_stylesheet(9)" in ctx
@pytest.mark.asyncio
async def test_build_session_context_survives_an_unreadable_design_system():
"""design_context returns None when the caller may not read the system.
That must degrade to "no design block", not to a crash that costs the
session its rules too."""
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
patch("scribe.services.plugin_context._topic_titles",
AsyncMock(return_value={1: "git-workflow"})), \
patch("scribe.services.plugin_context.projects_svc.get_project",
AsyncMock(return_value=project)), \
patch("scribe.services.plugin_context.notes_svc.list_notes",
AsyncMock(return_value=([], 0))), \
patch("scribe.services.plugin_context.design_systems_svc.design_context",
AsyncMock(return_value=None)):
from scribe.services.plugin_context import build_session_context
out = await build_session_context(user_id=7, project_id=2)
assert "## Design system" not in out["context"]
assert "## Active project: App (id 2)" in out["context"]
@pytest.mark.asyncio
@@ -229,3 +298,174 @@ async def test_build_session_context_caps_length():
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
assert "truncated" in out["context"]
# --- the reuse slot (#2246) --------------------------------------------------
_CFG = {"enabled": True, "threshold": 0.55, "top_k": 3}
async def _autoinject(main_hits, reuse_hits, cfg=None):
"""Run build_autoinject_hint with the two semantic queries stubbed in order:
the unscoped pool first, then the reserved reuse query."""
calls: list[dict] = []
async def fake_search(*_a, **kw):
calls.append(kw)
return reuse_hits if kw.get("note_type") else main_hits
with patch("scribe.services.plugin_context.get_autoinject_config",
AsyncMock(return_value=dict(cfg or _CFG))), \
patch("scribe.services.plugin_context.semantic_search_notes",
AsyncMock(side_effect=fake_search)), \
patch("scribe.services.plugin_context.record_retrieval", MagicMock()), \
patch("scribe.services.plugin_context.record_surfaced", MagicMock()), \
patch("scribe.services.plugin_context.owner_names_for",
AsyncMock(return_value={})):
from scribe.services.plugin_context import build_autoinject_hint
out = await build_autoinject_hint(1, "write a debounce helper")
return out, calls
@pytest.mark.asyncio
async def test_a_snippet_takes_the_last_slot_when_none_won_on_score():
"""The measured failure: a prompt asking for a helper returned three project
records ABOUT building the retrieval system, and zero snippets. Scribe's own
records are about software work, so they share vocabulary with any coding
prompt while answering none of them."""
main = [(0.66, _note(1, "Step 3: title-first auto-inject")),
(0.65, _note(2, "Task-reminder dedup query crashes", is_task=True)),
(0.64, _note(3, "Drafter hardening · write-path trigger", is_task=True))]
reuse = [(0.58, _note(9, "debounce — collapse rapid calls", note_type="snippet"))]
out, calls = await _autoinject(main, reuse)
assert out["note_ids"] == [1, 2, 9] # last slot displaced, not the first
assert "#9" in out["context"] and "[snippet]" in out["context"]
# The reserved query is scoped to the reuse kinds and asks for exactly one.
assert calls[1]["note_type"] == ("snippet", "process")
assert calls[1]["limit"] == 1
@pytest.mark.asyncio
async def test_the_reserved_query_is_skipped_when_a_snippet_already_won():
"""No second query, and no slot spent twice, when ranking already did the
right thing — the fix must be invisible in the case it isn't needed."""
main = [(0.81, _note(9, "debounce helper", note_type="snippet")),
(0.80, _note(1, "some task", is_task=True))]
out, calls = await _autoinject(main, [])
assert len(calls) == 1 # reserved query never ran
assert out["note_ids"] == [9, 1]
@pytest.mark.asyncio
async def test_a_weak_snippet_does_not_buy_the_slot():
"""The reserved hit skips the MARGIN band — that band is what snippets lose
to — but never the threshold. Silence stays the default; a slot spent on an
irrelevant snippet is how a menu teaches people to ignore it."""
main = [(0.66, _note(1, "a task", is_task=True))]
out, calls = await _autoinject(main, []) # threshold returned nothing
assert out["note_ids"] == [1]
assert len(calls) == 2 # it asked, and got nothing
@pytest.mark.asyncio
async def test_the_reserved_hit_is_not_held_to_the_margin_band():
"""0.58 is 0.08 below the top hit. Under the band it would survive; the point
is that it must survive even when it wouldn't — a snippet losing to a
same-vocabulary project record by a wide margin is the whole bug."""
main = [(0.90, _note(1, "a task", is_task=True))]
reuse = [(0.58, _note(9, "debounce", note_type="snippet"))]
out, _calls = await _autoinject(main, reuse)
assert 9 in out["note_ids"]
@pytest.mark.asyncio
async def test_a_process_counts_as_reuse_too():
"""A stored process answers 'how do we do X here' the same way a snippet
answers 'what do we already have' — both lose to the same project records."""
main = [(0.70, _note(1, "a task", is_task=True))]
reuse = [(0.60, _note(8, "DRY pass process", note_type="process"))]
out, _ = await _autoinject(main, reuse)
assert 8 in out["note_ids"]
# …and one already on the menu suppresses the reserved query.
out2, calls2 = await _autoinject(
[(0.70, _note(8, "DRY pass process", note_type="process"))], [])
assert len(calls2) == 1
# --- write-path widened beyond snippets (#2246, the mirror half) -------------
@pytest.mark.asyncio
async def test_write_path_semantic_arm_asks_for_experience_not_just_snippets():
"""The inverse of auto-inject's mistake. This arm was snippets-only, so an
issue saying "we tried this and it deadlocked" could never reach the moment
that code was about to be written — arguably the better prior art, because
it says what NOT to do."""
from scribe.services import plugin_context as pc
hits = [(0.72, _note(9, "debounce helper", note_type="snippet")),
(0.70, _note(7, "Debounce dropped the trailing call", is_task=True,
task_kind="issue"))]
search = AsyncMock(return_value=hits)
rec = MagicMock()
with patch.object(pc, "get_writepath_config",
AsyncMock(return_value={"enabled": True, "threshold": 0.6,
"top_k": 3})), \
patch.object(pc.snippets_svc, "list_snippets",
AsyncMock(return_value=([], 0))), \
patch.object(pc, "semantic_search_notes", search), \
patch.object(pc, "record_retrieval", rec), \
patch.object(pc, "record_surfaced", MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
patch.object(pc, "concept_query", MagicMock(return_value="debounce a callback")):
out = await pc.build_write_path_hint(
1, "src/utils/debounce.ts", code="x" * 400,
)
kw = search.await_args.kwargs
assert kw["note_type"] == ("snippet", "note")
# An open to-do resembling the code answers nothing; an ISSUE carries a root
# cause and a NOTE carries durable knowledge. Only the todo is excluded.
assert kw["task_kind"] == "issue"
# Telemetry must not claim this was a notes-only retrieval any more.
assert rec.call_args.kwargs["is_task"] is None
@pytest.mark.asyncio
async def test_write_path_labels_a_non_snippet_hit_with_its_kind():
"""An unlabelled issue on this menu reads as "here is code to reuse", which
is the opposite of what it says. A snippet stays unlabelled — it is the
menu's default and the header's default reading."""
from scribe.services import plugin_context as pc
hits = [(0.72, _note(9, "debounce helper", note_type="snippet")),
(0.71, _note(7, "Debounce dropped the trailing call", is_task=True,
task_kind="issue"))]
with patch.object(pc, "get_writepath_config",
AsyncMock(return_value={"enabled": True, "threshold": 0.6,
"top_k": 3})), \
patch.object(pc.snippets_svc, "list_snippets",
AsyncMock(return_value=([], 0))), \
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=hits)), \
patch.object(pc, "record_retrieval", MagicMock()), \
patch.object(pc, "record_surfaced", MagicMock()), \
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
patch.object(pc, "concept_query", MagicMock(return_value="debounce a callback")):
out = await pc.build_write_path_hint(
1, "src/utils/debounce.ts", code="x" * 400,
)
ctx = out["context"]
assert "· issue]" in ctx # the issue says what it is
assert '[similar 0.72] "debounce helper"' in ctx # the snippet does not
# The header now names the right opener for each kind.
assert "get_task(id)" in ctx and "get_snippet(id)" in ctx
+128
View File
@@ -0,0 +1,128 @@
"""Batched project summaries (#2384).
The bug was not wrong output — it was CONNECTION COUNT. A per-project summary
opened its own session and then one more per milestone, and the route fanned
that out with asyncio.gather. For 25 projects that asked for roughly 250
checkouts against a pool of 15, so most waited out the 30-second timeout and
every other route on the instance queued behind them:
QueuePool limit of size 5 overflow 10 reached, connection timed out
GET /api/settings 500 30584.0ms
GET /api/projects 200 30882.9ms
So these tests assert the number of sessions opened, not only the values
returned. A version that produced identical output while opening a session per
project would pass a correctness test and reproduce the outage.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _session_factory(counter: list[int], results: list):
"""A session whose .execute() returns queued results, counting opens."""
def _make():
s = AsyncMock()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
counter[0] += 1
async def _execute(*_a, **_kw):
rows = results.pop(0) if results else []
r = MagicMock()
r.fetchall = MagicMock(return_value=rows)
r.scalars = MagicMock(return_value=MagicMock(all=lambda: rows))
return r
s.execute = _execute
return s
return _make
@pytest.mark.asyncio
async def test_summaries_for_many_projects_open_ONE_session():
"""The whole point. Twenty-five projects must not mean twenty-five
checkouts — that is the shape that exhausted the pool."""
from scribe.services import projects as svc
opened = [0]
rows = [
[(1, "todo", 3), (1, "done", 2), (2, "in_progress", 1)], # task counts
[(1, 7)], # note counts
[], # last activity
]
with patch.object(svc, "async_session", _session_factory(opened, rows)), \
patch("scribe.services.milestones.get_project_milestone_summaries",
AsyncMock(return_value={})):
out = await svc.get_project_summaries(1, [1, 2])
assert opened[0] == 1, f"opened {opened[0]} sessions for 2 projects"
assert out[1]["task_counts"] == {"todo": 3, "in_progress": 0, "done": 2}
assert out[1]["note_count"] == 7
# A project with tasks but no notes still reports 0, not a missing key.
assert out[2]["task_counts"] == {"todo": 0, "in_progress": 1, "done": 0}
assert out[2]["note_count"] == 0
@pytest.mark.asyncio
async def test_every_requested_project_gets_an_entry():
"""A project with no notes at all must still appear. The frontend indexes
by id and renders `undefined.task_counts` as a crash, not a blank."""
from scribe.services import projects as svc
opened = [0]
with patch.object(svc, "async_session", _session_factory(opened, [[], [], []])), \
patch("scribe.services.milestones.get_project_milestone_summaries",
AsyncMock(return_value={})):
out = await svc.get_project_summaries(1, [4, 5, 6])
assert sorted(out) == [4, 5, 6]
for entry in out.values():
assert entry["task_counts"] == {"todo": 0, "in_progress": 0, "done": 0}
assert entry["note_count"] == 0
assert entry["last_activity"] is None
@pytest.mark.asyncio
async def test_no_projects_opens_no_session_at_all():
"""`.in_([])` is a valid but pointless query; the guard keeps an empty
install from paying for a connection to learn it has nothing."""
from scribe.services import projects as svc
opened = [0]
with patch.object(svc, "async_session", _session_factory(opened, [])):
assert await svc.get_project_summaries(1, []) == {}
assert opened[0] == 0
@pytest.mark.asyncio
async def test_milestone_summaries_for_many_projects_open_ONE_session():
"""Same property one level down — this was the nested half of the fan-out,
a session per MILESTONE, which is what turned 25 into ~250."""
from scribe.services import milestones as svc
m1 = MagicMock(id=10, project_id=1)
m1.to_dict = MagicMock(return_value={"id": 10, "title": "A"})
m2 = MagicMock(id=11, project_id=2)
m2.to_dict = MagicMock(return_value={"id": 11, "title": "B"})
opened = [0]
rows = [[m1, m2], [(10, "done", 2), (10, "todo", 1), (11, "cancelled", 1)]]
with patch.object(svc, "async_session", _session_factory(opened, rows)):
out = await svc.get_project_milestone_summaries(1, [1, 2])
assert opened[0] == 1
assert out[1][0]["completed"] == 2 and out[1][0]["total"] == 3
# Cancelled is excluded from the denominator, so a milestone whose only
# task was cancelled reads as complete rather than stalled at 0%.
assert out[2][0]["pct"] == 0.0 and out[2][0]["status_counts"]["cancelled"] == 1
def test_both_progress_paths_share_one_rule():
"""get_milestone_progress and the batch path must not compute pct
differently — two screens disagreeing about whether a milestone is done is
exactly the drift this codebase keeps finding."""
from scribe.services.milestones import _progress_from_counts
assert _progress_from_counts({"done": 3, "cancelled": 1})["pct"] == 100.0
assert _progress_from_counts({"cancelled": 2})["pct"] == 0.0
assert _progress_from_counts({})["total"] == 0
+2 -4
View File
@@ -40,8 +40,7 @@ async def test_editor_share_can_update_and_writes_as_the_owner():
with patch.object(svc, "get_snippet", AsyncMock(return_value=note)), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(svc.notes_svc, "update_note",
AsyncMock(return_value=updated)) as mock_update, \
patch.object(svc, "_embed_snippet", MagicMock()):
AsyncMock(return_value=updated)) as mock_update:
got = await svc.update_snippet(7, 1, name="formatDuration")
assert got is updated
@@ -107,8 +106,7 @@ async def test_merge_skips_sources_owned_by_someone_else():
with patch.object(svc, "get_snippet", AsyncMock(side_effect=fake_get)), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(svc.notes_svc, "update_note", AsyncMock(return_value=target)), \
patch("scribe.services.trash.delete", AsyncMock(return_value=object())), \
patch.object(svc, "_embed_snippet", MagicMock()):
patch("scribe.services.trash.delete", AsyncMock(return_value=object())):
_note, merged_ids = await svc.merge_snippets(7, 1, [2, 3])
assert merged_ids == [2]
+3 -6
View File
@@ -39,8 +39,7 @@ async def _run_merge(target, sources, source_ids):
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(s.notes_svc, "update_note",
AsyncMock(return_value=target)) as mock_update, \
patch("scribe.services.trash.delete", AsyncMock(return_value=object())), \
patch.object(s, "_embed_snippet", MagicMock()):
patch("scribe.services.trash.delete", AsyncMock(return_value=object())):
await s.merge_snippets(7, target.id, source_ids)
return mock_update.await_args.kwargs
@@ -88,8 +87,7 @@ async def test_an_ordinary_edit_carries_provenance_forward():
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(s.notes_svc, "update_note",
AsyncMock(return_value=note)) as mock_update, \
patch.object(s, "_embed_snippet", MagicMock()):
AsyncMock(return_value=note)) as mock_update:
await s.update_snippet(7, 1, signature="f(ms) -> string")
kwargs = mock_update.await_args.kwargs
@@ -106,8 +104,7 @@ async def test_a_pre_0070_row_keeps_provenance_through_the_body():
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(s.notes_svc, "update_note",
AsyncMock(return_value=note)) as mock_update, \
patch.object(s, "_embed_snippet", MagicMock()):
AsyncMock(return_value=note)) as mock_update:
await s.update_snippet(7, 1, when_to_use="humanize a ms count")
assert "**Merged from:** #5" in mock_update.await_args.kwargs["body"]
-3
View File
@@ -44,7 +44,6 @@ async def _run_unmerge(survivor, *, source_alive=None, restore=1):
patch("scribe.services.trash.restore_entity", AsyncMock(return_value=restore)),
patch.object(s.notes_svc, "update_note",
AsyncMock(return_value=survivor)) as upd,
patch.object(s, "_embed_snippet", MagicMock()),
):
await s.unmerge_snippet(7, 1, 2)
return upd.await_args.kwargs
@@ -127,7 +126,6 @@ async def test_a_purged_source_is_refused_and_the_survivor_is_untouched():
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
patch("scribe.services.trash.restore_entity", AsyncMock(return_value=None)),
patch.object(s.notes_svc, "update_note", AsyncMock()) as upd,
patch.object(s, "_embed_snippet", MagicMock()),
):
with pytest.raises(s.UnmergeError, match="purged"):
await s.unmerge_snippet(7, 1, 2)
@@ -150,7 +148,6 @@ async def test_an_entry_without_attribution_is_refused_not_guessed():
patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)),
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)),
patch.object(s.notes_svc, "update_note", AsyncMock()) as upd,
patch.object(s, "_embed_snippet", MagicMock()),
):
with pytest.raises(s.UnmergeError, match="provenance"):
await s.unmerge_snippet(7, 1, 2)
+10 -3
View File
@@ -23,13 +23,17 @@ def _snippet_item(nid, title, user_id=1):
return {"id": nid, "title": title, "user_id": user_id, "note_type": "snippet"}
def _note(nid, title, user_id=1):
def _note(nid, title, user_id=1, note_type="snippet", is_task=False, task_kind="work"):
n = MagicMock()
n.id, n.title, n.user_id = nid, title, user_id
# Explicitly None, not left to MagicMock's auto-attribute: the semantic arm
# reads `note.data` for the snippet's language (#2244), and an auto-created
# mock there is truthy, so it would render its repr into the menu line.
n.data = None
# Same reasoning, second instance: since #2246 this arm returns issues and
# dev-logs too, so the line names the kind. An auto-mock `is_task` is truthy,
# which would label every snippet here "task".
n.note_type, n.is_task, n.task_kind = note_type, is_task, task_kind
return n
@@ -183,8 +187,11 @@ async def test_semantic_arm_is_snippet_only_and_browse_scoped():
patch.object(pc, "record_retrieval", MagicMock()):
await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE)
kwargs = search.await_args.kwargs
# Prior art means snippets — not the dev-log that happens to mention one.
assert kwargs["note_type"] == "snippet"
# Prior art is snippets AND recorded experience (#2246) — an issue saying
# "we tried this and it broke" belongs here. What stays out is the open
# to-do list, which resembles the code and answers nothing.
assert kwargs["note_type"] == ("snippet", "note")
assert kwargs["task_kind"] == "issue"
# Nobody asked for this, so it must not reach a one-to-one direct share.
assert kwargs["scope"] == "browse"