Compare commits

..
Author SHA1 Message Date
bvandeusen f0c915a6bc Merge pull request 'Ledger follow-ups from the shape audit (milestone 294: #2868–#2874)' (#122) from dev into main
CI & Build / Build & push image (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 57s
CI & Build / integration (push) Successful in 21s
2026-08-21 15:24:03 -04:00
bvandeusenandClaude Fable 5 6a0f8ad328 feat(backup): code_shape_uses travels (v9); consumer-map test expects uses (#2870)
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:21:13 -04:00
bvandeusenandClaude Fable 5 d4c7b0e48d feat(ledger): uses edges — consumption is its own relation, conformance keeps one snippet_id (#2870, milestone 294)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Failing after 39s
CI & Build / Build & push image (push) Skipped
A shape can follow one convention canon AND call several helper canons; the
row's single snippet_id made the 2026-08 audit pick (hash_token won, the
service-function convention lost), and hook evidence — pulled a snippet, then
wrote code naming it — was stamped as instance when it is a uses fact.

- code_shape_uses (migration 0084): shape → snippet, basis, evidence; unique
  per pair; cascades with both ends. USE_BASES: reference | hook | agent |
  audit | import. A judgment-grade basis overwrites a mechanical one, never
  the reverse.
- classify_shapes items and classify_shapes_by_rule take uses=[snippet ids]
  (targets validated like snippet_id; all-or-nothing).
- The write-path hook writes a uses edge for every pulled canon the payload
  names (the instance stamp is unchanged); the proposer writes a uses edge
  for every canon a body names (reference_canons: kind + language family +
  stoplist, same rules as the reference basis) — the mechanical form of
  "auto-confirm own-import references" deferred from #2871.
- list_shapes(uses=N) lists the consumers of a canon; get_snippet's consumer
  map gains `uses` beside instances/variants.

Operator decision on #2870 (2026-08-21): keep one snippet_id, add uses edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:18:25 -04:00
bvandeusenandClaude Fable 5 bfe5a461b4 test(ledger): derive group sizes follow the #2872 order
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:15:32 -04:00
bvandeusenandClaude Fable 5 3849c6fff3 test(ledger): derive label order follows #2872; binding-ref test imports compute_coverage
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 22s
CI & Build / Python tests (push) Canceled after 22s
CI & Build / integration (push) Canceled after 21s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python lint (push) Successful in 4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:15:09 -04:00
bvandeusenandClaude Fable 5 1a8e5787e8 feat(ledger): reason codes, case-insensitive repo filter, next action on the coverage line (#2874, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Canceled after 32s
CI & Build / Python tests (push) Canceled after 31s
CI & Build / Build & push image (push) Canceled after 0s
- code_shapes.reason_code (migration 0083): an optional code from a fixed
  catalogue (scoped-css, one-off-handler, test-helper, convention-plumbing,
  pure-helper, generated, script, typed-record) beside the prose reason, so
  the ledger can be filtered/aggregated by kind of one-off; validated in
  classify_shapes and classify_shapes_by_rule; on to_dict/to_compact.
- Snippet location lookups match repo case-insensitively in both dialects
  (location_matches / location_jsonpath via like_regex flag "i") — "Scribe"
  vs "FabledScribe" vs "fabledscribe" recorded free-form hid half the canon
  from list_snippets(repo=, path=).
- coverage line names the next action: "top canon #N ×k" (biggest proposal
  queue) and "top copy <label> ×files" (widest body-identical group).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:14:34 -04:00
bvandeusenandClaude Fable 5 d01201539b test(ledger): derive summary expectation follows #2872 — dup group before name group
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 6s
CI & Build / Python tests (push) Successful in 58s
CI & Build / integration (push) Failing after 20s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Build & push image (push) Successful in 22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:11:25 -04:00
bvandeusenandClaude Fable 5 1209e1c2d9 feat(ledger): a repo binding names the branch its ledger follows — bind_repo(ref=) (#2873, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Canceled after 30s
CI & Build / Python tests (push) Canceled after 30s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / integration (push) Failing after 25s
Project 2 is bound to main, so every consolidation of the 2026-08 audit was
invisible to the ledger until the dev→main merge; the operator works on dev
(rule 1). repo_bindings.ref (migration 0082, nullable) is the branch the
coverage refresh reads; NULL keeps the forge default branch. set_binding takes
ref (name sets, "" clears, None leaves standing); bindings_for_project feeds
the refresh; bind_repo exposes ref ("-" clears). to_dict carries it.

Operator decision on #2873 (2026-08-21): per-binding ref, chosen at bind time,
default the repo default branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:10:54 -04:00
bvandeusenandClaude Fable 5 57d68c9355 feat(ledger): derive readout ranks body-identical groups first; CSS fingerprints are declarations, not selectors (#2872, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 32s
The 2026-08 audit consolidated identical bodies under different names and
files (five auth views' CSS, two Workspace formatDate()s, four modal blocks)
while the derive readout led with name groups (to_dict ×25, main ×5, load ×6)
that were convention or coincidence.

- proposal_summary ranks dup:<sha> groups above name groups, wider file spread
  first, and carries `files`; scoped rows (#2869) are in the readout, since
  that is where view-level copies live.
- extract_definitions fingerprints a CSS rule by its declarations — the row
  identity already carries the selector — so .closed-msg / .error-block /
  .success-msg with one body are one dup group. One-time effect: judged css
  rows whose stored fingerprint predates this may show a recheck on the next
  refresh (the judgment stands; re-confirm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:08:57 -04:00
bvandeusenandClaude Fable 5 1126bbe84f test(ledger): the divergence acceptance case uses TS canon — proposer v3 gates sym bases by language family (#2871)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Canceled after 4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:07:48 -04:00
bvandeusenandClaude Fable 5 1ab614bfbe feat(ledger): the scoped bucket — by-construction one-offs are stamped by the sync, not judged by a person (#2869, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Failing after 25s
CI & Build / Python tests (push) Canceled after 51s
CI & Build / Build & push image (push) Canceled after 0s
The 2026-08 audit left 77% of Scribe's ledger `exempt`, most of it a Vue
component's scoped <style> rules and <script setup> functions — one-offs by
construction (unreachable from any other file) that add nothing when judged
one by one and bury the rows a person should look at.

- coverage: Definition carries its line; scoped_definitions() names, per
  .vue file, every sym and every css rule inside <style scoped>; ArchiveShape
  carries the flag.
- sync: such rows are stamped status=scoped / classified_by=mechanical with
  the by-construction reason (history event recorded); un-stamped back to
  unclassified if a later tree makes them ordinary; a judgment overrides.
- The machine still sees them: proposer, derive grouping, divergence, hook
  evidence, canonical stamping and classify_shapes_by_rule's default all
  treat unclassified + scoped as the unjudged set (_MECHANICAL_TODO). Only
  the human todo (status=unclassified) and largest_gaps exclude them.
- accounting counts `scoped`; coverage line and the project card legend show
  it; SHAPE_STATUSES gains it (no DB CHECK on status — no migration).
- shape-accounting skill documents the bucket; plugin 0.1.37.

Operator decision on #2869 (2026-08-21): keep extracting everything, stamp
mechanically, keep `exempt` a human judgment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:06:52 -04:00
bvandeusenandClaude Fable 5 9abc4443fb feat(ledger): audit surfaces — list_shapes(compact=True) and classify_shapes_by_rule, the sweep form of a judgment (#2868, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit judged 3,427 rows in 14 hand-driven batches through a raw
MCP client because a list_shapes page overflowed the tool budget and every
row had to be sent back one by one. Now:

- list_shapes(compact=True): path · symbol · kind · status · signature, plus
  snippet_id / by / proposal / diverges_from / recheck only when set. A full
  500-row page fits. CodeShape.to_compact() is the row shape.
- classify_shapes_by_rule(project_id, path, status, pattern=, kind=,
  snippet_id=, reason=, via=, include_judged=): ONE judgment over every
  unclassified live row under a directory whose symbol matches a glob;
  judged rows are untouched unless include_judged; canonical is refused;
  same gates as the row form; one transaction; returns count + sample.
  shape_ledger.classify_shapes_where / rule_matches (pure) carry it.

Tests: compact row pinned, rule_matches directory/glob/kind semantics, the
tool mount, and an integration sweep (unclassified-only, include_judged,
gates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:02:55 -04:00
bvandeusenandClaude Fable 5 3a4031d7f8 fix(ledger): proposer v3 — sym bases gated by language family, reference skips generic verbs, semantic held to the shape's own project (#2871, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit examined 407 proposals. Every cross-language hit was wrong:
the Python MCP tool-module canon (symbol `register`) was offered for each auth
view's handleSubmit (it calls authStore.register()) and for the TS auth store's
own `register`; Minstrel/Forge TS canon matched Python bodies by resemblance.
Every cross-project semantic proposal was noise.

- Canon carries the snippet's language; match_canon skips a sym canon whose
  family (py / js / css / sh / sql, by language ↔ by path extension) differs
  from the shape's. Unknown on either side = no gate.
- The reference basis ignores a stoplist of generic verbs (register, load,
  save, get, …): a bare mention is not a call site of THIS canon; the symbol
  basis still catches a second definition, and the call-site relation moves
  to `uses` edges with #2870.
- The semantic arm only reaches canon in the shape's own project and family;
  symbol/text still reach family canon elsewhere (note 2786).
- _PROPOSER_VERSION 2 → 3 so standing proposals re-examine on the next refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:00:48 -04:00
bvandeusen 141246ac2c Merge pull request 'fix(frontend): drop the three .modal-overlay copies left after the modal canon move' (#121) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 39s
2026-08-21 12:58:25 -04:00
bvandeusenandClaude Fable 5 520381e22b fix(frontend): drop the three .modal-overlay copies the comment-preceded rule regex skipped — components.css owns it (#2831)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:52:29 -04:00
bvandeusen 58c074a324 Merge pull request 'Shape audit (milestone 296): tests helpers, alembic, models, MCP, routes, services, frontend consolidation' (#120) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 40s
2026-08-21 12:48:08 -04:00
bvandeusenandClaude Fable 5 2a6c55dacb refactor(frontend): auth-shared.css, apiErrorMessage, one date helper per shape, modal canon in components.css — the frontend pass of the shape audit (#2831 #2832, milestone 296)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 16s
- assets/auth-shared.css: the five auth views carried byte-identical scoped
  copies of the page/card/brand/footer/field/input/error rules (~60 lines
  each); they now load one stylesheet the way the editors load
  editor-shared.css. .closed-msg/.error-block/.success-msg (identical bodies)
  are one .auth-note; the form rules are scoped under .auth-card so nothing
  leaks into the rest of the app.
- api/client.apiErrorMessage(e, fallback): the one place the {"error"} envelope
  is unpacked; replaces ten six-line `"body" in e` catch blocks.
- utils/dateFormat: fmtDate / fmtStamp / fmtLogStamp replace eight local
  formatDate/formatTime copies (three byte-identical pairs); the file’s old
  Calendar/Home helpers had no callers and are gone. useRelativeTime gains
  relativeTimeOrDate for the two workspace panels’ identical variant.
- components.css now owns the .modal-* shape (overlay/card/title/message/
  actions/btn/primary/danger). It was copied into four views and lived in
  editor-shared.css, which ConfirmDialog — styleless, teleported to <body> —
  silently depended on: opened from SnippetDetailView before any editor view
  had loaded, it rendered unstyled. Views keep only their own overrides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:41:18 -04:00
bvandeusenandClaude Fable 5 7d48eb0b1b refactor(services): one periodic-task shape, one APScheduler job shape, one token hash, one summary rule — the services pass of the shape audit (#2830, milestone 296)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
- background.start_periodic(interval, work, label=) replaces the three hand-rolled
  while-True/sleep/try loops in logging, auth and notifications.
- services/scheduler.ScheduledJob replaces the four private BackgroundScheduler
  copies in recurrence/version_pinning/trash/db_maintenance schedulers; public
  start_/stop_/reschedule_ surfaces unchanged.
- api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x.
- auth.is_registration_open reads via settings.get_admin_setting; notification
  prefs read via settings.get_setting; _fire_share_email uses _get_user_email.
- projects.get_project_summary / milestones.get_project_milestone_summary are
  now the one-id view of their batch siblings instead of a second copy of the
  queries; sharing.best_permission_by (was _deduplicate_by_permission) is the
  one rank-dedup, now also used by list_projects_for_user.
- backup: the row builders for every section both exporters carry are named
  functions, so a column added to one export cannot silently miss the other.
- iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom
  and db_maintenance._iso across services; backup keeps its explicit shape.
- trash.py hoists the sql_delete/timedelta imports it re-imported per function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:34:28 -04:00
bvandeusenandClaude Fable 5 92e38ff17b test(routes): the prior-art contract guards read the handler plus its _project_scope helper (area 5 follow-up)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 6s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:26:01 -04:00
bvandeusenandClaude Fable 5 64c641ce80 refactor(routes): one supersession seam for REST and MCP; PUT/PATCH notes share a handler; shared mask/not-found/caller helpers (#2829, milestone 296 area 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Reading the 28 route modules against each other and against the MCP tools:
- routes/notes.py carried a PUT and a PATCH handler that were the same
  function minus the supersedes contract on one of them — one handler now
  serves both verbs, so both carry it.
- The two _attach_supersession copies (REST + MCP) become
  supersession_svc.attach_relations(uid, note_id, data, hint=) — the seam
  the two surfaces must agree through; only the agent surface adds the
  one-sentence reading hint.
- Three local _uid() wrappers over g.user.id → scribe.auth.get_current_user_id
  like every other module; design_systems' private _not_found → routes.utils.
  not_found; the four "********" literals → settings_svc.SECRET_MASK with the
  read/write contract written once.
- routes/plugin.py: the project_id/repo resolution block and the
  comma-separated id parse were copied into three endpoints — _project_scope()
  and _int_list() now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:23:47 -04:00
bvandeusenandClaude Fable 5 c211e12b61 refactor(mcp): one rules_payload() for every surface that hands rules to an agent; drop the dead bearer resolver (#2828, milestone 296 area 4)
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 26s
Reading the 16 tool modules against each other: the six-key applicable-rules
block (applicable_rules, applicable_rules_truncated, subscribed_rulebooks,
project_rules, suppressed_rules, suppressed_topics) was hand-built in five
places — enter_project, get_project, get_task (legacy plans), get_milestone
(three of the six) and services/planning.start_planning. rulebooks_svc.
rules_payload() is now the one place that names them; get_milestone gains the
three it lacked, so every rules-carrying payload reads the same. list_rules /
list_always_on_rules share _rule_summary. mcp/auth.resolve_bearer_to_user_id
duplicated resolve_bearer's parsing and had no product caller (only its own
tests) — removed; the tests now exercise resolve_bearer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:20:36 -04:00
bvandeusenandClaude Fable 5 b0eda32575 refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
Reading all 28 models against each other: 54 `x.isoformat() if x else None` /
`x.isoformat()` expressions in 23 to_dict methods, in two guarded/unguarded
wordings, become iso() from models/base.py — uniform, and a row read before
flush serialises as null instead of raising. Rulebook / RulebookTopic / Rule
carried byte-identical copies of TimestampMixin's two columns;
InvitationToken / PasswordResetToken / NoteUsageEvent carried CreatedAtMixin's —
all six now use the mixin. AppLog and RetrievalLog keep their explicit
created_at, commented: their composite index orders on `created_at.desc()`,
which needs the column object in the class body. Schema-neutral (same column
definitions) — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:17:42 -04:00
bvandeusenandClaude Fable 5 848ce1592e fix(tests): import make_mock_session in test_version_pinning_prune (batch-2 follow-up)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:14:44 -04:00
bvandeusenandClaude Fable 5 77bb3729a3 refactor(tests): per-model fakes, FakeMCP and session mocks come from tests/helpers (#2825, milestone 296 area 1, batch 2)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 36s
CI & Build / Build & push image (push) Skipped
Second pass over the tests/ ledger after bbee0d0. fake_record(**attrs) is the
one MagicMock-with-real-attributes builder (to_dict mirrors them; the
note-2109 hazard documented once); fake_note/fake_task/fake_snippet/
fake_project/fake_milestone/fake_system/fake_rulebook/fake_topic/fake_rule
carry each model's ordinary defaults on top of it, replacing 14 per-file
factories (two rulebook trios in tool-vs-service wordings, _fake_task, _fake_ms,
_fake_project, _plan_note, _fake_snippet, two _snippet adapters now one-liners
over fake_snippet). FakeMCP replaces the five closure-over-a-list registrar
fakes (+ _Recorder); loc() and design_token_stub() replace the paired _loc /
_token / _T stand-ins; every hand-built async_session mock (9 helper defs and
14 inline copies) now starts from make_mock_session(). Call sites rewritten by
AST with each file's former defaults made explicit, so behaviour is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:13:17 -04:00
bvandeusen ac6f248bf9 Merge pull request 'Shape audit area 1 — tests/: one definition each for the copied fixtures and fakes (#2825)' (#119) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
2026-08-21 11:07:11 -04:00
bvandeusenandClaude Fable 5 bbee0d0db1 refactor(tests): one definition each for the copied fixtures and fakes (#2825, milestone 296 area 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 18s
The shape ledger showed the same test scaffolding defined over and over:
_bind_user x12 (byte-identical), _dispose_engine x10 in three wordings,
_no_supersession x3, _make_mock_session x7 in three subsets, a get-or-create
User helper x2 (+3 inlined), and fifteen hand-rolled MagicMock note factories
each re-explaining the same "an auto-MagicMock attribute is truthy" hazard
(note 2109).

Now: conftest.py carries _bind_user / _dispose_engine / _no_supersession as
opt-in fixtures (pytestmark = usefixtures(...) per module, so unit tests pay
nothing), and tests/helpers.py carries make_mock_session(), ensure_user() and
fake_note(**attrs) — the hazard documented once, real values on every
attribute the product reads. Call sites were rewritten by AST so titles with
dashes and commas survived; the three SimpleNamespace _note stand-ins that
only feed a single function stay local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:03:48 -04:00
bvandeusen ac1c2625af Merge pull request 'fix(ledger): proposer precision — own-definition signatures, semantic floor 0.8, ruleset version' (#118) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 35s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 18s
2026-08-21 08:27:27 -04:00
bvandeusenandClaude Fable 5 dad56a51bd fix(ledger): the proposer matches signatures against a canon's own definition only and floors semantic at 0.8; ruleset version re-examines standing proposals
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 31s
First live run: a canon whose recorded code opens with a call-site example
(confirmed()'s onTrash) made every `async function x(): Promise<void>`
resemble it at 0.86, and the write-path semantic floor paired alembic
upgrade()/downgrade() bodies with unrelated canons at 0.68-0.75. Rows now
remember (body, ruleset) so a tightened rule looks again once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 07:42:12 -04:00
bvandeusen 79f0283608 Merge pull request 'Shape ledger steps 5–7: write-path stamping, mechanical proposer, divergence readout (#2791, #2792, #2793)' (#117) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 19s
2026-08-21 07:32:23 -04:00
bvandeusenandClaude Fable 5 efc650390e fix(plugin-context): the empty write-path payload carries the divergence key too
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:19:00 -04:00
bvandeusenandClaude Fable 5 d74a244b3a feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
CI & Build / TypeScript typecheck (push) Failing after 2s
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 / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint
judged (classified_sha) and writes a code_shape_events row; the sync writes
vanished / reappeared / drifted events and flags recheck_at when a body
moves under an instance/variant. The refresh flags diverges_from on shapes
new since the previous computation that sit where one canon dominates the
judged siblings of their directory+kind and were not proposed as that canon
(a first seed flags nothing); the write-path hint asks the same question
in-band for the shapes the hook names. list_shapes(flag=divergence|recheck),
shape_history(project_id, path, symbol) (read-only), coverage line/payload/
card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:13:39 -04:00
bvandeusenandClaude Fable 5 386b27e422 fix(ledger): the proposer prefers a same-project canon on a tie — family canon elsewhere is the fallback, not the first hit
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Python lint (push) Successful in 5s
CI & Build / integration (push) Successful in 35s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Build & push image (push) Successful in 40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:36:10 -04:00
bvandeusenandClaude Fable 5 ba0030e51d feat(ledger): mechanical proposer — every refresh proposes instances against canon and groups derive-first candidates; agents confirm in batches (#2792, milestone 294 step 6)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
Shapes now carry a content fingerprint (signature + whitespace/comment-
insensitive body_sha; migration 0080) and the proposer runs inside the
coverage refresh, the one moment bodies exist: symbol elsewhere → textual
containment → body references the canon → signature resemblance → semantic
(capped per refresh, unreached rows stay unexamined for the next). A hit is
a proposal on the row (proposed_snippet_id/basis/score), never a
classification; rows with no canon hit group by the derive-first rule
(identical body in ≥2 places, same name in ≥3 files) as proposal_basis=
derive + a group key. list_shapes(proposal=any|canon|derive|<basis>) is the
queue; confirm_shape_proposals(project_id, snippet_id|path|basis) confirms
in batches as agent instances; any classify_shapes/hook stamp retires the
proposal. Readout carries proposed + derive_groups (line, payload, card).
Plugin 0.1.35 (skill: the machine proposes, judgment classifies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:30:35 -04:00
bvandeusenandClaude Fable 5 a9e1cddba7 fix(tests): provisional ledger rows carry the empty seen-marker default, not NULL
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:23:12 -04:00
bvandeusenandClaude Fable 5 475f0857c9 feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
The prior-art hook now names the shapes being written (shapes=kind:name —
every definition in the payload, or the one enclosing an Edit found by
walking the file upward) and the server stamps them as instance rows when
the session PULLED a snippet inside PULL_WINDOW that the payload references
by symbol or that the semantic arm scored for this very payload.
classified_by=hook, evidence in reason; never overrides a judgment or a
canonical row, overridable by classify_shapes. Offered-but-unopened stamps
nothing. Pulled-and-already-seen snippets stay in the semantic query as
evidence without re-entering the deduped menu. A brand-new shape gets a
provisional row the next sync confirms or vanishes. Read-scoped keys get
the hint, never the stamp. Plugin 0.1.34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:19:45 -04:00
bvandeusen 9d5b89d785 Merge pull request 'Coverage self-seeds + refresh_pattern_coverage tool + shape-accounting skill (#2802)' (#116) from dev into main
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 16s
2026-08-20 19:05:48 -04:00
bvandeusenandClaude Fable 5 3e21978a9c fix(tests): restore the assert my seed-test insertion orphaned from its neighbor
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:24:05 -04:00
bvandeusenandClaude Fable 5 e2a084f1fb feat(ledger): coverage self-seeds — enter_project background refresh + refresh_pattern_coverage tool + shape-accounting skill (#2802, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 33s
CI & Build / Build & push image (push) Skipped
The UI Refresh button must not be the only seed path (operator directive,
hit live: the P7 backfill stalled waiting for a click). Three parts:

- enter_project fire-and-forgets refresh_if_stale on the project OWNER —
  absent or day-old readouts recompute in the background (same spawn the
  webhook path uses), the enter stays fast, forge-less owners exit quietly
  (rule #115 baseline), and an in-flight guard keeps concurrent enters from
  fetching the same tarball N times.
- refresh_pattern_coverage(project_id): the synchronous agent-facing form —
  write-gated, owner-keyring resolution, and ValueError messages that name
  the fix (add a connection / bind_repo) instead of measuring nothing
  silently.
- plugin 0.1.33 ships the shape-accounting skill: the five statuses, the
  seed/todo/judge loop, and the derive-first rule, triggered by the
  coverage line or any proved code-to-canon relationship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:18:36 -04:00
bvandeusen fd101d24ae Merge pull request 'Shape ledger steps 1-4 + direct-minting Systems bootstrap (milestone 294, #2798)' (#115) from dev into main
CI & Build / TypeScript typecheck (push) Successful in 50s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m14s
CI & Build / Build & push image (push) Successful in 16s
2026-08-19 21:24:40 -04:00
bvandeusenandClaude Fable 5 9f1a52a035 feat(ledger): audits write rows, not prose — instruction surfaces carry the classification duty (#2790, milestone 294 step 4)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 27s
verify_snippet docstring: consumers enumerated while checking are
classify_shapes instance rows; detail keeps the WHY and what changed,
nothing that belongs in a row. The MCP _INSTRUCTIONS REUSE bullet gains
"classify shapes against canon — a consumer map is rows, never prose"
(budget rebalanced to 1,998/2,000: the rules bullet lost its historical
push-optimisation clause, the task-note line tightened). The reusing-code
skill gains the full contract: instance/variant/exempt with required
reasons, list_shapes(status=unclassified) as the standing todo, and the
derive-one-first rule for repeating shapes with no canon. Plugin 0.1.32.

The operator-side halves of this step live outside the repo: the Drift
Audit process gained step 7 (classify what the walk proved) and the DRY
Pass s9 now sends consumer maps to the ledger with the note keeping only
the narrative. The Forge P7 backfill payload is enumerated and parked on
task 2790 — it fires after the next deploy + coverage refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:16:04 -04:00
bvandeusenandClaude Fable 5 5265d11a6a feat(systems): bootstrap mints directly — the standard vocabulary replaces operator approval (#2798)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 59s
Operator directive: creating Systems is the agent's call, not an approval
flow — "propose to the operator, create each confirmed one" made the
operator a permission gate. The bootstrap ask now says create_system 3-6
directly, in-session, and the consistency that approval was covering moves
to a standard cross-project vocabulary (CI & Release, Auth & Access, Data
Model & Storage, API Surface, UI & Design, Import & Export, Background
Jobs, Observability): use the standard name verbatim where the area fits,
mint freely beyond it, the duplicate gate guards sprawl. create_system and
enter_project docstrings carry the same contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:05:02 -04:00
bvandeusenandClaude Fable 5 d50ebbad66 fix(mcp): classify list_shapes as read-only for read-scoped API keys
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 32s
The auth guard test caught it: a read-shaped tool in neither set fails
closed for read keys, silently. list_shapes reads the ledger and writes
nothing; classify_shapes stays write-scoped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:02:12 -04:00
bvandeusenandClaude Fable 5 942edd1eb5 feat(ledger): classify_shapes + list_shapes MCP tools; get_snippet carries the consumer map (#2789, milestone 294 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
The judgment write path. classify_shapes applies a batch of classifications
to a project's live ledger rows — all-or-nothing (#2709's lesson: the whole
batch is validated, write-ACL'd, and every snippet target proven readable
before any row is touched); rows match by exact (path, symbol), kind narrows,
and shapes no live row matches come back as 'unmatched' rather than errors.
variant/exempt REQUIRE the reason — the why is the record (note 2786) — and
'unclassified' deliberately withdraws a judgment back to the todo. The 'via'
channel is caller-restricted to agent|audit|import; hook and mechanical stay
server-internal so a caller can't launder judgment as machinery.

list_shapes is the todo query (status=unclassified) with composable filters:
path is exact-or-under like recorded locations, snippet_id reads a consumer
map, include_vanished reads history; paged with the true total.

get_snippet now attaches  and  — the structured consumer
map, filtered to projects the CALLER can read so a shared snippet never side-
channels another project's file layout; attached only when non-empty (#2483).

Integration tests pin the batch atomicity, ACL gates, filter composition, the
consumer map on the MCP pull, and the SET NULL companion: a judgment whose
snippet was purged rejoins the todo on the next sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:55:47 -04:00
bvandeusenandClaude Fable 5 9d92df2825 fix(tests): drop the pre-ledger line-ending assertion the step-2 edit missed
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 22s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:43:11 -04:00
bvandeusenandClaude Fable 5 9b1597a3c9 feat(ledger): coverage refresh feeds the shape ledger; the readout inverts to accounting (#2788, milestone 294 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 44s
compute_coverage is now the ledger's sync point: every walk upserts the
extracted shapes (new → unclassified, the todo state; surviving → last-seen
bump; vanished → stamped, kept as history), re-files judgments whose snippet
target went away, and mechanically stamps snippet reference locations as
canonical — the one always-safe rule, self-healing only for its own stamps
(an agent's judgment is never unwound by machinery).

The covering predicate moves to shape_ledger.location_covers as the single
home (match_shapes retired with its consumer); coverage's payload and line
invert from 'N/M shapes recorded' to shape ACCOUNTING per note 2786:
accounted/total with a canonical·instance·variant·exempt breakdown, and
unclassified — THE todo — with its largest directories. Cache key bumps to
v2 so pre-ledger blobs honestly read 'not measured yet' instead of rendering
in a shape no longer spoken.

Readout is deliberately project-wide (all repos' live rows), while the walk
serves whichever repos the owner's keyring reaches this refresh.

Integration tests pin the new contract: rows for every extracted shape,
mechanical canonical stamps carrying snippet ids, idempotent recompute,
agent judgments surviving recompute AND vanish/return, vanished rows leaving
the readout but keeping their history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:37:18 -04:00
bvandeusenandClaude Fable 5 19fdc9aa89 feat(ledger): code_shapes — the shape ledger schema (#2787, milestone 294 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 45s
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small), this table accounts for EVERY extracted shape
(total). Identity is (project, repo_key, path, symbol, kind) — kind included
because one file can define '.foo' (css) and 'foo' (sym) as distinct shapes.
Status vocabulary: canonical / instance / variant / exempt / unclassified,
with unclassified as the default and THE todo state; classifications carry
who judged (agent|audit|hook|mechanical|import), when, and the why for
variants/exemptions. first/last-seen commits + vanished_at keep history
instead of deleting it; a rename reads as vanish+new (accepted for v1).

snippet_id is SET NULL on snippet deletion so accounting rows outlive their
target and rejoin the todo via the step-2 sync, never dangle silently.

Backups: v7 carries code_shapes (judgment data, worth moving) — full and
per-user export sections, and a restore that keeps a judgment only when its
snippet survives the id re-mapping, downgrading to unclassified otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:25:25 -04:00
bvandeusen c1cb8fb183 Merge pull request 'Per-user forge connections — keyring, host-keyed resolution, project pin (#2778)' (#114) from dev into main
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 20s
2026-08-19 15:24:47 -04:00
bvandeusenandClaude Fable 5 1faf8f3ece feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
A forge token is a user's credential, not an instance's. The single
admin-settings config is replaced by per-user keyring rows (one per forge
host), and every server-side forge read runs on the PROJECT OWNER's keyring:

- forge_connections table + projects.forge_connection_id pin (migration 0078,
  which also carries the existing admin config into the first admin's row and
  deletes the old setting keys — no legacy dual-read)
- get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector;
  resolve(repo) picks the connection whose host serves the repo. A pinned
  project uses ONLY its pinned connection; a stale pin (ownership moved) is
  ignored, never honored across users
- env FORGE_* config survives as an implicit entry for admin owners only;
  a stored row for the same host beats it
- consumers threaded: pull-time freshness (owner of the note), coverage
  (owner of the project), coverage routes' configured flag
- routes: /api/settings/forge-connections CRUD + per-connection test
  (own-rows only, tokens never returned); /api/admin/forge shrinks to
  /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins,
  owner-or-admin asking, owner's connections only
- UI: Git Forges card moves to Settings -> Integrations as a connection
  list; webhook secret stays in the admin Config tab; owner-only forge
  select on the project coverage card
- backups exclude forge_connections (credentials, api_keys precedent) and
  the pin, so restores fall back to keyring resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:23:22 -04:00
bvandeusen 5c14c2621c Merge pull request 'Strict MCP arguments (#2709) + zero-Systems bootstrap ask (#2683)' (#113) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 19s
2026-08-17 16:26:32 -04:00
bvandeusenandClaude Fable 5 7a5e2b18d9 feat(systems): evidence-carrying bootstrap ask for mature zero-Systems projects (#2683)
CI & Build / Build & push image (push) Successful in 39s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
The generic zero-state systems_hint never converts: identical on every
record, maximal in scope, asked at wrap-up time — Minstrel reached 282
records with zero Systems while vocabularied projects grew organically.
What converts is the project's own evidence at the moment of action.

bootstrap_systems_ask (mcp/tools/systems.py) fires only in a project
with >=20 records and no Systems: it names the record count and recent
titles, and asks for a concrete deliverable — propose 3-6 Systems,
confirm with the operator, create_system the set. Self-retiring: the
first System ends it everywhere. Wired at both moments the task named:
untagged_systems_hint escalates to it at write time, and enter_project
carries it as systems_bootstrap at arrival (attached only when it
applies). Young projects keep the mild question; populated vocabularies
never pay the count query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:53:46 -04:00
bvandeusenandClaude Fable 5 d6c9f08a59 fix(mcp): reject undeclared tool arguments instead of silently dropping them (#2709)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 37s
FastMCP validates tool arguments with a pydantic model whose extra-field
policy is 'ignore', so create_note(content=...) — a plausible near-miss
for body=, primed by add_task_log's content — ran successfully, stored
body: '', and left a record embedding/search cannot see. Two real notes
were persisted body-less before the pattern was noticed; create_task
only 'worked' because those calls happened to use the right name.

StrictArgsFastMCP rejects any tool call carrying arguments the tool does
not declare, before dispatch, with a did-you-mean hint when one is close
and the declared list when none is. Applied at the dispatch seam so
every tool gets the guarantee — an error the caller sees once beats data
half-written forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:54:58 -04:00
bvandeusen 25411cf223 Merge pull request 'Edit-time record-sync nudge — the sync class (#2708)' (#112) from dev into main
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 18s
2026-08-16 20:09:38 -04:00
bvandeusenandClaude Fable 5 3162332a13 feat(prior-art): edit-time record-sync nudge — the sync class (#2708)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 43s
A snippet recorded AT the exact file being edited is not a reuse
suggestion — it IS the record of the file being changed. The write-path
hint now renders those as their own SYNC class: 'snippet #N records this
file — updating the record is part of the edit (update_snippet /
verify_snippet)'. Nearby and semantic hits stay the reuse menu.

The two classes dedup on separate per-session channels (exclude_ids vs
exclude_sync_ids, .ids vs .sync.ids in the hook), so a reuse hint shown
early in a session can no longer silence the record-sync nudge when the
recorded file itself is edited later. Sync surfacing is measured under
its own note_usage source (write_path_sync) — its pull-through rate is
the scoreboard for whether edit-time sync actually happens, per decision
#2707 (no forge connection; records stay current in the session that has
the context). Plugin 0.1.31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:05:01 -04:00
bvandeusen 9f57cfad71 Merge pull request 'Milestone 288 — pattern library + forge integration' (#111) from dev into main
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 17s
2026-08-16 16:37:12 -04:00
bvandeusenandClaude Fable 5 765635bbf2 feat(forge): GitHub adapter — second implementation keeps the seam a contract (#2693, milestone 288 step 8)
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 45s
ForgeAdapter is now a named base class carrying the shared plumbing
(host join, error taxonomy, contents decoding, archive, default_branch,
latest_commit); GiteaForge keeps its exact behavior and GitHubForge joins
with the real differences: api.github.com / GHE /api/v3 host mapping,
Bearer auth, a commits call for the provenance stamp (GitHub's contents
payload only carries the blob sha), and the codeload tarball redirect.

The contract grew latest_commit, and with it the cached-SHA short-circuit
in pull-time freshness: a stored provenance commit that still heads the
recorded path confirms 'current' without a content transfer — the economy
that fits pulls inside GitHub's rate limits; every surprise falls back to
the full fetch. Webhook deliveries now also accept X-Hub-Signature-256
(sha256=<hex>); the payload shape was already common. Settings card copy
covers both forges' token scopes; the kind selector already flowed from
the server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:18:16 -04:00
bvandeusenandClaude Fable 5 cbccb6bd5d feat(coverage): pattern-library coverage measurement (#2692, milestone 288 step 7)
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Server-side shape enumeration per bound repo — one archive download via the
forge adapter, definitions extracted with a Python mirror of the write-path
hook's awk rules (shared test vectors pin the two together) — compared
against recorded snippet locations by path+symbol. Summary is cached in the
settings KV with a freshness stamp; recomputed on webhook push (spawned off
the delivery path) or explicit refresh, never in a request path.

Surfaces: GET/POST /api/projects/<id>/coverage[/refresh], a project-page
card (estimate-labeled, largest-gaps chips), and a one-line evidence-carrying
entry in enter_project read from cache only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:05:43 -04:00
bvandeusenandClaude Fable 5 89b07f7857 feat(forge): push webhook flags drift at the moment the repo moves (#2691)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 39s
Second adapter consumer. POST /api/webhooks/forge validates Gitea's
X-Gitea-Signature (HMAC-SHA256, constant-time; no secret configured =
the endpoint 404s out of existence), extracts changed/removed paths,
and flags matched snippets by writing verification.invalidated_by
{commit_sha, at, path, removed} — the existing attention vocabulary
extended, not a new flag: needs_attention includes it, both filter
dialects (Python + jsonpath SQL) include it in 'attention' and exclude
it from 'ok', and recording ANY fresh verdict clears it by construction
because compose_verification builds a new dict. Unverified snippets are
skipped (already in their own bucket); replayed deliveries at the same
head commit are no-ops; processing failures return 200 with a WARNING +
AppLog canary so the forge never marks deliveries failed and operators
never disable the hook over a transient (#2663's lesson).

Matching goes through repo BINDINGS: recorded location repos are
free-form names ('Scribe') that cannot address a forge, so a snippet
reaches its forge repo through its project's binding — which also fixes
step 5's pull-time resolution for every real record via the same
fallback. O(bindings + snippets-in-project + changed files).

Settings: webhook secret beside the forge config (masked, sentinel-
skipped, Docker-secret env channel, endpoint documented in the UI).
Tests: signature gate, payload parsing, path semantics, both filter
dialects extended in the drift-check guard file, and real-Postgres
end-to-end (flag lands, attention lists it, replay quiet, re-verify
clears, unbound repo untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:05:00 -04:00
bvandeusenandClaude Fable 5 eb760eb440 fix(snippets): read the stored provenance before writing the fresh stamp into the response
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 26s
fields aliases data['snippet'], so stamping the response first made the
staleness check compare the new stamp to itself — the background persist
never fired. Caught by test_current_code_confirms_and_refreshes_provenance
on run 3811, which exists for exactly this write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:53:34 -04:00
bvandeusenandClaude Fable 5 2fce57847b feat(snippets): pull-time freshness — the forge confirms the cache at the moment it's trusted (#2690)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Failing after 43s
CI & Build / Build & push image (push) Skipped
First consumer of the forge adapter. attach_live_body decorates both
pull surfaces (MCP get_snippet, REST detail) with body_source +
body_freshness when the instance has a forge: 'current' means the
cached code was just found verbatim (whitespace-normalized, the same
normalization the verdict hash uses) in the fetched file, and
provenance restamps to the file's last commit — reflected in the
response and persisted in the background. A snippet body is a FRAGMENT
of its file, so a fetch can honestly CONFIRM the cache or report
divergence, never clobber the record with the whole file: 'diverged'
is the reader's information, and a 404 stamps the mechanically-true
'missing' verdict into the existing attention state — once, not on
every pull of an already-flagged record.

The probe never raises and never blocks past 2.5s (tighter than the
adapter's own timeout — the pull is where a session decides whether
pulling is worth it, #2663's finding); a hung forge costs bounded time
and the cache serves. A no-forge instance's response stays
byte-identical to today's (rule #115 baseline, pinned by test).

services/background.py is the new one home for fire-and-forget tasks
with strong references (the #2663 GC footgun) — telemetry's two copies
predate it and keep their bespoke canaries; new callers use this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:46:45 -04:00
bvandeusenandClaude Fable 5 13e428c596 feat(forge): adapter seam + Gitea implementation — optional read access to the operator's forge (#2689)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 36s
Step 4 of milestone 288 (decision #2686). services/forge.py defines the
contract steps 5-7 consume — read_file (content + last_commit_sha, the
provenance stamp), default_branch, resolve_repo, check — with GiteaForge
as the first implementation over the REST contents/repo/version/user
endpoints. Repo identity reuses normalize_repo_key: the host segment
selects whether this forge serves a recorded repo, the remainder is the
API path, so no new identity scheme exists. Read-only by construction;
errors never carry the token; first outbound-HTTP timeout convention
(5s total, no retries — the consumer's fallback is the retry policy).

OPTIONAL per instance (rule #115): get_forge() returns None when
unconfigured and every consumer treats None as today's behavior. Config
lives in admin settings (Settings → Config → Git Forge: kind/base
URL/token, save + test-connection probe reporting version + identity),
with FORGE_* env / Docker-secret fallbacks; DB wins so a UI edit can't
silently lose to an env var. Token treatment follows the smtp_password
convention (masked on read, mask-sentinel skipped on write, absent from
audit details) — and wiring it surfaced that the generic GET/PUT
/api/settings dump bypassed that masking for the owning admin's raw KV
rows, so secret keys are now masked there too (fixes the same exposure
for smtp_password).

Contract tests run against httpx.MockTransport as the fake forge — the
reference behaviors the GitHub adapter (step 8) must reproduce — plus
the off-by-default gate, partial-config-is-off, env-vs-DB precedence,
and route/mask structural checks. Also: the step-2 definition detector
learned to skip dunders after flagging __init__ as 'already defined in
4 files' on this step's own build — guaranteed noise for a hint that
must stay trustworthy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:37:27 -04:00
bvandeusenandClaude Fable 5 7d26a3fc6a fix(tests): provenance itest user fixture is get-or-create — the lane DB persists across tests
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python tests (push) Successful in 53s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 19s
Both integration tests built the same username; the second insert died
on users_username_key. 19 passed, 1 error on run 3802.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:06:19 -04:00
bvandeusenandClaude Fable 5 1e7f66e72d feat(snippets): body provenance — the cache-with-provenance half of the pointer model (#2688)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 28s
Decision #2686: the recorded location is the source of truth for a
snippet's code; the stored body is a cache of it. data.provenance now
records what the cache is a cache OF — commit_sha + fetched_at — as a
carried JSONB field following the verification precedent, so no
migration is needed and absence keeps today's exact semantics.

The rules: provenance follows the code (fresh SHA restamps it, a code
edit without one drops it, a metadata edit carries it); writes ABOUT
the code carry it — record_verification rebuilds data from scratch and
would otherwise erase it silently; an ok verdict at a known commit
restamps it, since the checker just proved the cache matches the source
there. verify_snippet verdicts also record the commit they ran at,
making "the repo moved on since the check" computable once the forge
integration lands. create/update/verify MCP tools take commit_sha
(git rev-parse HEAD — free for any session with a checkout).

Unit tests pin the compose/carry logic; real-Postgres integration tests
run create→verify→update end-to-end (#2663: DB paths get no
mocked-only coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:00:40 -04:00
bvandeusenandClaude Fable 5 8407368c0c fix(hooks): definition detector covers all code, not a language shortlist (#2682)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 27s
ARM 1 extracted definitions with patterns for CSS/JS/TS/Python only —
the languages of the repo it was born in — so the local duplication
proof, and the #2664 record nudge gated on it, were structurally
unreachable in Go/Kotlin/Rust projects (Minstrel, FabledExchange):
precisely where recording was observed never to happen. One
modifier-strip plus a definition-keyword family (func/fun/fn/function/
def/sub, struct/trait/interface/enum/object/protocol/type, plus Go
method receivers) now covers them all; impl is excluded because several
impl blocks per type is normal Rust, and keyword-less declarations
(C/Java/Dart) are documented out of scope. Grep patterns mirror the
same forms so hits are definitions, never call sites. Parameterized
tests pin the coverage per language family. Plugin 0.1.30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:33:24 -04:00
bvandeusenandClaude Fable 5 2d58e74ec7 feat(reuse): recording model becomes the pattern library — every shape at first build (#2687)
Decision #2686: snippets are the project's pattern library, not a dedup
net. The floor, the reusing-code skill, and the snippet tool docstrings
now state the proactive model — record every shape the first time it is
built, with no will-it-recur judgment, and start later instances from
the recorded shape; second-copy consolidation stays as the backstop.
The floor guard test pins all three elements (tool, first-build trigger,
backstop) so the model cannot silently regress to the reactive wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:33:24 -04:00
bvandeusen e05a660471 Merge pull request 'Usage telemetry alive — readout fix, canaries, and the snippet-recording seam' (#110) from dev into main
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 38s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 23s
2026-08-14 22:36:43 -04:00
bvandeusenandClaude Fable 5 4107b17727 fix(telemetry): usage readout grouped by a rebuilt CASE — group by the label instead (#2663 root cause)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 39s
CI's new integration tests reproduced the outage on a clean database and
named the half: the writes land fine, and usage_for_notes fails on EVERY
call. The GROUP BY rebuilt the ambient case() expression, and asyncpg's
expanding IN-parameters give each instance its own bind names — so Postgres
sees a SELECT expression the GROUP BY doesn't cover and rejects the query
with a GroupingError, which the old code swallowed into zeros. One labelled
expression, grouped by its label. The deployed table has been accumulating
events all along; history appears as soon as this deploys.

Also: the two hook-execution tests now run with the real PATH and skip
when the hook's tools are absent (the restricted-PATH convention next door
is for silence contracts, where empty-for-the-wrong-reason still passes) —
and the unit lane installs jq so 'skip' never quietly becomes 'nowhere'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:49:48 -04:00
bvandeusenandClaude Fable 5 fb757fb4ba feat(reuse): recording gets a seam — the prior-art hook asks for create_snippet when duplication is proven and unrecorded (#2664)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 21s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Skipped
Zero snippets were ever recorded outside sessions already thinking about
snippets: the read side had a real seam (the PreToolUse hook) and the record
side had a trailing clause of a floor bullet. The trigger moment — 'I just
wrote the second copy' — is mid-Write/Edit, so the nudge now rides the same
hook: when the local arm proves the definition exists elsewhere in the repo
AND Scribe returned no record of it, the context block asks for
create_snippet. Both gates or silence, so a brand-new helper and an
already-recorded one stay nudge-free.

Floor bullet promoted to name the trigger moments (extract, hoist, second
copy); guarded by test the same way the Systems reflex is. Plugin 0.1.29 so
the cache picks up the hook (#2209).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:45:32 -04:00
bvandeusenandClaude Fable 5 77acee9239 fix(telemetry): usage counters get canaries, task references, and the missing integration tests (#2663)
The deployed instance ran with every usage counter at zero while surfacing
demonstrably fired. Every unit test was green because every unit test mocked
either _schedule or the session — the two functions that touch the database
ran against real Postgres nowhere. Both telemetry writers also held no
reference to their fire-and-forget tasks (the loop keeps only weak ones), and
swallowed every failure into logger.debug, so a total outage was
indistinguishable from an unused corpus.

- note_usage + retrieval_telemetry keep strong task references until done
- failures log at WARNING; note_usage additionally drops one AppLog error row
  per process per site, so the admin UI shows the outage without host access
- integration tests cover _insert_events -> usage_for_notes and the full
  record_pulled chain on a running loop, splitting the write and read halves
  so a failure names its side

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:45:32 -04:00
bvandeusen 5503580875 Merge pull request 'Process contracts — invoke by name, compose with the conversation, author as a shape' (#109) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 17s
2026-08-10 09:51:47 -04:00
bvandeusenandClaude Fable 5 5cb7cfe706 feat(processes): authoring contract — a process is a shape, never a script
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 26s
Operator's generalization of #2582: the benefit of nearly every stored
process is the accumulated SHAPE — steps, taxonomy, quality bar — and a
process must not force anything or overwrite the intent of the request
that invoked it. create_process now states the authoring side of the
composition contract: no embedded approach mandates, no pre-granted
approvals, clarify steps seed from the conversation instead of
re-asking it.

(All three stored processes were also audited: DRY Pass and Rulebook
Review were already propose-approve-apply with no forcing language;
both gained the seeded-clarify line — data changes, live already.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:40:23 -04:00
bvandeusenandClaude Fable 5 9c68faa0bd feat(processes): composition contract — the process is the skeleton, the conversation supplies the parameters
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 24s
Extends #2582's fix per the operator's direction: a stored Process must
incorporate the context it was triggered with, not displace it. No new
plumbing needed — the live context is already in the session; what was
missing is the stated contract. get_process now carries it: live
constraints/scope/focus fold into the procedure and override its
defaults where they disagree; a clarify step asks only what the
conversation has NOT already answered (confirm interpretations, don't
re-ask); stated concerns become lenses the procedure applies, not text
it discards. The sync-generated skill stubs state the short form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:39:02 -04:00
bvandeusenandClaude Fable 5 66c4fd1b88 fix(sync): process-skill triggers invoke by name only — resemblance offers, never substitutes
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 26s
Scribe issue #2582: the sync wrote "Use when {title}-type work is
requested" into every generated process skill, so a bespoke live prompt
that merely resembled a stored Process got the Process's canned
procedure — and inherited approvals embedded in its body (the Drift
Audit's fan-out opt-in) that the conversation never granted. Observed
live: an explicit systems-review request with its own constraints was
answered with the Drift Audit clarify menu recommending ~15 parallel
auditors.

New trigger contract, both variants: follow verbatim only when invoked
by name; on mere resemblance the live instructions govern — offer the
process, ask before following, and never inherit embedded approvals the
operator hasn't granted in this conversation.

(The Drift Audit process record itself was also rewritten to a
sequential in-context walk — data change, live already, recorded in
the same issue.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:33:40 -04:00
bvandeusen 266d8b8117 Merge pull request 'The Systems question rides every read and write — unified seam, create_system dedup gate, sweeps-are-discovery prose' (#107) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 17s
2026-08-09 16:04:58 -04:00
bvandeusenandClaude Fable 5 75bdb9c148 test(systems): fix two mock bugs in the seam tests — unset .id on the fake, shared to_dict dict leaking across creates
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 27s
Runs 3562/3565: _fake_system never sets .id so the dup-gate assertion
compared a MagicMock to 7; and a shared to_dict return_value dict let
the tagged create's mutation leak into the orphan create's response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:55:56 -04:00
bvandeusenandClaude Fable 5 3455f9cb9a refactor(systems): one seam — the Systems question rides every read and write of a record
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
Scribe issue #2570, from the operator's challenge: the invariant is
"always be asking whether what you're touching is a System's territory
and whether the work is filed there" — not a nudge in one corner. The
prior shape failed it twice: the hint fired only on creates (with a
special-cased zero-Systems branch), and get_task/get_note returned
records WITHOUT their Systems, so the read-side reflex had nothing to
fire on (same per-kind asymmetry as #2481).

- attach_systems(): single helper used by get/create/update for tasks,
  notes, and snippets, plus add_task_log. Tagged records always show
  `systems`; an untagged project record carries the `systems_hint`
  question instead. Neither field attaches empty (#2483). Hint is
  owner-only; everything fail-open (#2109).
- untagged_systems_hint unified to ONE question — the vocabulary
  listing varies, the question doesn't; the zero-Systems branch stops
  being special text.
- Docstrings state the uniform contract; floor prose now names the
  read-side reflex (systems visible -> list_system_records the pile).
- Plugin 0.1.27 -> 0.1.28.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:48:41 -04:00
bvandeusenandClaude Fable 5 56b1952de5 fix(systems): sweeps are the discovery moment — zero-Systems hint, create_system dedup gate, prose inverted
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
First real-world test of the #2562 fixes (Scribe issue #2569): a Forge
session ran a whole-codebase audit and created zero Systems — endorsed
by the shipped guidance, whose "no particular area takes none" clause
read as an exemption for exactly the record type that enumerates the
subsystem vocabulary. And the systems_hint was silent for a zero-Systems
project, the one state nothing else nudges out of.

- systems_hint gains a zero-Systems branch: prompt the FIRST
  create_system instead of going quiet.
- create_system is duplicate-gated like the other creates (normalized
  name, archived included, fail-open) — liberal creation becomes safe by
  construction, so the guidance can stop preaching restraint.
- Prose inverted on every surface (hint text, create_system docstring,
  floor bullet, using-scribe step 7): audits/sweeps take several tags
  and mint the Systems they name; the gate is the guardrail against
  sprawl, not holding back; only a record genuinely about no particular
  area goes untagged.
- Plugin 0.1.26 -> 0.1.27.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:35:59 -04:00
bvandeusen e80f2f233b Merge pull request 'Instruction delivery fits the fold — 2k server map, floor Systems reflex, write-time systems_hint' (#106) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python tests (push) Successful in 46s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 18s
2026-08-09 13:11:20 -04:00
bvandeusenandClaude Fable 5 3ff8803593 feat(instructions): fit the delivery fold — 2k server map, floor Systems reflex, write-time systems_hint
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 47s
CI & Build / Build & push image (push) Successful in 28s
Claude Code injects only the first ~2,048 chars of an MCP server's
instructions and silently cuts the rest mid-word (#2562, observed live):
_INSTRUCTIONS was 20,002 chars, so ~90% — including all Systems tagging
guidance — never reached any session. Rearchitect delivery around what
each surface actually delivers:

- _INSTRUCTIONS becomes a 1,997-char purpose-sorted map, with a header
  comment stating the budget and where detail belongs instead.
- Tool docstrings keep the per-tool HOW (audit: nearly all displaced
  topics were already duplicated there); backfill the four gaps —
  enter_project session scoping + project bootstrap, create_rule
  entity-vs-rule test, create_design_system not-a-rulebook,
  create_system two-records test.
- The plugin static context (the delivery floor) gains the
  tag-to-Systems reflex and a surfaces-layering statement; plugin
  0.1.25 -> 0.1.26 so the executing cache refreshes (#2209).
- create_task / create_note / create_snippet return a systems_hint when
  a record is created untagged in a project that has Systems — in-band
  at the exact write it applies to, fail-open like the dedup gate.
- Guards: _INSTRUCTIONS length budget, floor-states-the-reflex, and a
  displaced-topics sweep asserting every cut topic still lives on a
  delivered surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 12:53:35 -04:00
bvandeusen 854aae252a Merge pull request 'Per-kind duplicate-report floors — notes/tasks default 0.93' (#105) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 19s
2026-08-09 10:37:06 -04:00
bvandeusenandClaude Fable 5 272b7dbddf feat(dedup): per-kind duplicate-report floors — notes/tasks default 0.93
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 42s
At chunk grain (#280) a note-pair's similarity is its closest chunk pair,
so the shared 0.82 floor saturated the note/task reports with related
families (38 note / 155 task groups against the 200-pair cap, measured
2026-08-09). Split kb_duplicate_threshold into per-kind settings keys
with per-kind defaults: snippet 0.82 (single-chunk, scale unchanged),
note/task 0.93 (points the report at genuinely-alike records). Settings
UI grows the two new knobs; report entrypoints inherit the change via
get_duplicate_threshold(user_id, kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-09 10:33:11 -04:00
bvandeusen 063494094a Merge pull request 'Chunked embeddings — no record content invisible to search (#280)' (#104) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 28s
2026-08-08 23:54:34 -04:00
bvandeusenandClaude Fable 5 23cb396e65 fix(recurrence): drop the now-unused child binding
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 27s
The spawn's explicit embed call went with #280 step 3; the create_note result
had no other reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:52:33 -04:00
bvandeusenandClaude Fable 5 041d8defbc feat(embeddings): best-chunk-per-note on every retrieval surface (#280 step 4)
CI & Build / Plugin hooks (push) Failing after 1s
CI & Build / Python lint (push) Failing after 3s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Skipped
A note's relevance is now its best chunk's similarity, everywhere:

- semantic_search_notes keeps the indexed raw-distance top-k and over-fetches
  chunk rows (x4, composing with the x3 supersession over-fetch), then
  collapses to first-appearance-per-note — rows arrive distance-ordered, so
  first is best. Every ranked consumer (MCP/REST search, Browse, auto-inject,
  write-path, gate) inherits through the one function.
- list_notes semantic q swaps its join for a correlated MIN-distance
  subquery — the join would have repeated a long note once per matching chunk
  and made total count chunks.
- the duplicate report groups its self-join by note pair on MIN(distance):
  pair similarity = closest chunk pair, and the < join now also drops
  cross-chunk self-pairs that would flag every long note against itself.
- the write gate queries once per chunk of the candidate (capped at 8), so a
  note duplicating an existing record in ONE SECTION is caught — the
  whole-document query diluted exactly the section that mattered.

Integration test now seeds a two-chunk note and pins the collapse against
real pgvector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:51:01 -04:00
bvandeusenandClaude Fable 5 0e70a3896b feat(embeddings): per-chunk rows — schema, write path, version-aware backfill (#280 steps 2+3)
note_embeddings becomes one row per chunk: PK (note_id, chunk_index), plus
chunk_text (what this vector actually encodes) and chunker_version. Migration
0077 clears the table — embeddings are derived (0067 precedent) and the old
whole-document rows are indistinguishable from single-chunk notes, so the
startup backfill regenerates the corpus at the new shape. The backfill is now
version-aware: a future shape change is a CHUNKER_VERSION bump that re-embeds
exactly the stale notes, not another wipe.

upsert_note_embedding takes (title, body) and chunks internally — one path
for the write path, the recurrence spawn and the backfill. The recurrence
spawn's own embed call is deleted outright: create_note already embeds via
embed_note (#2056), so the spawn was a second copy of the rule. An emptied
record now CLEARS its stale vectors instead of leaving them findable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:47:34 -04:00
bvandeusenandClaude Fable 5 6b5043a69c feat(embeddings): chunk_document — the chunked document shape (#280 step 1)
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Build & push image (push) Successful in 25s
bge-small reads 512 tokens and fastembed truncates silently, so a single
whole-document vector permanently lost everything past ~400 words. The new
shape: split at markdown headings (fence-aware), merge small sections, split
oversize ones at paragraph boundaries, title-anchor every chunk, repeat the
section heading on continuation pieces. A record that fits the window yields
exactly one chunk identical to the historical title\nbody shape, so the
corpus's sharpest records are byte-for-byte unaffected. CHUNKER_VERSION added
so later shape changes re-embed by version comparison instead of a table wipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:43:51 -04:00
bvandeusen 875f6a7b5e Queue clear — shared ACL on lists, semantic q, full telemetry ledger, --color-* retired (#103)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 18s
2026-08-08 22:45:26 -04:00
bvandeusen 4ba544e2af refactor(theme): retire the --color-* shim — the sweep it promised, run
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.

73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.

One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.

Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.

Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).

Refs #2533
2026-08-08 22:42:37 -04:00
bvandeusen 2d1e26f38f feat(telemetry): ambient surfacings count, apart — enter_project and the skill sync emit
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 25s
#2477, option (a) as decided, with the readout changed in the same commit.

## The two silent surfaces

enter_project returns open tasks + recent notes on every project entry —
probably the largest surfacing by volume — and emitted nothing, so the pulls
it caused floated unattributed and the surfaced:pulled ratio ran against a
denominator missing its biggest contributor. Now source "enter_project".

build_process_manifest installs every reachable Process as an auto-surfacing
skill on the operator's machine — its own docstring calls it the most
consequential passive surface Scribe has — and emitted nothing, so a Process
matched on every relevant turn and never opened was indistinguishable from
one never installed. Now source "process_skill_sync": the honest event is
"installed", which is a surfacing in effect since the description sits in
front of the model each session.

## The readout, same commit — the condition option (a) carried

Both surfaces are AMBIENT: top-N-by-recency and install-everything are not
ranked choices. Pooling them into surfaced_count would make a note's number
dominated by "recently updated in a project you opened", and dead-weight
detection would read that as popularity — the wrong number read confidently,
which is the corrupts-data tier the survey ranked above everything else.

So usage_for_notes splits: surfaced_count stays RANKED-ONLY (every existing
consumer's reading — "surfaced often, never pulled → dead weight" — keeps
meaning what it meant), and ambient_count is new. Classified in SQL via a
CASE on AMBIENT_SOURCES so the group count stays three rows per note, not one
per distinct source. Pulls stay pooled: "did anyone ever open this?" does not
depend on how it was found.

#1038 and #2085 read agent pulls and ranked surfacings; both are unaffected
by ambient volume, which is the point.

Refs #2477
2026-08-08 22:39:13 -04:00
bvandeusen 9b3874b657 test: the auto-inject path now logs two retrievals, and that is the point
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 28s
45ba4aa made the reuse slot log its query (source: reuse_slot). The margin-gate
test pinned record_retrieval to exactly one call, which was asserting the very
asymmetry #2463 fixed — the displaced hit logged, the displacing query not.
Assert both sources in order instead.

Refs #2463
2026-08-08 22:32:58 -04:00
bvandeusen 45ba4aab25 fix(telemetry): the two invisible retrievals log, and /api/search takes scope
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python tests (push) Failing after 28s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 22s
#2463, the remaining findings (finding 3 landed with f11a547).

## The reuse slot logs (source: reuse_slot)

A real semantic query competing for an auto-inject menu slot, and the ledger
was asymmetric: the scored hit it DISPLACED was in retrieval_logs, the query
that displaced it was not — so the slot could never be evaluated against what
it replaced. #1038 and #2085 are gated on this ledger being complete.

## Browse search logs (source: browse_search)

The human's main search surface, and it logged nothing — so retrieval_logs
claimed the web's search was /api/search. Measured while fixing: /api/search
has ZERO frontend consumers; the web UI searches through /api/knowledge
exclusively. The table wasn't just under-describing the UI, it was describing
a surface the UI never touches.

The task's check — does folding human queries into the corpus skew the
precision signal thresholds are tuned against? — is answered by the source
column: distinct values (browse_search, reuse_slot beside the existing four)
mean tuning includes or excludes human traffic deliberately rather than by
accident. Same resolution as the mcp_/rest_ split in note_usage_events.

## /api/search takes project_id (and logs it)

The route logged project_id=None unconditionally while the MCP tool insists
the agent pass the active project. Now optional, default global: with no
frontend consumer, this route serves API callers, and an API caller states
its scope explicitly — the default-scope UI decision the task flagged is moot
until a UI actually consumes the route, which is recorded rather than guessed.

Refs #2463
2026-08-08 20:00:22 -04:00
bvandeusen f11a547cd2 fix(lists): shared-project records appear, and a list's q means what search means
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 28s
#2462, both decided halves.

## The ACL defect

list_notes filtered on Note.user_id == user_id with no scope parameter at all
— never a per-call decision, the capability was absent. query_knowledge beside
it was deliberately browse-scoped with a comment saying why. So a task in a
shared project was invisible in list_tasks, enter_project's open-task list,
the SessionStart todo count and the web UI's task views, while the same
project's notes and snippets appeared.

Now the shared clause: notes_visibility_clause(user_id, "browse"). Browse and
not read, per decision #2094 — an ambient list must never surface a record
someone shared one-to-one; those stay search-only. This is the remaining half
of a fix made twice before (#2159 widened fetch, #2092 widened meaning), and
the guard below is what stops a fourth half appearing.

Guarded by source inspection in test_retrieval_scopes: every list-shaped
service references a shared visibility clause AND carries no bare
Note.user_id comparison that would quietly re-narrow it. An owner-only list
returns correct-looking rows and simply omits the shared ones — the shape no
behavioural test catches.

## q is semantic (operator: "make it match")

The UI's note list keyword-matched while the UI's Browse search
semantic-matched, over the same records, with nothing saying so. Now one
meaning: q joins the embedding index and orders by cosine distance at the
interactive floor, with every lifecycle filter still applied in the same
indexed query. Relevance ordering wins over `sort` when q is present — a query
is a relevance claim, and sorting its results by date would shuffle the
answer. ILIKE survives only as the embedder-down fallback: degraded, never
empty.

Stated position: superseded records are NOT demoted in list-q. The penalty
reorders a top-k; reordering a paginated, counted list would make page
boundaries lie. The search surfaces carry the demotion.

## The interactive floor becomes one constant

INTERACTIVE_SEARCH_THRESHOLD = 0.3 in embeddings.py, consumed by
routes/search.py (was a commented constant), knowledge.py (was a bare
literal), and the new list-q. This closes #2463's finding 3 early — the same
number lived in two files with the reasoning attached to only one.

## Deferred, deliberately

The return-shape unification (ORM objects vs dicts) stays undone. It is a
14-caller refactor whose motivation — callers being unable to swap paths —
shrinks now that both paths share the clause and the meaning of q. If swap
pressure recurs it deserves its own change, not a rider on an ACL fix.

Refs #2462, #2463
2026-08-08 19:58:01 -04:00
bvandeusen baa2492053 Duplicate report for notes and tasks — milestone #278 complete (#102)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 18s
2026-08-08 19:05:08 -04:00
bvandeusen 52bf40de4f test: the empty duplicate report carries suggestion now — assert shape, not the exact dict
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 35s
The step-5 field addition (d7039dc) broke a test pinning the error-path
return literally. The field is deliberate: a failed scan must match a clean
scan in shape, or every consumer grows a second code path for the degraded
case. Assert the parts instead.

Refs #2547
2026-08-08 18:58:37 -04:00
bvandeusen d7039dc17c feat(dedup): the duplicate report reaches notes and tasks, with per-kind cures
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Failing after 30s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Skipped
Step 5 of #278, folding in #2534. The operator's no-gate decision for the web
UI (#2482 — "an llm attached to this surface is the corrections system") has a
precondition nobody had built: the corrector has to be able to SEE what needs
correcting. find_duplicate_snippets had no equivalent for notes or tasks, so a
duplicate note was only ever noticed by accident.

find_duplicate_records(kind="snippet"|"note"|"task") — the same indexed
self-join, parameterised. Tasks are notes with a status, not a note_type, so
the kind split is a status predicate; mixing them would propose folding a
to-do into a write-up. find_duplicate_snippets stays as a wrapper because both
surfaces and SnippetListView consume it by name.

What differs by kind is the CURE, and the report says so in a `suggestion`
field rather than leaving the caller to guess:

  snippet  merge — lossless, the survivor keeps every call site
  note     NEVER merge. A correction pair → supersedes on the newer; state
           smeared across dated records → extract to the System's reference
           note; genuinely parallel → leave alone. Choosing needs the records
           READ, which is the agent's job — so non-snippet groups carry
           `members` with dates and any `existing_supersessions` already
           declared inside the group. A pair someone ruled on is not an open
           question.
  task     usually the same work opened twice — keep the one with the history,
           cancel the other with a pointer.

The snippet sibling filter stays snippet-only: it keys on symbol/code_sha,
which other kinds don't carry — and for them a look-alike is a finding.

Surfaces: MCP find_duplicate_records (classified into _READ_ONLY_TOOLS — the
completeness test would have caught the omission), REST /api/notes/duplicates,
and a KnowledgeView panel mirroring SnippetListView's — links only, no merge
button, because for notes the report proposes and the correction is a read-
and-decide act. The panel follows the type filter and clears when it changes,
so a note report can't linger under a task view.

Correcting the task's own premise: it claimed the snippet report had "no view
consuming it" — stale; SnippetListView has consumed it since it shipped. The
UI gap was only ever notes/tasks.

Answers the question carried from #2482: yes, the update routes on BOTH
surfaces can turn a record into a duplicate — the gate is create-time by
design. This report is the mechanism that catches it after the fact, which is
the model the operator chose.

Refs #278, #2547
2026-08-08 18:51:49 -04:00
bvandeusen 914b701a50 Supersession (steps 1–4) — corrections demote, state lives on Systems (#101)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 18s
2026-08-08 18:25:30 -04:00
bvandeusen 3f1523b19f feat(systems): read-side teeth — the vocabulary at session start, a search filter, and the state/chronicle instructions
CI & Build / Python tests (push) Successful in 46s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 25s
Step 4 of #278, product half. The audit that motivated it: one System in
project 2, thirty records tagged, nothing since July 28 — three days after the
feature landed. Not a discipline failure; retrieval was completely blind to
the association (zero references in embeddings, knowledge, search, auto-inject,
or enter_project), so tagging was a write-side label with no read-side payoff,
and labels nobody reads don't get maintained.

Three changes, ordered by what makes the others workable:

1. enter_project returns the project's Systems (id, name, first line of the
   charter). Load-bearing for the tagging instruction: you cannot ask an agent
   to check a record against a vocabulary it never sees. Trimmed because it
   rides on every session start; the full charter stays get_system's job.
   Present-and-empty rather than absent when a project has none — "no named
   areas yet" is information the create-the-System instruction acts on.

2. search accepts system_id, MCP and REST (#33). Implemented once in
   semantic_search_notes as an EXISTS against record_systems — an association
   filter deciding candidate-set membership before scoring, like project_id,
   not a ranking signal. The REST route's missing project filter stays #2463's:
   it carries a default-scope UI decision this change must not preempt.

3. The instructions (#119, _INSTRUCTIONS + using-scribe skill; plugin 0.1.25
   for the cache):
   - Tag as you write, with an executable test — "would someone investigating
     that subsystem want this in the pile list_system_records returns?" —
     rather than "tag appropriately", which is what died.
   - Create the System when the area has no record: the two-or-more test
     snippets use, plus "don't wait to be asked to name an area that plainly
     exists", because the agent's default was leaving un-modelled areas
     un-modelled forever.
   - State vs chronicle: dev-logs are written once and never rewritten; durable
     findings live in the System's reference note, updated in place — safe
     because note versions are the changelog, which has existed since the
     feature shipped and was never named as one.

list_system_records' docstring now sells it as the way to READ a subsystem,
reference note first. No auto-inject boost by System — vocabulary and filter
first, measure before adding ranking behaviour (the #2486 lesson).

Refs #278, #2546
2026-08-08 18:19:58 -04:00
bvandeusen 6c4c1bccfc test: stub the auto-inject supersession lookup where there is no database
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 27s
CI on f20c019: nine failures across three files, all on the auto-inject path.
Step 3's own tests passed; these are the same shape as the step 2 breakage —
a real database call added to a path whose unit tests run without one.

Stubbed per file, each saying why, rather than once in conftest. A global stub
would hide the dependency from every future test on these paths too, which is
the same "make the code lie" trade refused in 984407f, one level up.

The auto-inject query is kept separate from the ranker's rather than threaded
through, and that is deliberate: `_reserve_slot_for_reuse` runs a SECOND search
and can add a hit to the menu, so the final `kept` set is not a subset of what
the ranker scored. Labelling whatever actually reached the menu needs its own
lookup over that final set — one indexed query on a handful of ids.

Refs #278
2026-08-08 02:07:14 -04:00
bvandeusen f20c019f2a feat(supersession): demote what a later note overtook, and label it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Failing after 30s
CI & Build / Build & push image (push) Skipped
Step 3 of #278. First step with visible effect.

## Where the demotion happens, and why not in SQL

Applied AFTER the pgvector fetch, over an over-fetched candidate set, not as
part of the ORDER BY.

Ordering by `distance + penalty` would be exact and would turn an indexed
top-k into a scan-and-sort of every embedded note — the HNSW index from
migration 0067 can only serve a raw-distance ordering. So the query fetches
3x the requested rows by raw distance and the re-rank happens in Python.

Demoting after a LIMIT k with no over-fetch would have been theatre: the cut
already happened, so a superseded record pushed down still sits in the results
and the live record that should have replaced it was never fetched.

The cost is stated in the code: a live record outside the over-fetch window
cannot be promoted in. With a 0.05 penalty against neighbours ~0.014 apart,
that needs the true answer more than three ranks down, which no observed query
approaches.

## Demote, never hide — enforced in three places

The penalty applies to the RANKING score, not to the relevance threshold. The
floor decides whether a record is relevant at all; the penalty decides which
relevant record comes first. Applying it to the floor would drop a superseded
record out of the results entirely, which is the one thing this must not do.

It is small on purpose. Supersession is a claim about SOME of a record's
content, so one that strongly answers a question nothing else answers still
surfaces — just behind anything comparable that is current. Its test asserts
both bounds, the upper one citing the operator's constraint rather than an
optimisation.

And every test here that could be satisfied by dropping a record instead
asserts the record is still present.

## The dedup gate opts out

A superseded record is still a duplicate of what you are about to write — the
claim is that it is no longer current, not that it is gone. Demoting it there
would let the same note be recorded a second time, and the second copy would be
the one nothing warns about.

## The label

Auto-inject marks a superseded line SUPERSEDED with a pointer to check the
later record. One query for the whole menu. An agent handed stale material with
nothing marking it acts on it with full confidence, which is worse than never
having surfaced it — the ranking is only half the fix.

Fails open: a supersession lookup error returns unpenalised results rather than
none, because unpenalised ranking is the behaviour that shipped for months and
a broken search is not.

Refs #278
2026-08-08 02:00:06 -04:00
bvandeusen 984407f931 fix(supersession): one query for both directions, not two per note read
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 25s
CI failed on 8d9e96c — eight tests in test_mcp_tool_notes.py, all
"Connect call failed (127.0.0.1, 5432)".

The proximate cause is that `_attach_supersession` runs on every note
read/write and those are unit tests of the tool layer with no database. But the
test failure exposed a worse decision underneath it.

I had written the two directions as two service calls, so every `get_note`
made TWO extra round trips plus TWO ACL checks — on the hottest path in the
product — to save a two-line partition in Python. That is the wrong trade
whether or not a test noticed.

`get_relations` replaces both: one OR query, one ACL check, partitioned by
which column holds the note's id. Its test asserts `execute.await_count == 1`,
so the collapse can't quietly come apart later.

The tests then get an autouse stub rather than the code getting a swallow. The
tool genuinely has a new dependency; hiding that behind a try/except to keep
unit tests green would be arranging for the code to lie about what it does.
This file already records the same hazard for note 2109, so the stub sits next
to that precedent.

Added the test that matters, which the first pass missed: a superseded record
still surfaces, so an agent WILL read stale material — and it must arrive with
a plain-language warning, not just a numeric field to notice. Also pinned that
both keys are ABSENT rather than present-and-empty when there are no relations.

Refs #278
2026-08-07 22:45:56 -04:00
bvandeusen 8d9e96cc6d feat(supersession): declare it — supersedes on both write paths
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Skipped
Step 2 of #278. Records and reads the claim; the demotion that makes it matter
is step 3.

`services/supersession.py` with set/get on both directions, following the
set_record_systems shape since this is the same kind of mutable M2M at the
tool/route layer rather than inside notes_svc.

## Both directions are exposed, and only one is obvious

`supersedes` is what the author claimed. `superseded_by` is what a READER needs
and what the note itself cannot know — a stale record handed over with no
marker gets acted on confidently, which is worse than never surfacing it. So
get_note carries it, says so in its docstring, and adds a plain-language line
telling the reader to open the newer note first.

Both are OMITTED when empty rather than serialised as empty lists. A field that
always says nothing trains readers to skip fields — the lesson consolidated_at
cost, removed in the previous commit.

## Refuse vs drop, which is the one real judgement here

Dropped silently: a target that doesn't exist, is trashed, is the note itself,
or would close a cycle. Each is a claim with no subject or no meaning; none is
something the caller can act on.

REFUSED with PermissionError: a target the caller can read but not write.
That is the single case where the caller could believe they succeeded and be
wrong in a way that matters — demoting someone else's record out of their
retrieval is damage invisible from the outside, with no symptom for the owner
to trace. Rule #47, and PermissionError because services/snippets.py already
uses it for read-but-not-write with both surfaces catching it.

The PATCH/PUT routes scope by the CALLER, not owner_uid: an editor-share holder
may edit the note and must not thereby inherit the owner's write access to
whatever they name as superseded.

## Cycles

A ring claims every member is obsolete. Under flat demotion that demotes them
all equally, so the set drops out of ranked retrieval together with nothing in
the data saying why. Refused by walking the existing graph from the proposed
target — iteratively with a visited set, because the graph is user-supplied and
a deep chain must not become a stack overflow on a write path. The visited set
also makes the walk terminate on a ring that already exists, which is pinned by
its own test rather than trusted.

Both surfaces (#33), the instruction surface per #119 — framed as the third
answer beside update-instead and force=true: not everything resembling an
existing record should be folded into it, and not everything distinct should
compete with it forever.

Refs #278
2026-08-07 22:38:09 -04:00
bvandeusen 5dcb738ce8 fix(backup): carry note_supersessions — the coverage guard caught the omission
CI & Build / integration (push) Successful in 13s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 36s
CI failed on 45c6b1c. `tests/test_services_backup.py` asserts every table is
either in `_BACKED_UP` or explicitly in `_NOT_INCLUDED`, and the new table was
in neither.

That guard exists for exactly this: 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. Its comment
says it was written to "stop the seventh" table slipping through. This was the
seventh.

Supersession claims are backed up rather than excluded because they are a
JUDGEMENT. Someone decided this note replaced that one, and nothing in either
note's text records the decision. Lose them and a restored corpus silently
returns to ranking stale material alongside current material — with no symptom
that says why.

Scoping differs by backup kind, deliberately. A whole-instance export takes
every row. A single-user export takes only claims where BOTH ends are that
user's notes: a claim spanning out to someone else's record cannot be restored
into a single-user import, since the far id is not in the map, so exporting it
would write a row that silently vanishes on the way back in.

Restore maps both ids and skips the row unless both resolve. A claim is about a
PAIR — half of one is not a weaker claim, it is a dangling row pointing at
whatever note holds that id next. Guarded by `data.get` like every post-v2
section, so v5 and older payloads restore cleanly without it.

BACKUP_VERSION 5 -> 6, and the version test moved with it. A payload section
added without moving the version produces backups that are structurally
different and indistinguishable by inspection.

Refs #278
2026-08-07 21:49:08 -04:00
bvandeusen 45c6b1c88a feat(supersession): the relation, and the dead column that stood where it should
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Failing after 32s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 25s
Step 1 of #278. Structure only — nothing reads or writes the new table yet.

Old records outrank newer ones on the same subject because a similarity score
cannot tell time. A note that accurately described how something worked in June
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
that, so nothing could act on it.

`note_supersessions(superseder_id, superseded_id)`. The claim points FORWARD —
the newer record names what it overtakes — because the older one cannot know it
has been overtaken; asking it to record its own obsolescence is asking it to
predict the future.

A table rather than a column because the relation is genuinely many-to-many and
partial, and both directions are hot: superseded_id answers "has this been
overtaken?" at ranking time, superseder_id answers "what does this replace?" in
a record view. An array column serves one and not the other.

CASCADE is safe because trashing is not a delete — trash_svc stamps deleted_at,
so a trashed note keeps its claims and restore brings them back. It fires only
on purge_trash, where a claim about the row would be unactionable anyway. A
CHECK rejects self-supersession, which under flat demotion would let a record
demote itself.

## consolidated_at, and what it actually was

Dropped. Written by nothing while serialised into every note and task payload
as null — and worse, it implied a capability.

The survey (#2483) read it as note consolidation modelled and abandoned. That
was wrong, and the frontend is what says so: `TaskViewerView` rendered
"✦ Auto-summarized from work logs" gated on this column. It is a survivor of
the pre-pivot auto-summary subsystem (migration 0030), whose own column #599
removed. Not an unbuilt feature — an outlived one.

So four more remnants went with it: the banner, its CSS, a `consolidatedAt` ref
in TaskEditorView assigned and never read, and `.auto-summary-banner-editor`
styling with zero template usage. That last one is presence-without-reference
in the same family as the column itself.

Dropped rather than repurposed for supersession, and the distinction is the
point: consolidation folds records into one survivor and destroys the
originals. Supersession is the opposite — both survive, the older ranks behind.
Smuggling one in under a column named for the other would bury that in schema.

## The hard delete_note

Removed, with a comment where it stood. Zero callers, and the danger was never
that it ran — it is that it was findable by name. Someone wanting to delete a
note greps `delete_note`, finds a function in the notes service with exactly
the right signature, and permanently destroys a record every path downstream
expects to be recoverable. The MCP tool of the same name already went through
trash_svc; only the service function was the trap.

Refs #278, #2483
2026-08-07 21:40:51 -04:00
bvandeusen bbba0b3ae3 refactor(embeddings): one definition of the document a record is embedded as
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 26s
`f"{title}\n{body}"` was written out four times. #2486 found three — the write
path, the recurring-task spawn, the startup backfill. The guard added here
found the fourth immediately, and it was the one that mattered most.

`dedup.find_duplicate_note` built the same string as a QUERY, compared against
embedded documents. Shaped differently from the corpus it searches, the gate
degrades silently: it still returns neighbours, just less apt ones, and nothing
says the query and the index stopped agreeing. The spawn path has the same
shape of risk — a recurring task embedded differently from everything else is
ranked against documents it doesn't match.

None of the four had diverged. That is what makes this worth doing now rather
than after: they are identical today, so collapsing them is a no-op, and the
whole point is that the next change to the shape can't hit three of four.

Which is imminent. #2486 measured a dev-log separating from five unrelated
dev-logs by 0.023 where a snippet separates by 0.153 — the difference being
that a snippet states its purpose twice in a short document. Whether that shape
is right is the open question; testing an alternative against four copies would
mean testing a shape that isn't the one in production. This is the precondition
the issue named.

The guard is source inspection, matching the f-string pattern rather than a
variable name, so a copy that renames its locals is still caught. A behavioural
test cannot see this: an inlined copy produces the same string today and
diverges the day the shape changes.

Refs #2486
2026-08-07 13:01:45 -04:00
bvandeusen 7defc6897c Project board — show all tasks, clamp plan bodies, stop re-collapsing (#100)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 18s
2026-08-07 08:26:59 -04:00
bvandeusen 3f26aa9485 fix(project): the board showed 100 of 166 tasks and said nothing
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 42s
The milestone progress bars and the cards beneath them came from different
places. The bar is counted SERVER-SIDE over every task; the kanban rendered
whatever a single `limit=100` returned. Project 2 has 166 tasks, so 66 never
arrived — and because the route sorts `updated_at desc`, the ones dropped were
the least recently touched, which is mostly done tasks in completed milestones.

So "v1.0 — 12/12" expanded to two cards, and the auto-collapse rule (100% done
starts collapsed) read as arbitrary because the number driving it disagreed
with what you saw when you opened it.

No benefit was being chased. The limit shipped the day the view was written
(012eb1d, March 2), when the project had a couple of dozen tasks. It became
wrong as the corpus grew, and nothing was watching: the route returns `total`
and the view discarded it. Correct when written, wrong later, silent in between
— the same shape as half the coherence survey.

Four changes:

- **Page until complete.** The board groups by milestone and shows per-milestone
  progress, so it cannot be right on a partial set. Guards against a page that
  returns nothing while `total` still claims more, rather than looping forever.

- **Stop swallowing the error.** `catch {}` left an empty board, which is
  indistinguishable from a project with no tasks — the same hidden-with-no-
  indicator failure one layer up. Styled apart from the empty state deliberately;
  "no tasks" and "the tasks did not load" must not look alike.

- **Clamp long plan bodies** to ~6.5rem with a Show more. A milestone IS the
  plan, so its body carries the whole design — several hundred words now — and
  rendered in full one plan pushes every other milestone off screen. max-height
  rather than line-clamp: the content is rendered markdown with block children,
  which line-clamp handles unpredictably. Length judged on the source string; a
  per-milestone scrollHeight measurement is a lot of machinery to decide whether
  to show one button, and the proxy is only wrong near the threshold.

- **Auto-collapse decides ONCE per milestone.** It re-ran on every reload, and
  `loadMilestones` runs after a task's status changes — so expanding a finished
  milestone and ticking anything snapped it shut again with no visible cause.
  That is the other half of why the collapse state looked mixed: it wasn't only
  deciding at start, it was overriding the reader continuously.

Reported by the operator after the fd7097c deploy. Not caused by it — but
restoring `.milestone-header` in #2444 is what made the progress track render
again, so the mismatch had been invisible rather than absent.
2026-08-07 08:20:49 -04:00
bvandeusen fd7097c6b7 Coherence survey fixes — instructions, read scope, pull telemetry, dedup (#99)
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 46s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Successful in 16s
2026-08-06 22:23:34 -04:00
bvandeusen 24d071619b fix(dedup): compare the artefact, not the prose describing it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 25s
The snippet gate was reading the wrong field, and #2464's UI recipes made it
measurable in both directions at once:

  .btn-danger vs .btn-danger-outline   0.92   siblings, BLOCKED
  .btn-primary re-recorded verbatim
  under a different name              <0.90   a literal copy, ALLOWED

The second is what settles it. Identical code at an identical repo·path·symbol
sailed through because the description differed, while two deliberately
parallel variants were refused because theirs did not. A snippet's embedded
document is mostly prose ABOUT the code, so no threshold fixes this: lowering
it blocks more siblings, raising it admits more copies.

So structure decides. Two exact signals, both index-served off the notes.data
mirror that already exists, no migration and no backfill:

  location  the same named thing in the same file. Requires BOTH path and
            symbol — a path alone is a directory of artefacts, and matching on
            it would refuse every second recipe from one stylesheet.
  code      byte-identical code anywhere, via the same fingerprint the drift
            check uses.

The semantic arm survives as a backstop for a genuine reword that shares
neither, raised to 0.96 so it sits above the 0.92 band where real variants
live. Structural hits say what they matched instead of hedging with "similar",
and point at merge rather than update — two records of one artefact is what
merge exists to fold back together.

find_duplicate_snippets gets the same correction: pairs where both snippets
name a symbol, name DIFFERENT symbols, and hold different code are variants,
not copies. Without it a design system's button family reports as one merge
set — eight recipes, every direct pair over the floor, top score 0.92, one
click from collapsing a component family. The cost is real and stated in the
code: a helper recorded twice under two names no longer reports. That trade
favours the report being usable, and same-symbol and unnamed duplicates — how
re-recording usually looks — still surface. The filter fails open, so a lookup
failure degrades to the old unfiltered report rather than to a reassuring
empty one.

resolve_locations extracted: compose_body, create_snippet and now the gate each
had their own copy of the repo/path/symbol shorthand fallback, and the gate is
the one where a disagreement would mean matching a location the record won't be
stored with. Applied to both create surfaces (#33) — the web UI must not be the
way to record what the agent was stopped from writing.

Refs #2518, #2464
2026-08-06 11:31:02 -04:00
bvandeusen c18139622c fix(telemetry): the human half of the pull ledger recorded one kind in three
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 27s
Opening a snippet in the UI recorded rest_snippet. Opening a note or a task
recorded nothing — so the most direct evidence the product has that anyone
cares about a record existed for one kind out of three, and the other two sat
at zero pulls looking like dead weight beside a kind that merely had a counter.

Not an open question about intent: models/note_usage.py already documented
'rest_note' as a source value. Nothing wrote it. The design named it and the
implementation stopped at snippets.

Adds rest_note and rest_task. Tagged by SURFACE rather than by the record's
kind, matching rest_snippet — the kind is a join away, but which surface asked
is not recoverable after the fact. The mcp_/rest_ split stays load-bearing:
"is this dead weight" is served by any pull, "was that injected line useful" by
agent pulls alone, and a human clicking a link would inflate exactly the number
#1038 and #2085 gate on.

The vocabulary comment in the model was itself the stale-enumeration shape this
survey keeps finding — it named a source nothing wrote while omitting sources
that existed. Replaced with the naming CONVENTION plus a pointer to grep, which
cannot drift, rather than a longer list that would go stale the same way.

Guard extended to the REST surface, same derivation as the MCP half: a route
registered at exactly /<int:x> for GET is a detail view, and one reaching a
note-backed loader must record. Handler source is expanded one level through
module-private helpers, without which get_snippet_route — the route that
already got this right — would drop out of the check by loading via
_load_snippet. Verified the guard fires when a call is removed.

Renamed test_mcp_pull_telemetry.py -> test_pull_telemetry.py; it is no longer
only about MCP.

Closes #2476
2026-08-06 08:51:21 -04:00
bvandeusen ac1ce0a7f0 fix(mcp): a read key could read notes but not snippets or design systems
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 28s
_READ_ONLY_TOOLS fails closed, which is the right design — but the list had
gone stale, so a read-only key could get_note and not get_snippet, both pure
reads of the same table, and could not read a design system at all. That
inverts the sensitivity ordering: the free-text records were reachable and the
structured, low-sensitivity ones were not. `find_duplicate_snippets` sitting in
the list was the tell — someone classified the report and missed the getters
beside it.

Adds the twelve reads that were missing: snippets, processes, the six design
system tools, and list_repo_bindings. Each verified to mutate nothing rather
than assumed — this is a security boundary, and a wrong entry does not cost
what a missing one costs. record_pulled on four getters is telemetry about the
read, not a change to what was read, and get_note already carried it inside the
boundary.

The list stays explicit. Deriving it from the name would be worse than
staleness: it makes the boundary follow a naming convention, so any future
get_* grants itself access. list_starter_role_groups is the live illustration —
it reads a constant, but names create_design_system in its docstring, so a
pattern-matcher flags it.

So derive the CANDIDATES and keep the DECISION explicit: a new test asserts
every read-shaped tool appears in _READ_ONLY_TOOLS or in a declared
_DELIBERATELY_WRITE_SCOPED, and that neither set names a tool that no longer
exists. Adding a getter now forces a classification at review time instead of
denying it silently. The second set is empty and stays declared — otherwise a
future get_or_create_* would be pushed into the allow-list to make the test
pass, which is the wrong way to satisfy it.

Third instance of the same shape, after #2476 and #2444: a hand-written
enumeration that missed the members added after it was written.

Refs #2496
2026-08-06 08:43:10 -04:00
bvandeusen ffd08507f1 fix(instructions): the push is an optimisation, not the bridge
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 29s
_INSTRUCTIONS told an agent the SessionStart hook was how rules reach a
session, and used that as the argument against a host-memory pointer. The
using-scribe skill said the opposite — pull them yourself, treat any push as a
bonus. Nothing said which wins, and #119 makes these surfaces the
specification, so this was the product behaving two ways.

#2198 is the case that settles it: every plugin hook was silently inert for an
extended period. An agent trusting the push would have run with no binding
rules and no signal, while those rules govern branch, commit and push.

So: _INSTRUCTIONS now leads with the explicit pull and names the hook as a
delivery optimisation. The argument against a host-memory pointer survives —
it never needed the hook to be reliable, because the pull IS the bridge and it
is written into every surface a session already loads.

The static context gains the tiebreaker for the next disagreement: follow the
surface that assumes least about its own delivery. "Most detailed wins" is
wrong precisely because the most detailed surface is the one with a delivery
precondition. It goes there by its own logic — a tiebreaker arriving over MCP
cannot arbitrate what to do when MCP is absent.

Guarded by tests/test_instruction_surfaces_agree.py: every session-start
surface states the pull, and no surface names the push without it. Plugin
version bumped so the cache that executes actually picks the file up (#2209).

Refs #2497
2026-08-06 08:23:36 -04:00
bvandeusenandClaude Opus 5 63c213b617 fix(processes): the least-equipped kind is the one that gets followed
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 41s
Survey pass 3 (#2250) tabulated capabilities per record kind. Processes came
out lowest on every column, and they are the kind with the most authority:
build_process_manifest turns each one into a skill file on the operator's
machine that auto-surfaces and is followed as written — its own docstring calls
it "the most consequential passive surface Scribe has."

Three gaps closed.

NO PULL TELEMETRY (#2476). get_process recorded nothing, while the auto-inject
menu header names get_process as the way to open that kind. Every note is
embedded regardless of note_type, so a Process is surfaceable — and the getter
the product points at was the one getter that recorded nothing, leaving every
Process permanently at zero pulls and looking like dead weight beside kinds
that merely had a counter.

get_note's own comment already listed processes as a reason to record pulls.
The fix for #2245 covered notes, tasks and snippets: it enumerated the kinds
someone thought of rather than the kinds that exist.

NO DEDUP GATE. create_process had no near-duplicate check and no force flag,
while notes, tasks, snippets and rules all have both. It matters more here than
elsewhere: two near-identical procedures don't just bloat the corpus, they
compete to be followed, and which one wins is decided by a slug collision.

NO DELETE. list/create/get/update, no delete — a kind that reads as one you
cannot retire. Deletion was always possible via delete_note, since a Process is
a note and the trash is kind-agnostic, so this was discoverability rather than
capability. delete_process checks note_type before trashing: the tool is
reached for by name, and letting it destroy an ordinary note whose id happened
to resolve would be a destructive action taken on a mistyped argument.

THE GUARD, which is the part that stops a fourth repeat.

tests/test_mcp_pull_telemetry.py discovers every get_* MCP tool by AST and
requires a record_pulled from any that loads a single note. Not a list of
getters — a get_<newkind> added tomorrow is covered the moment it loads a note
the way the others do. get_milestone is correctly excluded: it calls list_notes
for a milestone's steps, which is a surfacing, not an opening.

The loader NAMES are a list, and that residual weakness is pinned against a
rename rather than papered over. An earlier draft tried to discover new loaders
by return annotation and would have failed on create_note — which also returns
a Note. Readers and writers aren't distinguishable by type, so the honest
version is a pinned list, a non-empty assertion, and a docstring saying which
hole remains.

test_register_attaches_four_tools became a derived check of the module's public
coroutines, so the next tool added can't be left unregistered.

MCP _INSTRUCTIONS updated: product behaviour belongs in the instruction
surfaces, not in a rule (rule #119).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 16:32:05 -04:00
bvandeusenandClaude Opus 5 07bf58de46 fix(project): grid tracks that cannot shrink pushed the milestone rows off-page
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Reported after deploy: the milestone rows and the kanban's Done column run past
the right edge and get cut.

Both grids here use a bare `1fr`, and a `1fr` track carries an AUTO minimum —
it cannot size below its content. So one wide descendant anywhere in the
content column widens the column past the grid, everything inside inherits that
width, and `.project-view`'s `overflow-x: clip` cuts it at the page edge. The
milestone header only made it visible: it is a flex row now, so its tail
(progress track, percent, actions) sits at the right edge where the clipping
happens, where before those children stacked at the left and never reached it.

`minmax(0, 1fr)` on both, plus `min-width: 0` on the content area — a grid
item's default `min-width: auto` refuses to shrink even when its track will,
so the two halves are needed together.

Worth naming, because it is the same property twice with opposite intent: the
header nav was fixed two commits ago by RELYING on the auto minimum, so neither
side could be squeezed under its content and the pill bar stays centred. Here
that same behaviour is the defect. `1fr` is not a neutral default — it is a
statement that the track may not shrink.

I could not isolate which descendant was the wide one by reading, and said so
rather than guessing at it; this is the structural fix, which holds whichever
of the candidates it was.

Not changed: RulesView's `280px 300px 1fr` is the same shape and a plausible
latent instance, but nothing has reported it and I have not seen that surface
misbehave. Guessing at unreported layouts is how eleven fixes become eleven
regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 10:08:48 -04:00
bvandeusen 11c243c0fa Deleted CSS left its modifiers behind — restore six base rules, and check for the rest (#98)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 18s
2026-08-05 09:41:18 -04:00
bvandeusenandClaude Opus 5 a6d6550483 fix(ui): walk the eleven dangling-style reports — two were real
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 45s
#2444. Each needed reading rather than a batch fix, and the split was 2 real
losses, 4 false reports, 5 wrappers that are bare on purpose.

REAL:

  .system-card   was a flex row, and every child still says so —
                 .system-swatch and .system-actions are flex-shrink: 0,
                 .system-body and .system-form--inline are flex: 1.
                 align-items: flex-start is why the swatch carries
                 margin-top: 0.3rem: nudged onto the first line of text.
  .systems-list  no rule AT ALL, so the systems list rendered with browser
                 bullets and indent. Invisible to the check — see below.
  .graph-embed   the panel is a flex column whose header is flex-shrink: 0,
                 so this is the item that takes the remaining height. Without
                 it the `height: 100%` on the line below resolves against auto
                 and does nothing, which left the comment above it specifying
                 a rule that could not work.

FALSE REPORTS, and the checker was wrong rather than the code:

`.pane.empty` and `td.num` are base rules for the element that carries those
classes — the check read any compound with more than a lone class as a
modifier. It now records a compound's whole class SET and clears an element
carrying all of them, which is exact: recording the classes individually would
have cleared `.pane` everywhere on the strength of a rule that only applies
alongside `.empty`. Four reports gone, and a check with false reports is one
that gets skimmed.

BARE ON PURPOSE — .rb, .topic-group, .new-topic, .sub-list, .dash-head, and
both .detail-row rows. Each namespaces descendant rules and assumes nothing
about layout, which is the tell that separates them from a deleted base. All
seven now carry a comment saying so, so the next reader doesn't re-litigate
them and a NEW entry in the report means something actually changed.

Also recorded in the script: it cannot see a class with no rule anywhere, since
that is indistinguishable from a semantic-only hook. `.systems-list` was found
by reading the file beside a class that WAS half-styled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 09:26:35 -04:00
bvandeusenandClaude Opus 5 46271ccaa7 fix(project): the goal field is a textarea, not a one-line input
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 39s
A project goal is a paragraph in practice. This one rendered as "Maintain
Scribe as the reliabl" with no way to read the rest but arrowing through it,
in a sidebar with room to spare. Description gets two more rows for the same
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:42:58 -04:00
bvandeusenandClaude Opus 5 6ac821178f fix(design): declare --tp-fill so the token check can see it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Canceled after 32s
The swatch set it inline only, and a custom property that exists nowhere in a
stylesheet is exactly what check_design_tokens reports as unresolvable — it was
right, and it caught this on the commit that introduced it.

Declaring it on .tp-swatch is the real fix rather than a silencer: a token that
resolves to nothing now renders as bare checks instead of an invalid gradient,
which is what the inline value would produce when empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:41:24 -04:00
bvandeusenandClaude Opus 5 4a9744172f fix(ui): restore four base rules a CSS sweep deleted, and check for the rest
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Canceled after 38s
CI & Build / Build & push image (push) Canceled after 0s
Operator reported four things looking wrong. Two were the same bug, and it is
not a design drift — it is deleted CSS.

Removing a rule from a scoped stylesheet leaves its modifiers behind. The
selector still exists, so nothing reads as unused, and the element renders with
no base styling at all:

  .btn-workspace      base gone, :hover survived — the Workspace link rendered
                      as raw browser blue, underlined
  .milestone-header   base gone, .clickable and :hover survived. Every child is
                      written for a flex ROW (.ms-name { flex: 1 }, the progress
                      track, .ms-pct), so without the parent they stacked and a
                      one-line milestone became five. That is the "projects
                      section uses space poorly" — a deletion, not a redesign.
  .milestone-group    no rule at all; the card around each milestone
  .ds-header          only its h1 descendant survived

vue-tsc cannot see any of it. A dead style typechecks perfectly.

scripts/check_dangling_styles.py finds the shape: an element whose every static
class has no base rule anywhere, while at least one carries modifier rules. It
reports 11 more. Reported and not gated, because a genuinely bare wrapper is
legitimate — the signal is the count growing. Runs in the lint lane, stdlib
only, and knows no class name or convention (rule #115).

Also from the same report:

- The header pill bar was `position: absolute; left: 50%`, so it did not
  participate in layout: out of room, it OVERLAPPED the brand and the utility
  cluster instead of pushing them. A sixth link reached that at ~1270px, an
  ordinary window. Now `1fr auto 1fr` — a 1fr track has an auto minimum, so
  neither side can be squeezed under its content and the two stay equal, which
  is what keeps the bar centred in the viewport rather than in the leftover
  space. Overflow becomes the header growing, not two things sharing pixels.

- The token preview put its checkerboard on the whole specimen stage, so every
  swatch sat in a frame of checks and the pattern read as the loudest thing on
  the page. The checks now sit UNDER the colour as a second background layer:
  an opaque value hides them, a 15% tint shows exactly as much as it should.
  Text-bearing specimens lose the box entirely, and name/value/purpose are one
  line each with the full text on hover — they wrapped freely before, so a card
  was two lines tall or five depending on how long its color-mix() happened to
  be, and the grid had no rhythm.

- .btn-cta joins the shared button family: the gradient-and-glow brand moment
  the system carries tokens for, which had been living in one view's scoped
  block. That is what made it deletable. The header actions are now one size
  and one family instead of four sizes and two.

- The shared button shape gained inline-flex + gap, so a button carrying an
  icon centres it without each caller rebuilding the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:40:41 -04:00
bvandeusen d8dd017994 Design surface: starter roles, theme literals, and the view that could only inspect itself (#97)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 19s
2026-08-04 11:02:42 -04:00
bvandeusenandClaude Opus 5 8087ba4db0 feat(design): a project reports drift in its own recorded components
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 43s
The check has taken a project id since it was written — check_snippets_against_
system(user_id, design_system_id, project_id=0), and the route has always read
?project_id=. Nothing on the frontend ever passed one and no project-side
surface existed, so the capability shipped and stayed unreachable.

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

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

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

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

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

Step 3 of milestone #274.

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

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

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

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

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

Step 2 of milestone #274.

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

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

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

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

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

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

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

Step 1 of milestone #274.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 08:26:43 -04:00
bvandeusenandClaude Opus 5 5b824c1626 feat(design): the panel now asks whether the app agrees with its own sheet
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Retiring rulebook #2 left the /design drift panel with no data source, and
because its empty state was well-written the feature read as working while it
could only ever render "nothing designated" (#2419). The original question is
genuinely gone: theme.css is generated from design system 2, so checking the
system against a sheet derived from it would be a tautology.

The question that survives is the one no server can answer. A generated sheet
still has to be LOADED and APPLIED, and nothing checked that it was:

  absent      the record declares a token the app doesn't have — the sheet was
              never regenerated after the record changed, or never loaded
  differs     the app has it with another value — a stale sheet, or a later
              rule that overrode it
  unrecorded  the app declares a token in the record's own family that the
              record has never heard of

Both sides go through the same engine so the comparison is honest: declared
values are set on an offscreen probe and read back, which performs the same
var() substitution the browser already did to the live values. Comparing raw
strings would mark every derived token as drift.

The designation moved with the feature — design_rulebook_id becomes
ui_design_system_id, with a migration deleting the retired key rather than
leaving an inert row. The prose extractor it fed goes too (#2288 said its
runtime role ended when the import landed).

Three orphans of the same shape, found alongside and fixed here:

- darkOverriddenNames hardcoded [data-theme="dark"]. The sheet went dark-first
  months ago, so it matched nothing and the "mode-aware" flag silently left the
  gallery. Now matches the SHAPE of a mode selector, which also holds for an
  install whose modes aren't light and dark.
- groupFor's prefix table never heard of --fs-, so 110 tokens sat under
  "other". Groups now come from the record where there is one; the table can
  only know families that shipped with the product (rule #115).
- The type scale was a hand-written table of nine sizes marked "no token",
  true when written and false since the scale was recorded. Now rendered from
  whatever size tokens the sheet declares, so it can't go stale twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 20:50:03 -04:00
bvandeusenandClaude Opus 5 841506b10c feat(plugin): every session says which plugin version is executing
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 19s
Twice a shipped fix failed to reach a live install, and both times the only
detector was the operator saying "I don't think it updated" (#2198, #2209).

The reason it is hard to see: an install has two halves and only one
self-updates. The marketplace clone pulls on its own; the CACHE is what
executes and refreshes only when the manifest version changes. So inspecting
the clone shows the fix present while the broken copy keeps running — the
obvious debugging move actively misleads.

The SessionStart context now names the version it is running. That makes "what
is actually executing?" answerable from the transcript rather than by
archaeology in the cache directory.

DELIBERATELY SMALLER THAN THE ISSUE PROPOSED. #2220 recommended reporting the
version to the server, storing last-seen per user, and surfacing it in
Settings. That is three surfaces and a migration to answer a question the
session can answer about itself. Per the operator's framing on #2338 — "I'm
afraid of building another integration between two more surfaces, you being
able to notice is enough" — the visibility is the deliverable, not the
plumbing.

It also lands the issue's own caveat, which the server-side design could not:
the state most needing diagnosis is the one where credentials never arrive,
and there the dynamic tier does not run at all. This marker is keyless and
networkless, so it still appears — verified against both the unconfigured and
unreachable-instance paths.

CI pins it, asserting WITHOUT credentials for the same reason. Manifest 0.1.22
-> 0.1.23, because a shipped hook changed and an unbumped manifest is precisely
the failure this commit is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:57:33 -04:00
bvandeusenandClaude Opus 5 c34454b840 refactor(theme): the accent was hand-written 16 times — now derived
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 43s
`rgba(91, 74, 138, …)` is Scribe's accent in decimal. It appears sixteen times
across five files at ten different opacities, plus once as #5B4A8A. Change the
accent in the design system and none of them would have moved — which is the
precise failure the token system exists to prevent, hiding in a notation that
does not look like a colour constant.

Now `color-mix(in srgb, var(--color-primary) N%, transparent)`, so every one
follows the accent. The design system already uses this form for its own tints
(--fs-accent-soft, -faint, -wash), so this is the established idiom rather than
a new one.

The CI literal count barely moves (45 -> 44) because its regex matches #hex and
fifteen of these were rgba(). Worth stating plainly: **the count was never the
goal, and the check is blind to this whole class.** An rgba triple is a colour
literal in every sense that matters and the report does not see it.

Not touched: the badge palette in KnowledgeView (#7A6DA8, #fbbf24, #818cf8 for
note/task/plan) and the remaining greys. Those are genuine unmade decisions —
what colour IS a plan badge — not drift, and inventing tokens for them would be
deciding by implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:53:54 -04:00
bvandeusenandClaude Opus 5 bd60d679d9 refactor(theme): remove 184 var() fallbacks — every one was unreachable
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 43s
#2277 counted ~150 "raw colour literals bypassing the tokens". Measuring them
told a different story: 184 sat in `var(--token, #fallback)` position, and a
check against theme.css shows every one of those tokens IS declared. So the
fallbacks could not render. Not drift — vestigial.

They were also not this palette. The most common were Tailwind and Flat-UI
defaults — #6366f1 indigo, #22c55e green, #f59e0b amber, #3b82f6 blue,
#e74c3c and #27ae60 — a second, unsanctioned colour scheme sitting in the
codebase looking like the app's colours to anyone reading it.

Removing them is not tidying. #2319's lesson is that a fallback is WORSE than a
missing token: a missing token renders as nothing and someone eventually
notices, while a fallback renders something plausible forever. These 184 were
one token rename away from silently repainting the app in Tailwind. The design
token check would catch the rename — but the fallback is precisely the thing
that would make it invisible if the check were ever bypassed.

Literal count 152 -> 45, which matters beyond the number: a report that is
mostly unreachable noise is one people stop reading, and then it stops working
while still passing. What remains should be genuinely worth looking at.

Done with a paren-aware transform, not a regex — `var(--x, rgba(0,0,0,.5))`
nests parens and `[^)]+` would cut at the first one and leave `))` behind.
Verified after: every changed line is a fallback strip and nothing else, and
every var() reference still resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:52:04 -04:00
bvandeusenandClaude Opus 5 174ec8af46 feat(design): the starter-role checklist, in the creation UI
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 44s
Rule #27 — the backend half shipped without a surface an operator can touch,
so this is the other half of #2349.

StarterRolePicker is a component rather than inline markup because
DesignSystemsView has TWO creation forms: the empty state is a sibling branch
of the body, not a parent, so a form written into one is unreachable from the
other. Inlining the checklist would have made it the next thing in this
codebase defined twice and free to drift — which is what the button migration
spent nine commits undoing.

What it offers is names and purposes, never values. "Named now, valued later":
a role you haven't filled shows as to-be-decided, while a role that doesn't
exist is what gets written as a literal instead. Every group unchecks
individually, and the prefix is editable because `--fs-` is one family's
convention, not the product's.

Three deliberate details:

- All groups checked by DEFAULT, and that default lives in the UI, not the
  service. create_design_system treats None and [] alike (seed nothing) so it
  can never write 40 rows into a system whose caller never asked; a UI default
  is visible and reversible before the click. Different layers, different
  safe answers.
- A failed catalogue fetch is NOT fatal and does not read as an error. Starter
  roles are an accelerator, not a prerequisite — the form still creates, and
  the operator adds tokens by hand.
- The refs are not cleared after a successful create. The picker owns them and
  re-seeds on mount; resetting here would race that and silently create the
  next system with no roles.

props + defineEmits rather than defineModel, matching TagInput and the rest of
components/. defineModel is available (Vue 3.5) and would be shorter, but being
the only file in the codebase using a different binding idiom costs more than
the lines it saves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:45:48 -04:00
bvandeusenandClaude Opus 5 4852b0d3df test(design): add the starter-roles endpoint to the URL enumeration
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 35s
CI caught it: tests/test_routes_design_systems.py enumerates every routed rule,
and I added a handler without adding its rule. The guard doing exactly what its
docstring says it is for.

Two enumerations govern this blueprint and I had only extended one — the
parity list (handlers exist on both surfaces) but not the URL list (handlers
are actually routed). They catch different failures, which is why both exist.

Noted in place: /api/design-systems/starter-roles is a static segment sharing a
prefix with /api/design-systems/<int:design_system_id>. That pairing is where a
silently-shadowed route hides, so it is worth being explicit that the int
converter cannot match "starter-roles" — verified rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:42:11 -04:00
bvandeusenandClaude Opus 5 22f907c44d feat(design): offer starter token ROLES at creation, never values
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 46s
CI & Build / Build & push image (push) Skipped
A literal gets written into a stylesheet when there is no role to reach for.
This codebase demonstrated it: the house style had no "text on a filled colour"
role, so 76 call sites wrote a pure-white literal — not out of defiance, but
because nothing existed to write instead. The correction was not a better ban
list; it was declaring the missing role (#2275, #2349).

So the useful moment is creation. A system whose roles are named on day one
never presents the occasion.

Ten groups, ~40 roles: surface, text, action, semantic, border, accent, radius,
space, motion, state. Operator's call was one flat list, every group
individually skippable — presets keyed to app shape (web / CLI / docs) were
rejected because they need the product to hold opinions about app categories,
and a wrong category is worse than a list someone prunes once.

TWO BOUNDARIES THIS HAS TO HOLD, both rule #115:

- The ROLES ship; the VALUES never do. Every seeded token has an empty
  value_by_mode, so a fresh system is a set of named, deliberately-unanswered
  questions. A test asserts no hex appears anywhere in the module — not just
  that tokens are blank, but that no palette hides in a comment waiting to be
  pasted in.
- The PREFIX is the install's. `--fs-` is FabledSword's convention, not the
  product's; the default is a neutral `--ds-` and callers pass their own.

Valueless roles are already legible downstream — render_stylesheet emits them
as commented-out declarations and stylesheet_for_system reports them under
`valueless` (#2299) — so "declared but undecided" reads correctly with nothing
new built.

Both surfaces, per rule #33: MCP gains starter_role_groups/token_prefix plus
list_starter_role_groups(); REST gains the same on POST plus
GET /api/design-systems/starter-roles. The parity enumeration is extended
rather than loosened.

Note create_design_system treats None and [] alike (seed nothing), while
starter_tokens treats None as "all". Deliberate: creation must never write 40
rows into a system whose caller never asked, and the everything-checked default
belongs in the UI where the operator can see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:37:20 -04:00
269 changed files with 20112 additions and 6625 deletions
+19
View File
@@ -178,6 +178,15 @@ jobs:
- name: Design token check
run: python3 scripts/check_design_tokens.py --report-literals
# Dangling styles: an element whose classes have only modifier rules and
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
# this way (a link rendering as raw browser blue, a flex row whose parent
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
# style typechecks perfectly. Reported, not gated — a bare wrapper is
# legitimate, so the signal is the count growing.
- name: Dangling style check
run: python3 scripts/check_dangling_styles.py
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
@@ -216,6 +225,16 @@ jobs:
UV_PROJECT_ENVIRONMENT: /opt/venv
run: uv sync --locked --extra dev
# The hook-EXECUTION tests (test_write_path_trigger's nudge pair) run the
# real bash hook, which exits silently without jq — and those tests skip
# rather than fail when it's absent, so without this step they would
# quietly never be verified anywhere (ci-python ships without jq; same
# install the Plugin hooks job does).
- name: Install jq for hook execution tests
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends jq
- name: Run tests
# Integration tests (real Postgres) run in the `integration` job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
@@ -0,0 +1,47 @@
"""retire the two settings that designated a design source for the app itself
Revision ID: 0075
Revises: 0074
Create Date: 2026-08-03
Two keys, retired for the same reason a week apart, so they go in one change
rather than one migration each:
design_rulebook_id which rulebook described how this app should look
ui_design_system_id which design system this app's own UI was built from
Both named a design source for THE RUNNING INSTALL. The design surface is for
the projects an install tracks, and a project already carries its own pointer
(`projects.design_system_id`) — so an install-wide designation had nothing left
to mean. `ui_design_system_id` was introduced by this same migration's first
draft and never reached a deployed database; it is listed here rather than
undone by an 0076 that would reverse a change nobody ran.
Deleting settings rows by key is safe in a way dropping a column is not — the
table is free-form key/value, so an install that never designated one simply has
no row to delete.
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
lie: the pointer lives on the project now, and always did for anyone who set it
there.
"""
from alembic import op
import sqlalchemy as sa
revision = "0075"
down_revision = "0074"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM settings "
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
)
)
def downgrade() -> None:
pass
+115
View File
@@ -0,0 +1,115 @@
"""note_supersessions; drop the never-written notes.consolidated_at
Revision ID: 0076
Revises: 0075
Create Date: 2026-08-07
Step 1 of milestone #278. Structure only — nothing reads or writes the new
table yet, and nothing behaves differently after this runs.
## What the table is for
Old records outrank newer ones on the same subject, because a similarity score
cannot tell time. A note that accurately described how something worked in June
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
that, so nothing could act on it.
The claim points FORWARD — the newer record names what it overtakes — because
the older one cannot know it has been overtaken. Many-to-many and partial: a
note may supersede parts of several others and be overtaken piecemeal by
several later ones, which is why this is a table rather than a column. Both
directions are queried: `superseded_id` answers "has this been overtaken?" at
ranking time, `superseder_id` answers "what does this replace?" in a record
view. An array column could serve one and not the other.
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
them back. The cascade fires only on `purge_trash`, where the row genuinely
goes — and a claim about a row that no longer exists is not actionable.
## What is being dropped, and why now
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
— while being serialised into every note and task payload as `null`. It cost a
column, a line in every response, and worse: it IMPLIED a capability. A reader
reasonably concludes notes can be consolidated and this records when.
That reading was reasonable precisely because merge/unmerge exists for snippets
and not for notes, so the column looked like the notes-side half of that
feature, modelled and abandoned.
It is dropped rather than repurposed for supersession, and the distinction is
the point (#2483): consolidation folds several records into one survivor and
destroys the originals. Merging two snippets is lossless — one helper, several
call sites. Folding two dev-logs means writing a summary and losing what each
actually said. Supersession is the opposite act: both records survive, and the
older one is merely ranked behind. Smuggling one in under a column named for
the other would have buried that difference in schema.
## Downgrade
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
restores the shape, not the (nonexistent) data. Drops the table; any recorded
supersession claims are lost, which costs ranking its input and nothing else,
since no note's own content depends on them.
"""
import sqlalchemy as sa
from alembic import op
revision = "0076"
down_revision = "0075"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"note_supersessions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"superseder_id",
sa.Integer,
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"superseded_id",
sa.Integer,
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint(
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
),
# Declaring that a note supersedes ITSELF is meaningless, and under flat
# demotion it would demote a record on its own authority. Refused in the
# service too, with a message — this is the backstop that holds when
# something writes rows directly.
sa.CheckConstraint(
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
),
)
op.create_index(
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
)
op.create_index(
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
)
op.drop_column("notes", "consolidated_at")
def downgrade() -> None:
op.add_column(
"notes",
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
)
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
op.drop_table("note_supersessions")
@@ -0,0 +1,54 @@
"""Chunked embeddings: one note_embeddings row per chunk (#280)
Revision ID: 0077
Revises: 0076
Create Date: 2026-08-09
The embedding model reads at most 512 tokens and fastembed truncates the rest
silently, so the old one-row-per-note shape permanently lost everything past
~400 words of a record. A note now stores one row per chunk of
`embeddings.chunk_document`: PK (note_id, chunk_index), plus the chunk's text
(inspectability + future "matched section" surfacing) and the chunker version
that produced it (so later shape changes re-embed by version comparison
instead of repeating this wipe).
Embeddings are DERIVED data (0067 precedent): rows are cleared here and the
startup backfill regenerates the whole corpus at the new shape on next boot.
The HNSW index is untouched — it indexes chunk rows exactly as it indexed
note rows.
"""
from alembic import op
revision = "0077"
down_revision = "0076"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Derived data — the version-aware startup backfill re-embeds everything
# at the chunked shape. Old whole-document rows would be indistinguishable
# from properly-chunked single-chunk notes, so they cannot be carried over.
op.execute("DELETE FROM note_embeddings")
# Empty table, so NOT NULL columns need no defaults and the PK swap is
# instant.
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_index integer NOT NULL")
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_text text NOT NULL")
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunker_version integer NOT NULL")
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
op.execute(
"ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id, chunk_index)"
)
def downgrade() -> None:
# Same reasoning in reverse: chunk rows make no sense to a whole-document
# reader, so clear and let the old backfill regenerate.
op.execute("DELETE FROM note_embeddings")
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_index")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_text")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunker_version")
op.execute("ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id)")
+130
View File
@@ -0,0 +1,130 @@
"""Forge connections move to the user level (#2778)
Revision ID: 0078
Revises: 0077
Create Date: 2026-08-19
A forge token is a user's credential, not an instance's: the single
admin-settings config meant every user's snippet-freshness and coverage reads
ran under the operator's token. Each user now owns a keyring of connections —
one per forge host — and projects resolve forge reads on their OWNER's
keyring, with an optional per-project pin (projects.forge_connection_id).
The data move carries the existing admin config into a connection row for the
first admin user (host parsed from the base URL), then deletes the old
setting keys outright — no legacy dual-read (rule #22). The env-var channel
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
survives as an implicit keyring entry for admin users only.
"""
from urllib.parse import urlsplit
import sqlalchemy as sa
from alembic import op
revision = "0078"
down_revision = "0077"
branch_labels = None
depends_on = None
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
def upgrade() -> None:
op.create_table(
"forge_connections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("base_url", sa.Text(), nullable=False),
sa.Column("host", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
op.add_column(
"projects",
sa.Column(
"forge_connection_id",
sa.BigInteger(),
sa.ForeignKey(
"forge_connections.id",
ondelete="SET NULL",
name="fk_projects_forge_connection_id",
),
nullable=True,
),
)
# Data move: the admin-settings config becomes the first admin's keyring
# row. All three values must be present — a partial config never produced
# an adapter, so carrying it over would invent a connection that never
# worked.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT s.key, s.value FROM settings s"
" JOIN users u ON u.id = s.user_id"
" WHERE u.role = 'admin' AND s.key IN :keys"
" AND s.user_id = ("
" SELECT MIN(id) FROM users WHERE role = 'admin'"
" )"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
).fetchall()
values = {key: (value or "").strip() for key, value in row}
kind = values.get("forge_kind", "").lower()
base_url = values.get("forge_base_url", "").rstrip("/")
token = values.get("forge_token", "")
host = (urlsplit(base_url).hostname or "").lower()
if kind and base_url and token and host:
conn.execute(
sa.text(
"INSERT INTO forge_connections"
" (user_id, kind, base_url, host, token, created_at, updated_at)"
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
" FROM users WHERE role = 'admin'"
),
{"kind": kind, "base_url": base_url, "host": host, "token": token},
)
conn.execute(
sa.text(
"DELETE FROM settings WHERE key IN :keys"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
)
def downgrade() -> None:
# Reverse data move: the first admin's row (if any) becomes the admin
# settings again. Other users' rows have no pre-0078 representation and
# are dropped with the table.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT user_id, kind, base_url, token FROM forge_connections"
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
" ORDER BY id LIMIT 1"
)
).fetchone()
if row is not None:
for key, value in (
("forge_kind", row.kind),
("forge_base_url", row.base_url),
("forge_token", row.token),
):
conn.execute(
sa.text(
"INSERT INTO settings (user_id, key, value)"
" VALUES (:uid, :key, :value)"
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
),
{"uid": row.user_id, "key": key, "value": value},
)
op.drop_column("projects", "forge_connection_id")
op.drop_table("forge_connections")
@@ -0,0 +1,66 @@
"""The shape ledger: code_shapes (#2787, milestone 294)
Revision ID: 0079
Revises: 0078
Create Date: 2026-08-19
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small); this table accounts for EVERY shape the
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
from the coverage sync (step 2) and gain judgments — canonical / instance /
variant / exempt — from audits, hooks, and the mechanical proposer.
Unclassified IS the todo list.
"""
import sqlalchemy as sa
from alembic import op
revision = "0079"
down_revision = "0078"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shapes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"project_id",
sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_key", sa.Text(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="unclassified"),
sa.Column(
"snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
)
op.create_index(
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
)
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
op.drop_table("code_shapes")
@@ -0,0 +1,54 @@
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
Revision ID: 0080
Revises: 0079
Create Date: 2026-08-21
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
(definition line + a whitespace/comment-insensitive hash of its block) so the
proposer can match on content and a later drift recheck can notice change,
without the ledger ever storing code. The proposal columns carry the
proposer's standing suggestion for an unclassified row — instance-of-#N with
a basis and score, or a derive-first group key — and `proposed_sha`
remembers the content it was judged at so a refresh re-examines only what
changed. Mechanical and recomputable: a restore that lacks them loses
nothing the next refresh does not rebuild.
"""
import sqlalchemy as sa
from alembic import op
revision = "0080"
down_revision = "0079"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
op.add_column(
"code_shapes",
sa.Column(
"proposed_snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
op.create_index(
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
)
def downgrade() -> None:
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
for col in (
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
):
op.drop_column("code_shapes", col)
@@ -0,0 +1,70 @@
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
Revision ID: 0081
Revises: 0080
Create Date: 2026-08-21
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
a judgment was made at so a later body change under an instance/variant can
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
the previous refresh, where one canon dominates its directory+kind, and not
proposed as that canon). `code_shape_events` is the what-was-used-when
record: every classification, vanish, reappearance, and drift as it
happened — history the row alone cannot keep once it moves on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0081"
down_revision = "0080"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"code_shapes",
sa.Column(
"diverges_from",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
op.create_table(
"code_shape_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"shape_id",
sa.Integer(),
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=True),
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
op.create_index(
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
)
def downgrade() -> None:
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
op.drop_table("code_shape_events")
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
for col in ("diverges_from", "recheck_at", "classified_sha"):
op.drop_column("code_shapes", col)
+26
View File
@@ -0,0 +1,26 @@
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
Revision ID: 0082
Revises: 0081
Create Date: 2026-08-21
A repo binding used to imply the repo's default branch; the shape ledger
therefore only saw work after a merge to main, while the operator's work
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
NULL keeps today's behaviour (the forge's default branch).
"""
import sqlalchemy as sa
from alembic import op
revision = "0082"
down_revision = "0081"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("repo_bindings", "ref")
@@ -0,0 +1,26 @@
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
Revision ID: 0083
Revises: 0082
Create Date: 2026-08-21
The 2026-08 audit wrote the same free-text reason thousands of times
("scoped rule — styles one element of this view"); a judgment's WHY stays
prose, but an optional code from a fixed catalogue makes the ledger
filterable and aggregable ("how many pure helpers, how many test helpers").
"""
import sqlalchemy as sa
from alembic import op
revision = "0083"
down_revision = "0082"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("code_shapes", "reason_code")
+40
View File
@@ -0,0 +1,40 @@
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
Revision ID: 0084
Revises: 0083
Create Date: 2026-08-21
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
a canon). But a shape can also CALL several canonical helpers — a service
function that is an instance of the service-function convention and a
consumer of hash_token. The 2026-08 audit had to pick one; hook evidence
("pulled #N then wrote code referencing it") was stamped as instance when it
is a uses fact. This table holds the many-valued relation: shape → snippet,
with the basis and the evidence. Cascades with the shape and the snippet.
"""
import sqlalchemy as sa
from alembic import op
revision = "0084"
down_revision = "0083"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shape_uses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("basis", sa.Text(), nullable=False),
sa.Column("evidence", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
)
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
op.drop_table("code_shape_uses")
+17 -17
View File
@@ -254,7 +254,7 @@ onUnmounted(() => {
left: 0.5rem;
z-index: 9999;
padding: 0.4rem 0.75rem;
background: var(--color-primary);
background: var(--fs-accent);
color: var(--fs-text-on-action);
border-radius: 0 0 4px 4px;
font-size: 0.875rem;
@@ -290,7 +290,7 @@ onUnmounted(() => {
text-align: center;
padding: 0.2rem 0;
font-size: 0.68rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
opacity: 0.45;
user-select: none;
letter-spacing: 0.03em;
@@ -300,17 +300,17 @@ onUnmounted(() => {
.shortcuts-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
background: var(--fs-overlay);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
.shortcuts-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
box-shadow: 0 8px 32px var(--color-shadow);
width: min(420px, 92vw);
overflow: hidden;
}
@@ -319,25 +319,25 @@ onUnmounted(() => {
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.shortcuts-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
color: var(--fs-text-primary);
}
.shortcuts-close {
background: none;
border: none;
font-size: 1.4rem;
line-height: 1;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
cursor: pointer;
padding: 0 0.25rem;
}
.shortcuts-close:hover {
color: var(--color-text);
color: var(--fs-text-primary);
}
.shortcuts-body {
padding: 0.75rem 1rem 1rem;
@@ -350,7 +350,7 @@ onUnmounted(() => {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin-bottom: 0.4rem;
}
.shortcut-row {
@@ -365,23 +365,23 @@ onUnmounted(() => {
justify-content: center;
min-width: 1.8rem;
padding: 0.15rem 0.4rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-bottom-width: 2px;
border-radius: 4px;
font-size: 0.78rem;
font-family: ui-monospace, monospace;
color: var(--color-text);
color: var(--fs-text-primary);
white-space: nowrap;
user-select: none;
}
.shortcut-key-sep {
font-size: 0.78rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.shortcut-desc {
font-size: 0.875rem;
color: var(--color-text);
color: var(--fs-text-primary);
margin-left: 0.25rem;
}
+14
View File
@@ -38,6 +38,20 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
return res.json() as Promise<T>;
}
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client — views used to restate this
* as a six-line `"body" in e` branch at every catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: unknown } }).body;
if (body && typeof body.error === "string" && body.error) return body.error;
}
return fallback;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
return handleResponse<T>(res, path);
-9
View File
@@ -1,9 +0,0 @@
import { apiGet } from "@/api/client";
import type { ExpectationResponse } from "@/utils/designDrift";
/** Checkable claims from the rulebook this install designated as its design system.
*
* `rulebook_id: null` means none has been designated — the normal state for a
* fresh install, not an error. The caller shows an explanatory empty state. */
export const fetchDesignExpectations = () =>
apiGet<ExpectationResponse>("/api/design/expectations");
+28 -3
View File
@@ -74,11 +74,28 @@ export const fetchDesignSystems = () =>
export const fetchDesignSystem = (id: number) =>
apiGet<DesignSystem>(`/api/design-systems/${id}`);
export interface StarterRoleGroup {
group: string;
description: string;
token_count: number;
names: string[];
}
/** The starter token ROLES offered at creation — names and purposes, never
* values. A default palette would be one install's taste shipped as product
* (rule #115), so the values are always the operator's to fill. */
export const listStarterRoleGroups = () =>
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
"/api/design-systems/starter-roles",
);
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
starter_role_groups?: string[];
token_prefix?: string;
}) => apiPost<DesignSystem>("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
@@ -183,6 +200,14 @@ export interface SnippetCheck {
findings: SnippetFinding[];
}
/** Which recorded snippets disagree with this design system's sheet. */
export const checkSnippets = (id: number) =>
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
/** Which recorded snippets disagree with this design system's sheet.
*
* `projectId` narrows to the snippets one project owns — which is how a
* project asks about its OWN code. Omit it to check every project, which is
* the right default from the system's side: a component recorded elsewhere
* still has to use the same tags. */
export const checkSnippets = (id: number, projectId?: number) =>
apiGet<SnippetCheck>(
`/api/design-systems/${id}/snippet-check`
+ (projectId ? `?project_id=${projectId}` : ""),
);
+115
View File
@@ -0,0 +1,115 @@
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
The five auth views used to carry byte-identical copies of these rules in
their scoped blocks (2026-08 shape audit). Loaded per view with
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
rules are scoped under .auth-card so nothing leaks into the app's other
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
stay in the view. */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
/* A centred status paragraph block: registration closed, invalid/expired
token, "check your inbox". One rule — the views used to name it
.closed-msg / .error-block / .success-msg with identical bodies. */
.auth-note {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.auth-note p {
margin: 0.5rem 0;
}
.auth-loading {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.auth-card .field {
margin-bottom: 1rem;
}
.auth-card .field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.auth-card .input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.auth-card .input:focus {
outline: none;
border-color: var(--fs-accent);
}
.auth-card .input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .input-error,
.auth-card .input-error:focus {
border-color: var(--fs-error);
}
.auth-card .field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.auth-card .error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.auth-card .error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
+123 -17
View File
@@ -36,7 +36,8 @@
.btn-secondary,
.btn-ghost,
.btn-danger,
.btn-danger-outline {
.btn-danger-outline,
.btn-cta {
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 */
@@ -46,6 +47,14 @@
line-height: var(--fs-leading-body);
white-space: nowrap;
cursor: pointer;
/* So a button carrying an icon centres it against the label without each
caller re-inventing the flex row — the shape they all reached for
separately, and the reason icon buttons sat a pixel or two off. */
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--fs-space-2);
text-decoration: none;
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);
@@ -58,7 +67,8 @@
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled,
.btn-danger-outline:disabled {
.btn-danger-outline:disabled,
.btn-cta:disabled {
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
@@ -67,7 +77,8 @@
.btn-secondary:focus-visible,
.btn-ghost:focus-visible,
.btn-danger:focus-visible,
.btn-danger-outline:focus-visible {
.btn-danger-outline:focus-visible,
.btn-cta:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
@@ -78,19 +89,19 @@
* 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);
background: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.btn-primary:not(:disabled):hover {
background: var(--color-action-primary-hover);
background: var(--fs-action-primary-hover);
}
.btn-secondary {
background: var(--color-action-secondary);
background: var(--fs-action-secondary);
color: var(--fs-text-on-action);
}
.btn-secondary:not(:disabled):hover {
background: var(--color-action-secondary-hover);
background: var(--fs-action-secondary-hover);
}
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
@@ -101,21 +112,21 @@
.btn-ghost {
background: none;
border: var(--fs-border);
color: var(--color-text);
color: var(--fs-text-primary);
}
.btn-ghost:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--color-hover);
background: var(--fs-surface-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);
background: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
.btn-danger:not(:disabled):hover {
background: var(--color-action-destructive-hover);
background: var(--fs-action-destructive-hover);
}
/* A bare text button: no fill, no border. The most common shape in the dense
@@ -125,7 +136,7 @@
.btn-text {
background: none;
border: none;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
padding: var(--fs-space-1) var(--fs-space-2);
font-family: var(--fs-font-body);
font-size: var(--fs-size-tiny);
@@ -133,7 +144,7 @@
cursor: pointer;
transition: color var(--fs-dur-fast) var(--fs-ease);
}
.btn-text:not(:disabled):hover { color: var(--color-text); }
.btn-text:not(:disabled):hover { color: var(--fs-text-primary); }
.btn-text:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
@@ -142,14 +153,33 @@
* 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);
border: 1px solid var(--fs-action-destructive);
color: var(--fs-action-destructive);
}
.btn-danger-outline:not(:disabled):hover {
background: var(--color-action-destructive);
background: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
/* The one place the accent is allowed on a button: a deliberate brand moment,
* never an ordinary action. The system carries `--fs-gradient-cta` and
* `--fs-glow-cta` for exactly this and nothing else was using them.
*
* It exists because ProjectView's Workspace link WAS this button, defined in a
* scoped block that the migration deleted — leaving a `:hover` rule with no
* base and a link that rendered as raw browser blue. A variant living in one
* view is a variant waiting to be deleted by someone tidying another; this is
* the shared home so the next sweep can't strand it. */
.btn-cta {
background: var(--fs-gradient-cta);
color: var(--fs-text-on-action);
box-shadow: var(--fs-glow-cta);
text-decoration: none;
}
.btn-cta:not(:disabled):hover {
box-shadow: var(--fs-glow-cta-hover);
}
/* --- size modifiers ------------------------------------------------------
*
* THREE sizes, because the app genuinely has three. Measured across the ~100
@@ -184,10 +214,86 @@
/* 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;
display: flex; /* not `block` — the shared shape centres with flex */
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);
}
/* ── Modal ─────────────────────────────────────────────────────────────────
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
the create-project / merge-snippet / systems dialogs, the editors' confirm
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
styles of its own, so these must be loaded with the app, not with whichever
view happens to be open. Views add only their own overrides (a wider card,
a form layout). Destructive = action-destructive per the Hybrid rule. */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.modal-message {
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 0 0 1.25rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.modal-btn-danger {
background: var(--fs-action-destructive);
border-color: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
.modal-btn-danger:hover {
background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover);
}
+76 -123
View File
@@ -13,7 +13,7 @@
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.5rem 0.5rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.editor-body {
flex: 1;
@@ -41,36 +41,36 @@
with a Trash icon at the call site to reinforce intent. */
.title-input:focus {
outline: none;
border-bottom-color: var(--color-primary);
border-bottom-color: var(--fs-accent);
}
.title-input::placeholder {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-weight: 400;
}
.editor-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.tab {
padding: 0.45rem 1rem;
border: none;
background: none;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
color: var(--fs-accent);
border-bottom-color: var(--fs-accent);
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
min-height: 200px;
background: var(--color-bg-card);
background: var(--fs-surface-raised);
}
/* ── Tag suggestions ── */
@@ -85,21 +85,21 @@
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0.55rem;
border: 1px solid var(--color-primary);
border: 1px solid var(--fs-accent);
border-radius: 999px;
background: transparent;
color: var(--color-primary);
color: var(--fs-accent);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tag-pill:hover:not(:disabled) {
background: var(--color-primary);
background: var(--fs-accent);
color: var(--fs-text-on-action);
}
.tag-pill.applied {
background: var(--color-success, #2ecc71);
border-color: var(--color-success, #2ecc71);
background: var(--fs-success);
border-color: var(--fs-success);
color: var(--fs-text-on-action);
cursor: default;
}
@@ -111,8 +111,8 @@
.assist-panel {
width: 320px;
flex-shrink: 0;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
border-left: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -123,13 +123,13 @@
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -149,13 +149,13 @@
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin-bottom: 0.2rem;
}
.assist-sections {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
@@ -165,46 +165,46 @@
cursor: pointer;
font-size: 0.82rem;
border-left: 3px solid transparent;
color: var(--color-text);
color: var(--fs-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-section-item:hover {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.assist-section-item.selected {
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
border-left-color: var(--fs-accent);
background: var(--fs-surface-raised);
font-weight: 500;
}
.assist-empty,
.assist-hint {
padding: 0.6rem 0.7rem;
font-size: 0.82rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-target-preview em {
font-style: normal;
color: var(--color-text);
color: var(--fs-text-primary);
}
.assist-instruction {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.88rem;
font-family: inherit;
resize: vertical;
background: var(--color-bg);
color: var(--color-text);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
min-height: 3.5rem;
}
@@ -216,22 +216,22 @@
/* Streaming */
.assist-streaming-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-preview-box {
padding: 0.65rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
font-size: 0.9rem;
max-height: 300px;
overflow-y: auto;
}
.typing-indicator {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.75rem;
letter-spacing: 0.15em;
animation: blink 1s step-end infinite;
@@ -244,18 +244,18 @@
.assist-active-hint {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
text-align: center;
}
/* Error */
.assist-error {
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
border: 1px solid var(--fs-error);
border-radius: var(--fs-radius-sm);
font-size: 0.85rem;
color: var(--color-danger);
color: var(--fs-error);
}
/* Review / diff */
@@ -265,12 +265,12 @@
justify-content: space-between;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.diff-view {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
font-size: 0.82rem;
font-family: monospace;
max-height: 340px;
@@ -284,15 +284,15 @@
line-height: 1.5;
}
.diff-delete {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error);
}
.diff-insert {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success);
}
.diff-equal {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.diff-marker {
flex-shrink: 0;
@@ -308,7 +308,7 @@
}
.diff-empty {
padding: 0.5rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.82rem;
}
.assist-actions {
@@ -316,63 +316,16 @@
gap: 0.5rem;
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--color-danger);
color: var(--fs-text-on-action);
border-color: var(--color-danger);
}
/* ── Floating inline assist button (teleported to body) ── */
.inline-assist-btn {
position: fixed;
z-index: 100;
transform: translateX(-50%);
padding: 0.3rem 0.75rem;
background: var(--color-action-primary);
background: var(--fs-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.8rem;
box-shadow: 0 2px 8px var(--color-shadow);
@@ -384,12 +337,12 @@
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
border: none;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
@@ -408,7 +361,7 @@
.sb-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -416,10 +369,10 @@
.sb-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
@@ -427,11 +380,11 @@
.sb-select:focus,
.sb-input:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
.sb-divider {
height: 1px;
background: var(--color-border);
background: var(--fs-border-color);
margin: 0.15rem 0;
}
@media (max-width: 720px) {
@@ -452,8 +405,8 @@
width: auto;
flex: 0 0 45%;
border-left: none;
border-top: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-top: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
}
.editor-header {
padding: 0.75rem 1rem 0.5rem;
@@ -480,14 +433,14 @@
.btn-accept,
.btn-generate,
.btn-save {
background: var(--color-action-primary);
background: var(--fs-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);
background: var(--fs-action-primary-hover);
}
.btn-back,
@@ -497,7 +450,7 @@
.btn-suggest-tags {
background: none;
border: var(--fs-border);
color: var(--color-text);
color: var(--fs-text-primary);
}
.btn-back:not(:disabled):hover,
.btn-clear:not(:disabled):hover,
@@ -505,16 +458,16 @@
.btn-proofread:not(:disabled):hover,
.btn-suggest-tags:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--color-hover);
background: var(--fs-surface-hover);
}
.btn-delete {
background: var(--color-action-destructive);
background: var(--fs-action-destructive);
color: var(--fs-text-on-action);
border: none;
}
.btn-delete:not(:disabled):hover {
background: var(--color-action-destructive-hover);
background: var(--fs-action-destructive-hover);
}
/* Shared geometry for every alias above. */
@@ -541,12 +494,12 @@
.btn-dismiss-tags {
background: none;
border: none;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
padding: 2px var(--fs-space-1);
font-size: var(--fs-size-tiny);
line-height: 1;
}
.btn-dismiss-tags:hover { color: var(--color-text); }
.btn-dismiss-tags:hover { color: var(--fs-text-primary); }
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
+24 -24
View File
@@ -41,8 +41,8 @@
}
.prose pre {
background: var(--color-code-bg);
border: 1px solid var(--color-border);
background: var(--fs-surface-code);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
padding: 0.75rem;
overflow-x: auto;
@@ -57,7 +57,7 @@
}
.prose code {
background: var(--color-code-inline-bg);
background: var(--fs-surface-code-inline);
border-radius: 3px;
padding: 0.15rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
@@ -73,25 +73,25 @@
.prose th,
.prose td {
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
padding: 0.4rem 0.6rem;
text-align: left;
}
.prose thead th {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
font-weight: 600;
}
.prose tbody tr:nth-child(even) {
background: var(--color-table-stripe);
background: var(--fs-table-stripe);
}
.prose blockquote {
border-left: 3px solid var(--color-border);
border-left: 3px solid var(--fs-border-color);
margin: 0 0 0.6rem;
padding: 0.25rem 0 0.25rem 0.75rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.prose blockquote p:last-child {
@@ -100,7 +100,7 @@
.prose hr {
border: none;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
margin: 1rem 0;
}
@@ -110,7 +110,7 @@
}
.prose a {
color: var(--color-primary);
color: var(--fs-accent);
text-decoration: none;
}
@@ -119,8 +119,8 @@
}
.prose .inline-tag {
color: var(--color-tag-text);
background: var(--color-tag-bg);
color: var(--fs-accent);
background: var(--fs-accent-soft);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
@@ -133,8 +133,8 @@
}
.prose .wikilink {
color: var(--color-wikilink);
background: var(--color-wikilink-bg);
color: var(--fs-wikilink);
background: var(--fs-accent-soft);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
@@ -168,7 +168,7 @@
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
cursor: pointer;
accent-color: var(--color-primary);
accent-color: var(--fs-accent);
width: 0.95em;
height: 0.95em;
margin: 0;
@@ -180,7 +180,7 @@
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* Interactive checkboxes — marked output in the list-note viewer */
@@ -196,7 +196,7 @@
}
.prose--checklist li input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--color-primary);
accent-color: var(--fs-accent);
cursor: pointer;
width: 0.95em;
height: 0.95em;
@@ -205,7 +205,7 @@
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
.prose--checklist li:has(input[type="checkbox"]:checked) {
text-decoration: line-through;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
text-decoration: none; /* don't strike through the checkbox itself */
@@ -219,7 +219,7 @@
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--color-text-muted, var(--color-text-secondary));
color: var(--fs-text-tertiary);
content: attr(data-placeholder);
float: left;
height: 0;
@@ -227,12 +227,12 @@
}
.tiptap-wrapper {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
}
.tiptap-wrapper:focus-within {
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
box-shadow: var(--fs-focus-ring);
}
+11 -116
View File
@@ -188,126 +188,21 @@
*/
/* ==========================================================================
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
These exist so ~55 components keep working while they migrate to --fs-*
one at a time. Every one is a plain var() reference, which is what lets this
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
--color-bg follows, because the alias resolves at use time.
That is why this file lost 48 of its 60 dark-mode overrides — they were all
restating relationships the aliases now express directly.
Removing this block is a rename sweep across the components, tracked
separately. Nothing new should reference a --color-* name.
The compatibility-alias block that lived here is GONE (#2533). It let ~55
components keep their historical --color-* names while theme.css was
repointed at the design system; the rename sweep it promised ran on
2026-08-08 and every component now references --fs-* directly. Do not
reintroduce app-local alias names — the design system's tokens are the
vocabulary, and check_snippets_against_design_system can only see through
names the system actually declares.
========================================================================== */
:root {
/* surfaces */
--color-bg: var(--fs-surface-page);
--color-bg-secondary: var(--fs-surface-raised);
--color-bg-card: var(--fs-surface-raised);
--color-surface: var(--fs-surface-hover);
--color-code-bg: var(--fs-surface-code);
--color-code-inline-bg: var(--fs-surface-code-inline);
--color-table-stripe: var(--fs-table-stripe);
--color-overlay: var(--fs-overlay);
/* text */
--color-text: var(--fs-text-primary);
--color-text-secondary: var(--fs-text-secondary);
--color-text-muted: var(--fs-text-tertiary);
/* lines */
--color-border: var(--fs-border-color);
--color-input-border: var(--fs-border-color);
--focus-ring: var(--fs-focus-ring);
/* brand */
--color-primary: var(--fs-accent);
--color-primary-solid: var(--fs-accent);
--color-primary-deep: var(--fs-accent-deep);
--color-primary-faint: var(--fs-accent-faint);
--color-primary-tint: var(--fs-accent-soft);
--color-primary-wash: var(--fs-accent-wash);
--color-tag-bg: var(--fs-accent-soft);
--color-tag-text: var(--fs-accent);
--color-wikilink: var(--fs-wikilink);
--color-wikilink-bg: var(--fs-accent-soft);
--gradient-cta: var(--fs-gradient-cta);
--glow-cta: var(--fs-glow-cta);
--glow-cta-hover: var(--fs-glow-cta-hover);
/* actions */
--color-action-primary: var(--fs-action-primary);
--color-action-primary-hover: var(--fs-action-primary-hover);
--color-action-secondary: var(--fs-action-secondary);
--color-action-secondary-hover: var(--fs-action-secondary-hover);
--color-action-destructive: var(--fs-action-destructive);
--color-action-destructive-hover: var(--fs-action-destructive-hover);
/* semantic */
--color-success: var(--fs-success);
--color-warning: var(--fs-warning);
--color-danger: var(--fs-error);
--color-overdue: var(--fs-overdue);
--color-toast-success: var(--fs-success);
--color-toast-error: var(--fs-error);
/* A VALUE, not an alias — the one survivor of the alias block. The design
system has no shadow-colour token yet, so this is a recorded gap: when a
second app needs it, promote it to an --fs-* token in the system and
regenerate, rather than copying this line. */
--color-shadow: rgba(0, 0, 0, 0.4);
/* task status + priority */
--color-status-todo: var(--fs-status-todo);
--color-status-todo-bg: var(--fs-status-todo-bg);
--color-status-in-progress: var(--fs-status-in-progress);
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
--color-status-done: var(--fs-status-done);
--color-status-done-bg: var(--fs-status-done-bg);
--color-priority-low: var(--fs-priority-low);
--color-priority-low-bg: var(--fs-priority-low-bg);
--color-priority-medium: var(--fs-priority-medium);
--color-priority-medium-bg: var(--fs-priority-medium-bg);
--color-priority-high: var(--fs-priority-high);
--color-priority-high-bg: var(--fs-priority-high-bg);
/* geometry */
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
--page-max-width: var(--fs-layout-page-max);
--page-padding-x: var(--fs-layout-page-pad);
--sidebar-width: var(--fs-layout-sidebar);
--header-height: var(--fs-layout-header);
/* ------------------------------------------------------------------
Names components reference that were NEVER declared anywhere.
Each of these was reached for with a hardcoded fallback, so the page
rendered — but the fallback was what rendered, always, and several were
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
to a brick that is not the oxblood, --color-status-cancelled to a grey from
no palette in this system.
Wiring them to real tokens is the whole point of the exercise. Expect small
visual shifts exactly where a fallback had drifted; that shift IS the fix.
------------------------------------------------------------------ */
--color-accent: var(--fs-accent);
/* Foreground ON the accent, so it follows the accent's mode-independence,
not the page text's. Pointing this at --fs-text-primary made it invert to
obsidian on light — over a mid-tone accent, well under the AA floor. */
--color-accent-fg: var(--fs-text-on-action);
--color-hover: var(--fs-surface-hover);
--color-bg-hover: var(--fs-surface-hover);
--color-bg-tertiary: var(--fs-surface-hover);
--color-surface-2: var(--fs-surface-hover);
--color-surface-alt: var(--fs-surface-hover);
--color-surface-raised: var(--fs-surface-raised);
--color-input-bg: var(--fs-surface-page);
--color-muted: var(--fs-text-tertiary);
--color-destructive: var(--fs-destructive);
--color-primary-bg: var(--fs-accent-soft);
--color-status-cancelled: var(--fs-status-cancelled);
--font-display: var(--fs-font-display);
--font-mono: var(--fs-font-mono);
}
/* ==========================================================================
+12 -12
View File
@@ -15,27 +15,27 @@
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
color: var(--fs-accent);
border-color: var(--fs-accent);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
background: color-mix(in srgb, var(--fs-accent) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--fs-text-secondary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
}
+82 -50
View File
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -50,6 +50,12 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<!-- A design system is a RECORD you author, not a setting. It sat in
the utility cluster with Trash and Settings while /design was a
read-only gallery, and stayed there after it became a record type
with its own table, sharing and MCP tools. Content, by the same
rule that puts Snippets and Rulebooks here. -->
<router-link to="/design-systems" class="nav-link">Design</router-link>
</div>
</div>
@@ -64,16 +70,6 @@ router.afterEach(() => {
<Moon v-else :size="16" />
</button>
<!-- Design. An icon rather than a sixth primary nav link: it's a
meta-surface like Trash and Settings, but hiding it entirely would
defeat the point of having somewhere the design system is visible.
Points at the RECORD, not the live-token view — the record is what
you work with; the live view is the check on it, and it's a tab
away. -->
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
<Palette :size="16" />
</router-link>
<!-- Trash link -->
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
<Trash2 :size="16" />
@@ -106,9 +102,9 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div>
@@ -128,20 +124,35 @@ router.afterEach(() => {
<style scoped>
.app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
background: linear-gradient(180deg, var(--fs-surface-hover), var(--fs-surface-page));
border-bottom: 1px solid color-mix(in srgb, var(--fs-accent) 18%, transparent);
position: relative;
}
/* Three tracks, not a flex row with an absolutely-centred overlay.
*
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
* not participate in layout: when the header ran out of room it OVERLAPPED the
* brand and the utility cluster rather than pushing them, and nothing wrapped
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
* is an ordinary window on any monitor.
*
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
* neither side can be squeezed below its content, and the two side tracks stay
* equal to each other — which is what keeps the bar centred in the viewport
* rather than merely centred in the leftover space. Overflow becomes the
* header growing, not two things sharing pixels. */
.nav {
padding: 0.6rem 1.5rem;
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
position: relative;
}
/* Left — brand */
.nav-brand {
justify-self: start;
display: flex;
align-items: center;
gap: 0.45rem;
@@ -159,9 +170,7 @@ router.afterEach(() => {
/* Center — pill bar */
.nav-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
justify-self: center;
display: flex;
align-items: center;
}
@@ -169,21 +178,23 @@ router.afterEach(() => {
display: flex;
align-items: center;
gap: 2px;
background: var(--color-primary-faint);
background: var(--fs-accent-faint);
border-radius: 10px;
padding: 3px;
}
/* Right */
.nav-right {
justify-self: end;
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
min-width: 0;
}
.nav-link {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
@@ -191,14 +202,14 @@ router.afterEach(() => {
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--color-text);
background: var(--color-primary-tint);
color: var(--fs-text-primary);
background: var(--fs-accent-soft);
}
.nav-link.router-link-active {
color: var(--color-primary-solid);
color: var(--fs-accent);
font-weight: 500;
background: rgba(91, 74, 138, 0.25);
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
}
/* Status indicator */
@@ -218,7 +229,7 @@ router.afterEach(() => {
.status-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* Status dots are indicator lights, not semantic-palette buttons —
they want to read as vital (Moss/Warning/Error are too muted for
@@ -228,7 +239,7 @@ router.afterEach(() => {
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: #ef4444; }
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
.status-gray .status-dot { background: var(--fs-text-tertiary); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
@@ -241,12 +252,12 @@ router.afterEach(() => {
/* Icon buttons (?, theme, gear) */
.btn-icon {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.25rem 0.45rem;
cursor: pointer;
font-size: 0.95rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
line-height: 1;
display: flex;
align-items: center;
@@ -254,9 +265,9 @@ router.afterEach(() => {
}
.btn-icon:hover,
.btn-icon.active {
background: var(--color-bg-card);
color: var(--color-text);
border-color: var(--color-primary);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-color: var(--fs-accent);
}
/* User info */
@@ -266,36 +277,42 @@ router.afterEach(() => {
gap: 0.4rem;
margin-left: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid var(--color-border);
border-left: 1px solid var(--fs-border-color);
}
.username {
font-size: 0.85rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-weight: 500;
/* The widest thing on the right and the only one that can give: a long
username shouldn't be what decides where the nav bar sits. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12ch;
}
.admin-badge {
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
}
.btn-logout {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-family: inherit;
}
.btn-logout:hover {
color: var(--color-danger);
border-color: var(--color-danger);
color: var(--fs-error);
border-color: var(--fs-error);
}
/* Hamburger — mobile only */
@@ -313,7 +330,7 @@ router.afterEach(() => {
display: block;
width: 20px;
height: 2px;
background: var(--color-text);
background: var(--fs-text-primary);
border-radius: 1px;
}
@@ -322,13 +339,13 @@ router.afterEach(() => {
display: flex;
flex-direction: column;
padding: 0.5rem 1rem 0.75rem;
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
border-top: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
gap: 0.1rem;
}
.mobile-divider {
height: 1px;
background: var(--color-border);
background: var(--fs-border-color);
margin: 0.4rem 0;
}
.mobile-actions {
@@ -342,10 +359,25 @@ router.afterEach(() => {
align-items: center;
gap: 0.5rem;
padding-top: 0.4rem;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
margin-top: 0.25rem;
}
/* The grid above means running out of room can no longer cause a collision —
but it can still make the header wider than the window, and a horizontally
scrolling header is its own defect. So shed width before that happens. The
wordmark goes first: the logo beside it says the same thing and is still the
link home. */
@media (max-width: 1280px) {
.brand-text {
display: none;
}
.nav-link {
padding: 0.3rem 0.5rem;
font-size: 0.78rem;
}
}
@media (max-width: 768px) {
.nav-center {
display: none;
@@ -366,7 +398,7 @@ router.afterEach(() => {
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: var(--color-primary-wash);
background: var(--fs-accent-wash);
box-shadow: none;
}
.mobile-user .btn-logout {
+5 -5
View File
@@ -13,8 +13,8 @@ defineProps<{ size?: number }>();
>
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
<stop offset="0%" stop-color="var(--fs-accent)" />
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
</linearGradient>
</defs>
<!-- Book body -->
@@ -44,12 +44,12 @@ defineProps<{ size?: number }>();
<style scoped>
.logo-book {
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
stroke: color-mix(in srgb, var(--fs-accent) 70%, transparent);
}
.logo-spine {
stroke: var(--color-text-secondary);
stroke: var(--fs-text-secondary);
}
.logo-lines {
stroke: var(--color-text-muted);
stroke: var(--fs-text-tertiary);
}
</style>
-51
View File
@@ -1,51 +0,0 @@
<script setup lang="ts">
/**
* Sub-navigation for the Design surface.
*
* There are two pages here and they are halves of ONE thing: the record that
* decides the styling, and what the browser is actually rendering from it. They
* were briefly two top-level nav entries, which put the read-only diagnostic
* first and buried the editable record under it — backwards, since the record
* is the thing you work with and the live view is the check on it.
*
* A component rather than the same markup pasted into both views: two copies of
* a tab bar diverge the moment a third tab appears, and that is the exact shape
* of duplication this whole surface exists to make visible.
*/
</script>
<template>
<nav class="design-tabs" aria-label="Design views">
<router-link to="/design-systems" class="design-tab">Design system</router-link>
<router-link to="/design" class="design-tab">Live tokens</router-link>
</nav>
</template>
<style scoped>
.design-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.design-tab {
padding: 0.5rem 0.9rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.design-tab:hover {
color: var(--color-text);
}
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
exact matching would drop the highlight on any future child route. */
.design-tab.router-link-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+16 -16
View File
@@ -90,9 +90,9 @@ function markerFor(type: DiffLine['type']): string {
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
overflow: hidden;
}
@@ -103,15 +103,15 @@ function markerFor(type: DiffLine['type']): string {
display: flex;
gap: 1rem;
padding: 0.4rem 0.75rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.78rem;
font-family: monospace;
font-weight: 600;
}
.diff-summary-ins { color: var(--color-success, #2ecc71); }
.diff-summary-del { color: var(--color-danger, #e74c3c); }
.diff-summary-ins { color: var(--fs-success); }
.diff-summary-del { color: var(--fs-error); }
.diff-scroll {
flex: 1;
@@ -123,7 +123,7 @@ function markerFor(type: DiffLine['type']): string {
.diff-empty {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.diff-line {
@@ -136,24 +136,24 @@ function markerFor(type: DiffLine['type']): string {
}
.diff-delete {
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
color: var(--color-danger, #e74c3c);
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error);
}
.diff-insert {
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
color: var(--color-success, #2ecc71);
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success);
}
.diff-equal {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.diff-collapse {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
opacity: 0.6;
border-top: 1px dashed var(--color-border);
border-bottom: 1px dashed var(--color-border);
border-top: 1px dashed var(--fs-border-color);
border-bottom: 1px dashed var(--fs-border-color);
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
+28 -35
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
import DiffView from "@/components/DiffView.vue";
import type { DiffLine } from "@/composables/useAssist";
import { fmtStamp } from "@/utils/dateFormat";
interface NoteVersion {
id: number;
@@ -56,14 +57,6 @@ const diff = computed<DiffLine[]>(() => {
return result;
});
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
async function loadVersions() {
loading.value = true;
try {
@@ -212,7 +205,7 @@ onMounted(loadVersions);
v-if="v.pin_kind === 'manual' && v.pin_label"
class="history-item-label"
>{{ v.pin_label }}</div>
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
</div>
</div>
@@ -309,7 +302,7 @@ onMounted(loadVersions);
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.history-title {
@@ -322,11 +315,11 @@ onMounted(loadVersions);
border: none;
font-size: 1.25rem;
cursor: pointer;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
line-height: 1;
padding: 0.1rem 0.3rem;
}
.history-close:hover { color: var(--color-text); }
.history-close:hover { color: var(--fs-text-primary); }
.history-body {
flex: 1;
@@ -338,7 +331,7 @@ onMounted(loadVersions);
.history-list {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--color-border);
border-right: 1px solid var(--fs-border-color);
overflow-y: auto;
}
@@ -346,18 +339,18 @@ onMounted(loadVersions);
padding: 0.6rem 0.9rem;
cursor: pointer;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.history-item:hover { background: var(--color-bg-secondary); }
.history-item:hover { background: var(--fs-surface-raised); }
.history-item.selected {
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
border-left-color: var(--fs-accent);
background: var(--fs-surface-raised);
}
.history-item-title {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -365,7 +358,7 @@ onMounted(loadVersions);
.history-item-date {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin-top: 0.15rem;
}
@@ -381,7 +374,7 @@ onMounted(loadVersions);
.history-empty {
padding: 1rem;
font-size: 0.85rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.history-footer {
@@ -390,7 +383,7 @@ onMounted(loadVersions);
gap: 0.5rem;
justify-content: flex-end;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
}
@@ -403,12 +396,12 @@ onMounted(loadVersions);
font-size: 0.85em;
line-height: 1;
}
.pin-badge-manual { color: var(--color-primary, #6366f1); }
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
.pin-badge-manual { color: var(--fs-accent); }
.pin-badge-auto { color: var(--fs-text-tertiary); }
.history-item-label {
font-size: 0.72rem;
color: var(--color-primary, #6366f1);
color: var(--fs-accent);
font-style: italic;
margin-top: 0.15rem;
overflow: hidden;
@@ -419,7 +412,7 @@ onMounted(loadVersions);
/* ── Pin controls above the diff ────────────────────────────────────────── */
.version-pin-controls {
padding: 0.4rem 0.5rem 0.5rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.82rem;
}
.pin-actions {
@@ -430,7 +423,7 @@ onMounted(loadVersions);
}
.pin-state {
font-style: italic;
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
color: var(--fs-text-tertiary);
flex: 1;
min-width: 0;
overflow: hidden;
@@ -442,13 +435,13 @@ onMounted(loadVersions);
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border: 1px solid var(--fs-border-color);
border-radius: 999px;
cursor: pointer;
}
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--color-primary, #6366f1);
border-color: var(--fs-accent);
}
.btn-unpin:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.10);
@@ -463,27 +456,27 @@ onMounted(loadVersions);
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: inherit;
}
.pin-label-input:focus {
outline: none;
border-color: var(--color-primary, #6366f1);
border-color: var(--fs-accent);
}
.btn-pin-save, .btn-pin-cancel {
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
cursor: pointer;
}
.btn-pin-save:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--color-primary, #6366f1);
border-color: var(--fs-accent);
}
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
+34 -34
View File
@@ -74,11 +74,11 @@ const markers: Record<DiffLine["type"], string> = {
<style scoped>
.iap {
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-bg);
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-page);
}
/* ── Header ── */
@@ -88,16 +88,16 @@ const markers: Record<DiffLine["type"], string> = {
gap: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
}
/* ── Streaming ── */
.iap-streaming {
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
.iap-streaming .iap-header {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
}
.iap-pulse {
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
background: var(--fs-accent);
flex-shrink: 0;
animation: iap-pulse 1.2s ease-in-out infinite;
}
@@ -117,7 +117,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-label {
flex: 1;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -125,9 +125,9 @@ const markers: Record<DiffLine["type"], string> = {
.iap-btn-cancel {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
flex-shrink: 0;
}
.iap-btn-cancel:hover {
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-color: var(--fs-error);
color: var(--fs-error);
}
.iap-stream-preview {
@@ -150,29 +150,29 @@ const markers: Record<DiffLine["type"], string> = {
.iap-waiting {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* ── Review ── */
.iap-review-title {
flex: 1;
font-weight: 600;
color: var(--color-text);
color: var(--fs-text-primary);
}
.iap-btn-toggle {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.iap-btn-toggle:hover {
border-color: var(--color-primary);
color: var(--color-primary);
border-color: var(--fs-accent);
color: var(--fs-accent);
}
.iap-actions {
@@ -183,7 +183,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-btn-accept,
.iap-btn-reject {
border: none;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
padding: 0.2rem 0.65rem;
cursor: pointer;
font-size: 0.8rem;
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
background: var(--color-success, #22c55e);
background: var(--fs-success);
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
.iap-btn-reject {
background: var(--color-bg-card, var(--color-bg));
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
}
.iap-btn-reject:hover {
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-color: var(--fs-error);
color: var(--fs-error);
}
/* ── Diff ── */
@@ -224,14 +224,14 @@ const markers: Record<DiffLine["type"], string> = {
word-break: break-word;
}
.iap-diff-equal { color: var(--color-text-muted); }
.iap-diff-equal { color: var(--fs-text-tertiary); }
.iap-diff-delete {
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
color: var(--color-danger, #e74c3c);
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
color: var(--fs-error);
}
.iap-diff-insert {
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
color: var(--color-success, #22c55e);
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
color: var(--fs-success);
}
.iap-diff-marker {
@@ -243,7 +243,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-diff-empty {
padding: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.85rem;
font-family: inherit;
}
+11 -11
View File
@@ -111,9 +111,9 @@ const groups = [
align-items: center;
gap: 2px;
flex-wrap: wrap;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 3px 4px;
}
@@ -127,7 +127,7 @@ const groups = [
display: block;
width: 1px;
height: 18px;
background: var(--color-border);
background: var(--fs-border-color);
flex-shrink: 0;
margin: 0 3px;
}
@@ -141,7 +141,7 @@ const groups = [
border: none;
border-radius: 5px;
background: transparent;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
@@ -149,19 +149,19 @@ const groups = [
}
.md-btn:hover {
background: var(--color-bg-card);
color: var(--color-text);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.md-btn.active {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
color: var(--fs-accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
}
.md-btn.active:hover {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
}
.btn-icon {
@@ -66,10 +66,10 @@ function onChange(e: Event) {
<style scoped>
.milestone-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
@@ -77,7 +77,7 @@ function onChange(e: Event) {
}
.milestone-select:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
.milestone-select:disabled {
opacity: 0.5;
+17 -17
View File
@@ -60,15 +60,15 @@ function goEdit() {
.note-card {
display: block;
padding: 1rem;
border-radius: var(--radius-md);
border-radius: var(--fs-radius-lg);
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
background: var(--fs-surface-raised);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
transform: translateY(-2px);
}
@@ -78,18 +78,18 @@ function goEdit() {
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: var(--color-bg-card);
background: var(--fs-surface-raised);
box-shadow: none;
border-radius: 0;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
transform: none !important;
}
.note-card.compact:first-child {
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
}
.note-card.compact:hover {
box-shadow: none;
background: rgba(91, 74, 138, 0.04);
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
transform: none;
}
.note-title-compact {
@@ -108,7 +108,7 @@ function goEdit() {
}
.timestamp-compact {
font-size: 0.72rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
flex-shrink: 0;
white-space: nowrap;
}
@@ -133,20 +133,20 @@ function goEdit() {
flex-shrink: 0;
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-edit:hover {
color: var(--color-primary);
border-color: var(--color-primary);
color: var(--fs-accent);
border-color: var(--fs-accent);
}
.note-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
@@ -163,6 +163,6 @@ function goEdit() {
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
</style>
+7 -7
View File
@@ -60,11 +60,11 @@ onUnmounted(() => {
.btn-bell {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.25rem 0.45rem;
cursor: pointer;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
display: flex;
align-items: center;
justify-content: center;
@@ -72,16 +72,16 @@ onUnmounted(() => {
}
.btn-bell:hover,
.btn-bell.active {
background: var(--color-bg-card);
color: var(--color-text);
border-color: var(--color-primary);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-color: var(--fs-accent);
}
.bell-badge {
position: absolute;
top: -5px;
right: -5px;
background: var(--color-danger, #ef4444);
background: var(--fs-error);
color: var(--fs-text-on-action);
font-size: 0.6rem;
font-weight: 700;
@@ -85,9 +85,9 @@ onMounted(() => store.fetchAll())
width: 340px;
max-height: 400px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
z-index: 500;
}
@@ -97,10 +97,10 @@ onMounted(() => store.fetchAll())
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
position: sticky;
top: 0;
background: var(--color-surface);
background: var(--fs-surface-hover);
}
.notif-panel-title {
@@ -114,12 +114,12 @@ onMounted(() => store.fetchAll())
align-items: flex-start;
gap: 0.6rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
cursor: pointer;
transition: background 0.1s;
}
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: var(--color-hover); }
.notif-item:hover { background: var(--fs-surface-hover); }
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
@@ -127,10 +127,10 @@ onMounted(() => store.fetchAll())
.notif-msg {
margin: 0 0 0.2rem;
font-size: 0.85rem;
color: var(--color-text);
color: var(--fs-text-primary);
line-height: 1.4;
word-break: break-word;
}
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
.notif-time { font-size: 0.75rem; color: var(--fs-text-tertiary); }
</style>
+8 -8
View File
@@ -74,27 +74,27 @@ function goToPage(page: number) {
}
.page-btn {
padding: 0.35rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.85rem;
}
.page-btn:hover:not(:disabled) {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.page-btn:disabled {
opacity: 0.4;
cursor: default;
}
.page-btn.active {
background: var(--color-primary);
background: var(--fs-accent);
color: var(--fs-text-on-action);
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
.ellipsis {
padding: 0 0.25rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
</style>
+6 -6
View File
@@ -33,15 +33,15 @@ const labels: Record<TaskPriority, string> = {
letter-spacing: 0.025em;
}
.priority-low {
background: var(--color-priority-low-bg);
color: var(--color-priority-low);
background: var(--fs-priority-low-bg);
color: var(--fs-priority-low);
}
.priority-medium {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium);
}
.priority-high {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
background: var(--fs-priority-high-bg);
color: var(--fs-priority-high);
}
</style>
@@ -0,0 +1,234 @@
<script setup lang="ts">
/**
* A project's own code, checked against the design system it is bound to (#2432).
*
* This is what the design surface is FOR: a project's recorded components
* measured against the sheet they are supposed to use. The check itself is not
* new — `check_snippets_against_system` has taken a project id since it was
* written, and the route has always read `?project_id=`. Nothing on this side
* ever passed one, so the capability shipped and stayed unreachable.
*
* The finding that matters most is the quiet one. `local_definitions` is a
* snippet minting its own custom property instead of reaching for the shared
* one — the codebase re-solving a solved problem, one component at a time.
* Nothing breaks, no test fails, and the duplication only becomes visible when
* someone changes the shared value and half the components don't move.
*
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
* snippets — because that is the code Scribe holds. A repository's own sources
* are checked where they live, by that project's CI.
*/
import { onMounted, ref, watch } from "vue";
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
const check = ref<SnippetCheck | null>(null);
const loading = ref(false);
const failed = ref(false);
async function run() {
check.value = null;
failed.value = false;
if (props.designSystemId === null) return;
loading.value = true;
try {
check.value = await checkSnippets(props.designSystemId, props.projectId);
} catch {
// Said out loud rather than rendered as an empty result. "Couldn't check"
// and "nothing to report" look identical if you let them, and that is how
// a check comes to sit dead without anyone noticing (#2419).
failed.value = true;
} finally {
loading.value = false;
}
}
onMounted(run);
watch(() => [props.projectId, props.designSystemId], run);
</script>
<template>
<div class="pdt">
<div v-if="designSystemId === null" class="pdt-note">
<strong>No design system for this project.</strong>
<p>
Bind one in the sidebar and this tab reports where the project's recorded
components disagree with it — references to tokens the system doesn't
have, literals it says to stop writing, and properties a component mints
for itself instead of reusing.
</p>
</div>
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets</p>
<div v-else-if="failed" class="pdt-note">
<strong>The check couldn't run.</strong>
<p>Nothing was compared — this is a failure, not a clean result.</p>
</div>
<template v-else-if="check">
<p v-if="!check.checked" class="pdt-muted">
This project has no recorded snippets, so nothing was checked. Record the
components you reuse and they get measured against the sheet.
</p>
<p v-else-if="!check.findings.length" class="pdt-clean">
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
every reference resolves, and none mints a property of its own.
</p>
<template v-else>
<p class="pdt-summary">
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
</p>
<ul class="pdt-list">
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
{{ f.title || "Untitled snippet" }}
</router-link>
<!-- Renders as nothing at all: no error, no failing test, just an
element that quietly isn't styled. Leads for that reason. -->
<div v-if="f.unknown.length" class="pdt-row">
<span class="pdt-tag unknown">no such token</span>
<span class="pdt-detail">
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
</span>
</div>
<div v-if="f.local_definitions.length" class="pdt-row">
<span class="pdt-tag local">defines its own</span>
<span class="pdt-detail">
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
</span>
</div>
<div v-if="f.superseded_literals.length" class="pdt-row">
<span class="pdt-tag superseded">write the token</span>
<span class="pdt-detail">
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
<code>{{ s.literal }}</code> <code>{{ s.use_instead }}</code>
</span>
</span>
</div>
</li>
</ul>
</template>
</template>
</div>
</template>
<style scoped>
.pdt {
padding: var(--fs-space-2) 0;
}
.pdt-note {
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-left: 3px solid var(--fs-warning);
border-radius: var(--fs-radius-sm);
padding: var(--fs-space-3) var(--fs-space-4);
}
.pdt-note p {
margin: var(--fs-space-2) 0 0;
color: var(--fs-text-secondary);
font-size: var(--fs-size-body-sm);
line-height: var(--fs-leading-body);
max-width: 70ch;
}
.pdt-muted,
.pdt-clean,
.pdt-summary {
color: var(--fs-text-tertiary);
font-size: var(--fs-size-body-sm);
margin: 0 0 var(--fs-space-3);
max-width: 70ch;
}
.pdt-clean {
color: var(--fs-status-done);
}
.pdt-summary {
color: var(--fs-text-secondary);
}
.pdt-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--fs-space-3);
}
.pdt-finding {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
min-width: 0;
}
.pdt-title {
display: block;
font-weight: var(--fs-weight-medium);
color: var(--fs-text-primary);
text-decoration: none;
margin-bottom: var(--fs-space-2);
}
.pdt-title:hover { color: var(--fs-accent); }
.pdt-row {
display: flex;
align-items: baseline;
gap: var(--fs-space-2);
flex-wrap: wrap;
padding: 0.15rem 0;
min-width: 0;
}
.pdt-tag {
font-size: var(--fs-size-tiny);
text-transform: uppercase;
letter-spacing: var(--fs-tracking-tiny);
padding: 0.1rem 0.45rem;
border-radius: var(--fs-radius-sm);
white-space: nowrap;
flex: none;
}
.pdt-tag.unknown {
background: var(--fs-priority-high-bg);
color: var(--fs-priority-high);
}
.pdt-tag.local {
background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium);
}
.pdt-tag.superseded {
background: var(--fs-surface-hover);
color: var(--fs-text-tertiary);
}
.pdt-detail {
display: flex;
flex-wrap: wrap;
gap: var(--fs-space-2);
font-size: var(--fs-size-code);
color: var(--fs-text-secondary);
min-width: 0;
}
.pdt-swap {
white-space: nowrap;
}
</style>
+5 -5
View File
@@ -51,10 +51,10 @@ function onChange(e: Event) {
<style scoped>
.project-selector {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.9rem;
font-family: inherit;
width: 100%;
@@ -62,6 +62,6 @@ function onChange(e: Event) {
}
.project-selector:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
</style>
+6 -6
View File
@@ -153,19 +153,19 @@ const calendarDayMax = computed(() =>
}
.rec-label {
font-size: 0.78rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
min-width: 2.5rem;
}
.rec-num-input {
width: 4rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-input-border, var(--color-border));
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.85rem;
font-family: inherit;
}
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
.rec-num-input:focus { outline: none; border-color: var(--fs-accent); }
.rec-unit { min-width: 6rem; }
</style>
+5 -5
View File
@@ -28,14 +28,14 @@ defineExpose({ focus: () => inputRef.value?.focus() });
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 1rem;
box-sizing: border-box;
background: var(--color-bg-card);
color: var(--color-text);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
}
.search-input::placeholder {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
</style>
+26 -26
View File
@@ -206,9 +206,9 @@ onMounted(async () => {
}
.share-dialog {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
width: 480px;
max-width: 95vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
@@ -220,7 +220,7 @@ onMounted(async () => {
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.share-title {
@@ -228,7 +228,7 @@ onMounted(async () => {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
color: var(--fs-text-primary);
}
@@ -240,17 +240,17 @@ onMounted(async () => {
.share-tab {
background: none;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
padding: 0.3rem 0.8rem;
font-size: 0.82rem;
cursor: pointer;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
transition: all 0.15s;
}
.share-tab.active {
background: var(--color-primary);
border-color: var(--color-primary);
background: var(--fs-accent);
border-color: var(--fs-accent);
color: var(--fs-text-on-action);
}
@@ -269,23 +269,23 @@ onMounted(async () => {
.share-input {
width: 100%;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
.share-input:focus { border-color: var(--color-primary); }
.share-input:focus { border-color: var(--fs-accent); }
.user-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
margin-top: 2px;
list-style: none;
@@ -304,24 +304,24 @@ onMounted(async () => {
cursor: pointer;
transition: background 0.1s;
}
.user-result-item:hover { background: var(--color-bg-secondary); }
.user-result-item:hover { background: var(--fs-surface-raised); }
.user-result-name { font-weight: 600; font-size: 0.88rem; }
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
.perm-select {
padding: 0.45rem 0.5rem;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.85rem;
cursor: pointer;
}
.btn-add-share {
padding: 0.45rem 1rem;
background: var(--gradient-cta);
background: var(--fs-gradient-cta);
color: var(--fs-text-on-action);
border: none;
border-radius: 6px;
@@ -342,7 +342,7 @@ onMounted(async () => {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin: 0 0 0.5rem;
}
@@ -361,7 +361,7 @@ onMounted(async () => {
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
@@ -369,10 +369,10 @@ onMounted(async () => {
.perm-select-inline {
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
font-size: 0.8rem;
cursor: pointer;
}
@@ -0,0 +1,212 @@
<script setup lang="ts">
/**
* The starter token ROLES offered when a design system is created (#2349).
*
* WHY THIS IS A COMPONENT
* DesignSystemsView has two creation forms — the empty state and the one inside
* the body — because the empty state is a sibling branch, not a parent. Putting
* the checklist inline would make it the third thing in this codebase defined
* twice and free to drift, which is what the whole button migration was about.
*
* WHAT IT OFFERS
* Names and purposes, never values. A role is a question the operator answers
* with their own palette; a default palette would be one install's taste
* shipped as product (rule #115). Every group is individually skippable —
* an operator who wants three tokens should get three.
*
* All groups are checked by default. That default lives HERE rather than in the
* service, because the service must never seed rows into a system whose caller
* did not ask; a UI default is visible and reversible before the click.
*/
import { onMounted, ref } from "vue";
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
// props + emit rather than defineModel, matching TagInput and the rest of
// components/ — being the only file using a different binding idiom costs more
// than the few lines it saves.
const props = defineProps<{ selected: string[]; prefix: string }>();
const emit = defineEmits<{
"update:selected": [value: string[]];
"update:prefix": [value: string];
}>();
const groups = ref<StarterRoleGroup[]>([]);
const defaultPrefix = ref("--ds-");
const loading = ref(false);
const failed = ref(false);
onMounted(async () => {
loading.value = true;
try {
const data = await listStarterRoleGroups();
groups.value = data.groups;
defaultPrefix.value = data.default_prefix;
if (!props.prefix) emit("update:prefix", data.default_prefix);
// Everything on by default — see the note above.
if (!props.selected.length) {
emit("update:selected", data.groups.map((g) => g.group));
}
} catch {
// A creation form must still work when this fails. Roles are an
// accelerator, not a prerequisite: the operator can add tokens by hand.
failed.value = true;
} finally {
loading.value = false;
}
});
function toggle(group: string) {
emit(
"update:selected",
props.selected.includes(group)
? props.selected.filter((g) => g !== group)
: [...props.selected, group],
);
}
const totalTokens = () =>
groups.value
.filter((g) => props.selected.includes(g.group))
.reduce((n, g) => n + g.token_count, 0);
</script>
<template>
<div v-if="loading" class="srp-note">Loading starter roles</div>
<!-- Failure is not fatal and should not read as one. -->
<div v-else-if="failed" class="srp-note">
Starter roles unavailable you can add tokens by hand after creating.
</div>
<fieldset v-else-if="groups.length" class="srp">
<legend class="srp-legend">Start with these token roles</legend>
<p class="srp-intro">
Named now, valued later. A role you haven't filled in shows as
<em>to be decided</em>; a role that doesn't exist is what gets written as a
literal instead. Uncheck anything this system won't have.
</p>
<div class="srp-grid">
<label v-for="g in groups" :key="g.group" class="srp-item">
<input
type="checkbox"
:checked="props.selected.includes(g.group)"
@change="toggle(g.group)"
/>
<span class="srp-name">{{ g.group }}</span>
<span class="srp-count">{{ g.token_count }}</span>
<span class="srp-desc">{{ g.description }}</span>
</label>
</div>
<div class="srp-footer">
<label class="srp-prefix">
<span>Prefix</span>
<input
:value="props.prefix" class="input srp-prefix-input" type="text"
:placeholder="defaultPrefix"
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
/>
</label>
<span class="srp-total">
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
</span>
</div>
</fieldset>
</template>
<style scoped>
.srp {
border: var(--fs-border);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-4);
margin: 0 0 var(--fs-space-4);
min-width: 0;
}
.srp-legend {
font-size: var(--fs-size-label);
font-weight: var(--fs-weight-medium);
color: var(--fs-text-primary);
padding: 0 var(--fs-space-2);
}
.srp-intro,
.srp-note {
margin: 0 0 var(--fs-space-3);
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
line-height: var(--fs-leading-body);
max-width: 62ch;
}
.srp-note {
margin-bottom: var(--fs-space-4);
}
.srp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--fs-space-2);
}
.srp-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: baseline;
gap: var(--fs-space-2);
padding: var(--fs-space-1) var(--fs-space-2);
border-radius: var(--fs-radius-sm);
cursor: pointer;
min-width: 0;
}
.srp-item:hover { background: var(--fs-surface-hover); }
.srp-name {
font-size: var(--fs-size-body-sm);
color: var(--fs-text-primary);
}
.srp-count {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
/* The description is the useful part on a wide card and the first thing worth
dropping on a narrow one — the group name alone still identifies the row. */
.srp-desc {
grid-column: 1 / -1;
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
line-height: var(--fs-leading-body);
}
.srp-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--fs-space-3);
margin-top: var(--fs-space-4);
}
.srp-prefix {
display: flex;
align-items: center;
gap: var(--fs-space-2);
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
}
.srp-prefix-input {
width: 8rem;
font-family: var(--fs-font-mono);
font-size: var(--fs-size-code);
}
.srp-total {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
</style>
+8 -8
View File
@@ -38,20 +38,20 @@ const labels: Record<TaskStatus, string> = {
letter-spacing: 0.025em;
}
.status-todo {
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%);
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%);
}
.status-in_progress {
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%);
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%);
}
.status-done {
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%);
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%);
}
.status-cancelled {
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
color: var(--color-text-muted);
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
color: var(--fs-text-tertiary);
}
.clickable {
cursor: pointer;
@@ -70,9 +70,9 @@ defineExpose({ onKeyDown });
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
@@ -85,6 +85,6 @@ defineExpose({ onKeyDown });
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
</style>
+78 -75
View File
@@ -253,7 +253,7 @@ async function confirmDelete() {
<template v-else>
<span
class="system-swatch"
:style="{ background: system.color || 'var(--color-text-muted)' }"
:style="{ background: system.color || 'var(--fs-text-tertiary)' }"
aria-hidden="true"
></span>
<div class="system-body">
@@ -325,41 +325,41 @@ async function confirmDelete() {
/* ── Open issues ──────────────────────────────────────────────── */
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fs-text-tertiary); }
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
.issue-link:hover { background: var(--color-bg-secondary); }
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--color-primary); }
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--fs-radius-sm); text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
.issue-link:hover { background: var(--fs-surface-raised); }
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--fs-accent); }
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
/* ── Toolbar ──────────────────────────────────────────────────── */
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.btn-add-system {
background: none;
border: 1px dashed var(--color-border);
color: var(--color-text-secondary);
border: 1px dashed var(--fs-border-color);
color: var(--fs-text-secondary);
padding: 0.28rem 0.65rem;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
.btn-add-system:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-add-system:focus-visible { outline: none; border-color: var(--fs-accent); color: var(--fs-accent); }
.archived-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
cursor: pointer;
user-select: none;
}
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
.archived-checkbox { accent-color: var(--fs-accent); cursor: pointer; }
/* ── Create / edit form ───────────────────────────────────────── */
.system-form {
@@ -367,26 +367,61 @@ async function confirmDelete() {
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
.system-input, .system-textarea {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived.
The card WAS a flex row and every child still says so — `.system-swatch`
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
of text rather than centred against the whole card.
The list had no rule at all, so it rendered with browser bullets and
indent — invisible to the dangling-style check, which can only see a class
that is PARTLY styled. A class with no rules anywhere looks exactly like a
semantic-only hook.
Surface values match `.system-form` above, which is the same card shape in
this file and the reason they can be recovered rather than guessed. */
.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.6rem;
padding: 0.6rem 0.75rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.system-card--archived { opacity: 0.6; }
.system-swatch {
@@ -398,13 +433,13 @@ async function confirmDelete() {
}
.system-body { flex: 1; min-width: 0; }
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.system-name { font-weight: 500; color: var(--color-text); word-break: break-word; }
.system-name { font-weight: 500; color: var(--fs-text-primary); word-break: break-word; }
.issue-badge {
font-size: 0.7rem;
font-weight: 500;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--fs-accent);
border-radius: 999px;
padding: 0.05rem 0.45rem;
flex-shrink: 0;
@@ -414,15 +449,15 @@ async function confirmDelete() {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
color: var(--fs-text-tertiary);
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.system-description {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
line-height: 1.4;
word-break: break-word;
}
@@ -437,15 +472,15 @@ async function confirmDelete() {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
transition: background 0.12s, color 0.12s;
}
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
.action-delete:hover { color: var(--color-danger, #e74c3c); }
.action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
.action-btn:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 1px; opacity: 1; }
.action-delete:hover { color: var(--fs-error); }
/* ── Empty ────────────────────────────────────────────────────── */
.systems-empty {
@@ -455,61 +490,29 @@ async function confirmDelete() {
gap: 0.4rem;
padding: 2rem 1rem;
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
/* ── Skeleton ─────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
.skel-row {
height: 3rem;
border-radius: var(--radius-md);
border-radius: var(--fs-radius-lg);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-row--short { width: 65%; }
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
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: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
</style>
+3 -3
View File
@@ -60,7 +60,7 @@ function scrollTo(id: string) {
.toc-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin: 0 0 0.5rem;
letter-spacing: 0.05em;
}
@@ -73,11 +73,11 @@ function scrollTo(id: string) {
margin-bottom: 0.25rem;
}
.toc-link {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
text-decoration: none;
cursor: pointer;
}
.toc-link:hover {
color: var(--color-primary);
color: var(--fs-accent);
}
</style>
+13 -13
View File
@@ -154,9 +154,9 @@ function focusInput() {
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
cursor: text;
min-height: 2.25rem;
}
@@ -166,9 +166,9 @@ function focusInput() {
gap: 0.2rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border: 1px solid var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border: 1px solid var(--fs-accent);
color: var(--fs-accent);
font-size: 0.8rem;
white-space: nowrap;
}
@@ -195,7 +195,7 @@ function focusInput() {
border: none;
outline: none;
background: transparent;
color: var(--color-text);
color: var(--fs-text-primary);
font-size: 0.875rem;
padding: 0;
}
@@ -205,9 +205,9 @@ function focusInput() {
left: 0;
min-width: 160px;
max-width: 280px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
list-style: none;
margin: 0;
@@ -218,11 +218,11 @@ function focusInput() {
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text);
color: var(--fs-text-primary);
}
.tag-autocomplete-item:hover,
.tag-autocomplete-item.selected {
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
color: var(--color-primary);
background: var(--fs-surface-hover);
color: var(--fs-accent);
}
</style>
+5 -5
View File
@@ -29,8 +29,8 @@ defineEmits<{
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-tag-bg);
color: var(--color-tag-text);
background: var(--fs-accent-soft);
color: var(--fs-accent);
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
@@ -39,13 +39,13 @@ defineEmits<{
transition: color 0.15s, background 0.15s;
}
.tag-pill:hover {
color: var(--color-primary);
background: var(--color-primary-tint);
color: var(--fs-accent);
background: var(--fs-accent-soft);
}
.dismiss {
background: none;
border: none;
color: var(--color-tag-text);
color: var(--fs-accent);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
+20 -20
View File
@@ -108,15 +108,15 @@ function isOverdue(): boolean {
.task-card {
display: block;
padding: 1rem;
border-radius: var(--radius-md);
border-radius: var(--fs-radius-lg);
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
background: var(--fs-surface-raised);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
transform: translateY(-2px);
}
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
opacity: 0.8;
}
.dot-todo {
background: var(--color-status-todo, #94a3b8);
border: 2px solid var(--color-status-todo, #94a3b8);
background: var(--fs-status-todo);
border: 2px solid var(--fs-status-todo);
background: transparent;
border: 2px solid var(--color-text-muted);
border: 2px solid var(--fs-text-tertiary);
}
.dot-in-progress {
background: var(--color-status-in-progress, #3b82f6);
background: var(--fs-status-in-progress);
}
.dot-done {
background: var(--color-status-done, #22c55e);
background: var(--fs-status-done);
}
.dot-cancelled {
background: var(--color-status-cancelled, #6b7280);
background: var(--fs-status-cancelled);
}
.task-title-compact {
@@ -170,10 +170,10 @@ function isOverdue(): boolean {
}
.project-crumb {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.1rem 0.4rem;
white-space: nowrap;
flex-shrink: 0;
@@ -185,12 +185,12 @@ function isOverdue(): boolean {
}
.due-compact {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
}
.due-compact.overdue {
color: var(--color-danger, #e74c3c);
color: var(--fs-error);
font-weight: 600;
}
/* Full layout */
@@ -211,7 +211,7 @@ function isOverdue(): boolean {
}
.task-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
@@ -224,15 +224,15 @@ function isOverdue(): boolean {
}
.due-date {
font-size: 0.8rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.due-date.overdue {
color: var(--color-overdue);
color: var(--fs-overdue);
font-weight: 600;
}
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
</style>
+21 -27
View File
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import type { TaskLog } from "@/types/task";
import { fmtStamp } from "@/utils/dateFormat";
const props = defineProps<{ taskId: number }>();
@@ -15,13 +16,6 @@ const editingId = ref<number | null>(null);
const editContent = ref("");
const editDuration = ref("");
function formatDate(iso: string): string {
const d = new Date(iso);
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return `${datePart}, ${timePart}`;
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
@@ -128,7 +122,7 @@ onMounted(loadLogs);
</template>
<template v-else>
<div class="log-entry-meta">
<span class="log-date">{{ formatDate(log.created_at) }}</span>
<span class="log-date">{{ fmtStamp(log.created_at) }}</span>
<span v-if="log.duration_minutes" class="log-duration-badge">
{{ formatDuration(log.duration_minutes) }}
</span>
@@ -175,9 +169,9 @@ onMounted(loadLogs);
<style scoped>
.log-section {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
@@ -187,7 +181,7 @@ onMounted(loadLogs);
.log-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.15rem;
@@ -195,11 +189,11 @@ onMounted(loadLogs);
.log-empty {
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.log-entry {
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
padding-top: 0.5rem;
}
@@ -212,11 +206,11 @@ onMounted(loadLogs);
}
.log-date {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.log-duration-badge {
background: var(--color-primary);
background: var(--fs-accent);
color: var(--fs-text-on-action);
border-radius: 99px;
padding: 0.1rem 0.5rem;
@@ -235,10 +229,10 @@ onMounted(loadLogs);
.log-textarea {
width: 100%;
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
@@ -247,7 +241,7 @@ onMounted(loadLogs);
.log-textarea:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
.log-add-controls,
@@ -259,7 +253,7 @@ onMounted(loadLogs);
.log-duration-label {
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
display: flex;
align-items: center;
gap: 0.25rem;
@@ -268,16 +262,16 @@ onMounted(loadLogs);
.log-duration-input {
width: 5rem;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.875rem;
}
.log-duration-input:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
+1 -1
View File
@@ -156,7 +156,7 @@ defineExpose({ editor });
<style scoped>
.editor-error {
padding: 1rem;
color: var(--color-danger);
color: var(--fs-error);
font-size: 0.9rem;
}
</style>
@@ -57,13 +57,13 @@ const toastStore = useToastStore();
color: var(--fs-text-on-action);
}
.toast--success {
background: var(--color-toast-success);
background: var(--fs-success);
}
.toast--error {
background: var(--color-toast-error);
background: var(--fs-error);
}
.toast--warning {
background: var(--color-warning);
background: var(--fs-warning);
color: #1a1a1a;
}
.toast--warning .toast-close {
+362
View File
@@ -0,0 +1,362 @@
<script setup lang="ts">
/**
* A design system's tokens, drawn rather than listed (#2431).
*
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
* Every value is resolved on an offscreen probe carrying only this system's
* declarations (`resolveDeclared`), never read from the page. So a token like
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
* accent, not the accent of the app you happen to be looking at. Previewing
* another project's palette from here is the point; a preview that quietly
* borrows the host app's values would be worse than no preview, because it
* would look right.
*
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
* A colour is drawn as a swatch, a length as a rule of that length, a font
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
* naming convention, because the convention is the install's (rule #115) — a
* system that calls its spacing `--gap-N` gets the same treatment.
*
* A token with no value for the chosen mode is shown as undecided rather than
* skipped. A named role awaiting a decision is information; a gap in a grid
* is not.
*/
import { computed, ref, watch } from "vue";
import type { ResolvedToken } from "@/api/designSystems";
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
const props = defineProps<{ tokens: ResolvedToken[] }>();
const modes = computed(() => modesPresent(props.tokens));
const mode = ref(BASE_MODE);
/** Values as the browser would compute them, for the chosen mode. */
const rendered = ref<Map<string, string>>(new Map());
function recompute() {
const declared = new Map<string, string>();
for (const token of props.tokens) {
const value = valueForMode(token.value_by_mode, mode.value);
if (value) declared.set(token.name, value);
}
rendered.value = resolveDeclared(declared);
}
watch(
[() => props.tokens, mode],
() => {
// Keep the selection only while it still exists — switching systems can
// drop a mode, and a stale one would silently render as base.
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
recompute();
},
{ immediate: true, deep: false },
);
type Shape = "colour" | "surface" | "length" | "font" | "plain";
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
const GRADIENT = /gradient\(/;
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
/** A stack of family names: commas, no functions, no digits. */
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
function shapeOf(value: string): Shape {
const v = value.trim();
if (!v) return "plain";
if (COLOUR.test(v)) return "colour";
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
if (LENGTH.test(v)) return "length";
if (FONT_STACK.test(v)) return "font";
return "plain";
}
interface Specimen {
name: string;
declared: string;
rendered: string;
shape: Shape;
purpose: string | null;
/** True when `var()` substitution changed the value — worth showing on hover. */
substituted: boolean;
}
const groups = computed(() => {
const out = new Map<string, Specimen[]>();
for (const token of props.tokens) {
const declared = valueForMode(token.value_by_mode, mode.value);
const value = rendered.value.get(token.name) ?? "";
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
bucket.push({
name: token.name,
declared,
rendered: value,
shape: shapeOf(value),
purpose: token.purpose,
substituted: Boolean(declared) && value !== declared,
});
out.set(token.group_name ?? "ungrouped", bucket);
}
return [...out.entries()];
});
/**
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
* are visibly different — but a stray `100vw` can't stretch the row.
*/
function ruleWidth(value: string): string {
return `min(${value}, 12rem)`;
}
</script>
<template>
<div class="tp">
<div v-if="modes.length > 1" class="tp-modes">
<button
v-for="m in modes"
:key="m"
class="tp-mode"
:class="{ active: m === mode }"
@click="mode = m"
>{{ m }}</button>
<span class="tp-modes-note">
The system's own modes — independent of the theme this app is in.
</span>
</div>
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
<h3 class="tp-group-heading">{{ group }}</h3>
<ul class="tp-grid">
<li v-for="s in specimens" :key="s.name" class="tp-item">
<div
class="tp-specimen"
:class="`is-${s.shape}`"
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
>
<span
v-if="s.shape === 'colour'"
class="tp-swatch"
:style="{ '--tp-fill': s.rendered }"
/>
<span
v-else-if="s.shape === 'surface'"
class="tp-surface"
:style="s.rendered.includes('gradient(')
? { background: s.rendered }
: { boxShadow: s.rendered }"
/>
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
<span class="tp-rule-label">{{ s.rendered }}</span>
</span>
<span
v-else-if="s.shape === 'font'"
class="tp-font"
:style="{ fontFamily: s.rendered }"
>Ag</span>
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
<span v-else class="tp-plain">{{ s.rendered }}</span>
</div>
<code class="tp-name">{{ s.name }}</code>
<span class="tp-value" :title="s.declared">{{ s.declared || "" }}</span>
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.tp-modes {
display: flex;
align-items: center;
gap: var(--fs-space-2);
flex-wrap: wrap;
margin-bottom: var(--fs-space-4);
}
.tp-mode {
padding: 0.2rem 0.6rem;
font: inherit;
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
background: transparent;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
cursor: pointer;
}
.tp-mode:hover { color: var(--fs-text-primary); }
.tp-mode.active {
color: var(--fs-accent);
border-color: var(--fs-accent);
background: var(--fs-accent-faint);
}
.tp-modes-note {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
.tp-group { margin-bottom: var(--fs-space-6); }
.tp-group-heading {
text-transform: uppercase;
letter-spacing: var(--fs-tracking-tiny);
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
margin: 0 0 var(--fs-space-3);
padding-bottom: var(--fs-space-2);
border-bottom: var(--fs-border);
}
.tp-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: var(--fs-space-4) var(--fs-space-3);
}
.tp-item {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
*
* The stage itself is plain. An earlier version put the checkerboard here, so
* every specimen — including opaque colours and plain text — sat inside a
* frame of checks, and the pattern read as the loudest thing on the page. The
* checks belong to the ONE case that needs them: a colour that might be
* translucent. */
.tp-specimen {
height: 2.5rem;
display: flex;
align-items: center;
border-radius: var(--fs-radius-sm);
padding: var(--fs-space-1);
overflow: hidden;
background: var(--fs-surface-raised);
}
/* Text-bearing specimens get no box at all — a border around a value is a
frame around nothing, which is most of what made the grid feel busy. */
.tp-specimen.is-plain,
.tp-specimen.is-length,
.tp-specimen.is-font {
background: none;
padding: 0 var(--fs-space-1);
}
/* Checks UNDER the colour, not around it: an opaque value hides them
completely, and a 15% tint shows exactly as much of them as it should.
Layering the fill as a gradient is what lets one element do both. */
.tp-swatch {
/* Declared here, overridden inline per swatch. Two reasons it is a real
default rather than a formality: a token that resolves to nothing renders
as bare checks instead of an invalid gradient, and a custom property that
exists ONLY as an inline style is invisible to the CI token check — which
reads it as an unresolvable reference, correctly, since nothing in any
stylesheet declares it. */
--tp-fill: transparent;
width: 100%;
height: 100%;
border-radius: calc(var(--fs-radius-sm) - 2px);
background-image:
linear-gradient(var(--tp-fill), var(--tp-fill)),
repeating-conic-gradient(
var(--fs-border-color) 0% 25%,
var(--fs-surface-raised) 0% 50%
);
background-size: auto, 10px 10px;
}
.tp-surface {
width: 100%;
height: 100%;
border-radius: calc(var(--fs-radius-sm) - 2px);
background: var(--fs-surface-raised);
}
.tp-rule-wrap {
width: 100%;
display: flex;
align-items: center;
gap: var(--fs-space-2);
min-width: 0;
}
.tp-rule {
height: 0.4rem;
min-width: 1px;
flex: none;
background: var(--fs-accent);
border-radius: 999px;
}
.tp-rule-label {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
white-space: nowrap;
}
.tp-font {
font-size: 1.4rem;
color: var(--fs-text-primary);
line-height: 1;
}
.tp-plain {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-code);
color: var(--fs-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tp-undecided {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
font-style: italic;
}
/* One line each, with the full text on hover.
*
* These wrapped freely at first, so a card was two lines tall or five depending
* on how long its `color-mix()` happened to be, and the grid lost any rhythm —
* which is most of what "messy" was. A derived value is not something anyone
* reads character by character in a gallery; it is something you check the
* shape of and open if it matters. */
.tp-name,
.tp-value,
.tp-purpose {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tp-name {
font-size: var(--fs-size-body-sm);
color: var(--fs-text-primary);
}
.tp-value {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-tiny);
color: var(--fs-text-secondary);
}
.tp-purpose {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
</style>
@@ -164,7 +164,7 @@ function restore() {
<style scoped>
.vh-section {
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
}
.vh-header {
@@ -179,19 +179,19 @@ function restore() {
font-family: inherit;
text-align: left;
}
.vh-header:hover { background: var(--color-bg-secondary); }
.vh-header:hover { background: var(--fs-surface-raised); }
.vh-title {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vh-chevron {
font-size: 0.7rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.vh-body {
@@ -201,20 +201,20 @@ function restore() {
.vh-empty {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.vh-item {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text);
color: var(--fs-text-primary);
cursor: pointer;
font-family: monospace;
border-left: 2px solid transparent;
}
.vh-item:hover {
background: var(--color-bg-secondary);
border-left-color: var(--color-primary);
background: var(--fs-surface-raised);
border-left-color: var(--fs-accent);
}
.vh-diff-actions {
@@ -225,20 +225,20 @@ function restore() {
.vh-btn-back {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
cursor: pointer;
font-family: inherit;
}
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
.vh-btn-back:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.vh-btn-restore {
background: var(--color-action-primary);
background: var(--fs-action-primary);
border: none;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: var(--fs-text-on-action);
+2 -2
View File
@@ -50,11 +50,11 @@ const label = computed(() => {
background: none;
border: none;
font-size: 0.72rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
cursor: pointer;
padding: 0;
white-space: nowrap;
flex-shrink: 0;
}
.word-count:hover { color: var(--color-text); }
.word-count:hover { color: var(--fs-text-primary); }
</style>
+37 -50
View File
@@ -11,6 +11,7 @@ import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue";
import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{
projectId: number;
@@ -252,20 +253,6 @@ async function confirmDelete(id: number) {
}
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
watch(noteTitle, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
watch(noteTags, () => { dirty.value = true; });
@@ -346,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
>
<div class="note-row-main">
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
<span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
</div>
<div v-if="note.tags?.length" class="note-row-tags">
<span
@@ -452,8 +439,8 @@ defineExpose({ reload: loadProjectNotes });
flex-direction: row;
height: 100%;
overflow: hidden;
background: var(--color-surface);
border-left: 1px solid var(--color-border);
background: var(--fs-surface-hover);
border-left: 1px solid var(--fs-border-color);
}
/* ── Left rail ── */
@@ -463,7 +450,7 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-card, var(--color-bg-secondary));
background: var(--fs-surface-raised);
}
.rail-header {
@@ -471,12 +458,12 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
gap: 0.3rem;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
.rail-title {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
@@ -490,7 +477,7 @@ defineExpose({ reload: loadProjectNotes });
background: transparent;
border: none;
font-size: 0.78rem;
color: var(--color-text);
color: var(--fs-text-primary);
min-width: 0;
padding: 0;
}
@@ -503,7 +490,7 @@ defineExpose({ reload: loadProjectNotes });
.rail-state {
padding: 1rem 0.65rem;
font-size: 0.78rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* Note list */
@@ -519,16 +506,16 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
flex-direction: column;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 60%, transparent);
cursor: pointer;
gap: 0.15rem;
border-right: 2px solid transparent;
transition: background 0.12s;
}
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.note-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
.note-row.active {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-right-color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
border-right-color: var(--fs-accent);
}
.note-row-main {
@@ -544,12 +531,12 @@ defineExpose({ reload: loadProjectNotes });
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
color: var(--fs-text-primary);
}
.note-row-age {
font-size: 0.62rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
}
@@ -562,8 +549,8 @@ defineExpose({ reload: loadProjectNotes });
.note-tag-pill {
font-size: 0.58rem;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
border-radius: 999px;
padding: 0 0.3rem;
white-space: nowrap;
@@ -572,13 +559,13 @@ defineExpose({ reload: loadProjectNotes });
max-width: 5rem;
}
.note-tag-pill.tag-match {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
outline: 1px solid var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
outline: 1px solid var(--fs-accent);
}
.note-tag-more {
font-size: 0.58rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
white-space: nowrap;
}
@@ -606,8 +593,8 @@ defineExpose({ reload: loadProjectNotes });
margin-left: auto;
}
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
.unsaved { font-size: 0.72rem; color: var(--fs-text-tertiary); }
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
/* Moss action-primary per Hybrid */
@@ -618,14 +605,14 @@ defineExpose({ reload: loadProjectNotes });
font-size: 1.4rem;
font-weight: 500;
line-height: 1.25;
color: var(--color-text);
color: var(--fs-text-primary);
padding: 0;
font-family: 'Fraunces', serif;
letter-spacing: -0.01em;
}
.note-title-input:focus { outline: none; }
.note-title-input::placeholder {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.tag-row {
@@ -645,25 +632,25 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
flex-shrink: 0;
}
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
.tag-suggestions-label { font-size: 0.72rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
.btn-tag-suggestion {
background: none;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text);
color: var(--fs-text-primary);
cursor: pointer;
}
.btn-tag-suggestion:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-tag-suggestion:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-tag-suggestion.applied {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border-color: var(--fs-accent);
color: var(--fs-accent);
}
@@ -673,12 +660,12 @@ defineExpose({ reload: loadProjectNotes });
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.3rem 0.6rem;
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
flex-shrink: 0;
}
.link-suggest-label {
font-size: 0.7rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
flex-shrink: 0;
font-weight: 500;
text-transform: uppercase;
@@ -688,15 +675,15 @@ defineExpose({ reload: loadProjectNotes });
.btn-chip-link {
background: none;
border: 1px solid var(--color-primary);
border: 1px solid var(--fs-accent);
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
color: var(--color-primary);
color: var(--fs-accent);
cursor: pointer;
font-family: monospace;
white-space: nowrap;
}
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
.btn-chip-link:hover { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
</style>
+51 -64
View File
@@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue";
import { renderMarkdown } from "@/utils/markdown";
import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{ projectId: number }>();
@@ -198,20 +199,6 @@ function cancelDeleteTask() {
deleteConfirmPending.value = false;
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
onMounted(loadAll);
defineExpose({ reload: loadAll });
</script>
@@ -256,7 +243,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li>
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
</ul>
@@ -281,7 +268,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li>
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
</ul>
@@ -344,8 +331,8 @@ defineExpose({ reload: loadAll });
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
background: var(--fs-surface-hover);
border-right: 1px solid var(--fs-border-color);
}
/* ── List view ── */
@@ -360,17 +347,17 @@ defineExpose({ reload: loadAll });
flex: 0 0 44%;
}
.task-active {
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important;
background: color-mix(in srgb, var(--fs-accent) 6%, var(--fs-surface-hover)) !important;
}
.panel-header {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
.panel-title {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
@@ -381,20 +368,20 @@ defineExpose({ reload: loadAll });
display: flex;
gap: 0.4rem;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
.task-add-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: 5px;
padding: 0.28rem 0.5rem;
font-size: 0.83rem;
color: var(--color-text);
color: var(--fs-text-primary);
}
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
.task-add-input:focus { outline: none; border-color: var(--fs-accent); }
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
@@ -404,7 +391,7 @@ defineExpose({ reload: loadAll });
}
.ms-group {
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.ms-group-header {
@@ -413,18 +400,18 @@ defineExpose({ reload: loadAll });
gap: 0.4rem;
width: 100%;
padding: 0.4rem 0.65rem;
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
background: var(--fs-surface-raised);
border: none;
cursor: pointer;
text-align: left;
font-size: 0.8rem;
color: var(--color-text);
color: var(--fs-text-primary);
}
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
.ms-group-header:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover)); }
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
.ms-chevron { font-size: 0.6rem; color: var(--fs-text-tertiary); width: 0.8rem; }
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
.ms-count { font-size: 0.72rem; color: var(--fs-text-tertiary); background: var(--fs-surface-page); border-radius: 10px; padding: 0 0.4rem; }
.ms-status {
font-size: 0.68rem;
@@ -432,8 +419,8 @@ defineExpose({ reload: loadAll });
border-radius: 10px;
text-transform: capitalize;
}
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.task-items {
list-style: none;
@@ -447,9 +434,9 @@ defineExpose({ reload: loadAll });
gap: 0.4rem;
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
cursor: pointer;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 50%, transparent);
}
.task-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.task-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
.task-row:last-child { border-bottom: none; }
.status-dot {
@@ -457,7 +444,7 @@ defineExpose({ reload: loadAll });
width: 1.35rem;
height: 1.35rem;
border-radius: 50%;
border: 1.5px solid var(--color-border);
border: 1.5px solid var(--fs-border-color);
background: none;
cursor: pointer;
font-size: 0.62rem;
@@ -465,8 +452,8 @@ defineExpose({ reload: loadAll });
align-items: center;
justify-content: center;
}
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
.task-title {
flex: 1;
@@ -474,20 +461,20 @@ defineExpose({ reload: loadAll });
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
color: var(--fs-text-primary);
}
.task-title.done { text-decoration: line-through; color: var(--color-text-muted); }
.task-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
.task-age {
font-size: 0.68rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
}
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--fs-text-tertiary); }
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--fs-text-tertiary); }
/* ── Detail pane (bottom split) ── */
.task-detail {
@@ -496,8 +483,8 @@ defineExpose({ reload: loadAll });
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-surface);
border-top: 2px solid var(--color-border);
background: var(--fs-surface-hover);
border-top: 2px solid var(--fs-border-color);
}
.detail-header {
@@ -505,7 +492,7 @@ defineExpose({ reload: loadAll });
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
@@ -515,21 +502,21 @@ defineExpose({ reload: loadAll });
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: 1.5px solid var(--color-border);
border: 1.5px solid var(--fs-border-color);
background: none;
text-transform: capitalize;
user-select: none;
margin-left: auto;
}
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
.status-badge.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.status-badge.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
.btn-edit-task { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; }
.detail-body {
padding: 0.5rem 0.75rem 0.5rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
max-height: 40%;
overflow-y: auto;
@@ -537,17 +524,17 @@ defineExpose({ reload: loadAll });
.body-loading {
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.detail-body .prose {
font-size: 0.83rem;
line-height: 1.5;
color: var(--color-text);
color: var(--fs-text-primary);
}
.btn-delete-task { margin-left: 0.25rem; }
.btn-delete-task:hover { color: var(--color-action-destructive); }
.btn-delete-task:hover { color: var(--fs-action-destructive); }
.btn-delete-confirm { margin-left: 0.25rem; }
@@ -563,9 +550,9 @@ defineExpose({ reload: loadAll });
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-tertiary);
text-transform: capitalize;
}
@@ -573,20 +560,20 @@ defineExpose({ reload: loadAll });
font-size: 0.72rem;
padding: 0.15rem 0.4rem;
border-radius: 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-tertiary);
cursor: pointer;
max-width: 140px;
}
.milestone-select:disabled { opacity: 0.5; cursor: default; }
.milestone-select:focus { outline: none; border-color: var(--color-primary); }
.milestone-select:focus { outline: none; border-color: var(--fs-accent); }
.detail-log {
flex: 1;
overflow-y: auto;
padding: 0 0.6rem 0.6rem;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
}
/* Detail fade transition */
@@ -609,12 +596,12 @@ defineExpose({ reload: loadAll });
/* Due date on task rows */
.task-due {
font-size: 0.65rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
}
.task-due.overdue {
color: var(--color-danger, #e74c3c);
color: var(--fs-error);
font-weight: 500;
}
@@ -46,13 +46,19 @@ watch(() => props.projectId, load);
<style scoped>
.plan-rules {
margin-top: 1.5rem;
border-top: 1px solid var(--color-border, #2a2a2e);
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.plan-rules h3 {
font-size: 0.9em; opacity: 0.7;
text-transform: uppercase; letter-spacing: 0.05em;
}
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
below, and its children carry their own spacing (the h4 keeps the UA
margin-top that separates one rulebook group from the next). Nothing here
assumes a flex or grid parent, which is the tell that distinguishes this
from a base rule someone deleted (#2444). Stated so the next reader doesn't
re-open the question. */
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
.rb h5 {
font-size: 0.8em; opacity: 0.7;
@@ -60,7 +66,7 @@ watch(() => props.projectId, load);
}
.plan-rules ul {
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
border-left: 2px solid var(--color-primary, #6366f1);
border-left: 2px solid var(--fs-accent);
}
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
@@ -329,7 +329,7 @@ h3 {
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 0.25rem;
background: var(--color-primary-bg, rgba(99,102,241,0.15));
background: var(--fs-accent-soft);
padding: 0.25rem 0.5rem; border-radius: 999px;
}
.chip a { cursor: pointer; }
@@ -337,25 +337,29 @@ h3 {
.chip-remove:hover { opacity: 1; }
.add {
background: none;
border: 1px dashed var(--color-border, #2a2a2e);
border: 1px dashed var(--fs-border-color);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
color: inherit;
}
select {
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.applicable { margin-top: 2rem; }
.rb-group { margin-bottom: 1.5rem; }
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
one and the flex row further down), with the h5's own margin-top doing the
separating. Its children assume nothing about it, which is what tells it
apart from a base rule someone deleted (#2444). */
.topic-group h5 {
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0.75rem;
}
ul { list-style: none; padding: 0; margin: 0; }
.rule {
border-left: 2px solid var(--color-primary, #6366f1);
border-left: 2px solid var(--fs-accent);
padding-left: 0.75rem; margin: 0.5rem 0;
}
.rule-head { cursor: pointer; }
@@ -363,12 +367,12 @@ ul { list-style: none; padding: 0; margin: 0; }
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
.rule-detail {
margin-top: 0.5rem; padding: 0.5rem;
background: var(--color-bg, #111113); border-radius: 6px;
background: var(--fs-surface-page); border-radius: 6px;
}
.rule-detail > div { margin-bottom: 0.5rem; }
.edit-link {
background: none; border: none; cursor: pointer;
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
color: var(--fs-accent); padding: 0.5rem 0 0 0;
}
.empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; }
@@ -377,18 +381,18 @@ ul { list-style: none; padding: 0; margin: 0; }
.new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem;
padding: 0.75rem; margin: 0.5rem 0;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color); border-radius: 6px;
}
.new-rule-form input, .new-rule-form textarea {
background: var(--color-surface, #18181b); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--fs-surface-hover); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem; font: inherit; resize: vertical;
}
.rule-list { margin-top: 0.5rem; }
.delete-link {
background: none; border: none; cursor: pointer;
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
color: var(--fs-destructive); padding: 0.5rem 0 0 0;
}
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
@@ -400,14 +404,14 @@ ul { list-style: none; padding: 0; margin: 0; }
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--color-muted, #888); font-size: 0.75rem;
color: var(--fs-text-tertiary); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
.skip-btn:hover { color: var(--fs-destructive); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
@@ -426,13 +430,13 @@ ul { list-style: none; padding: 0; margin: 0; }
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--color-primary, #6366f1); font-size: 0.85em;
color: var(--fs-accent); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style>
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
.slide-over {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(520px, 90vw);
background: var(--color-surface, #18181b);
border-left: 2px solid var(--color-primary, #6366f1);
background: var(--fs-surface-hover);
border-left: 2px solid var(--fs-accent);
padding: 1.5rem;
overflow-y: auto;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
@@ -110,11 +110,11 @@ header h2 {
font-family: Fraunces, serif; font-style: italic;
}
label { display: block; margin-bottom: 1rem; }
.required { color: var(--color-primary, #6366f1); }
.required { color: var(--fs-accent); }
input, textarea {
width: 100%; margin-top: 0.25rem;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem; font: inherit;
font-family: inherit;
}
@@ -22,18 +22,18 @@ const emit = defineEmits<{
</template>
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li {
padding: 0.75rem;
cursor: pointer;
border-radius: 6px;
border-left: 2px solid var(--color-primary, #6366f1);
border-left: 2px solid var(--fs-accent);
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.02);
}
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
li:hover { background: var(--fs-surface-hover); }
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
.new-rule { cursor: pointer; }
@@ -122,7 +122,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</template>
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle {
@@ -133,18 +133,23 @@ header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem
.always-on-toggle input { cursor: pointer; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
button-or-form whose children style themselves; the second is a `<ul>`, and
the bare `ul` rule above already gives it list-style, padding and margin —
a base a class-name check cannot see, since it comes from an element
selector. Both namespace descendant rules and assume nothing about layout. */
.new-topic input {
width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--color-border, #2a2a2e);
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
@@ -48,27 +48,27 @@ async function submitNew() {
</template>
<style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
.always-on-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--color-accent, rgba(91,74,138,0.25));
color: var(--color-accent-fg, inherit);
background: var(--fs-accent);
color: var(--fs-text-on-action);
margin-left: auto;
}
.new-rulebook { margin-top: 1rem; }
.new-rulebook input {
width: 100%; margin-bottom: 0.5rem;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
@@ -9,3 +9,15 @@ export function relativeTime(iso: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/**
* relativeTime() for the recent past, a short date once it's a week old —
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
* panels used to carry identical copies of this.
*/
export function relativeTimeOrDate(iso: string): string {
const d = new Date(iso);
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
if (days < 7) return relativeTime(iso);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
+5 -9
View File
@@ -110,15 +110,11 @@ const router = createRouter({
component: () => import("@/views/RulesView.vue"),
},
{
// Meta-surface, same family as /rules: it describes the app rather than
// holding the operator's records.
path: "/design",
name: "design",
component: () => import("@/views/DesignView.vue"),
},
{
// The editable half of the same surface: /design is what the browser
// renders, /design-systems is the record that ought to decide it.
// The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the
// running app's own stylesheet out of the browser; it could only ever
// inspect the instance it was served from, which made it a mirror rather
// than a tool (#274).
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
-1
View File
@@ -10,7 +10,6 @@ export interface Note {
title: string;
body: string;
description: string | null;
consolidated_at: string | null;
tags: string[];
parent_id: number | null;
parent_title?: string | null;
+24 -57
View File
@@ -1,65 +1,32 @@
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}
/** "9:30 AM" */
export function fmtTime(dt: string): string {
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
export function fmtDateTime(dt: string, allDay: boolean): string {
const d = new Date(dt)
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
if (allDay) return datePart
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
}
/**
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
* Shared date/time formatting — one rule per display shape. Views import
* these instead of carrying a local formatDate(): the 2026-08 shape audit
* found eight copies across views/components, three of them byte-identical.
* (The previous Calendar/Home helpers in this file had no callers left and
* were removed in the same pass.)
*
* Relative forms ("5m ago") live next door in composables/useRelativeTime.
*/
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
if (_isSameDay(d, now)) return `Today${timeStr}`
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
} catch {
return dt
}
/** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
}
/**
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
*/
export function fmtDayLabel(dt: string): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
if (_isSameDay(d, now)) return "Today"
if (_isSameDay(d, tomorrow)) return "Tomorrow"
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
} catch {
return dt
}
/** "Jan 15, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
export function fmtStamp(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
export function fmtCompact(dt: string, allDay: boolean): string {
const d = new Date(dt)
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
/** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
export function fmtLogStamp(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
-169
View File
@@ -1,169 +0,0 @@
/**
* Drift comparison — what the rulebook claims vs what the stylesheet does.
*
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
* prose into claims) is server-side in `services/design_system.py`, where pytest
* can assert on it. What's left here is set arithmetic over live token values,
* which is the one thing the browser knows and the server doesn't.
*
* SCOPE, and it is a real limit rather than an omission. This compares the
* rulebook against the TOKENS. It cannot see the third category of drift — a
* literal hardcoded in a component where a token should be referenced (#2275,
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
*
* Catching it needs the component sources, which would mean bundling every SFC
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
* matters: a drift report that silently omits a category invites the reader to
* conclude the category is clean.
*/
import type { DesignToken } from "@/utils/designTokens";
export type ExpectationKind = "token" | "color" | "prohibited_color";
export interface Expectation {
kind: ExpectationKind;
value: string;
rule_id: number;
rule_title: string;
context: string;
}
export interface ExpectationResponse {
rulebook_id: number | null;
expectations: Expectation[];
}
export type FindingStatus = "ok" | "missing" | "violated";
export interface Finding {
expectation: Expectation;
status: FindingStatus;
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
matches: string[];
}
/**
* Normalise a colour for comparison — the client-side twin of
* `normalize_hex` in services/design_system.py.
*
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
* spellings of one colour, and a comparison that misses any of them under-reports
* rather than erroring. The rgb() case is browser-specific and therefore has no
* server-side counterpart, which is exactly why it is handled here.
*/
export function normalizeColour(value: string): string | null {
const raw = value.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) {
digits = digits.split("").map((c) => c + c).join("");
}
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
}
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
if (rgb) {
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
if (parts.length < 3) return null;
const channels = parts.slice(0, 3).map((p) => Number(p));
if (channels.some((n) => !Number.isFinite(n))) return null;
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
const base = `#${channels.map(hexOf).join("")}`;
if (parts.length === 3) return base;
const alpha = Number(parts[3]);
if (!Number.isFinite(alpha) || alpha >= 1) return base;
return `${base}${hexOf(alpha * 255)}`;
}
return null;
}
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
const index = new Map<string, string[]>();
for (const token of tokens) {
const colour = normalizeColour(token.value);
if (!colour) continue;
const names = index.get(colour);
if (names) names.push(token.name);
else index.set(colour, [token.name]);
}
return index;
}
/**
* Compare claims against the live tokens.
*
* A `token` claim asks whether a custom property of that name exists.
* A `color` claim asks whether any token resolves to that value.
* A `prohibited_color` claim INVERTS the test — present is the failure.
*/
export function compareToTokens(
expectations: Expectation[],
tokens: DesignToken[],
): Finding[] {
const names = new Set(tokens.map((t) => t.name));
const colours = colourIndex(tokens);
return expectations.map((expectation) => {
if (expectation.kind === "token") {
const present = names.has(expectation.value);
return {
expectation,
status: present ? "ok" : "missing",
matches: present ? [expectation.value] : [],
};
}
const matches = colours.get(expectation.value) ?? [];
if (expectation.kind === "prohibited_color") {
return {
expectation,
status: matches.length ? "violated" : "ok",
matches,
};
}
return {
expectation,
status: matches.length ? "ok" : "missing",
matches,
};
});
}
export interface DriftSummary {
ok: number;
missing: number;
violated: number;
total: number;
}
export function summarise(findings: Finding[]): DriftSummary {
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
for (const finding of findings) summary[finding.status] += 1;
return summary;
}
/**
* Findings worth leading with.
*
* A panel that opens with every row gets closed and never reopened — the same
* principle the auto-inject menu is built on: a short list that gets read beats
* a complete one that doesn't. Violations first (something is actively wrong),
* then missing (something was never built), and `ok` rows are not "findings" at
* all — they belong behind an expansion.
*/
export function rankFindings(findings: Finding[]): Finding[] {
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
return [...findings].sort((a, b) => {
const byStatus = order[a.status] - order[b.status];
if (byStatus !== 0) return byStatus;
return a.expectation.rule_id - b.expectation.rule_id;
});
}
-181
View File
@@ -1,181 +0,0 @@
/**
* Design-token inventory — what tokens exist, and what they actually resolve to.
*
* Foundation for the design explorer (milestone #251): the gallery renders
* against these, and the drift panel compares them to the design rulebook.
*
* DESIGN NOTE — why this parses NAMES but never VALUES.
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
* its VALUE is not: values contain nested parens, commas inside rgba(),
* `var()` references to other tokens, multi-part shadows, and gradients — and
* `theme.css` has all of those today. So we take the names from the source and
* ask the BROWSER for every value.
*
* That is not just easier, it is more correct. getComputedStyle reports what
* actually won the cascade, resolves `var()` chains, and — critically for this
* milestone — reflects live overrides set on a container, which is exactly what
* the preview surface needs (see #2261). Parsing the source would report what
* the file says rather than what the user is looking at.
*
* It also means this module needs no unit tests to be trustworthy: the only
* logic here is a name regex and a group lookup. The frontend has no test
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
* error-prone half in the browser rather than in our code is deliberate.
*/
import themeCss from "@/assets/theme.css?raw";
export type TokenGroup =
| "color"
| "radius"
| "gradient"
| "glow"
| "focus"
| "layout"
| "other";
export type ThemeMode = "light" | "dark";
export interface DesignToken {
/** Full custom-property name, including the leading `--`. */
name: string;
/** Coarse family, derived from the name prefix. */
group: TokenGroup;
/** Resolved value in the requested context, straight from the browser. */
value: string;
/** True when the declaration appears inside the dark block in source. */
overriddenInDark: boolean;
}
/**
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
*
* The discriminator is the COLON, not the preceding character. A declaration is
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
* actively wrong: an earlier version required the match to follow `{` or `;`,
* which silently dropped every declaration that came after a comment —
* including `--color-bg`, the first and most-used token in the file.
*/
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
/** Comments are stripped first so a commented-out declaration isn't counted. */
const COMMENT = /\/\*[\s\S]*?\*\//g;
/** The dark block's selector, as written in theme.css. */
const DARK_SELECTOR = '[data-theme="dark"]';
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
["--color-", "color"],
["--radius-", "radius"],
["--gradient-", "gradient"],
["--glow-", "glow"],
["--focus-", "focus"],
["--page-", "layout"],
["--sidebar-", "layout"],
["--chat-", "layout"],
];
export function groupFor(name: string): TokenGroup {
for (const [prefix, group] of GROUP_PREFIXES) {
if (name.startsWith(prefix)) return group;
}
return "other";
}
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
export function tokenNames(css: string = themeCss): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
const name = match[1];
if (!seen.has(name)) {
seen.add(name);
out.push(name);
}
}
return out;
}
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
export function darkOverriddenNames(css: string = themeCss): Set<string> {
const bare = css.replace(COMMENT, "");
const start = bare.indexOf(DARK_SELECTOR);
if (start === -1) return new Set();
const open = bare.indexOf("{", start);
const close = bare.indexOf("}", open);
if (open === -1 || close === -1) return new Set();
return new Set(tokenNames(bare.slice(open, close)));
}
/**
* Read the resolved value of every token in `host`'s context.
*
* Pass a container to read the tokens as they apply INSIDE it — which is how
* the preview surface reads a scoped override without disturbing the page.
* Defaults to the document root, i.e. the app-wide values.
*/
export function readTokens(host: Element = document.documentElement): DesignToken[] {
const computed = getComputedStyle(host);
const dark = darkOverriddenNames();
return tokenNames().map((name) => ({
name,
group: groupFor(name),
value: computed.getPropertyValue(name).trim(),
overriddenInDark: dark.has(name),
}));
}
/**
* Read tokens as they would resolve in a given mode, without touching the page.
*
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
* mutated to take a reading.
*
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
* function: light is declared on `:root` while dark is declared on
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
* subtree, but there is no `[data-theme="light"]` block to add the light ones
* back. So reading "light" from inside a dark page returns the dark values —
* the probe has nothing to match.
*
* Concretely: dark-inside-light previews work, light-inside-dark previews do
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
* (milestone #251 step 6) is what makes this symmetric, and until then callers
* should treat a cross-mode read as best-effort.
*/
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
const probe = document.createElement("div");
probe.setAttribute("data-theme", mode);
probe.style.display = "none";
document.body.appendChild(probe);
try {
return readTokens(probe);
} finally {
probe.remove();
}
}
/** Tokens grouped by family, preserving source order within each group. */
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
const out = new Map<TokenGroup, DesignToken[]>();
for (const token of tokens) {
const bucket = out.get(token.group);
if (bucket) bucket.push(token);
else out.set(token.group, [token]);
}
return out;
}
/**
* Tokens declared in the stylesheet that nothing references with `var()`.
*
* Dead tokens are drift too: `--chat-reading-width` and
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
* in the MCP-first pivot, and nothing has referenced them since. Takes the
* corpus of source files to search as an argument so the caller decides what
* "used" means — this module has no opinion about the project layout.
*/
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
const haystack = sources.join("\n");
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Turning a design system's RECORDED values into ones you can look at.
*
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
* running app's own stylesheet — names out of a bundled `theme.css`, values out
* of `getComputedStyle(document.documentElement)`. That could only ever describe
* the install serving the page, and the design surface is for the projects an
* install TRACKS (#274). What is left here works on any system's record,
* including one for an app this browser has never loaded.
*
* Nothing in this module reads the document's own tokens or mutates the page.
*/
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
export const BASE_MODE = "base";
/**
* Which declared value applies in `mode`.
*
* Falls back to base, which is the storage model rather than a convenience: a
* mode block is an OVERRIDE layer, so a token with no entry for the current
* mode is not missing — it is inheriting, exactly as the generated sheet has it.
*/
export function valueForMode(
valueByMode: Record<string, string>,
mode: string,
): string {
const own = valueByMode[mode];
if (own !== undefined && own !== "") return own;
return valueByMode[BASE_MODE] ?? "";
}
/** Every mode any token in the set declares, base first then the rest by name. */
export function modesPresent(
tokens: { value_by_mode: Record<string, string> }[],
): string[] {
const modes = new Set<string>();
for (const token of tokens) {
for (const [mode, value] of Object.entries(token.value_by_mode)) {
if (value) modes.add(mode);
}
}
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
}
/**
* Resolve declared values the way a browser would, without applying them.
*
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
* as text that is a string; shown as a swatch it needs `var()` substituted and
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
* offscreen probe and read them back — the substitution is done by the
* implementation that would do it for real.
*
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
* sitting in this page would resolve any reference the record leaves undeclared
* against the surrounding app's own tokens. Previewing another project's system
* would then quietly borrow this one's palette wherever that system was
* incomplete, and a token the record already knows is broken (it shows up under
* `unknown_refs`) would render as though it were fine.
*
* So every name referenced but not declared is blanked on the probe first. It
* resolves to nothing, which is what the record says it is.
*/
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
const probe = document.createElement("div");
probe.style.display = "none";
for (const value of declared.values()) {
for (const match of value.matchAll(VAR_REFERENCE)) {
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
}
}
for (const [name, value] of declared) probe.style.setProperty(name, value);
document.body.appendChild(probe);
try {
const computed = getComputedStyle(probe);
const out = new Map<string, string>();
for (const name of declared.keys()) {
out.set(name, computed.getPropertyValue(name).trim());
}
return out;
} finally {
probe.remove();
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
/** Cyclic color palette for milestone progress bars. */
export const MILESTONE_PALETTE = [
'var(--color-primary)',
'var(--color-success, #22c55e)',
'var(--fs-accent)',
'var(--fs-success, #22c55e)',
'#c98a00',
'#8b5cf6',
'var(--color-danger, #ef4444)',
'var(--fs-error, #ef4444)',
'#06b6d4',
]
+41 -38
View File
@@ -79,7 +79,7 @@ onMounted(async () => {
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
<header class="proj-head">
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
<span class="proj-dot" :style="{ background: p.color || 'var(--fs-accent)' }" />
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
</header>
@@ -156,7 +156,7 @@ onMounted(async () => {
:to="`/projects/${p.id}`"
class="pstat-row"
>
<span class="pstat-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
<span class="pstat-dot" :style="{ background: p.color || 'var(--fs-accent)' }" />
<span class="pstat-title">{{ p.title }}</span>
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
</router-link>
@@ -176,69 +176,72 @@ onMounted(async () => {
<style scoped>
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
/* `.dash-head` is deliberately bare: a block header whose two children carry
all the spacing between them (h1 zeroed, .dash-sub margined). Nothing in it
assumes a flex parent, which is the tell for a deleted rule (#2444). */
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--fs-text-tertiary); font-size: 0.9rem; }
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fs-text-tertiary); margin-bottom: 0.6rem; }
.dash-empty { color: var(--fs-text-tertiary); padding: 1rem 0; }
.dash-empty.card { padding: 1rem; border: 1px dashed var(--fs-border-color); border-radius: 10px; }
.done-recent { margin-bottom: 1.5rem; }
.done-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
.done-row:hover { background: var(--color-hover); }
.done-mark { color: var(--color-primary); flex-shrink: 0; }
.done-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.86rem; }
.done-row:hover { background: var(--fs-surface-hover); }
.done-mark { color: var(--fs-accent); flex-shrink: 0; }
.done-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.done-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
.done-more { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--color-primary); font-family: inherit; }
.done-meta { font-size: 0.72rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
.done-more { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--fs-accent); font-family: inherit; }
.done-more:hover { text-decoration: underline; }
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
.dash-main { flex: 1.7; min-width: 0; }
.dash-rail { flex: 1; min-width: 240px; }
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
.proj-panel { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
.proj-title:hover { color: var(--color-primary); }
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
.proj-title { font-weight: 700; color: var(--fs-text-primary); text-decoration: none; }
.proj-title:hover { color: var(--fs-accent); }
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--fs-text-tertiary); }
.bar { height: 5px; background: var(--fs-border-color); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
.bar-fill { height: 5px; background: var(--fs-accent); border-radius: 3px; }
.ms-block { margin-top: 0.7rem; }
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--fs-text-primary); }
.ms-title.ms-none { color: var(--fs-text-tertiary); font-weight: 500; }
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--fs-text-tertiary); }
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
.task-row:hover { background: var(--color-hover); }
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
.task-mark { color: var(--color-muted); }
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.86rem; }
.task-row:hover { background: var(--fs-surface-hover); }
.task-inprogress { border-left: 3px solid var(--fs-accent); padding-left: calc(0.55rem - 3px); }
.task-mark { color: var(--fs-text-tertiary); }
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--fs-border-color); color: var(--fs-text-tertiary); }
.pri-high { color: #c0556b; border-color: #c0556b66; }
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--fs-accent); text-decoration: none; }
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
.rail-card { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
.stats-sub { color: var(--fs-text-tertiary); font-size: 0.78rem; }
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
.pstat-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
.pstat-row:hover { background: var(--color-hover); }
.pstat-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
.pstat-row:hover { background: var(--fs-surface-hover); }
.pstat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.pstat-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.pstat-counts { font-size: 0.74rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
.pstat-counts { font-size: 0.74rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.qa-btn { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--fs-text-primary); text-decoration: none; }
.qa-btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.issues-card { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
.issue-row:hover { background: var(--color-hover); }
.issue-mark { color: var(--color-muted); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--color-primary); }
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
.issue-row:hover { background: var(--fs-surface-hover); }
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--fs-accent); }
.issue-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
.issue-meta { font-size: 0.72rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
</style>
+121 -67
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
/**
* Design systems — editing the stylesheet Scribe holds (milestone #254 step 5).
* Design systems — the stylesheets this install RECORDS, for the projects it
* tracks (milestone #254 step 5).
*
* Sibling of /design, which shows the system as the BROWSER has it. This page
* shows it as the RECORD has it, which is the half you can change.
* It had a sibling, `/design`, which showed the system as the BROWSER had it
* names out of a bundled stylesheet, values out of `getComputedStyle`. That
* could only ever describe the install serving the page, so it was a mirror
* rather than a tool and was retired (#274). Everything here works on a system
* whose app this browser has never loaded.
*
* The layout follows the model rather than decorating it. A system with a
* parent holds only what it changes, so this page has two lists and they are
@@ -39,9 +43,10 @@ import {
type SnippetCheck,
type StylesheetResult,
} from "@/api/designSystems";
import DesignTabs from "@/components/DesignTabs.vue";
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import StarterRolePicker from "@/components/StarterRolePicker.vue";
import TokenPreview from "@/components/TokenPreview.vue";
const toast = useToastStore();
@@ -148,6 +153,10 @@ const newTitle = ref("");
const newDescription = ref("");
const newParentId = ref<number | null>(null);
const creating = ref(false);
// Starter roles (#2349). The picker fills these on mount; empty means the
// operator unchecked everything, which is a real answer.
const starterGroups = ref<string[]>([]);
const tokenPrefix = ref("");
async function submitCreate() {
const title = newTitle.value.trim();
@@ -158,11 +167,16 @@ async function submitCreate() {
title,
description: newDescription.value.trim() || undefined,
parent_id: newParentId.value,
starter_role_groups: starterGroups.value.length ? starterGroups.value : undefined,
token_prefix: tokenPrefix.value.trim() || undefined,
});
newTitle.value = "";
newDescription.value = "";
newParentId.value = null;
showCreate.value = false;
// NOT reset: the picker owns these and re-seeds on mount. Clearing them
// here would race the next mount and silently create the following system
// with no roles at all.
await loadSystems();
selectedId.value = created.id;
toast.show(`Created ${created.title}`);
@@ -479,14 +493,28 @@ watch(selectedId, () => {
snippetCheck.value = null;
});
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
/**
* A colour this row can draw HONESTLY — self-contained, no `var()` inside.
*
* The provenance list below shows values as the record states them, and a
* `var()` reference states nothing on its own: rendering
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves
* `--accent` against THIS app, so a system that isn't the one Scribe runs on
* would be drawn in Scribe's palette. It looked right, which is why it went
* unnoticed (#274).
*
* The preview above resolves values properly, on a probe carrying only that
* system's declarations. So this list draws only what needs no resolving, and
* leaves the rest to the surface built for it.
*/
function isSelfContainedColour(value: string): boolean {
const v = value.trim();
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(v) && !v.includes("var(");
}
</script>
<template>
<div class="ds-view">
<DesignTabs />
<header class="ds-header">
<h1>Design systems</h1>
@@ -540,6 +568,10 @@ function isColourish(value: string): boolean {
placeholder="What it covers"
/>
</div>
<StarterRolePicker
v-model:selected="starterGroups"
v-model:prefix="tokenPrefix"
/>
<div class="row-actions">
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
{{ creating ? "Creating…" : "Create" }}
@@ -601,6 +633,10 @@ function isColourish(value: string): boolean {
A system with a parent stores only its differences from it.
</p>
</div>
<StarterRolePicker
v-model:selected="starterGroups"
v-model:prefix="tokenPrefix"
/>
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
{{ creating ? "Creating…" : "Create" }}
</button>
@@ -750,7 +786,7 @@ function isColourish(value: string): boolean {
<ul class="dupe-list">
<li v-for="[value, names] in duplicateEntries" :key="value">
<span
v-if="isColourish(value)" class="swatch"
v-if="isSelfContainedColour(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<code>{{ value }}</code> — {{ names.join(", ") }}
@@ -908,7 +944,7 @@ function isColourish(value: string): boolean {
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
<span
v-if="isColourish(row.value)" class="swatch"
v-if="isSelfContainedColour(row.value)" class="swatch"
:style="{ background: row.value }" aria-hidden="true"
/>
<button
@@ -946,7 +982,7 @@ function isColourish(value: string): boolean {
<span class="token-values">
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
<span
v-if="isColourish(value)" class="swatch"
v-if="isSelfContainedColour(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<span class="mode-name">{{ mode }}</span>
@@ -969,6 +1005,18 @@ function isColourish(value: string): boolean {
</ul>
</section>
<!-- What it looks like. Drawn from the record on an isolated probe,
so this is THIS system's palette even when the app around it is
running a different one. -->
<section v-if="resolved.length" class="ds-section">
<h2>Preview</h2>
<p class="section-note">
{{ selected.title }} as it would render — resolved from the record,
not from the stylesheet this app happens to be running.
</p>
<TokenPreview :tokens="resolved" />
</section>
<!-- Effective set -->
<section class="ds-section">
<h2>Effective tokens</h2>
@@ -1002,7 +1050,7 @@ function isColourish(value: string): boolean {
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
v-if="isSelfContainedColour(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
@@ -1023,7 +1071,7 @@ function isColourish(value: string): boolean {
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
<span class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
v-if="isSelfContainedColour(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
@@ -1054,6 +1102,12 @@ function isColourish(value: string): boolean {
padding: 1.5rem 1rem 4rem;
}
/* Restored with the same sweep that took `.milestone-header` — only the `h1`
descendant rule survived, so the header had no spacing of its own. */
.ds-header {
margin-bottom: 2rem;
}
.ds-header h1 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
@@ -1061,7 +1115,7 @@ function isColourish(value: string): boolean {
}
.lede {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
max-width: 70ch;
line-height: 1.6;
margin: 0 0 1.5rem;
@@ -1083,9 +1137,9 @@ function isColourish(value: string): boolean {
/* Sidebar ---------------------------------------------------------------- */
.ds-sidebar {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1rem;
}
@@ -1102,7 +1156,7 @@ function isColourish(value: string): boolean {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.system-list {
@@ -1119,22 +1173,22 @@ function isColourish(value: string): boolean {
text-align: left;
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
padding: 0.5rem 0.6rem;
cursor: pointer;
color: var(--color-text);
color: var(--fs-text-primary);
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.system-btn:hover {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.system-btn.active {
border-color: var(--color-primary);
background: var(--color-bg-secondary);
border-color: var(--fs-accent);
background: var(--fs-surface-raised);
}
@@ -1144,7 +1198,7 @@ function isColourish(value: string): boolean {
.system-kind {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* Sections --------------------------------------------------------------- */
@@ -1157,9 +1211,9 @@ function isColourish(value: string): boolean {
}
.ds-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
}
@@ -1178,7 +1232,7 @@ function isColourish(value: string): boolean {
.section-note,
.muted {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.85rem;
margin: 0 0 1rem;
}
@@ -1193,17 +1247,17 @@ function isColourish(value: string): boolean {
}
.chain-link {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.chain-link.self {
color: var(--color-text);
color: var(--fs-text-primary);
font-weight: 500;
}
.chain-arrow,
.chain-note {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* Fields ----------------------------------------------------------------- */
@@ -1228,24 +1282,24 @@ function isColourish(value: string): boolean {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin-bottom: 0.3rem;
}
.field-hint {
margin: 0.3rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
line-height: 1.5;
}
.input {
width: 100%;
padding: 0.45rem 0.6rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary);
font: inherit;
}
@@ -1274,15 +1328,15 @@ textarea.input {
.confirm-copy {
font-size: 0.85rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
max-width: 40ch;
}
/* Tokens ----------------------------------------------------------------- */
.token-form {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 1rem;
margin-bottom: 1rem;
}
@@ -1317,7 +1371,7 @@ textarea.input {
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.token-name {
@@ -1332,7 +1386,7 @@ textarea.input {
}
.token-meta {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.8rem;
flex: 1;
min-width: 0;
@@ -1351,18 +1405,18 @@ textarea.input {
.finding-list li {
padding: 0.6rem 0;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.finding-title {
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
}
.finding-line {
margin: 0.3rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -1374,24 +1428,24 @@ textarea.input {
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
flex: none;
}
.spec-status.violated {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
background: var(--fs-priority-high-bg);
color: var(--fs-priority-high);
}
.spec-status.missing {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium);
}
.sheet {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem 1rem;
margin-top: 0.75rem;
overflow-x: auto;
@@ -1418,7 +1472,7 @@ textarea.input {
.supersedes {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-style: italic;
}
@@ -1430,7 +1484,7 @@ textarea.input {
}
.mode-name {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.06em;
@@ -1441,7 +1495,7 @@ textarea.input {
width: 0.9rem;
height: 0.9rem;
border-radius: 3px;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
flex: none;
}
@@ -1456,12 +1510,12 @@ textarea.input {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.resolved-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.resolved-head {
@@ -1497,33 +1551,33 @@ textarea.input {
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text-muted);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
}
.origin-badge.own {
background: var(--color-primary-tint);
color: var(--color-primary);
background: var(--fs-accent-soft);
color: var(--fs-accent);
}
/* Notices ---------------------------------------------------------------- */
.notice {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
max-width: 60ch;
}
.notice-warn {
border-left: 3px solid var(--color-warning);
border-left: 3px solid var(--fs-warning);
}
.notice p {
margin: 0.5rem 0 1rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
line-height: 1.6;
}
</style>
-545
View File
@@ -1,545 +0,0 @@
<script setup lang="ts">
/**
* Design explorer — the gallery (milestone #251 step 3).
*
* Renders the design system against the tokens that are actually live, read at
* runtime rather than parsed from source, so what you see here is what the app
* is using right now.
*
* HONESTY RULE, and the reason parts of this page say "not implemented":
* a gallery of hand-written look-alikes drifts from the app within a month and
* then lies — which is the same failure this whole surface exists to catch. So
* every specimen below is either a REAL component imported from the app, or a
* real token read from the browser, or it is explicitly marked as missing.
*
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
* in five `<style scoped>` blocks, all five drifted, and this page reported it
* as a gap because drawing a look-alike would have made it a sixth copy.
* `assets/components.css` is now the single definition (#2273), so the
* specimens below are the app's real classes — they cannot drift from the app
* without drifting the app itself.
*/
import { computed, onMounted, ref } from "vue";
import { fetchDesignExpectations } from "@/api/design";
import DesignTabs from "@/components/DesignTabs.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import TagPill from "@/components/TagPill.vue";
import {
compareToTokens,
rankFindings,
summarise,
type Expectation,
type Finding,
} from "@/utils/designDrift";
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
const tokens = ref<DesignToken[]>([]);
const expectations = ref<Expectation[]>([]);
const designRulebookId = ref<number | null>(null);
const driftLoaded = ref(false);
const showCleanRows = ref(false);
/**
* Read on mount, not at module scope: the values depend on the live cascade,
* which needs the app's stylesheets applied and the theme attribute set.
*/
onMounted(async () => {
tokens.value = readTokens();
try {
const response = await fetchDesignExpectations();
designRulebookId.value = response.rulebook_id;
expectations.value = response.expectations;
} catch {
// The gallery is useful without the panel, so a failed fetch degrades to
// "no drift data" rather than taking the page down with it.
designRulebookId.value = null;
} finally {
driftLoaded.value = true;
}
});
const findings = computed<Finding[]>(() =>
rankFindings(compareToTokens(expectations.value, tokens.value)),
);
const driftSummary = computed(() => summarise(findings.value));
const visibleFindings = computed(() =>
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
);
const grouped = computed(() => groupTokens(tokens.value));
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
const orderedGroups = computed(() =>
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
);
/** A token whose value reads as a colour is worth showing as a swatch. */
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
}
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
const RULEBOOK_BUTTONS = [
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
];
const TYPE_SPECIMENS = [
{ token: "Display", spec: "40 / 500 / Fraunces" },
{ token: "H1", spec: "32 / 500 / Fraunces" },
{ token: "H2", spec: "24 / 500 / Fraunces" },
{ token: "H3", spec: "18 / 500 / Inter" },
{ token: "Body", spec: "15 / 400 / Inter" },
{ token: "Body small", spec: "13 / 400 / Inter" },
{ token: "Label", spec: "12 / 500 / Inter" },
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
];
</script>
<template>
<div class="design-view">
<DesignTabs />
<header class="design-header">
<h1>Live tokens</h1>
<p class="lede">
The system as it actually is. Token values are read from the browser at
runtime, so this page reflects the live cascade rather than what the
stylesheet says. Components shown are the real ones where a piece of
the system has no shared implementation, it is marked missing rather
than mocked up.
</p>
</header>
<!-- Drift: what the rulebook claims vs what the tokens do. -->
<section class="design-section">
<h2>Rulebook drift</h2>
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook</p>
<div v-else-if="designRulebookId === null" class="gap-notice">
<strong>No design rulebook designated.</strong>
<p>
This install hasn't said which rulebook describes its design system, so
there is nothing to check the tokens against. Designate one in
<router-link to="/settings">Settings</router-link> and this panel will
compare every colour and token the rulebook names against what the
stylesheet actually resolves to.
</p>
</div>
<template v-else>
<p class="section-note">
<strong>{{ driftSummary.violated }}</strong> violated ·
<strong>{{ driftSummary.missing }}</strong> missing ·
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
claims in rulebook #{{ designRulebookId }}.
</p>
<div class="gap-notice">
<strong>This compares the rulebook against the TOKENS only.</strong>
<p>
A value hardcoded in a component — where a token should have been
referenced — is invisible here, because the drift isn't in the tokens
at all. Reading it would mean bundling every component's source into
the app. That check belongs in CI and is tracked separately, so treat
a clean panel as "the tokens agree", not "the app agrees".
</p>
</div>
<p v-if="!findings.length" class="muted">
The rulebook names nothing this panel can check. Rules that state values
— colours, token names — produce claims; rules that state judgement
don't, by design.
</p>
<ul v-else class="spec-list">
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
<span class="spec-name">
<span
v-if="finding.expectation.kind !== 'token'"
class="swatch"
:style="{ background: finding.expectation.value }"
aria-hidden="true"
/>
<code>{{ finding.expectation.value }}</code>
</span>
<span class="spec-detail">
rule #{{ finding.expectation.rule_id }} {{ finding.expectation.rule_title }}
<span v-if="finding.matches.length" class="matches">
· {{ finding.matches.join(", ") }}
</span>
</span>
<span class="spec-status" :class="finding.status">
{{ finding.status === "violated" ? "forbidden, but present"
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
</span>
</li>
</ul>
<button
v-if="findings.length && driftSummary.ok"
class="reveal-toggle"
@click="showCleanRows = !showCleanRows"
>
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
</button>
</template>
</section>
<!-- Real components: these are imported, not recreated. -->
<section class="design-section">
<h2>Components</h2>
<p class="section-note">Imported from the app. What you see is what ships.</p>
<div class="specimen">
<span class="specimen-label">Status badge</span>
<div class="specimen-row">
<StatusBadge status="todo" />
<StatusBadge status="in_progress" />
<StatusBadge status="done" />
<StatusBadge status="cancelled" />
</div>
</div>
<div class="specimen">
<span class="specimen-label">Priority badge</span>
<div class="specimen-row">
<PriorityBadge priority="low" />
<PriorityBadge priority="medium" />
<PriorityBadge priority="high" />
<span class="muted">(<code>none</code> renders nothing, by design)</span>
</div>
</div>
<div class="specimen">
<span class="specimen-label">Tag pill</span>
<div class="specimen-row">
<TagPill tag="design-system" />
<TagPill tag="dismissible" dismissible />
</div>
</div>
</section>
<!-- No longer a gap: these are the app's real classes, from the shared
sheet. Nothing here is a look-alike — change components.css and these
specimens change with it, which is the only way this page stays true. -->
<section class="design-section">
<h2>Buttons</h2>
<div class="button-specimens">
<button class="btn-primary">Save</button>
<button class="btn-secondary">Detect</button>
<button class="btn-ghost">Cancel</button>
<button class="btn-danger">Delete</button>
<button class="btn-primary" disabled>Disabled</button>
</div>
<p class="spec-caption">
Three sizes, because the app has three kinds of button: a page action, a
row action, and an affordance that sits inside a card without disturbing
its rhythm.
</p>
<div class="button-specimens">
<button class="btn-primary">Default — page action</button>
<button class="btn-primary btn-compact">Compact — row action</button>
<button class="btn-primary btn-inline">Inline</button>
</div>
<ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status ok">shared</span>
</li>
</ul>
</section>
<!-- Typography: the families load, the scale does not exist as tokens. -->
<section class="design-section">
<h2>Type scale</h2>
<div class="gap-notice">
<strong>Families load; the scale has no tokens.</strong>
<p>
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
scale is not expressed as custom properties, so sizes and weights are
set ad hoc per component. Listed here as specification, not as a live
specimen there is nothing to read.
</p>
</div>
<ul class="spec-list">
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
<span class="spec-name">{{ t.token }}</span>
<span class="spec-detail">{{ t.spec }}</span>
<span class="spec-status missing">no token</span>
</li>
</ul>
</section>
<!-- Tokens: entirely real, read live. -->
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
<ul class="token-list">
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
<span
v-if="isColourish(token.value)"
class="swatch"
:style="{ background: token.value }"
aria-hidden="true"
/>
<span v-else class="swatch swatch-none" aria-hidden="true" />
<code class="token-name">{{ token.name }}</code>
<code class="token-value">{{ token.value || "—" }}</code>
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
mode-aware
</span>
</li>
</ul>
</section>
<p v-if="!tokens.length" class="muted">Reading tokens</p>
</div>
</template>
<style scoped>
.design-view {
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem var(--page-padding-x) 4rem;
}
.design-header {
margin-bottom: 2rem;
}
.lede {
color: var(--color-text-secondary);
max-width: 60ch;
line-height: 1.7;
}
.design-section {
margin-bottom: 2.5rem;
}
.design-section h2 {
margin-bottom: 0.25rem;
}
.token-group-heading {
text-transform: capitalize;
}
.count {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 400;
}
.section-note,
.muted {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 1rem;
}
/* Specimens -------------------------------------------------------------- */
.specimen {
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
}
.specimen-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.specimen-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* Gaps ------------------------------------------------------------------- */
.spec-caption {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
line-height: 1.5;
max-width: 60ch;
}
/* Layout only. The buttons inside style themselves from the shared sheet —
adding any appearance rule here would recreate the copy this section
just stopped being. */
.button-specimens {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--fs-space-3);
margin-bottom: 1rem;
}
.gap-notice {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.gap-notice p {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.6;
max-width: 70ch;
}
.spec-list {
list-style: none;
padding: 0;
margin: 0;
}
.spec-list li {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.spec-name {
min-width: 8rem;
font-weight: 500;
}
.spec-detail {
color: var(--color-text-secondary);
font-size: 0.85rem;
flex: 1;
}
.spec-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
}
.spec-status.missing {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.spec-status.violated {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.ok {
background: var(--color-status-done-bg);
color: var(--color-status-done);
}
.spec-name .swatch {
vertical-align: middle;
margin-right: 0.4rem;
}
.matches {
color: var(--color-text-muted);
}
.reveal-toggle {
margin-top: 0.75rem;
padding: 0.35rem 0.75rem;
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
}
.reveal-toggle:hover {
border-color: var(--color-text-muted);
}
/* Tokens ----------------------------------------------------------------- */
.token-list {
list-style: none;
padding: 0;
margin: 0;
}
.token-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.swatch {
width: 1.25rem;
height: 1.25rem;
flex: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.swatch-none {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
var(--color-border) 3px,
var(--color-border) 4px
);
}
.token-name {
min-width: 16rem;
font-size: 0.8rem;
}
.token-value {
color: var(--color-text-secondary);
font-size: 0.8rem;
flex: 1;
word-break: break-all;
}
.token-flag {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.05rem 0.35rem;
}
@media (max-width: 640px) {
.token-name {
min-width: 0;
}
}
</style>
+4 -88
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { apiPost } from "@/api/client";
import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue";
const email = ref("");
@@ -15,12 +15,7 @@ async function handleSubmit() {
await apiPost("/api/auth/forgot-password", { email: email.value });
submitted.value = true;
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Something went wrong";
} else {
error.value = "Something went wrong";
}
error.value = apiErrorMessage(e, "Something went wrong");
} finally {
submitting.value = false;
}
@@ -55,7 +50,7 @@ async function handleSubmit() {
</form>
</template>
<div v-else class="success-msg">
<div v-else class="auth-note">
<p>If an account exists with that email address, you will receive a password reset link shortly.</p>
<p>Check your email and follow the instructions to reset your password.</p>
</div>
@@ -67,83 +62,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.success-msg {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
<style src="@/assets/auth-shared.css" />
+60 -60
View File
@@ -208,7 +208,7 @@ function initGraph() {
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-4L8,0L0,4")
.attr("fill", "var(--color-primary)")
.attr("fill", "var(--fs-accent)")
.attr("opacity", "0.6");
// Zoom layer
@@ -237,7 +237,7 @@ function initGraph() {
.append("line")
.attr("class", "graph-edge")
.attr("stroke", (d: any) =>
d.type === "wikilink" ? "var(--color-primary)" : "var(--color-text-muted)"
d.type === "wikilink" ? "var(--fs-accent)" : "var(--fs-text-tertiary)"
)
.attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
.attr("stroke-dasharray", null)
@@ -295,11 +295,11 @@ function initGraph() {
.append("circle")
.attr("r", (d: GraphNode) => d.radius ?? 8)
.attr("fill", (d: GraphNode) => {
if (d.type === "tag") return "color-mix(in srgb, var(--color-primary) 20%, transparent)";
return d.project_color ?? "var(--color-bg-secondary)";
if (d.type === "tag") return "color-mix(in srgb, var(--fs-accent) 20%, transparent)";
return d.project_color ?? "var(--fs-surface-raised)";
})
.attr("stroke", (d: GraphNode) =>
d.type === "tag" ? "var(--color-primary)" : "var(--color-border)"
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-border-color)"
)
.attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
.attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
@@ -317,7 +317,7 @@ function initGraph() {
.attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
.attr("font-size", "10px")
.attr("fill", (d: GraphNode) =>
d.type === "tag" ? "var(--color-primary)" : "var(--color-text-secondary)"
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-text-secondary)"
)
.attr("pointer-events", "none");
@@ -600,7 +600,7 @@ onUnmounted(() => {
.graph-page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height, 52px));
height: calc(100vh - var(--fs-layout-header));
overflow: hidden;
}
@@ -609,17 +609,17 @@ onUnmounted(() => {
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
flex-wrap: wrap;
}
.graph-select {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary);
font-size: 0.875rem;
padding: 0.3rem 0.6rem;
cursor: pointer;
@@ -631,27 +631,27 @@ onUnmounted(() => {
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
cursor: pointer;
user-select: none;
}
.graph-toggle input {
cursor: pointer;
accent-color: var(--color-primary);
accent-color: var(--fs-accent);
}
.graph-stats {
margin-left: auto;
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.graph-physics-btn {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-secondary);
font-size: 0.875rem;
font-family: inherit;
padding: 0.3rem 0.7rem;
@@ -659,12 +659,12 @@ onUnmounted(() => {
transition: border-color 0.15s, color 0.15s;
}
.graph-physics-btn:hover {
border-color: var(--color-primary);
color: var(--color-text);
border-color: var(--fs-accent);
color: var(--fs-text-primary);
}
.graph-physics-btn.active {
border-color: var(--color-primary);
color: var(--color-primary);
border-color: var(--fs-accent);
color: var(--fs-accent);
}
.graph-physics-panel {
@@ -672,8 +672,8 @@ onUnmounted(() => {
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
@@ -688,20 +688,20 @@ onUnmounted(() => {
.knob-label {
font-size: 0.75rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
display: flex;
justify-content: space-between;
}
.knob-label em {
font-style: normal;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
.physics-knob input[type="range"] {
width: 100%;
accent-color: var(--color-primary);
accent-color: var(--fs-accent);
cursor: pointer;
}
@@ -709,7 +709,7 @@ onUnmounted(() => {
flex: 1;
position: relative;
overflow: hidden;
background: var(--color-bg);
background: var(--fs-surface-page);
}
.graph-svg {
@@ -728,15 +728,15 @@ onUnmounted(() => {
}
.graph-empty p {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.95rem;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border: 3px solid var(--fs-border-color);
border-top-color: var(--fs-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@@ -747,10 +747,10 @@ onUnmounted(() => {
.graph-tooltip {
position: absolute;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15));
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
box-shadow: 0 4px 16px var(--color-shadow);
padding: 0.5rem 0.75rem;
pointer-events: none;
z-index: 10;
@@ -760,7 +760,7 @@ onUnmounted(() => {
.tooltip-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
margin-bottom: 0.25rem;
}
@@ -773,15 +773,15 @@ onUnmounted(() => {
.tag-chip {
font-size: 0.7rem;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
border-radius: 999px;
padding: 0.1rem 0.4rem;
}
.tooltip-meta {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
text-transform: capitalize;
}
@@ -792,8 +792,8 @@ onUnmounted(() => {
right: 0;
bottom: 0;
width: 340px;
background: var(--color-bg-card);
border-left: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border-left: 1px solid var(--fs-border-color);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
@@ -815,7 +815,7 @@ onUnmounted(() => {
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
@@ -826,10 +826,10 @@ onUnmounted(() => {
letter-spacing: 0.05em;
padding: 0.15rem 0.45rem;
border-radius: 10px;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-tertiary);
}
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
.peek-type-task { border-color: var(--fs-accent); color: var(--fs-accent); }
.peek-actions {
display: flex;
@@ -840,7 +840,7 @@ onUnmounted(() => {
.peek-link {
font-size: 0.78rem;
color: var(--color-primary);
color: var(--fs-accent);
text-decoration: none;
}
.peek-link:hover { text-decoration: underline; }
@@ -848,20 +848,20 @@ onUnmounted(() => {
.peek-close {
background: none;
border: none;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.peek-close:hover { color: var(--color-text); }
.peek-close:hover { color: var(--fs-text-primary); }
.peek-title {
margin: 0;
padding: 0.75rem 0.75rem 0.4rem;
font-size: 1rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
flex-shrink: 0;
}
@@ -877,7 +877,7 @@ onUnmounted(() => {
flex: 1;
overflow-y: auto;
padding: 0 0.75rem 0.5rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.peek-prose {
@@ -887,7 +887,7 @@ onUnmounted(() => {
.peek-loading,
.peek-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
padding-top: 0.5rem;
}
@@ -901,7 +901,7 @@ onUnmounted(() => {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin-bottom: 0.4rem;
}
@@ -921,14 +921,14 @@ onUnmounted(() => {
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--color-text);
color: var(--fs-text-primary);
cursor: pointer;
padding: 0.25rem 0.4rem;
border-radius: 4px;
}
.peek-linked-item:hover {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
color: var(--fs-accent);
}
.peek-linked-type {
@@ -937,12 +937,12 @@ onUnmounted(() => {
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--color-bg);
border: 1px solid var(--color-border);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
</style>
+220 -60
View File
@@ -46,6 +46,50 @@ const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// ─── Near-duplicate report ────────────────────────────────────────────────────
// On demand, never automatic — a corpus-wide pairwise scan, and most visits to
// this page aren't a tidy-up (same reasoning as SnippetListView's report).
interface DupMember {
id: number; title: string;
created_at?: string | null; updated_at?: string | null;
task_kind?: string | null;
}
interface DupGroup {
note_ids: number[];
members: DupMember[];
top_score: number;
existing_supersessions?: { superseder_id: number; superseded_id: number }[];
}
const dupGroups = ref<DupGroup[]>([]);
const dupSuggestion = ref("");
const dupLoading = ref(false);
const dupChecked = ref(false);
// The report follows the type filter: viewing tasks checks tasks. Anything
// else (all / plan / process) checks notes — the kind with the most to find.
const dupKind = computed(() => (activeType.value === "task" ? "task" : "note"));
async function loadDuplicates() {
dupLoading.value = true;
try {
const data = await apiGet<{ groups: DupGroup[]; suggestion: string }>(
`/api/notes/duplicates?kind=${dupKind.value}`
);
dupGroups.value = data.groups;
dupSuggestion.value = data.suggestion;
dupChecked.value = true;
} catch {
dupChecked.value = false;
} finally {
dupLoading.value = false;
}
}
// A stale report is worse than none: switching the type filter changes which
// kind the button checks, so the old kind's groups must not linger under it.
watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
// ─── Type counts ──────────────────────────────────────────────────────────────
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
@@ -385,6 +429,51 @@ onUnmounted(() => {
<Share2 :size="16" />
Graph
</button>
<button
class="btn-ghost btn-compact"
:disabled="dupLoading"
title="Find notes or tasks already recorded that closely resemble each other — the report proposes, it never changes anything"
@click="loadDuplicates"
>
{{ dupLoading ? "Checking…" : "Find duplicates" }}
</button>
</div>
<!-- Near-duplicate report. A proposal surface only: unlike snippets
(which merge losslessly), notes are never merged the right fix is
supersession, extraction into a reference note, or leaving parallel
records alone, and choosing needs the records READ. That reading is
the assistant's job; this panel shows the human what exists. -->
<div v-if="dupChecked && !dupLoading" class="dup-panel">
<p v-if="!dupGroups.length" class="dup-empty">
No near-duplicate {{ dupKind }}s found — nothing recorded resembles
anything else closely enough to flag.
</p>
<template v-else>
<p class="dup-head">
{{ dupGroups.length }} possible duplicate
{{ dupGroups.length > 1 ? "sets" : "set" }} among your
{{ dupKind }}s. {{ dupSuggestion }}
</p>
<div v-for="(g, i) in dupGroups" :key="i" class="dup-group">
<div class="dup-members">
<router-link
v-for="m in g.members"
:key="m.id"
class="dup-member"
:to="dupKind === 'task' ? `/tasks/${m.id}` : `/notes/${m.id}`"
>
#{{ m.id }} {{ m.title }}
</router-link>
</div>
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
<span
v-if="g.existing_supersessions?.length"
class="dup-claimed"
title="A supersession has already been declared inside this set — it is not an open question"
>already ruled on</span>
</div>
</template>
</div>
<!-- Loading / empty -->
@@ -490,7 +579,7 @@ onUnmounted(() => {
.knowledge-root {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height, 56px));
height: calc(100vh - var(--fs-layout-header));
overflow: hidden;
}
@@ -501,8 +590,8 @@ onUnmounted(() => {
justify-content: space-between;
gap: 12px;
padding: 8px 20px;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
font-size: 0.82rem;
flex-wrap: wrap;
@@ -518,7 +607,7 @@ onUnmounted(() => {
font-size: 0.78rem;
}
.today-link {
color: var(--color-primary);
color: var(--fs-accent);
text-decoration: none;
font-weight: 500;
opacity: 0.85;
@@ -536,19 +625,19 @@ onUnmounted(() => {
/* ── Filter panel ────────────────────────────────────────── */
.filter-panel {
width: var(--sidebar-width);
width: var(--fs-layout-sidebar);
flex-shrink: 0;
padding: 16px 12px;
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-right: 1px solid var(--fs-border-color);
overflow-y: auto;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: rgba(91, 74, 138, 0.3);
color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
@@ -556,7 +645,7 @@ onUnmounted(() => {
.filter-label {
font-family: 'Fraunces', Georgia, serif;
font-size: 0.95rem;
color: var(--color-primary);
color: var(--fs-accent);
margin-bottom: 8px;
padding: 0 4px;
}
@@ -573,14 +662,14 @@ onUnmounted(() => {
padding: 8px 12px;
border-radius: 10px;
border: none;
background: var(--gradient-cta);
background: var(--fs-gradient-cta);
color: var(--fs-text-on-action);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: box-shadow 0.15s;
}
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
.btn-new-note:hover { box-shadow: var(--fs-glow-cta-hover); }
.btn-new-icon {
font-size: 1.1rem;
line-height: 1;
@@ -591,8 +680,8 @@ onUnmounted(() => {
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 10px;
overflow: hidden;
z-index: 50;
@@ -607,15 +696,15 @@ onUnmounted(() => {
padding: 9px 14px;
background: none;
border: none;
color: var(--color-text);
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.84rem;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.new-note-menu button:hover {
background: var(--color-primary-tint);
color: var(--color-primary);
background: var(--fs-accent-soft);
color: var(--fs-accent);
}
.new-note-menu button svg {
flex-shrink: 0;
@@ -623,7 +712,7 @@ onUnmounted(() => {
}
.new-note-menu button:hover svg {
opacity: 1;
stroke: var(--color-primary);
stroke: var(--fs-accent);
}
.filter-btn {
@@ -636,7 +725,7 @@ onUnmounted(() => {
border-radius: 7px;
border: none;
background: transparent;
color: var(--color-text);
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.85rem;
margin-bottom: 2px;
@@ -645,8 +734,8 @@ onUnmounted(() => {
}
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
.filter-btn.active {
background: var(--color-primary-wash);
color: var(--color-primary);
background: var(--fs-accent-wash);
color: var(--fs-accent);
opacity: 1;
}
.filter-btn-label { flex: 1; }
@@ -655,15 +744,15 @@ onUnmounted(() => {
padding: 1px 6px;
border-radius: 10px;
background: rgba(255,255,255,0.07);
color: var(--color-muted);
color: var(--fs-text-tertiary);
font-weight: 500;
min-width: 20px;
text-align: center;
flex-shrink: 0;
}
.filter-btn.active .filter-count {
background: rgba(91, 74, 138, 0.2);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
color: var(--fs-accent);
}
.filter-tag { font-size: 0.78rem; }
@@ -683,7 +772,7 @@ onUnmounted(() => {
gap: 10px;
padding: 12px 20px;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-bottom: 1px solid var(--fs-border-color);
}
.search-wrap {
flex: 1;
@@ -694,27 +783,27 @@ onUnmounted(() => {
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--color-muted);
color: var(--fs-text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 7px 12px 7px 32px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
color: var(--color-text);
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
font-size: 0.88rem;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--color-primary); }
.search-input:focus { border-color: var(--fs-accent); }
.sort-select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
color: var(--color-text);
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
font-size: 0.85rem;
cursor: pointer;
outline: none;
@@ -735,9 +824,9 @@ onUnmounted(() => {
.k-card {
position: relative;
background: var(--color-surface, rgba(255,255,255,0.03));
border: 1px solid var(--color-border, rgba(255,255,255,0.07));
border-radius: var(--radius-lg, 14px);
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
padding: 14px;
cursor: pointer;
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
@@ -749,12 +838,12 @@ onUnmounted(() => {
}
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(91, 74, 138, 0.35);
box-shadow: 0 8px 28px color-mix(in srgb, var(--fs-accent) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: color-mix(in srgb, var(--fs-accent) 35%, transparent);
}
/* Type-specific card DNA */
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
.k-card--note { border-color: color-mix(in srgb, var(--fs-accent) 20%, transparent); }
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
/* Top gradient bars */
@@ -769,7 +858,7 @@ onUnmounted(() => {
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
background: linear-gradient(90deg, var(--fs-accent), #7A6DA8);
}
.k-card--task::before {
right: 0;
@@ -788,7 +877,7 @@ onUnmounted(() => {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
@@ -805,7 +894,7 @@ onUnmounted(() => {
}
.k-card-snippet {
font-size: 0.8rem;
color: var(--color-muted);
color: var(--fs-text-tertiary);
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
@@ -825,9 +914,9 @@ onUnmounted(() => {
padding: 1px 6px;
border-radius: 8px;
background: rgba(255,255,255,0.05);
color: var(--color-muted);
color: var(--fs-text-tertiary);
}
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
.k-card-date { font-size: 0.72rem; color: var(--fs-text-secondary); white-space: nowrap; opacity: 0.7; }
/* Only rendered for a record another user owns, so an unmarked card is
unambiguously the viewer's own. */
.shared-tag {
@@ -835,8 +924,8 @@ onUnmounted(() => {
padding: 0.08rem 0.35rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
color: var(--fs-text-secondary);
}
/* ── Task card ──────────────────────────────────────────── */
@@ -856,10 +945,10 @@ onUnmounted(() => {
border-radius: 8px;
font-weight: 500;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); }
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); }
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
@@ -867,16 +956,16 @@ onUnmounted(() => {
border-radius: 8px;
font-weight: 500;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low); }
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.task-overdue {
color: var(--color-overdue);
color: var(--fs-overdue);
font-weight: 500;
}
@@ -888,7 +977,7 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--color-muted);
color: var(--fs-text-tertiary);
text-align: center;
gap: 6px;
}
@@ -896,7 +985,7 @@ onUnmounted(() => {
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-size: 1rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
opacity: 0.85;
}
@@ -910,17 +999,17 @@ onUnmounted(() => {
}
.sentinel-loading {
font-size: 0.8rem;
color: var(--color-muted);
color: var(--fs-text-tertiary);
}
/* ── Graph panel ─────────────────────────────────────────── */
.graph-panel {
width: 500px;
flex-shrink: 0;
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-left: 1px solid var(--fs-border-color);
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
transition: width 0.2s ease;
}
.graph-panel.expanded {
@@ -933,12 +1022,83 @@ onUnmounted(() => {
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 500;
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
`flex-shrink: 0`, so this is the item that takes the remaining height — and
without it the `height: 100%` below resolves against `auto` and does
nothing, which made the comment underneath a spec for a rule that could not
work. `min-height: 0` is the companion that lets a flex item shrink under
its content instead of overflowing the panel. */
.graph-embed {
flex: 1;
min-height: 0;
}
/* Override GraphView's 100vh height so it fills the panel instead */
.graph-embed :deep(.graph-page) {
height: 100%;
}
/* ── Near-duplicate report ──────────────────────────────────────────────────
Mirrors SnippetListView's panel so the two reports read as one feature.
Scoped styles can't be shared across SFCs; if a third view ever grows this
panel, promote the family to components.css and record it (#2464's rule:
two-or-more is when a recipe earns the shared sheet). */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty { margin-bottom: 0; }
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
color: var(--fs-text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.dup-member:hover { background: var(--fs-surface-hover); }
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
.dup-claimed {
font-size: 0.72rem;
color: var(--fs-text-tertiary);
border: 1px solid var(--fs-border-color);
border-radius: 4px;
padding: 0.05rem 0.4rem;
white-space: nowrap;
}
</style>
+7 -82
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter();
const route = useRoute();
@@ -30,12 +31,7 @@ async function handleSubmit() {
const redirect = (route.query.redirect as string) || "/";
router.push(redirect);
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Login failed";
} else {
error.value = "Login failed";
}
error.value = apiErrorMessage(e, "Login failed");
} finally {
submitting.value = false;
}
@@ -112,92 +108,21 @@ function loginWithOAuth() {
</main>
</template>
<style src="@/assets/auth-shared.css" />
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--color-primary);
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
border-top: 1px solid var(--color-border);
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
border-top: 1px solid var(--fs-border-color);
}
.forgot-link {
text-align: right;
@@ -205,9 +130,9 @@ function loginWithOAuth() {
font-size: 0.85rem;
}
.forgot-link a {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.forgot-link a:hover {
color: var(--color-primary);
color: var(--fs-accent);
}
</style>
+41 -49
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, watch } from "vue";
import { apiGet } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import PaginationBar from "@/components/PaginationBar.vue";
import { fmtLogStamp } from "@/utils/dateFormat";
const toastStore = useToastStore();
@@ -98,17 +99,6 @@ function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDetails(details: string | null): string {
if (!details) return "";
try {
@@ -210,7 +200,7 @@ function clearFilters() {
:class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)"
>
<td class="cell-time">{{ formatTime(entry.created_at) }}</td>
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }}
@@ -263,9 +253,9 @@ function clearFilters() {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
@@ -292,23 +282,23 @@ function clearFilters() {
.stat-count {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
color: var(--fs-text-primary);
}
.stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.stat-audit {
color: var(--color-primary);
color: var(--fs-accent);
}
.stat-usage {
color: var(--color-success);
color: var(--fs-success);
}
.stat-error {
color: var(--color-danger);
color: var(--fs-error);
}
/* Filters */
@@ -321,10 +311,10 @@ function clearFilters() {
.filter-input,
.filter-date {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.85rem;
}
.filter-select {
@@ -342,7 +332,7 @@ function clearFilters() {
.loading-msg,
.empty-msg {
text-align: center;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
@@ -356,13 +346,13 @@ function clearFilters() {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.logs-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.85rem;
}
.logs-table tbody tr:last-child td {
@@ -373,18 +363,18 @@ function clearFilters() {
transition: background 0.1s;
}
.log-row:hover {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.row-expanded {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.cell-time {
white-space: nowrap;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.8rem;
}
.cell-user {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.cell-action {
max-width: 280px;
@@ -399,22 +389,22 @@ function clearFilters() {
.cell-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
white-space: nowrap;
}
.cell-duration {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.8rem;
white-space: nowrap;
}
.detail-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin-bottom: 0.4rem;
}
.text-error {
color: var(--color-danger);
color: var(--fs-error);
}
/* Category badges */
@@ -425,19 +415,19 @@ function clearFilters() {
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
}
.cat-audit {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.cat-usage {
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--fs-success);
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
}
.cat-error {
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: var(--fs-error);
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
}
/* Method tag */
@@ -448,22 +438,24 @@ function clearFilters() {
font-family: monospace;
padding: 0.05rem 0.25rem;
border-radius: 3px;
background: var(--color-bg-secondary);
color: var(--color-text-muted);
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
margin-right: 0.25rem;
}
/* Detail row */
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
cells don't carry, and the row exists to scope the rule below (#2444). */
.detail-row td {
padding: 0 0.75rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.detail-json {
margin: 0;
padding: 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
+35 -35
View File
@@ -633,13 +633,13 @@ onUnmounted(() => assist.clearSelection());
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.editor-tabs {
display: inline-flex;
background: var(--color-bg);
border: 1px solid var(--color-border);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: 8px;
padding: 2px;
gap: 2px;
@@ -653,14 +653,14 @@ onUnmounted(() => assist.clearSelection());
padding: 0.22rem 0.75rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tab:hover { color: var(--color-text); }
.tab:hover { color: var(--fs-text-primary); }
.tab.active {
background: var(--color-surface);
color: var(--color-text);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
@@ -679,14 +679,14 @@ onUnmounted(() => assist.clearSelection());
.stream-label {
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.stream-preview {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--color-bg-card);
background: var(--fs-surface-raised);
min-height: 200px;
}
@@ -699,7 +699,7 @@ onUnmounted(() => assist.clearSelection());
.note-sidebar {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--color-border);
border-left: 1px solid var(--fs-border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -708,17 +708,17 @@ onUnmounted(() => assist.clearSelection());
.sb-select, .sb-input {
width: 100%;
padding: 5px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
color: var(--color-text);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
font-size: 0.82rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.sb-select:focus, .sb-input:focus {
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
/* Tag suggest row inside sidebar */
@@ -742,13 +742,13 @@ onUnmounted(() => assist.clearSelection());
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
background: none;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
border: 1px solid var(--fs-accent);
border-radius: var(--fs-radius-sm);
color: var(--fs-accent);
cursor: pointer;
font-family: inherit;
}
.btn-link-all:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
.btn-link-all:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
.link-suggest-list {
display: flex;
@@ -766,14 +766,14 @@ onUnmounted(() => assist.clearSelection());
.link-suggest-title {
flex: 1;
font-family: monospace;
color: var(--color-primary);
color: var(--fs-accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-suggest-count {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.72rem;
flex-shrink: 0;
}
@@ -783,13 +783,13 @@ onUnmounted(() => assist.clearSelection());
font-size: 0.72rem;
padding: 0.1rem 0.4rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-family: inherit;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-apply-link:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
/* Writing Assistant section */
.assist-section {
@@ -801,7 +801,7 @@ onUnmounted(() => assist.clearSelection());
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -810,19 +810,19 @@ onUnmounted(() => assist.clearSelection());
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-size: 0.92rem;
color: var(--color-primary);
color: var(--fs-accent);
}
.prompt-editor {
width: 100%;
min-height: 60vh;
margin-top: 8px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
/* Prompts are plain markdown — a code-style editor, not rich text. */
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-family: var(--fs-font-mono);
font-size: 0.88rem;
line-height: 1.55;
tab-size: 2;
@@ -830,7 +830,7 @@ onUnmounted(() => assist.clearSelection());
outline: none;
}
.prompt-editor:focus {
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
@@ -839,7 +839,7 @@ onUnmounted(() => assist.clearSelection());
.note-sidebar {
width: 100%;
border-left: none;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
overflow-y: visible;
}
}
+19 -19
View File
@@ -340,7 +340,7 @@ async function convertToTask() {
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.meta-item {
@@ -359,7 +359,7 @@ async function convertToTask() {
}
.backlinks {
margin-top: 2.5rem;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
padding-top: 1.25rem;
}
.backlinks-heading {
@@ -370,14 +370,14 @@ async function convertToTask() {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.4;
@@ -392,18 +392,18 @@ async function convertToTask() {
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
text-decoration: none;
color: var(--color-text);
color: var(--fs-text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
color: var(--color-primary);
color: var(--fs-accent);
}
.backlink-type-badge {
font-size: 0.68rem;
@@ -415,9 +415,9 @@ async function convertToTask() {
flex-shrink: 0;
}
.badge-note {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
}
.badge-task {
background: color-mix(in srgb, #f59e0b 12%, transparent);
@@ -446,12 +446,12 @@ async function convertToTask() {
.skel-title,
.skel-meta,
.skel-line {
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
@@ -463,7 +463,7 @@ async function convertToTask() {
}
.skel-btn { width: 70px; height: 32px; }
.skel-btn--wide { width: 90px; }
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--radius-md); }
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--fs-radius-lg); }
.skel-meta { height: 0.85rem; width: 40%; }
.skel-line { height: 0.9rem; }
.skel-line--short { width: 55%; }
+44 -90
View File
@@ -317,9 +317,9 @@ function overallPct(project: Project): { total: number; pct: number } {
<style scoped>
.projects-list {
max-width: var(--page-max-width);
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
@@ -340,7 +340,7 @@ function overallPct(project: Project): { total: number; pct: number } {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
padding-bottom: 0;
}
.tab-btn {
@@ -350,40 +350,40 @@ function overallPct(project: Project): { total: number; pct: number } {
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-family: inherit;
margin-bottom: -1px;
}
.tab-btn:hover {
color: var(--color-primary);
color: var(--fs-accent);
}
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
color: var(--fs-accent);
border-bottom-color: var(--fs-accent);
font-weight: 500;
}
.loading-msg,
.error-msg {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--color-danger);
color: var(--fs-error);
}
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
.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-title { font-size: 1rem; font-weight: 500; color: var(--fs-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-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); }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--fs-action-primary); border-radius: var(--fs-radius-sm); color: var(--fs-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
.empty-action:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
.skeleton-card {
height: 140px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
border-radius: var(--fs-radius-lg);
background: linear-gradient(90deg, var(--fs-surface-raised) 25%, var(--fs-border-color) 50%, var(--fs-surface-raised) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@@ -404,9 +404,9 @@ function overallPct(project: Project): { total: number; pct: number } {
}
.project-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1rem 1.1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
@@ -415,7 +415,7 @@ function overallPct(project: Project): { total: number; pct: number } {
gap: 0.5rem;
}
.project-card:hover {
border-color: var(--color-primary);
border-color: var(--fs-accent);
box-shadow: 0 2px 8px var(--color-shadow);
transform: translateY(-2px);
}
@@ -429,7 +429,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.project-title {
font-size: 1rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
min-width: 0;
flex: 1;
word-break: break-word;
@@ -446,32 +446,32 @@ function overallPct(project: Project): { total: number; pct: number } {
white-space: nowrap;
}
.status-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
color: var(--fs-success);
}
.status-completed {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
.status-archived {
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
}
.project-goal {
font-size: 0.875rem;
color: var(--color-text);
color: var(--fs-text-primary);
margin: 0;
line-height: 1.4;
}
.field-label {
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.project-desc {
font-size: 0.82rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
margin: 0;
line-height: 1.45;
}
@@ -486,18 +486,18 @@ function overallPct(project: Project): { total: number; pct: number } {
.overall-bar-track {
flex: 1;
height: 6px;
background: var(--color-border);
background: var(--fs-border-color);
border-radius: 999px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
background: var(--fs-accent);
transition: width 0.3s ease;
}
.overall-bar-pct {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
@@ -517,7 +517,7 @@ function overallPct(project: Project): { total: number; pct: number } {
font-size: 0.72rem;
}
.milestone-bar-label {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
min-width: 0;
flex: 0 0 30%;
overflow: hidden;
@@ -527,7 +527,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.milestone-bar-track {
flex: 1;
height: 5px;
background: var(--color-border);
background: var(--fs-border-color);
border-radius: 999px;
overflow: hidden;
}
@@ -537,7 +537,7 @@ function overallPct(project: Project): { total: number; pct: number } {
transition: width 0.3s ease;
}
.milestone-bar-pct {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
@@ -546,7 +546,7 @@ function overallPct(project: Project): { total: number; pct: number } {
/* 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);
color: var(--fs-text-tertiary);
font-size: var(--fs-size-tiny);
padding-top: 0.15rem;
}
@@ -556,27 +556,11 @@ function overallPct(project: Project): { total: number; pct: number } {
}
.meta-date {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
@@ -593,18 +577,18 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-field label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
}
.required {
color: var(--color-danger);
color: var(--fs-error);
}
.modal-input,
.modal-textarea {
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
@@ -613,41 +597,11 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-input:focus,
.modal-textarea:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
.modal-textarea {
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--color-bg);
}
.modal-btn-primary {
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;
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) {
.projects-grid {
File diff suppressed because it is too large Load Diff
+5 -109
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
@@ -68,12 +68,7 @@ async function handleSubmit() {
await authStore.checkAuth();
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
error.value = apiErrorMessage(e, "Registration failed");
} finally {
submitting.value = false;
}
@@ -85,9 +80,9 @@ async function handleSubmit() {
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
<div v-if="validating" class="loading-msg">Validating invitation...</div>
<div v-if="validating" class="auth-loading">Validating invitation...</div>
<div v-else-if="!token || !valid" class="error-block">
<div v-else-if="!token || !valid" class="auth-note">
<p>This invitation link is invalid or has expired.</p>
<p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link>
@@ -157,103 +152,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.95rem;
padding: 1rem 0;
}
.error-block {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.input-error {
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-danger);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
<style src="@/assets/auth-shared.css" />
+5 -104
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter();
const authStore = useAuthStore();
@@ -39,12 +40,7 @@ async function handleSubmit() {
await authStore.register(username.value, password.value, email.value || undefined);
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
error.value = apiErrorMessage(e, "Registration failed");
} finally {
submitting.value = false;
}
@@ -56,9 +52,9 @@ async function handleSubmit() {
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
<div v-if="checking" class="loading-msg">Checking registration status...</div>
<div v-if="checking" class="auth-loading">Checking registration status...</div>
<div v-else-if="!authStore.registrationOpen" class="closed-msg">
<div v-else-if="!authStore.registrationOpen" class="auth-note">
<p>Registration is currently closed.</p>
<p>Contact an administrator to get an account.</p>
<p class="auth-footer">
@@ -130,99 +126,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
.closed-msg {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.closed-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.input-error {
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-danger);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
<style src="@/assets/auth-shared.css" />
+5 -108
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
import { apiPost } from "@/api/client";
import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue";
const route = useRoute();
@@ -35,12 +35,7 @@ async function handleSubmit() {
});
success.value = true;
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Failed to reset password";
} else {
error.value = "Failed to reset password";
}
error.value = apiErrorMessage(e, "Failed to reset password");
} finally {
submitting.value = false;
}
@@ -52,7 +47,7 @@ async function handleSubmit() {
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div>
<div v-if="!token" class="error-block">
<div v-if="!token" class="auth-note">
<p>Invalid reset link. Please request a new password reset.</p>
<p class="auth-footer">
<router-link to="/forgot-password">Request new link</router-link>
@@ -94,7 +89,7 @@ async function handleSubmit() {
</form>
</template>
<div v-else class="success-msg">
<div v-else class="auth-note">
<p>Your password has been reset successfully.</p>
<p>You can now sign in with your new password.</p>
</div>
@@ -106,102 +101,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.error-block {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.success-msg {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.input-error {
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-danger);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
<style src="@/assets/auth-shared.css" />
+2 -2
View File
@@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute);
grid-template-columns: 280px 300px 1fr;
height: 100vh;
gap: 1px;
background: var(--color-border, #2a2a2e);
background: var(--fs-border-color);
}
.pane.empty {
background: var(--color-surface, #18181b);
background: var(--fs-surface-hover);
padding: 1rem;
opacity: 0.6;
font-style: italic;
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -106,11 +106,11 @@ onMounted(async () => {
font-size: 1.8rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
color: var(--fs-text-primary);
}
.loading-state {
color: var(--color-muted);
color: var(--fs-text-tertiary);
padding: 2rem;
text-align: center;
}
@@ -124,7 +124,7 @@ onMounted(async () => {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-muted);
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
@@ -136,9 +136,9 @@ onMounted(async () => {
.shared-card {
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
overflow: hidden;
text-decoration: none;
transition: box-shadow 0.15s, transform 0.15s;
@@ -151,7 +151,7 @@ onMounted(async () => {
.card-color-bar {
width: 4px;
flex-shrink: 0;
background: var(--color-border);
background: var(--fs-border-color);
}
.card-body {
@@ -163,7 +163,7 @@ onMounted(async () => {
.card-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--color-text);
color: var(--fs-text-primary);
margin-bottom: 0.3rem;
white-space: nowrap;
overflow: hidden;
@@ -179,12 +179,12 @@ onMounted(async () => {
.card-owner {
font-size: 0.78rem;
color: var(--color-muted);
color: var(--fs-text-tertiary);
}
.card-desc {
font-size: 0.82rem;
color: var(--color-muted);
color: var(--fs-text-tertiary);
margin: 0;
overflow: hidden;
display: -webkit-box;
@@ -203,25 +203,25 @@ onMounted(async () => {
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: 8px;
text-decoration: none;
transition: background 0.1s;
}
.shared-row:hover {
background: var(--color-hover);
background: var(--fs-surface-hover);
}
.row-icon {
font-size: 0.9rem;
flex-shrink: 0;
color: var(--color-muted);
color: var(--fs-text-tertiary);
}
.row-title {
flex: 1;
font-size: 0.9rem;
color: var(--color-text);
color: var(--fs-text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -229,7 +229,7 @@ onMounted(async () => {
}
.row-owner {
font-size: 0.78rem;
color: var(--color-muted);
color: var(--fs-text-tertiary);
white-space: nowrap;
}
@@ -242,12 +242,12 @@ onMounted(async () => {
border-radius: 4px;
white-space: nowrap;
}
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
.empty-msg {
color: var(--color-muted);
color: var(--fs-text-tertiary);
font-size: 0.88rem;
margin: 0;
padding: 1rem 0;
+33 -33
View File
@@ -205,7 +205,7 @@ async function confirmDelete() {
.snippet-detail {
max-width: 820px;
margin: 2rem auto;
padding: 0 var(--page-padding-x);
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
@@ -213,20 +213,20 @@ async function confirmDelete() {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--color-primary);
color: var(--fs-accent);
}
.state-msg {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--color-danger);
color: var(--fs-error);
font-size: 0.9rem;
margin-top: 1rem;
}
@@ -239,7 +239,7 @@ async function confirmDelete() {
}
.snippet-name {
margin: 0;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
font-size: 1.4rem;
word-break: break-word;
}
@@ -253,7 +253,7 @@ async function confirmDelete() {
.when-to-use {
margin: 0.75rem 0 1.25rem;
font-size: 1rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
line-height: 1.55;
}
@@ -262,12 +262,12 @@ async function confirmDelete() {
.shared-notice {
margin: 0.75rem 0 0;
padding: 0.6rem 0.85rem;
border-left: 3px solid var(--color-text-muted);
border-left: 3px solid var(--fs-text-tertiary);
border-radius: 6px;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.meta-grid {
@@ -280,23 +280,23 @@ async function confirmDelete() {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
padding-top: 0.15rem;
}
.meta-grid dd {
margin: 0;
font-size: 0.9rem;
color: var(--color-text);
color: var(--fs-text-primary);
min-width: 0;
}
.meta-grid code,
.tag-row + * code {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
font-size: 0.82rem;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent);
padding: 0.08rem 0.35rem;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
word-break: break-all;
}
.location-list {
@@ -317,7 +317,7 @@ async function confirmDelete() {
align-items: baseline;
}
.merged-hint {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.8rem;
}
.merged-entry {
@@ -328,15 +328,15 @@ async function confirmDelete() {
.unmerge-btn {
font-size: 0.72rem;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
cursor: pointer;
}
.unmerge-btn:hover:not(:disabled) {
color: var(--color-text);
border-color: var(--color-text-muted);
color: var(--fs-text-primary);
border-color: var(--fs-text-tertiary);
}
.unmerge-btn:disabled {
opacity: 0.6;
@@ -344,30 +344,30 @@ async function confirmDelete() {
}
.unmerge-na {
font-size: 0.72rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
/* Cursor cues that the explanation is in the tooltip. */
cursor: help;
}
.code-block {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
overflow: hidden;
background: var(--color-bg);
background: var(--fs-surface-page);
}
.code-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.5rem 0.4rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
}
.code-lang {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.btn-copy {
padding: 0.2rem 0.65rem;
@@ -379,10 +379,10 @@ async function confirmDelete() {
overflow-x: auto;
}
.code-block code {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
font-size: 0.85rem;
line-height: 1.6;
color: var(--color-text);
color: var(--fs-text-primary);
white-space: pre;
}
@@ -396,9 +396,9 @@ async function confirmDelete() {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
}
@media (max-width: 600px) {
+37 -37
View File
@@ -365,7 +365,7 @@ function cancel() {
.snippet-editor {
max-width: 760px;
margin: 2rem auto;
padding: 0 var(--page-padding-x);
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
.snippet-editor h1 {
@@ -376,19 +376,19 @@ function cancel() {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--color-primary);
color: var(--fs-accent);
}
.state-msg {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.9rem;
}
.error-msg {
color: var(--color-danger);
color: var(--fs-error);
font-size: 0.9rem;
}
@@ -415,27 +415,27 @@ function cancel() {
.location-set legend {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
}
.required {
color: var(--color-danger);
color: var(--fs-error);
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin: 0.1rem 0 0;
}
.hint-inline {
font-weight: 400;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
@@ -443,11 +443,11 @@ function cancel() {
}
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
.mono {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
}
.code-area {
resize: vertical;
@@ -459,8 +459,8 @@ function cancel() {
}
.location-set {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 0.85rem 1rem 1rem;
margin: 0;
}
@@ -477,32 +477,32 @@ function cancel() {
.loc-remove {
width: 2rem;
height: 2rem;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
background: transparent;
color: var(--color-text-muted);
border-radius: var(--radius-sm);
color: var(--fs-text-tertiary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.loc-remove:hover {
border-color: var(--color-danger);
color: var(--color-danger);
border-color: var(--fs-error);
color: var(--fs-error);
}
.loc-add {
margin-top: 0.15rem;
padding: 0.35rem 0.7rem;
border: 1px dashed var(--color-border);
border: 1px dashed var(--fs-border-color);
background: transparent;
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
}
.loc-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
border-color: var(--fs-accent);
color: var(--fs-accent);
}
@media (max-width: 600px) {
@@ -514,7 +514,7 @@ function cancel() {
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
}
.systems {
display: flex;
@@ -528,11 +528,11 @@ function cancel() {
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
color: var(--color-text);
color: var(--fs-text-primary);
cursor: pointer;
}
.system-opt input {
accent-color: var(--color-primary);
accent-color: var(--fs-accent);
cursor: pointer;
}
@@ -541,25 +541,25 @@ function cancel() {
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning, var(--color-primary));
border: 1px solid var(--fs-border-color);
border-left: 3px solid var(--fs-warning);
border-radius: 8px;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.duplicate-title {
margin: 0;
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
}
.duplicate-body {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.duplicate-body a {
color: var(--color-primary);
color: var(--fs-accent);
}
.duplicate-actions {
display: flex;
+73 -118
View File
@@ -519,9 +519,9 @@ function usageTitle(s: SnippetListItem): string {
<style scoped>
.snippets-list {
max-width: var(--page-max-width);
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
@@ -536,7 +536,7 @@ function usageTitle(s: SnippetListItem): string {
}
.page-sub {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
max-width: 60ch;
@@ -553,8 +553,8 @@ function usageTitle(s: SnippetListItem): string {
}
/* Filter is engaged — the accent marks "you are here", per the design system. */
.filter-on {
border-color: var(--color-primary);
color: var(--color-primary);
border-color: var(--fs-accent);
color: var(--fs-accent);
}
.location-row {
@@ -569,12 +569,12 @@ function usageTitle(s: SnippetListItem): string {
min-width: 0;
max-width: 12rem;
padding: 0.4rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.85rem;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
box-sizing: border-box;
}
.loc-input-wide {
@@ -583,46 +583,46 @@ function usageTitle(s: SnippetListItem): string {
}
.loc-input::placeholder {
font-family: inherit;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.loc-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
.loc-clear {
padding: 0.4rem 0.65rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
text-decoration: underline;
}
.loc-clear:hover {
color: var(--color-text);
color: var(--fs-text-primary);
}
.search-input {
width: 100%;
max-width: 420px;
padding: 0.5rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
.error-msg {
color: var(--color-danger);
color: var(--fs-error);
font-size: 0.9rem;
margin-top: 1rem;
}
@@ -630,10 +630,10 @@ function usageTitle(s: SnippetListItem): string {
.empty-state-rich {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.empty-icon {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.35;
@@ -641,7 +641,7 @@ function usageTitle(s: SnippetListItem): string {
.empty-title {
font-size: 1rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
margin: 0 0 0.35rem;
}
.empty-sub {
@@ -654,16 +654,16 @@ function usageTitle(s: SnippetListItem): string {
.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);
border: 1px solid var(--fs-action-primary);
border-radius: var(--fs-radius-sm);
color: var(--fs-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);
background: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
@@ -675,8 +675,8 @@ function usageTitle(s: SnippetListItem): string {
}
.skeleton-card {
height: 96px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
border-radius: var(--fs-radius-lg);
background: linear-gradient(90deg, var(--fs-surface-raised) 25%, var(--fs-border-color) 50%, var(--fs-surface-raised) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@@ -686,9 +686,9 @@ function usageTitle(s: SnippetListItem): string {
}
.snippet-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 0.9rem 1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
@@ -697,14 +697,14 @@ function usageTitle(s: SnippetListItem): string {
gap: 0.4rem;
}
.snippet-card:hover {
border-color: var(--color-primary);
border-color: var(--fs-accent);
box-shadow: 0 2px 8px var(--color-shadow);
transform: translateY(-2px);
}
.snippet-card:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
.card-header {
@@ -716,11 +716,11 @@ function usageTitle(s: SnippetListItem): string {
.snippet-name {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text);
color: var(--fs-text-primary);
min-width: 0;
flex: 1;
word-break: break-word;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
}
/* Language tag — accent pill per the design system's tag treatment. */
@@ -731,13 +731,13 @@ function usageTitle(s: SnippetListItem): string {
border-radius: 999px;
flex-shrink: 0;
white-space: nowrap;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
.snippet-when {
font-size: 0.83rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
margin: 0;
line-height: 1.45;
}
@@ -751,7 +751,7 @@ function usageTitle(s: SnippetListItem): string {
}
.meta-date {
font-size: 0.73rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* Marks a record someone else owns. Present only on shared rows, so an
unmarked card is unambiguously the operator's own. */
@@ -760,24 +760,24 @@ function usageTitle(s: SnippetListItem): string {
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
}
/* Near-duplicate report */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--color-surface-alt, var(--color-surface));
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.dup-empty {
@@ -790,7 +790,7 @@ function usageTitle(s: SnippetListItem): string {
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
@@ -805,14 +805,14 @@ function usageTitle(s: SnippetListItem): string {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
/* Long snippet names must not push the row into a horizontal scroll. */
overflow-wrap: anywhere;
}
.dup-score {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@@ -828,8 +828,8 @@ function usageTitle(s: SnippetListItem): string {
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
color: var(--color-danger, #b91c1c);
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
color: var(--fs-error);
}
.usage-tag {
@@ -838,15 +838,15 @@ function usageTitle(s: SnippetListItem): string {
border-radius: 4px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
}
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
color: var(--color-warning, #b45309);
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
color: var(--fs-warning);
}
/* Header + select-mode */
@@ -858,7 +858,7 @@ function usageTitle(s: SnippetListItem): string {
/* Selected card = 2px accent border per the design system (featured/active). */
.snippet-card.selected {
border-color: var(--color-primary);
border-color: var(--fs-accent);
border-width: 2px;
padding: calc(0.9rem - 1px) calc(1rem - 1px);
}
@@ -867,14 +867,14 @@ function usageTitle(s: SnippetListItem): string {
height: 16px;
flex-shrink: 0;
margin-top: 0.15rem;
border: 1px solid var(--color-border);
border: 1px solid var(--fs-border-color);
border-radius: 4px;
background: transparent;
transition: background 0.12s, border-color 0.12s;
}
.select-box.on {
background: var(--color-primary);
border-color: var(--color-primary);
background: var(--fs-accent);
border-color: var(--fs-accent);
}
.select-bar {
@@ -885,41 +885,26 @@ function usageTitle(s: SnippetListItem): string {
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
box-shadow: 0 4px 16px var(--color-shadow);
}
.select-count {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text);
color: var(--fs-text-primary);
}
.select-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
flex: 1;
min-width: 0;
}
/* Merge modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 460px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
@@ -931,7 +916,7 @@ function usageTitle(s: SnippetListItem): string {
.modal-desc {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
line-height: 1.5;
}
.merge-choices {
@@ -944,18 +929,18 @@ function usageTitle(s: SnippetListItem): string {
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
cursor: pointer;
}
.merge-choice.chosen {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
}
.merge-choice-name {
flex: 1;
min-width: 0;
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-family: var(--fs-font-mono);
font-size: 0.85rem;
word-break: break-word;
}
@@ -963,39 +948,9 @@ function usageTitle(s: SnippetListItem): string {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
flex-shrink: 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--color-bg);
}
.modal-btn-primary {
background: var(--color-action-primary);
border-color: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--color-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) {
.snippets-grid {
+40 -60
View File
@@ -41,7 +41,6 @@ const toast = useToastStore();
const title = ref("");
const body = ref("");
const description = ref("");
const consolidatedAt = ref<string | null>(null);
const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
@@ -303,7 +302,6 @@ onMounted(async () => {
title.value = store.currentTask.title;
body.value = store.currentTask.body;
description.value = store.currentTask.description ?? "";
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
tags.value = [...(store.currentTask.tags || [])];
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
@@ -832,7 +830,7 @@ useEditorGuards(dirty, save);
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
/* .task-main is a flex column; without flex-shrink: 0, long body content
@@ -854,7 +852,7 @@ useEditorGuards(dirty, save);
.task-sidebar {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--color-border);
border-left: 1px solid var(--fs-border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -868,21 +866,21 @@ useEditorGuards(dirty, save);
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
flex-shrink: 0;
}
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
.btn-clear-parent:hover { color: var(--fs-error); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
box-shadow: 0 4px 12px var(--color-shadow);
z-index: 50;
max-height: 200px;
@@ -892,21 +890,21 @@ useEditorGuards(dirty, save);
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text);
color: var(--fs-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
.parent-empty { color: var(--color-text-muted); cursor: default; }
.parent-dropdown-item:hover { background: var(--fs-surface-raised); }
.parent-empty { color: var(--fs-text-tertiary); cursor: default; }
.parent-empty:hover { background: none; }
/* Sub-tasks */
.subtasks-section {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.5rem 0.65rem;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.subtasks-header {
display: flex;
@@ -917,23 +915,23 @@ useEditorGuards(dirty, save);
.subtasks-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
.subtask-title {
font-size: 0.83rem;
color: var(--color-text);
color: var(--fs-text-primary);
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtask-title:hover { color: var(--color-primary); }
.subtask-title.done { text-decoration: line-through; color: var(--color-text-muted); }
.subtasks-empty { font-size: 0.78rem; color: var(--color-text-muted); margin: 0; }
.subtask-title:hover { color: var(--fs-accent); }
.subtask-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
.subtasks-empty { font-size: 0.78rem; color: var(--fs-text-tertiary); margin: 0; }
.subtask-add-row {
display: flex;
align-items: center;
@@ -943,19 +941,19 @@ useEditorGuards(dirty, save);
.subtask-input {
flex: 1;
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.83rem;
font-family: inherit;
}
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
.stream-preview {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--color-bg-card);
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
@@ -965,9 +963,9 @@ useEditorGuards(dirty, save);
/* Systems multi-select (in sidebar) */
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--color-text); cursor: pointer; }
.sb-system-opt input { accent-color: var(--color-primary); cursor: pointer; }
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--color-text-muted); }
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
.sb-system-opt input { accent-color: var(--fs-accent); cursor: pointer; }
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
/* Writing Assistant section (in sidebar) */
.assist-section {
@@ -978,7 +976,7 @@ useEditorGuards(dirty, save);
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -1009,11 +1007,11 @@ useEditorGuards(dirty, save);
font-size: 0.75rem;
}
.sb-ts-label {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
flex-shrink: 0;
}
.sb-ts-value {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
text-align: right;
}
@@ -1024,7 +1022,7 @@ useEditorGuards(dirty, save);
.task-sidebar {
width: 100%;
border-left: none;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
overflow-y: visible;
}
}
@@ -1037,13 +1035,13 @@ useEditorGuards(dirty, save);
margin: 0.5rem 0 0.25rem;
}
.task-goal-label {
font-family: var(--font-display, "Fraunces", serif);
font-family: var(--fs-font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
color: var(--fs-text-tertiary);
}
.task-goal-input {
width: 100%;
@@ -1053,32 +1051,14 @@ useEditorGuards(dirty, save);
font: inherit;
font-size: 0.95rem;
line-height: 1.4;
color: var(--color-text, inherit);
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-md, 8px);
color: var(--fs-text-primary);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.task-goal-input:focus {
outline: none;
border-color: var(--color-primary, #6366f1);
border-color: var(--fs-accent);
}
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
.auto-summary-banner-editor {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.7rem;
margin-bottom: 0.5rem;
font-size: 0.82rem;
font-style: italic;
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
background: rgba(99, 102, 241, 0.06);
border-left: 2px solid var(--color-primary, #6366f1);
border-radius: var(--radius-sm, 4px);
}
.auto-summary-banner-editor .auto-summary-icon {
color: var(--color-primary, #6366f1);
font-style: normal;
}
</style>
+42 -62
View File
@@ -365,13 +365,6 @@ const subTaskProgress = computed(() => {
<p class="goal-text">{{ store.currentTask.description }}</p>
</div>
<div
v-if="store.currentTask.consolidated_at"
class="auto-summary-banner"
>
<span class="auto-summary-icon" aria-hidden="true"></span>
Auto-summarized from work logs.
</div>
<div
class="body prose"
@@ -482,7 +475,7 @@ const subTaskProgress = computed(() => {
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.meta-item {
@@ -501,10 +494,10 @@ const subTaskProgress = computed(() => {
}
.due-date {
font-size: 0.85rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.due-date.overdue {
color: var(--color-overdue);
color: var(--fs-overdue);
font-weight: 500;
}
.task-meta-row {
@@ -515,10 +508,10 @@ const subTaskProgress = computed(() => {
}
.task-meta-item {
font-size: 0.78rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.task-meta-recurrence {
color: var(--color-primary);
color: var(--fs-accent);
font-weight: 500;
}
.tags {
@@ -531,7 +524,7 @@ const subTaskProgress = computed(() => {
/* Sub-tasks */
.subtasks {
margin-top: 2rem;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.subtasks-header {
@@ -547,21 +540,21 @@ const subTaskProgress = computed(() => {
}
.subtasks-progress {
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.subtasks-pct {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.subtasks-track {
height: 4px;
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
border-radius: 2px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.subtasks-fill {
height: 100%;
background: var(--color-status-done, #22c55e);
background: var(--fs-status-done);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -578,10 +571,10 @@ const subTaskProgress = computed(() => {
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
}
.subtask-row:hover {
background: var(--color-bg-secondary);
background: var(--fs-surface-raised);
}
.sub-dot {
flex-shrink: 0;
@@ -599,21 +592,21 @@ const subTaskProgress = computed(() => {
}
.dot-todo {
background: transparent;
border: 2px solid var(--color-text-muted);
border: 2px solid var(--fs-text-tertiary);
}
.dot-in-progress {
background: var(--color-status-in-progress, #3b82f6);
background: var(--fs-status-in-progress);
}
.dot-done {
background: var(--color-status-done, #22c55e);
background: var(--fs-status-done);
}
.dot-cancelled {
background: var(--color-text-muted, #6b7280);
background: var(--fs-text-tertiary);
}
.sub-title {
flex: 1;
font-size: 0.9rem;
color: var(--color-text);
color: var(--fs-text-primary);
text-decoration: none;
min-width: 0;
overflow: hidden;
@@ -621,21 +614,21 @@ const subTaskProgress = computed(() => {
white-space: nowrap;
}
.sub-title:hover {
color: var(--color-primary);
color: var(--fs-accent);
}
.sub-title.sub-done {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
text-decoration: line-through;
}
.sub-due {
font-size: 0.75rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
flex-shrink: 0;
}
.backlinks {
margin-top: 2.5rem;
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--fs-border-color);
padding-top: 1.25rem;
}
.backlinks-heading {
@@ -646,14 +639,14 @@ const subTaskProgress = computed(() => {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.4;
@@ -668,18 +661,18 @@ const subTaskProgress = computed(() => {
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
text-decoration: none;
color: var(--color-text);
color: var(--fs-text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: var(--color-primary);
color: var(--fs-accent);
}
.backlink-type-badge {
font-size: 0.68rem;
@@ -691,9 +684,9 @@ const subTaskProgress = computed(() => {
flex-shrink: 0;
}
.badge-note {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
}
.badge-task {
background: color-mix(in srgb, #f59e0b 12%, transparent);
@@ -723,12 +716,12 @@ const subTaskProgress = computed(() => {
.skel-meta,
.skel-badges,
.skel-line {
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
@@ -740,7 +733,7 @@ const subTaskProgress = computed(() => {
}
.skel-btn { width: 70px; height: 32px; }
.skel-btn--wide { width: 90px; }
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--radius-md); }
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--fs-radius-lg); }
.skel-meta { height: 0.85rem; width: 45%; }
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
.skel-line { height: 0.9rem; }
@@ -749,39 +742,26 @@ const subTaskProgress = computed(() => {
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
.task-goal-display {
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-left: 2px solid var(--fs-border-color);
padding: 0.4rem 0 0.4rem 0.9rem;
margin: 0.75rem 0 1.25rem;
background: rgba(255, 255, 255, 0.02);
}
.goal-label {
font-family: var(--font-display, "Fraunces", serif);
font-family: var(--fs-font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
color: var(--fs-text-tertiary);
margin: 0 0 0.25rem;
}
.goal-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.45;
color: var(--color-text, inherit);
color: var(--fs-text-primary);
white-space: pre-wrap;
}
.auto-summary-banner {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
font-style: italic;
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
margin: 0 0 0.75rem;
}
.auto-summary-icon {
color: var(--color-primary, #6366f1);
font-size: 0.85rem;
}
</style>
+3 -3
View File
@@ -68,7 +68,7 @@ onMounted(() => store.fetchTrash());
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
.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); }
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--fs-border-color); background: none; color: inherit; }
.btn-restore:hover { border-color: var(--fs-action-primary); color: var(--fs-action-primary); }
.btn-purge:hover { border-color: var(--fs-action-destructive); color: var(--fs-action-destructive); }
</style>
+36 -53
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth";
import { fmtDate } from "@/utils/dateFormat";
interface Invitation {
id: number;
@@ -69,12 +70,7 @@ async function sendInvite() {
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send invitation", "error");
} else {
toastStore.show("Failed to send invitation", "error");
}
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
} finally {
sendingInvite.value = false;
}
@@ -128,24 +124,11 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete user", "error");
} else {
toastStore.show("Failed to delete user", "error");
}
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
} finally {
deleting.value = null;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
@@ -212,8 +195,8 @@ function formatDate(iso: string): string {
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button
class="btn-ghost btn-compact"
@@ -255,7 +238,7 @@ function formatDate(iso: string): string {
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td>
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="cell-actions">
<template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span>
@@ -297,9 +280,9 @@ function formatDate(iso: string): string {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
@@ -317,16 +300,16 @@ function formatDate(iso: string): string {
.invite-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.invite-input:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--fs-accent);
}
.invite-list {
margin-top: 1rem;
@@ -334,7 +317,7 @@ function formatDate(iso: string): string {
.invite-list h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
/* Registration toggle */
@@ -352,33 +335,33 @@ function formatDate(iso: string): string {
font-size: 0.95rem;
}
.text-success {
color: var(--color-success);
color: var(--fs-success);
}
.text-muted {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
/* 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);
border: 1px solid var(--color-border);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color);
}
.btn-toggle-close:hover:not(:disabled) {
border-color: var(--color-warning);
color: var(--color-warning);
border-color: var(--fs-warning);
color: var(--fs-warning);
}
/* Users table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
@@ -392,13 +375,13 @@ function formatDate(iso: string): string {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
}
.users-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.9rem;
}
.users-table tbody tr:last-child td {
@@ -408,10 +391,10 @@ function formatDate(iso: string): string {
font-weight: 600;
}
.cell-email {
color: var(--color-text-secondary);
color: var(--fs-text-secondary);
}
.cell-date {
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
font-size: 0.85rem;
}
.cell-actions {
@@ -426,21 +409,21 @@ function formatDate(iso: string): string {
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
border-radius: var(--radius-sm);
border-radius: var(--fs-radius-sm);
}
.role-admin {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.role-user {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
}
/* Action buttons */
.you-label {
font-size: 0.8rem;
color: var(--color-text-muted);
color: var(--fs-text-tertiary);
}
@media (max-width: 768px) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.22",
"version": "0.1.37",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+3
View File
@@ -50,6 +50,9 @@ On install you'll be asked for:
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
`additionalContext` with **no** permission decision, so it can inform the write
but never stop it; silent when nothing is recorded, which is most of the time.
Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a
snippet records the exact file being edited — "updating the record is part of
the edit" — each with its own once-per-session dedup.
Toggle in **Settings → Knowledge auto-inject**.
- `skills/` → the universal process-skills, surfaced by description match.
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
+143 -26
View File
@@ -8,6 +8,18 @@
# that path or in its directory, plus snippets resembling the code about to be
# written. Titles + ids only, never bodies.
#
# The answer comes in two framings (#2708). A snippet recorded AT the exact
# file being edited is the SYNC class — "you are editing the recorded file;
# updating the record is part of the edit" — which is how records stay current
# on an instance with no forge connection (decision #2707). Everything else is
# the REUSE menu. The two dedup separately (see the state files below).
#
# It is also the shape ledger's write-path feed (#2791): it names the
# definitions being written (`shapes=`), and the server — only when the
# session has PULLED a snippet this code references or resembles — records
# them as instance rows, classified_by=hook. Evidence, not judgment; the
# context line says what landed so a wrong stamp is corrected in the moment.
#
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
# the write proceeds untouched and Claude sees the note beside the tool result.
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
@@ -79,34 +91,72 @@ fi
# 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.
#
# ALL code, not a language shortlist (#2682): the detector was born covering
# only the languages of the repo it was written in, which silently amputated
# this whole arm — and the record nudge gated on it — for every Go/Kotlin/Rust
# project. Definitions are announced by a small keyword family across
# languages (func/fun/fn/function/def/sub · class/struct/trait/interface/
# enum/object/protocol/type), so one modifier-strip + keyword match covers
# them all. Known out of scope: keyword-less declaration syntax (C/Java/Dart
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
# because several per type is normal Rust, not duplication.
# ---------------------------------------------------------------------------
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=""
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
# program, two consumers: the local duplicate arm (every definition in the
# payload) and the ledger feed (#2791, below: the definitions being written,
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition.
scribe_defs() {
awk '
{
# CSS class definition: .name { or .name,
if (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
}
line = $0; sub(/^[[:space:]]+/, "", line)
# Strip leading declaration modifiers so the definition keyword is the
# first word regardless of language (export/pub/private/suspend/...).
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
# Go method with receiver: func (r *T) Name(
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
sub(/[^A-Za-z0-9_].*$/, "", t)
if (t != "") print "sym\t" t; next
}
# Keyword-announced definitions, functions and named types alike.
# Dunders are skipped: every class defines __init__, so "already defined
# in N other files" is guaranteed noise for them — and noise is what
# teaches sessions to skip the hint.
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
}
# Arrow/expression assignment: const name = (…) / let name = async (
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
if (t != "") print "sym\t" t; next
}
}
' 2>/dev/null
}
names=""
if [ -n "$code" ]; then
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
fi
local_lines=""
if [ -n "$repo_root" ] && [ -n "$names" ]; then
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:]]*=" ;;
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
esac
# -I skips binaries; :(exclude) drops the file being written, which would
# otherwise always match itself on an Edit.
@@ -124,6 +174,38 @@ 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
# ---------------------------------------------------------------------------
# THE LEDGER FEED (#2791). The server keeps a shape ledger — every definition
# in the bound repo, classified against recorded canon — and this hook is the
# one place that sees a shape AT THE MOMENT IT IS WRITTEN. So it names the
# shapes in play: every definition in the payload, or — for an Edit that
# changes the inside of a function rather than its signature — the definition
# enclosing the edit, found by walking the target file upward from the edited
# lines. The server decides whether evidence exists (the session pulled a
# snippet this code references or resembles) and stamps instance rows; with
# no pulled canon in play, nothing is recorded. Titles only still — this sends
# names, not bodies.
# ---------------------------------------------------------------------------
shapes="$names"
if [ -z "$shapes" ] && [ -f "$file_path" ] && command -v tac >/dev/null 2>&1; then
old_first=$(printf '%s' "$event" \
| jq -r '.tool_input.old_string // .tool_input.old_str // empty' 2>/dev/null \
| grep -m1 -v '^[[:space:]]*$') || old_first=""
if [ -n "$old_first" ]; then
ln=$(grep -nF -m1 -- "$old_first" "$file_path" 2>/dev/null | cut -d: -f1) || ln=""
if [ -n "$ln" ]; then
shapes=$(head -n "$ln" "$file_path" | tac | scribe_defs | head -1) || shapes=""
fi
fi
fi
shapes_q=""
if [ -n "$shapes" ]; then
enc=$(printf '%s\n' "$shapes" \
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
| jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
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.
@@ -168,31 +250,66 @@ fi
# surface shows a given snippet at most once per session, but they don't silence
# each other: a title that flew past in a prompt menu twenty turns ago is
# exactly what should reappear at the moment the duplicate is being written.
#
# TWO channels, not one (#2708). The server answers in two classes — REUSE
# ("something similar/nearby is recorded") and SYNC ("a snippet records the
# exact file being edited — updating the record is part of the edit"). They
# dedup separately: a reuse hint shown early in the session must not suppress
# the sync nudge when the recorded file itself is edited later.
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
idfile=""
syncfile=""
exclude_q=""
sync_exclude_q=""
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
idfile="$state_dir/${safe_sid}.ids"
syncfile="$state_dir/${safe_sid}.sync.ids"
if [ -f "$idfile" ]; then
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
fi
if [ -f "$syncfile" ]; then
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
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) || body=""
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body=""
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
# Remember what was surfaced so it isn't shown again this session — each
# class into its own channel: sync ids (snippets recording the edited file)
# to the sync file, everything else to the reuse file.
if [ -n "$context" ]; then
if [ -n "$idfile" ]; then
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
fi
if [ -n "$syncfile" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
fi
fi
fi
# ARM 1½ — the RECORD nudge (#2664). The local arm just proved the thing being
# written already exists elsewhere in this repo, and Scribe returned no record
# of anything for it. That is the one moment "record it" is earned rather than
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
# an ordinary new helper (no other copies) and an already-recorded one (the
# server spoke) stay nudge-free — a reflex that fires on everything is one
# that gets skipped. An unreachable server counts as "nothing recorded": the
# local finding needed no server, and the nudge fails open with it.
if [ -n "$local_lines" ]; then
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy."
fi
fi
+25
View File
@@ -61,6 +61,31 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
# --- Which version is actually RUNNING (keyless, networkless) ---
#
# An install has two halves and only one self-updates:
#
# marketplaces/…/scribe-plugin/ git clone — pulls on its own
# cache/…/scribe/<version>/ what EXECUTES — refreshed only when the
# manifest version changes
#
# So inspecting the clone shows a fix present while the broken copy keeps
# running, and the obvious debugging move actively misleads (#2209). Twice, the
# only detector was the operator saying "I don't think it updated".
#
# Naming the running version in every session makes that answerable from the
# transcript instead of by archaeology in the cache directory. Deliberately NOT
# a server round-trip or a stored per-user record: the state most needing
# diagnosis is the one where credentials never arrive, and this line still
# appears there.
manifest="$here/../.claude-plugin/plugin.json"
if [ -f "$manifest" ]; then
plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
if [ -n "$plugin_version" ]; then
append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
fi
fi
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
+50 -5
View File
@@ -37,11 +37,36 @@ for the operator's work, and as your own working memory across sessions.
moment it's complete. When you **fix** something — even in passing — record it
as its own issue (`create_task(kind="issue")`), not as a work-log line on an
unrelated open task.
- **Reuse before rebuilding** — before writing a new helper/utility/component,
search recorded **snippets** (reusable code recorded once for recall) and
reuse the prior art instead of re-solving it; when you build something
reusable, record it with `create_snippet` (name, code, when-to-reach-for-it,
location) so a later session is offered it, not left to write it again.
- **Tag to Systems as you write** — `enter_project` lists the project's
Systems (its named subsystems/areas). When you create or meaningfully update
a record, ask which areas it is about and pass `system_ids`; if an area has
no System yet, create it with `create_system` (name + a one-paragraph
charter) rather than leaving it unmodelled. Cross-cutting records — audits,
sweeps, reviews — take SEVERAL tags, and are the best moment to DISCOVER
missing Systems: a pass that walks the subsystems has just enumerated the
vocabulary, so mint what it names. Create liberally; the duplicate gate on
`create_system` (and reviewing the existing list) is the guardrail against
sprawl, not restraint. Every read and write of a project record shows its
`systems` — that is the "am I in a System's territory?" signal, and
`list_system_records` reads that territory's whole pile before you work in
it. An untagged project record carries the `systems_hint` question instead,
on creates, updates, and work-logs alike — treat it as the tagging question
asked at the moment of work, not as noise to skip past.
- **The pattern library: start from recorded shapes, and record every shape
at first build** — recorded **snippets** are the project's pattern library,
not a dedup net. Before building ANY shape — a button, an input field, a
modal, a route handler, a service class, a test scaffold, up through complex
subsystem patterns — search snippets and START from the recorded shape; a
deliberate departure is recorded as its own named variant, never left as
silent drift. And the FIRST time a shape is built, record it with
`create_snippet` (name, when-to-reach-for-it, location, code) in the same
breath — do not judge whether it "might recur": the builder of the first
instance can never know, and a missed record is invisible until it
resurfaces as an uninformed duplicate. A mature project's snippet corpus
should read as a map of every shape in it. The backstop still holds:
noticing the second copy of anything, or consolidating copies into a shared
X, means X gets recorded before that work is finished — which is how a
codebase is kept from growing four `.btn-primary` definitions.
- Do **not** keep the operator's rules, plans, or project notes in local
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
- **Compact at clean seams** — because you record as you go, a context
@@ -51,5 +76,25 @@ for the operator's work, and as your own working memory across sessions.
`/compact` (name what you logged). You can't run it yourself — surface the
recommendation and let them decide. Suggest it at seams, not every turn.
**How the instruction surfaces divide the work:** this file carries the
session-level reflexes (WHEN to reach for Scribe); each tool's own description
carries its full contract (HOW to call it — read it when you load the tool);
the bundled skills carry process arcs (planning, debugging, verification). The
MCP server's instruction block is deliberately only a map — the client injects
roughly its first 2,000 characters and silently cuts the rest, so nothing
load-bearing lives below that fold.
**If two Scribe instruction surfaces disagree** — this file, the MCP server's
tool instructions, the `using-scribe` skill — **follow the one that assumes
least about its own delivery.** This file is the floor: it ships with the
plugin and needs no API key and no network, so it still applies in exactly the
session where the others never arrived. The others may elaborate on what is
written here; they must not contradict it. Weigh a disagreement by which way it
fails, not by which surface said more: doing something a push would also have
covered costs one redundant call, while skipping it because you expected a push
that never came means working without the operator's rules and not knowing.
A contradiction between surfaces is a defect in the product — say so, so it
gets recorded and fixed rather than silently arbitrated again next session.
If the Scribe tools are unavailable, say so rather than silently falling back
to local notes.
+52 -15
View File
@@ -1,20 +1,24 @@
---
name: reusing-code
description: Use when you're about to write a helper, utility, hook, or reusable component — search recorded snippets FIRST so prior art is reused instead of re-solved. And the moment you build or notice something reusable, record it as a snippet so a later session finds it. Triggers on "write a util/helper", "I need a function that…", "let me add a component", or just having built something worth reusing.
description: Use when you're about to build ANY shape — a component, control, route handler, service class, helper, test scaffold — search recorded snippets FIRST and start from the recorded shape instead of re-solving it. And the FIRST time a shape is built, record it as a snippet so every later instance starts from it. Triggers on "write a util/helper", "I need a function that…", "let me add a component/button/field/route", or having just built the first instance of anything.
---
# Reusing code — recall before you rebuild
# Reusing code — the pattern library
Reusable code is worth writing once. Scribe stores **snippets** — a named,
reusable function or component recorded with its language, signature, canonical
location (repo · path · symbol), a one-line *"when to reach for it,"* and the
code itself — so prior art can surface *before* it's re-written as a one-off.
Snippets are the project's **pattern library**, not a dedup net. Each records a
named shape — with its language, signature, canonical location (repo · path ·
symbol), a one-line *"when to reach for it,"* and the code — so every later
instance STARTS from the recorded shape: buttons start from the button shape,
fields from the field shape, and "special" is a deliberate, named exception
rather than drift. A mature project's snippet corpus reads as a map of every
shape in it, from the humblest control to the most complex subsystem pattern.
Snippets are ordinary embedded notes, so a recorded one also surfaces on its own
through recall/auto-inject; this skill is the active reflex around that.
## Before you write a new helper — search first
## Before you build any shape — search first
- About to write a utility, hook, formatter, adapter, or a reusable component?
- About to build a component, control, route handler, service class, utility,
hook, formatter, adapter, or test scaffold?
**Search snippets before writing it.** `list_snippets(q="…")` (or a plain
`search`) — a matching one may already exist, in this project or another.
`list_snippets` searches every project by default; that's deliberate, since a
@@ -30,6 +34,9 @@ through recall/auto-inject; this skill is the active reflex around that.
to ask both at once.
- If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its
`location` points at the reference implementation. Adapt, don't re-derive.
The pull also does the accounting: the code you then write that references
or resembles it is stamped an `instance` of that canon in the shape ledger
(classified_by=hook) — reuse from memory leaves no row.
- If auto-inject already surfaced a snippet title that looks relevant, that's
your cue to `get_snippet` it rather than start from scratch.
- **Prior art offered beside a write is not noise — read it.** When Scribe notes
@@ -37,11 +44,24 @@ through recall/auto-inject; this skill is the active reflex around that.
it before you go any further. Either it's the helper you were about to
duplicate — reuse it and drop yours — or it isn't, and the record needs the new
location adding. Both are cheaper now than after the duplicate settles in.
- **A `[records this file]` hint is a duty, not a menu.** When the hint says a
snippet records the very file you're editing, the record's freshness is now
YOUR edit's responsibility: if the edit changes the recorded shape,
`update_snippet(id, code=…)` with the new form as part of the same task; if
it doesn't, `verify_snippet(id, status="ok", commit_sha=…)` costs one call
and re-stamps the record as checked. Scribe never reads the repo — this
moment, in the session that has the context, is the only place the record
gets kept true.
## The moment you build something reusable — record it
## The first time a shape is built — record it
- Just wrote (or noticed) a helper, hook, pattern, or component worth repeating?
Record it with `create_snippet` while it's fresh:
- Just built the FIRST instance of anything with a shape — a component, a
field, a route, a service pattern, a scaffold? Record it with
`create_snippet` while it's fresh. Do **not** stop to judge whether it will
recur: the builder of the first instance can never know, and a missed record
is invisible until it resurfaces as an uninformed duplicate. Over-recording
is safe — dead weight shows up in the usage counters and can be pruned;
under-recording has no signal at all. The record is cheap — these fields:
- **name** — what it's called, e.g. `useDebouncedRef`.
- **code** — the implementation.
- **when_to_use** — one sharp line on when to reach for it. This becomes part
@@ -90,9 +110,26 @@ The result is a single entry that shows every place the thing is used — which
exactly the signal that it was worth consolidating. This is the cure the create
gate only hints at when it blocks a near-duplicate.
## Consumer maps are rows, never prose
Every enumerated relationship between code and canon belongs in the shape
ledger, not in a sentence. When you establish that call sites route through a
canonical helper — during an audit, a verify pass, or a consolidation —
record each consuming definition with
`classify_shapes(project_id, [{path, symbol, status: "instance", snippet_id}])`.
A deliberate departure is a `"variant"` (reason required — the why IS the
record); a judged one-off is `"exempt"` (reason required). Prose in a
verification detail cannot be sorted, queried, or diffed; rows are what make
"what uses this?" answerable forever. `list_shapes(project_id,
status="unclassified")` is the standing todo — and N same-shaped occurrences
matching no canon means derive one first (consolidate, `create_snippet`,
then classify the rest against it), never N loose classifications.
## Why this pays off
A one-off written a second time is the cost this avoids. Recording a snippet
once — with a location and a crisp "when to use" — means the next session is
offered the prior art instead of re-solving it. Search before writing; record
what's worth reusing.
A one-off written a second time is the cost this avoids — and at project
scale, the cost is an application whose buttons, fields, and services each
exist in four diverging shapes. Recording every shape once — with a location
and a crisp "when to use" — means every later session starts from the pattern
library instead of re-deriving it. Search before building; record every shape
at first build.

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