Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf55fc488 | ||
|
|
fefae606ed | ||
|
|
5795fa908a | ||
|
|
be3a0ffaf9 | ||
|
|
da6bb815bb | ||
|
|
5f8b824523 | ||
|
|
3fc693443e | ||
|
|
3d6931b838 | ||
|
|
c7cf07824a | ||
|
|
67fdf7c55b | ||
|
|
37616682f0 | ||
|
|
97b93bcaea | ||
|
|
f491b6d7b9 | ||
|
|
1a959b1db0 | ||
|
|
6d01788326 | ||
|
|
17d59fa3e0 | ||
|
|
6a6a388ecd | ||
|
|
8288c6e4a7 | ||
|
|
2cc9e1380e | ||
|
|
84541f392b | ||
|
|
da2383b079 | ||
|
|
f8522fb28f | ||
|
|
5c51e29f26 | ||
|
|
4c9a637507 | ||
|
|
731ca284c3 | ||
|
|
1e139d0d18 | ||
|
|
6eedb0f6b9 | ||
|
|
378a4b8f99 | ||
|
|
716f227bc7 | ||
|
|
67a529a38e | ||
|
|
473280e690 | ||
|
|
d1d335e293 | ||
|
|
1fde646c60 | ||
|
|
7872e7d9ec | ||
|
|
23a385e2db | ||
|
|
15eae532bd | ||
|
|
8eef9e7845 | ||
|
|
f00e9747ad | ||
|
|
15d2e0c682 | ||
|
|
0f80b790c7 | ||
|
|
46d88f9e7e | ||
|
|
b0a7d9e89b | ||
|
|
4dc57f8ab2 | ||
|
|
3da40abcb8 | ||
|
|
78489308b8 | ||
|
|
0937b1761e | ||
|
|
143b968c5d | ||
|
|
839d6902ad | ||
|
|
03b3998585 | ||
|
|
4ca3ab02c4 | ||
|
|
d3ee24f239 | ||
|
|
3c0192d749 | ||
|
|
61e6e38419 | ||
|
|
293a14361a | ||
|
|
1adf57739d | ||
|
|
6ca215d2b6 | ||
|
|
390846a3d5 | ||
|
|
a36837c96a | ||
|
|
57781770c3 | ||
|
|
0550bf4687 | ||
|
|
1fb883b72f | ||
|
|
05b64ccacb | ||
|
|
327b8a99a4 | ||
|
|
b0a0bf8abd | ||
|
|
dd878bc498 | ||
|
|
1feef179d2 | ||
|
|
51e5c22818 | ||
|
|
f039a46dae | ||
|
|
21cb9ee537 | ||
|
|
3284ac67dd | ||
|
|
fe63f3985b | ||
|
|
6db791965f | ||
|
|
84c5c0dc81 | ||
|
|
35f3f09d12 | ||
|
|
2b85443dd1 | ||
|
|
c569cdd0eb | ||
|
|
6153231f9c | ||
|
|
1b81310847 | ||
|
|
a47e1b9c4e | ||
|
|
8bab0c762b | ||
|
|
ef7ebddadf | ||
|
|
d5d0b012b1 | ||
|
|
aa850ac1e1 | ||
|
|
ca94c332ee | ||
|
|
e0328f2b1c | ||
|
|
083944f0fd | ||
|
|
dd1b5e5ddb | ||
|
|
0d396de215 | ||
|
|
9fa474b3c4 | ||
|
|
8977bed28d | ||
|
|
fc9c8119a2 | ||
|
|
cca40affe4 | ||
|
|
a8f152b7d4 | ||
|
|
4b5d9005fd | ||
|
|
3ffdbbc521 | ||
|
|
e9cd3435ad | ||
|
|
ef1dbdfc86 | ||
|
|
8b069cc93f | ||
|
|
04b58ce01e | ||
|
|
b7d6fc7e5d | ||
|
|
b33e2a79c6 | ||
|
|
7a81b7333e | ||
|
|
85625de394 | ||
|
|
eb400a521b | ||
|
|
d257c0fd67 | ||
|
|
0ea3bff797 | ||
|
|
1942913366 |
+100
-20
@@ -40,11 +40,23 @@ on:
|
||||
- "frontend/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
# The lock now determines what gets installed, so a lock-only change has
|
||||
# to trigger a run — otherwise a dependency bump lands untested.
|
||||
- "uv.lock"
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
# The plugin ships straight from this repo — installs fetch it via
|
||||
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
|
||||
# the release, with no build step in between. Omitting these paths meant
|
||||
# plugin changes triggered no workflow at all, which is how #2198's three
|
||||
# broken hooks and then #2209's missing version bump both reached a live
|
||||
# install. See the `plugin` job below.
|
||||
- "plugin/**"
|
||||
- ".claude-plugin/**"
|
||||
- "scripts/check_plugin.py"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# Manual trigger from the Forgejo Actions UI. Useful when an image has
|
||||
# been built but the deployment didn't pick it up, or when re-running
|
||||
@@ -99,6 +111,48 @@ jobs:
|
||||
run: npx vue-tsc --noEmit
|
||||
working-directory: frontend
|
||||
|
||||
# Guards the one part of this repo that ships to users without a build step.
|
||||
# See scripts/check_plugin.py for what it checks and, as importantly, what it
|
||||
# can't check yet (shellcheck and jq are absent from ci-python).
|
||||
plugin:
|
||||
name: Plugin hooks
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
# Bare `uses:`, no `with:` block. Adding one made this action fail to
|
||||
# extract on the act_runner ("Cannot find module .../dist/index.js") while
|
||||
# every bare checkout in the same run succeeded — see run 3027. Nothing
|
||||
# here needs `fetch-depth: 0` anyway: the version check diffs two trees,
|
||||
# and a tree diff needs both trees, not a common ancestor. A depth-1 fetch
|
||||
# of main's tip is enough, and cheaper.
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Per-job, not in the image, per CI-runner's docs/process.md: "If only one
|
||||
# project needs the dep, prefer that project installing it per-job in
|
||||
# their workflow — at least until a second consumer arrives." Scribe is
|
||||
# the only consumer today. Promotion into ci-python is filed as an issue
|
||||
# on CI-runner rather than assumed here.
|
||||
#
|
||||
# jq is not optional for the smoke test: every hook exits at line 1
|
||||
# without it, so the check would pass while exercising nothing.
|
||||
- name: Install shell tooling
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends jq shellcheck
|
||||
|
||||
# On main the comparison would be against itself, so only the syntax and
|
||||
# pattern checks mean anything there.
|
||||
- name: Check plugin hooks and manifest
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
python3 scripts/check_plugin.py --no-version
|
||||
else
|
||||
git fetch --no-tags --depth=1 origin main:refs/remotes/origin/main
|
||||
python3 scripts/check_plugin.py
|
||||
fi
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -111,7 +165,18 @@ jobs:
|
||||
# ruff is pre-installed in the ci-python image — no install
|
||||
# step needed, lint runs in ~2s.
|
||||
- name: Lint
|
||||
run: ruff check src/
|
||||
run: ruff check src/ scripts/
|
||||
|
||||
# Design tokens: does the frontend's CSS agree with the stylesheet the
|
||||
# design system generates? Fails only on an unresolvable var() reference —
|
||||
# that count is at zero, so this is a ratchet rather than a backlog. The
|
||||
# literal findings are printed, not gated; hundreds exist and a
|
||||
# permanently-red job is one nobody reads.
|
||||
#
|
||||
# Stdlib only, no install, no network: the source of truth is theme.css,
|
||||
# which is generated from the design system and committed.
|
||||
- name: Design token check
|
||||
run: python3 scripts/check_design_tokens.py --report-literals
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
@@ -126,20 +191,30 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('pyproject.toml') }}
|
||||
# Keyed on the LOCK, not pyproject: the lock is what determines the
|
||||
# installed set now, and a pyproject edit that doesn't change
|
||||
# resolution shouldn't throw the cache away.
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: uv-
|
||||
|
||||
- name: Create virtual environment
|
||||
run: uv venv /opt/venv
|
||||
|
||||
- name: Install package with dev deps
|
||||
run: |
|
||||
# http-ece doesn't declare setuptools as a build dep, and uv
|
||||
# creates bare venvs without it. Install setuptools first so
|
||||
# --no-build-isolation can find it.
|
||||
uv pip install --python /opt/venv/bin/python setuptools wheel
|
||||
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
|
||||
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
# Installs exactly what uv.lock pins, and resolves nothing itself.
|
||||
#
|
||||
# This replaced `uv pip install -e ".[dev]"`, which resolved from the
|
||||
# pyproject constraints and ignored the lock entirely. Every dependency
|
||||
# floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main`
|
||||
# red with no repo change (issue #2194). Green CI has to mean "these exact
|
||||
# versions passed", or it isn't evidence of anything.
|
||||
#
|
||||
# `--locked` also FAILS when uv.lock is stale against pyproject, so a
|
||||
# dependency edit has to go through a deliberate `uv lock` — it can't
|
||||
# arrive on its own. That check earned its place immediately: it caught
|
||||
# that the lock had been missing `pgvector` entirely (added to pyproject,
|
||||
# never re-locked), which the old install path had been silently papering
|
||||
# over by resolving from pyproject instead.
|
||||
- name: Install locked dependencies
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
|
||||
- name: Run tests
|
||||
# Integration tests (real Postgres) run in the `integration` job below.
|
||||
@@ -179,13 +254,12 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Create virtual environment
|
||||
run: uv venv /opt/venv
|
||||
- name: Install package with dev deps
|
||||
run: |
|
||||
uv pip install --python /opt/venv/bin/python setuptools wheel
|
||||
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
|
||||
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
# Same locked install as the unit lane — the two must agree on versions,
|
||||
# or "unit green, integration red" stops being a signal about the code.
|
||||
- name: Install locked dependencies
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
- name: Integration suite (resolve service IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -216,6 +290,12 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
# `plugin` is deliberately NOT in needs. The plugin isn't in the image —
|
||||
# installs fetch it from git — so gating the server image on a hook lint
|
||||
# would couple two things that don't ship together, and blocking the build
|
||||
# wouldn't un-publish a bad hook anyway: the push already did that. A failed
|
||||
# `plugin` job still turns the whole run red, which is the signal that
|
||||
# matters.
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
||||
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
||||
|
||||
+20
-3
@@ -12,10 +12,27 @@ RUN npm run build
|
||||
FROM python:3.14-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
# Installed from uv.lock, exactly like CI (issue #2194). This used to be
|
||||
# `COPY pyproject.toml .` + `pip install .`, which never even copied the lock:
|
||||
# the shipped image resolved its own dependency set, so CI could be green on one
|
||||
# set of versions while the published image ran another. On 2026-07-28 that
|
||||
# class of drift turned `main` red when mcp 2.0.0 shipped mid-session.
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install .
|
||||
pip install --no-cache-dir uv
|
||||
|
||||
# Dependencies before source, so the expensive layer is cached on every build
|
||||
# that doesn't change the lock.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev --no-install-project
|
||||
|
||||
COPY src/ src/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# uv sync installs into a project venv rather than the system interpreter, so
|
||||
# alembic and hypercorn in CMD have to be found there.
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY --from=build-frontend /build/dist/ src/scribe/static/
|
||||
COPY alembic.ini .
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""add notes.data JSONB — queryable structured fields for typed records
|
||||
|
||||
Revision ID: 0070
|
||||
Revises: 0069
|
||||
Create Date: 2026-07-26
|
||||
|
||||
Snippets (note_type='snippet') carry structured fields — name, language,
|
||||
signature, and a list of canonical locations (repo · path · symbol). Those were
|
||||
stored as a markdown body-convention, which reads well and feeds the embedding
|
||||
but cannot be QUERIED: answering "which snippets live in this file?" meant
|
||||
scanning every snippet and regexing its body.
|
||||
|
||||
This adds a general `data` JSONB column plus a GIN index, so those fields become
|
||||
indexable. The body stays exactly as it was — it is still the human-readable
|
||||
form and still what gets embedded. `data` is a queryable mirror of the same
|
||||
facts, not a replacement, and the code itself is deliberately NOT copied into it
|
||||
(the body already holds it; duplicating a blob to index fields around it would
|
||||
be waste).
|
||||
|
||||
Relationship to 0069: that migration DROPPED `notes.metadata`, a JSONB column
|
||||
which only ever held person/place/list entity fields, when those surfaces were
|
||||
removed. This is not a revival of it — different name, different purpose, and
|
||||
nothing reads the old shape. The column is named `data` rather than `metadata`
|
||||
because `metadata` collides with SQLAlchemy's declarative `Base.metadata`, which
|
||||
is why the old model had to map an awkward `entity_metadata` attribute onto it.
|
||||
|
||||
Nullable with no backfill, deliberately: rows written before this migration keep
|
||||
working because the service falls back to parsing the body when `data` is
|
||||
absent. That means no migration deadline and no risk of a backfill mangling a
|
||||
hand-edited body.
|
||||
|
||||
Downgrade drops the index and the column. Any structured fields it held remain
|
||||
recoverable from the body convention, which is the same source they mirror.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0070"
|
||||
down_revision = "0069"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("data", JSONB, nullable=True))
|
||||
# GIN supports containment (`data @> '{"locations":[{"repo":"x"}]}'`), which
|
||||
# covers exact repo/path/symbol and language lookups. Path PREFIX matching
|
||||
# ("everything under frontend/src/") is not an index-served operation here
|
||||
# and still filters after the fact — acceptable while snippet counts are
|
||||
# small, and a generated column is the escape hatch if that changes.
|
||||
op.create_index(
|
||||
"ix_notes_data_gin", "notes", ["data"], postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notes_data_gin", table_name="notes")
|
||||
op.drop_column("notes", "data")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""add note_usage_events — did anyone actually open what we surfaced?
|
||||
|
||||
Revision ID: 0071
|
||||
Revises: 0070
|
||||
Create Date: 2026-07-28
|
||||
|
||||
`retrieval_logs` records what the ranker returned and with what scores, which is
|
||||
the right substrate for tuning a similarity threshold. It cannot answer the
|
||||
different question the snippet corpus needs: was a surfaced snippet ever pulled
|
||||
in full? A snippet nobody opens still competes for the injection budget on every
|
||||
turn, so the surfaced:pulled ratio is what makes dead weight visible.
|
||||
|
||||
Two reasons this is its own table rather than columns on `notes` or rows in
|
||||
`retrieval_logs`:
|
||||
|
||||
- Counters on `notes` would answer "how many" but not "when, from where, and
|
||||
by which arm" — and the place arm vs semantic arm comparison is precisely
|
||||
what was missing (the write-path place arm surfaced snippets while leaving
|
||||
no trace anywhere).
|
||||
- Folding un-scored surfacing into `retrieval_logs` would corrupt the score
|
||||
distribution that table exists to capture. Location hits have no score.
|
||||
|
||||
Grain is one row per note per event, which is what the per-snippet readout needs
|
||||
and what `retrieval_logs.result_ids` (a JSONB array, one row per *call*) cannot
|
||||
be indexed at.
|
||||
|
||||
FK-free on note_id and user_id, matching retrieval_logs and app_logs: telemetry
|
||||
should outlive what it describes. Deleting a note must not erase the evidence
|
||||
that it was surfaced forty times and opened none.
|
||||
|
||||
Downgrade drops the table outright. The data is purely observational — nothing
|
||||
reads it for correctness, so losing it costs history and no behavior.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0071"
|
||||
down_revision = "0070"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"note_usage_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("note_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.Text(), nullable=False),
|
||||
)
|
||||
# Every readout is "these note ids, split by event", so the composite is the
|
||||
# one that actually gets used; the others serve pruning and per-user views.
|
||||
op.create_index(
|
||||
"ix_note_usage_note_event", "note_usage_events", ["note_id", "event"]
|
||||
)
|
||||
op.create_index("ix_note_usage_created_at", "note_usage_events", ["created_at"])
|
||||
op.create_index("ix_note_usage_user_id", "note_usage_events", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_note_usage_user_id", table_name="note_usage_events")
|
||||
op.drop_index("ix_note_usage_created_at", table_name="note_usage_events")
|
||||
op.drop_index("ix_note_usage_note_event", table_name="note_usage_events")
|
||||
op.drop_table("note_usage_events")
|
||||
@@ -0,0 +1,138 @@
|
||||
"""design systems + tokens, and the project pointer
|
||||
|
||||
Revision ID: 0072
|
||||
Revises: 0071
|
||||
Create Date: 2026-07-30
|
||||
|
||||
Makes the design system a first-class record instead of prose in a rulebook: a
|
||||
named set of tokens with an optional parent, so a family system holds the house
|
||||
style and an app system holds only what it changes.
|
||||
|
||||
`design_systems.parent_id` is the whole model. It replaces both an `always_on`
|
||||
flag (a family system is one with no parent) and a subscription join table (a
|
||||
project points at ONE system, and the chain supplies the rest), which is less
|
||||
schema than the rulebook shape it mirrors.
|
||||
|
||||
Two deliberate choices worth stating here rather than leaving to be re-derived:
|
||||
|
||||
- **`design_tokens.value_by_mode` is JSONB keyed by mode**, not `value_light` +
|
||||
`value_dark` columns. In a child system an unset mode means "inherit"; in a
|
||||
root it would mean "not mode-dependent", and as columns both are NULL and
|
||||
indistinguishable. As a map, resolution is a dict merge at every level with
|
||||
no special case for roots — and a third mode (high-contrast, print) is data
|
||||
rather than a schema change. The cost is that a typo'd mode key is not
|
||||
rejected by the database. Nothing filters tokens by value in SQL, so the
|
||||
queryability the columns would have bought is for a query no caller makes.
|
||||
(Named `value_by_mode` rather than `values`, which is reserved in SQL.)
|
||||
- **`group_name` is free text, not a CHECK enum.** Groupings are each design
|
||||
system's own vocabulary; a whitelist would bake one install's kit into the
|
||||
schema. No CHECK is introduced anywhere in this migration.
|
||||
|
||||
`parent_id` and `projects.design_system_id` are both ON DELETE SET NULL. Deleting
|
||||
a family system must orphan its children into roots that still hold their own
|
||||
overrides, not cascade away every app system that inherited from it; deleting a
|
||||
system a project points at must unstyle that project, not delete it.
|
||||
|
||||
Downgrade drops both tables and the column. Any design system defined this way
|
||||
is lost — this is the migration that introduces the concept, so there is no
|
||||
earlier representation to fall back to.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0072"
|
||||
down_revision = "0071"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"design_systems",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"owner_user_id", sa.BigInteger(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"parent_id", sa.BigInteger(),
|
||||
sa.ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_design_systems_owner_user_id", "design_systems", ["owner_user_id"]
|
||||
)
|
||||
op.create_index("ix_design_systems_parent_id", "design_systems", ["parent_id"])
|
||||
|
||||
op.create_table(
|
||||
"design_tokens",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"design_system_id", sa.BigInteger(),
|
||||
sa.ForeignKey("design_systems.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
# NOT NULL with a '{}' default: a nullable JSONB column has two empty
|
||||
# states (SQL NULL and JSON null) and every reader has to test for both.
|
||||
sa.Column(
|
||||
"value_by_mode", JSONB,
|
||||
nullable=False, server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column("group_name", sa.Text(), nullable=True),
|
||||
sa.Column("purpose", sa.Text(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_design_tokens_design_system_id", "design_tokens", ["design_system_id"]
|
||||
)
|
||||
# Partial unique: a name is unique among LIVE tokens in a system. Two live
|
||||
# rows with the same name are a duplicate definition and the cascade would
|
||||
# pick between them arbitrarily; a trashed row must not block reusing its
|
||||
# name.
|
||||
op.create_index(
|
||||
"uq_token_per_design_system", "design_tokens", ["design_system_id", "name"],
|
||||
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"projects", sa.Column("design_system_id", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_projects_design_system_id", "projects", "design_systems",
|
||||
["design_system_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_projects_design_system_id", "projects", type_="foreignkey")
|
||||
op.drop_column("projects", "design_system_id")
|
||||
op.drop_index("uq_token_per_design_system", table_name="design_tokens")
|
||||
op.drop_index("ix_design_tokens_design_system_id", table_name="design_tokens")
|
||||
op.drop_table("design_tokens")
|
||||
op.drop_index("ix_design_systems_parent_id", table_name="design_systems")
|
||||
op.drop_index("ix_design_systems_owner_user_id", table_name="design_systems")
|
||||
op.drop_table("design_systems")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""design_tokens.supersedes — the literals a token should be used instead of
|
||||
|
||||
Revision ID: 0073
|
||||
Revises: 0072
|
||||
Create Date: 2026-07-30
|
||||
|
||||
Records what a prohibition was actually trying to say.
|
||||
|
||||
A design rulebook writes "pure white #FFFFFF is NEVER used as text color". That
|
||||
sentence has no row in a table of tokens, because a design system stores what
|
||||
things ARE — which looked like a gap in the model and was really a sentence
|
||||
written backwards. The positive fact is "text is Parchment", and the useful
|
||||
record is the mapping from the literal someone would otherwise write to the
|
||||
token they should write instead.
|
||||
|
||||
So `supersedes` is a JSONB array of literal values, e.g. `["#fff", "#ffffff"]`
|
||||
on a text-on-action token. A finding built from it can say what to write, not
|
||||
merely what not to.
|
||||
|
||||
It must be DECLARED rather than derived. `#fff` and Parchment `#E8E4D8` are
|
||||
different colours, so no value-matching rule could ever have connected them —
|
||||
which is exactly why the prohibition felt unrepresentable until it was turned
|
||||
around.
|
||||
|
||||
Consumed by the source lint that reads component CSS, not by the drift panel:
|
||||
these literals are in the components, which the panel cannot see.
|
||||
|
||||
NOT NULL with a `'[]'` default, matching `value_by_mode` — a nullable JSONB
|
||||
column has two empty states and every reader has to test for both.
|
||||
|
||||
Downgrade drops the column; the declarations are lost, which costs the lint its
|
||||
input and nothing else.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0073"
|
||||
down_revision = "0072"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"design_tokens",
|
||||
sa.Column(
|
||||
"supersedes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("design_tokens", "supersedes")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""central prose on a design system, and rationale on a token
|
||||
|
||||
Revision ID: 0074
|
||||
Revises: 0073
|
||||
Create Date: 2026-07-31
|
||||
|
||||
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
|
||||
on the system. Two fields rather than one per category:
|
||||
|
||||
design_systems.guidance the narrative a token table cannot hold — aesthetic,
|
||||
voice and tone, what is deliberately out of scope.
|
||||
Markdown, free-form.
|
||||
design_tokens.rationale WHY this token is this value, which is a different
|
||||
question from `purpose` (what it is FOR). "Success
|
||||
equals Moss, aligned by design" is a rationale;
|
||||
"page bg, deepest surface" is a purpose.
|
||||
|
||||
Free-form rather than a column per category on purpose. A schema with `voice`,
|
||||
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
|
||||
every install (rule #115), and the next install's design system would have three
|
||||
empty columns and nowhere to put what it actually cares about.
|
||||
|
||||
Both nullable: a design system with no prose at all is complete, not a draft.
|
||||
|
||||
Downgrade drops both columns and the prose with them.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0074"
|
||||
down_revision = "0073"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("design_systems", sa.Column("guidance", sa.Text(), nullable=True))
|
||||
op.add_column("design_tokens", sa.Column("rationale", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("design_tokens", "rationale")
|
||||
op.drop_column("design_systems", "guidance")
|
||||
+15
-11
@@ -9,8 +9,9 @@
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
```
|
||||
|
||||
Used by all four jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
||||
lint (ruff), test (pytest), build (docker buildx).
|
||||
Used by all six jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
||||
plugin (hook checks), lint (ruff), test (pytest), integration (pytest +
|
||||
real Postgres), build (docker buildx).
|
||||
|
||||
## Image deps used
|
||||
|
||||
@@ -18,22 +19,25 @@ lint (ruff), test (pytest), build (docker buildx).
|
||||
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
|
||||
frontend builder stage inside the production `Dockerfile`)
|
||||
- ruff (lint job runs `ruff check src/` with zero install overhead)
|
||||
- uv (test + integration jobs run `uv sync --locked`; installed in the
|
||||
image since the ci-python Dockerfile started pip-installing it)
|
||||
- docker CLI + buildx (build job pushes the production image to the
|
||||
Forgejo registry)
|
||||
Fabled-Git registry)
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
Anything CI installs at job time that isn't in the image. Promotion
|
||||
candidates if more than one project needs them.
|
||||
|
||||
- `uv` — installed inline in the test job (`curl -LsSf
|
||||
https://astral.sh/uv/install.sh | sh`). **Temporary**: belongs in the
|
||||
ci-python image so every consumer doesn't re-install on cold start.
|
||||
Tracked at [CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner).
|
||||
- `http-ece` is `--no-build-isolation`-installed before the editable
|
||||
package install because http-ece doesn't declare `setuptools` as a
|
||||
build dep and uv creates bare venvs without it. Not promotion-worthy
|
||||
(one project, one wheel).
|
||||
- `jq` + `shellcheck` — apt-installed in the **plugin** job, which lints
|
||||
the four Claude Code hook scripts and runs their fail-open smoke test.
|
||||
Per `docs/process.md`'s decision checkpoint, single-consumer deps stay
|
||||
per-job until a second consumer wants them; Scribe is the only one so
|
||||
far. Both are small (jq ~1 MB, shellcheck ~20 MB) and would be
|
||||
promotion candidates the moment another project lints shell.
|
||||
**jq is load-bearing for the smoke test specifically**: every hook
|
||||
starts with `command -v jq || exit 0`, so without it the test passes
|
||||
while exercising nothing.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ onUnmounted(() => {
|
||||
z-index: 9999;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 0 0 4px 4px;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiGet } from "@/api/client";
|
||||
import type { ExpectationResponse } from "@/utils/designDrift";
|
||||
|
||||
/** Checkable claims from the rulebook this install designated as its design system.
|
||||
*
|
||||
* `rulebook_id: null` means none has been designated — the normal state for a
|
||||
* fresh install, not an error. The caller shows an explanatory empty state. */
|
||||
export const fetchDesignExpectations = () =>
|
||||
apiGet<ExpectationResponse>("/api/design/expectations");
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Design systems — the stylesheet held as records (milestone #254).
|
||||
*
|
||||
* A design system is a named set of tokens with an optional parent. A system
|
||||
* with no parent is a "family"; one with a parent holds ONLY what it changes,
|
||||
* so "what does this app alter?" is a plain list rather than a diff.
|
||||
*/
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
|
||||
|
||||
export interface DesignSystem {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
|
||||
guidance: string;
|
||||
parent_id: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** A token as STORED — one system's own row for it. */
|
||||
export interface DesignToken {
|
||||
id: number;
|
||||
design_system_id: number;
|
||||
name: string;
|
||||
/** Values keyed by mode. `base` applies when no mode is more specific. */
|
||||
value_by_mode: Record<string, string>;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
/** WHY it is this value — distinct from `purpose`, which is what it is FOR. */
|
||||
rationale: string | null;
|
||||
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
|
||||
*
|
||||
* How a design system records what a prohibition was trying to say: not
|
||||
* "white is banned" but "write this token instead". Declared rather than
|
||||
* inferred, because a superseded literal and the token's own value are
|
||||
* usually different values and nothing could connect them by matching. */
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface Contribution {
|
||||
system_id: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A token after the cascade.
|
||||
*
|
||||
* `contributions` is every system that offered a value, per mode, DEEPEST
|
||||
* FIRST — entry 0 won and the rest were shadowed. `value_by_mode` and
|
||||
* `origin_by_mode` are the winners, provided so the client never has to derive
|
||||
* them (and so it cannot derive them differently).
|
||||
*
|
||||
* Provenance is per MODE because overriding is: a system can own `base` and
|
||||
* inherit `dark` at the same time.
|
||||
*/
|
||||
export interface ResolvedToken {
|
||||
name: string;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
rationale: string | null;
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
value_by_mode: Record<string, string>;
|
||||
origin_by_mode: Record<string, number>;
|
||||
contributions: Record<string, Contribution[]>;
|
||||
}
|
||||
|
||||
export const fetchDesignSystems = () =>
|
||||
apiGet<{ design_systems: DesignSystem[] }>("/api/design-systems");
|
||||
|
||||
export const fetchDesignSystem = (id: number) =>
|
||||
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
||||
|
||||
export const createDesignSystem = (body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||
|
||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||
export const updateDesignSystem = (
|
||||
id: number,
|
||||
body: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
},
|
||||
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
|
||||
|
||||
export const deleteDesignSystem = (id: number) =>
|
||||
apiDelete(`/api/design-systems/${id}`);
|
||||
|
||||
/** The EFFECTIVE set: everything inherited, with this system's on top. */
|
||||
export const fetchResolvedTokens = (id: number) =>
|
||||
apiGet<{ design_system_id: number; tokens: ResolvedToken[] }>(
|
||||
`/api/design-systems/${id}/resolved`,
|
||||
);
|
||||
|
||||
/** This system's OWN tokens — its override set. */
|
||||
export const fetchDesignTokens = (id: number) =>
|
||||
apiGet<{ tokens: DesignToken[] }>(`/api/design-systems/${id}/tokens`);
|
||||
|
||||
export const createDesignToken = (
|
||||
designSystemId: number,
|
||||
body: {
|
||||
name: string;
|
||||
value_by_mode?: Record<string, string>;
|
||||
group_name?: string | null;
|
||||
purpose?: string | null;
|
||||
rationale?: string | null;
|
||||
supersedes?: string[];
|
||||
order_index?: number;
|
||||
},
|
||||
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
|
||||
|
||||
export const updateDesignToken = (
|
||||
tokenId: number,
|
||||
body: Partial<Omit<DesignToken, "id" | "design_system_id">>,
|
||||
) => apiPatch<DesignToken>(`/api/design-tokens/${tokenId}`, body);
|
||||
|
||||
export const deleteDesignToken = (tokenId: number) =>
|
||||
apiDelete(`/api/design-tokens/${tokenId}`);
|
||||
|
||||
/** Point a project at a design system. `null` clears it. */
|
||||
export const setProjectDesignSystem = (
|
||||
projectId: number,
|
||||
designSystemId: number | null,
|
||||
) =>
|
||||
apiPut<{ project_id: number; design_system_id: number | null }>(
|
||||
`/api/projects/${projectId}/design-system`,
|
||||
{ design_system_id: designSystemId },
|
||||
);
|
||||
|
||||
export interface StylesheetResult {
|
||||
design_system_id: number;
|
||||
/** The master sheet: purpose tokens only, no element or class rules. */
|
||||
css: string;
|
||||
token_count: number;
|
||||
/** Tokens the system names but has no value for yet. */
|
||||
valueless: string[];
|
||||
/** Values declared under more than one name — alias, or one idea twice. */
|
||||
duplicates: Record<string, string[]>;
|
||||
derivation: {
|
||||
/** Tokens computed from others, mapped to what they're computed from. */
|
||||
derived: Record<string, string[]>;
|
||||
/** Formulas pointing at tokens that don't exist — the browser drops these. */
|
||||
unknown_refs: Record<string, string[]>;
|
||||
/** Derivation loops, which resolve to nothing for the same reason. */
|
||||
cycles: string[][];
|
||||
};
|
||||
}
|
||||
|
||||
/** The master CSS sheet a design system generates.
|
||||
*
|
||||
* Purpose tokens only. Components (buttons, tables, input schemes) are
|
||||
* snippets that reference these names, so a value is stated once and reused
|
||||
* rather than restated per element. */
|
||||
export const fetchStylesheet = (id: number) =>
|
||||
apiGet<StylesheetResult>(`/api/design-systems/${id}/stylesheet`);
|
||||
|
||||
export interface SnippetFinding {
|
||||
snippet_id: number;
|
||||
title: string;
|
||||
/** References that resolve against the sheet. */
|
||||
used: string[];
|
||||
/** `var(--x)` where the system has no `--x` — renders as nothing at all. */
|
||||
unknown: string[];
|
||||
/** Literals the sheet says to stop writing, paired with what to write. */
|
||||
superseded_literals: { literal: string; use_instead: string }[];
|
||||
/** Custom properties the snippet mints for itself instead of reusing. */
|
||||
local_definitions: string[];
|
||||
}
|
||||
|
||||
export interface SnippetCheck {
|
||||
design_system_id: number;
|
||||
checked: number;
|
||||
/** Only snippets with something to act on; clean ones are omitted. */
|
||||
findings: SnippetFinding[];
|
||||
}
|
||||
|
||||
/** Which recorded snippets disagree with this design system's sheet. */
|
||||
export const checkSnippets = (id: number) =>
|
||||
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
||||
@@ -0,0 +1,212 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** One canonical location of a reusable thing. A snippet that unifies several
|
||||
* one-offs carries several — one per call site. */
|
||||
export interface SnippetLocation {
|
||||
repo: string;
|
||||
path: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
/** Structured fields parsed out of a snippet note (mirrors the backend
|
||||
* `parse_snippet_fields` — see services/snippets.py). `repo`/`path`/`symbol`
|
||||
* mirror the first location for back-compat; `locations` is the full list. */
|
||||
export interface SnippetFields {
|
||||
name: string;
|
||||
when_to_use: string;
|
||||
signature: string;
|
||||
language: string;
|
||||
repo: string;
|
||||
path: string;
|
||||
symbol: string;
|
||||
locations: SnippetLocation[];
|
||||
/** The snippets folded into this one by merge, oldest first. Read-only: merge
|
||||
* is the only thing that adds to it, and an edit carries it forward.
|
||||
*
|
||||
* Each entry records what THAT source contributed — never what the survivor
|
||||
* already had — which is what lets un-merge subtract exactly. An entry with
|
||||
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
|
||||
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
|
||||
* as returned by the backend `snippet_to_dict`. */
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
note_type: string;
|
||||
project_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
snippet: SnippetFields;
|
||||
systems?: { id: number; name: string }[];
|
||||
/** Set when another user owns this record; `owner` is their username and
|
||||
* `permission` your access level on it. */
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** How often a record was put in front of an agent versus actually opened.
|
||||
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
||||
* slot in every future auto-inject menu while never being used. */
|
||||
export interface SnippetUsage {
|
||||
surfaced_count: number;
|
||||
pull_count: number;
|
||||
last_surfaced_at: string | null;
|
||||
last_pulled_at: string | null;
|
||||
}
|
||||
|
||||
/** Result of the last drift check — does the recorded location and code still
|
||||
* match source? The check runs agent-side (Scribe has no checkout); this is the
|
||||
* remembered verdict. `current` is false once the snippet has been edited since
|
||||
* the check, at which point the verdict describes code that's no longer there. */
|
||||
export interface SnippetVerification {
|
||||
status: "ok" | "missing" | "moved" | "changed" | "unverified";
|
||||
current: boolean;
|
||||
checked_at: string | null;
|
||||
detail?: string | null;
|
||||
path?: string | null;
|
||||
needs_attention?: boolean;
|
||||
}
|
||||
|
||||
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
||||
* field here is a truncated *body preview* (the knowledge feed's naming), not
|
||||
* the parsed fields above. */
|
||||
export interface SnippetListItem {
|
||||
id: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
note_type: string;
|
||||
snippet: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Present only when the record belongs to someone else — a suggestion from
|
||||
* them, not one of your own. Absent means it's yours. */
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
/** The recorded language, when one was given. Projected from the `data`
|
||||
* mirror so lists can show it without parsing the body — and so a prior-art
|
||||
* hit can be flagged as being in a DIFFERENT language than the file being
|
||||
* written, which is a shape to adapt rather than code to paste. */
|
||||
language?: string;
|
||||
/** Always present from the backend, zero-filled for records with no events. */
|
||||
usage?: SnippetUsage;
|
||||
/** Present on the detail record; the list feed carries it when a check has
|
||||
* been recorded. */
|
||||
verification?: SnippetVerification;
|
||||
}
|
||||
|
||||
/** Create/update payload — discrete fields the backend serializes into the
|
||||
* note (title/body/tags). Empty strings are applied; omitted keys are left
|
||||
* unchanged on update. */
|
||||
export interface SnippetInput {
|
||||
name: string;
|
||||
code: string;
|
||||
language?: string;
|
||||
signature?: string;
|
||||
when_to_use?: string;
|
||||
locations?: SnippetLocation[];
|
||||
tags?: string[];
|
||||
project_id?: number | null;
|
||||
system_ids?: number[];
|
||||
/** Create only: record it even though a near-duplicate already exists. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** `repo`/`path`/`symbol` are the reverse lookup — "what already lives here?"
|
||||
* They must all match the same recorded location; `path` also matches anything
|
||||
* beneath it. Combinable with `q`. */
|
||||
export async function listSnippets(
|
||||
params: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
projectId?: number | null;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
symbol?: string;
|
||||
/** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */
|
||||
verification?: string;
|
||||
} = {},
|
||||
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q) qs.set("q", params.q);
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
if (params.projectId) qs.set("project_id", String(params.projectId));
|
||||
if (params.repo) qs.set("repo", params.repo);
|
||||
if (params.path) qs.set("path", params.path);
|
||||
if (params.symbol) qs.set("symbol", params.symbol);
|
||||
if (params.verification) qs.set("verification", params.verification);
|
||||
const query = qs.toString();
|
||||
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function getSnippet(id: number): Promise<Snippet> {
|
||||
return apiGet(`/api/snippets/${id}`);
|
||||
}
|
||||
|
||||
export async function createSnippet(data: SnippetInput): Promise<Snippet> {
|
||||
return apiPost("/api/snippets", data);
|
||||
}
|
||||
|
||||
export async function updateSnippet(
|
||||
id: number,
|
||||
data: Partial<SnippetInput>,
|
||||
): Promise<Snippet> {
|
||||
return apiPatch(`/api/snippets/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteSnippet(id: number): Promise<void> {
|
||||
return apiDelete(`/api/snippets/${id}`);
|
||||
}
|
||||
|
||||
/** A set of snippets that resemble each other closely enough to be worth
|
||||
* merging. Grouping is transitive, so a set can hold members that don't
|
||||
* directly resemble each other — read it as a proposal, not a verdict. */
|
||||
export interface DuplicateGroup {
|
||||
note_ids: number[];
|
||||
snippets: { id: number; title: string }[];
|
||||
/** The strongest resemblance within the set — how confident the suggestion is. */
|
||||
top_score: number;
|
||||
}
|
||||
|
||||
/** Near-duplicates already in the record. The create gate prevents new ones and
|
||||
* merge cures the ones you point it at; this is what finds them. */
|
||||
export async function findDuplicateSnippets(
|
||||
threshold?: number,
|
||||
): Promise<{ groups: DuplicateGroup[]; threshold: number }> {
|
||||
const qs = threshold ? `?threshold=${threshold}` : "";
|
||||
return apiGet(`/api/snippets/duplicates${qs}`);
|
||||
}
|
||||
|
||||
/** Record a drift-check verdict. The check itself runs where the code is — an
|
||||
* agent with the working tree — since Scribe has no checkout. This stores what
|
||||
* was found, and is how the UI clears a stale marker after a manual fix. */
|
||||
export async function verifySnippet(
|
||||
id: number,
|
||||
verdict: { status: string; detail?: string; path?: string },
|
||||
): Promise<Snippet> {
|
||||
return apiPost(`/api/snippets/${id}/verify`, verdict);
|
||||
}
|
||||
|
||||
/** Pull one source back out of a merged survivor: restores it and strips exactly
|
||||
* what it contributed. Also repairs a half-undone merge — a source restored
|
||||
* from the trash by hand leaves the survivor still claiming its call sites. */
|
||||
export async function unmergeSnippet(
|
||||
survivorId: number,
|
||||
sourceId: number,
|
||||
): Promise<{ survivor: Snippet; restored: Snippet | null }> {
|
||||
return apiPost(`/api/snippets/${survivorId}/unmerge`, { source_id: sourceId });
|
||||
}
|
||||
|
||||
/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged
|
||||
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
|
||||
export async function mergeSnippets(
|
||||
targetId: number,
|
||||
sourceIds: number[],
|
||||
): Promise<Snippet & { merged_ids: number[] }> {
|
||||
return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds });
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Shared component styles — the house recipes, in one place.
|
||||
* ===========================================================================
|
||||
*
|
||||
* WHY THIS FILE EXISTS
|
||||
*
|
||||
* `.btn-primary` was defined five times, in five scoped stylesheets, and all
|
||||
* five had drifted: three paddings, three font sizes, three disabled opacities,
|
||||
* and one view with no disabled style at all (#2273). Nothing detected that,
|
||||
* because a scoped duplicate is invisible to every tool — it isn't a rule
|
||||
* violation, isn't a broken reference, and isn't a recorded snippet.
|
||||
*
|
||||
* GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or
|
||||
* scale change moves the buttons rather than stranding a copy that no longer
|
||||
* matches.
|
||||
*
|
||||
* A button is `variant + size`, composed in the template:
|
||||
* btn-primary a page action
|
||||
* btn-primary btn-compact a row action
|
||||
* btn-ghost btn-inline an affordance inside a card
|
||||
* btn-primary btn-block a form's single submitting action
|
||||
* Semantic per-view names (.btn-save, .btn-delete-task, …) were the thing that
|
||||
* drifted, because a name says what a button is FOR and nothing about what it
|
||||
* should look like — so two buttons doing the same job in two views had no
|
||||
* reason to match, and didn't.
|
||||
*
|
||||
* MIGRATION NOTE — this file is deliberately safe to land ahead of the removals.
|
||||
* These are plain selectors (specificity 0,1,0); a Vue `<style scoped>` rule
|
||||
* compiles to `.btn-primary[data-v-…]` (0,2,0) and therefore WINS. So a view
|
||||
* still carrying its own copy is unaffected until that copy is deleted, and
|
||||
* every intermediate state of the migration is coherent.
|
||||
*/
|
||||
|
||||
/* --- the shared shape ---------------------------------------------------- */
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger,
|
||||
.btn-danger-outline {
|
||||
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
||||
border: none;
|
||||
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-label); /* 12px */
|
||||
font-weight: var(--fs-weight-medium); /* 500 — the heaviest the system goes */
|
||||
line-height: var(--fs-leading-body);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||
color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
|
||||
/* One rule, so a disabled button can never look enabled in one view and
|
||||
* disabled in another — which is exactly what ProjectListView shipped, having
|
||||
* no disabled style at all. */
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-danger-outline:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary:focus-visible,
|
||||
.btn-secondary:focus-visible,
|
||||
.btn-ghost:focus-visible,
|
||||
.btn-danger:focus-visible,
|
||||
.btn-danger-outline:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* --- variants ------------------------------------------------------------ */
|
||||
|
||||
/* The accent is deliberately ABSENT from every filled variant. Action colours
|
||||
* are universal across the family so a Save button looks identical in every
|
||||
* app — the accent is identity, not action. */
|
||||
.btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-action-secondary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-secondary:not(:disabled):hover {
|
||||
background: var(--color-action-secondary-hover);
|
||||
}
|
||||
|
||||
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
|
||||
* are the same token rather than two values that happen to match. Hover moves
|
||||
* the BORDER, not the text to the accent — SnippetDetailView tinted the label
|
||||
* with the accent on hover, which the house style reserves for identity and
|
||||
* active state, not for general chrome. */
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-ghost:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
/* Destructive is NOT the error colour: an error is a failure that happened, a
|
||||
* destructive action is one about to happen. Pair with an icon. */
|
||||
.btn-danger {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-danger:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* A bare text button: no fill, no border. The most common shape in the dense
|
||||
* surfaces — a dismiss, a cancel next to a confirm, a clear-search — where a
|
||||
* border would draw a box around something that should read as an action on
|
||||
* the text beside it. Distinct from ghost, which IS a box. */
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.btn-text:not(:disabled):hover { color: var(--color-text); }
|
||||
.btn-text:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
|
||||
/* Destructive, outlined — fills on hover. Already existed independently in
|
||||
* three views before this sheet, which is what makes it a variant rather than
|
||||
* a one-off: it is what a delete looks like when it must not shout. */
|
||||
.btn-danger-outline {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
}
|
||||
.btn-danger-outline:not(:disabled):hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
/* --- size modifiers ------------------------------------------------------
|
||||
*
|
||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
||||
* bespoke button rules before this scale existed, vertical padding fell into
|
||||
* clusters rather than a spread: ~27 at 0.4–0.45rem, ~28 at 0.25–0.3rem, ~23
|
||||
* at 0.1–0.15rem. Those are three different components — a page action, a row
|
||||
* action, and an affordance living inside a card — that happen to share a name
|
||||
* prefix. Collapsing them to one size would visibly break the card layouts.
|
||||
*
|
||||
* A button carries its size modifier; the DEFAULT (no modifier) is the page
|
||||
* action, which is the one the house style specifies.
|
||||
*/
|
||||
|
||||
/* Row actions: a toolbar, a table row, a list item's controls. */
|
||||
.btn-compact,
|
||||
.btn-small, /* pre-existing spellings, kept so no template churns */
|
||||
.btn-sm {
|
||||
padding: var(--fs-space-1) var(--fs-space-3); /* 4px 12px */
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
|
||||
/* Inline affordances: a dismiss ×, a confirm tick, an add-chip — things that
|
||||
* sit INSIDE a line of text or a card and must not disturb its rhythm. Below
|
||||
* the spacing scale's first step on the vertical axis by necessity: 4px of
|
||||
* padding on a 11px label already exceeds the line box these live in. */
|
||||
.btn-inline {
|
||||
padding: 2px var(--fs-space-1); /* 2px 4px */
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Full width, for a form's single submitting action — the auth screens. Width
|
||||
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
||||
because a full-width button
|
||||
is the page's main action */
|
||||
font-size: var(--fs-size-body-sm);
|
||||
}
|
||||
@@ -34,86 +34,11 @@
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||
the software" — not a brand moment. Accent gradient is reserved for
|
||||
Send / empty-state CTAs. */
|
||||
.btn-save {
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.btn-delete {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-delete:hover { background: var(--color-action-destructive-hover); }
|
||||
.btn-assist-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-assist-toggle.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.title-input {
|
||||
padding: 0.4rem 0;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--color-primary);
|
||||
@@ -155,23 +80,6 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn-suggest-tags {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-suggest-tags:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -187,29 +95,17 @@
|
||||
}
|
||||
.tag-pill:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.tag-pill.applied {
|
||||
background: var(--color-success, #2ecc71);
|
||||
border-color: var(--color-success, #2ecc71);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: default;
|
||||
}
|
||||
.tag-check {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.btn-dismiss-tags {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-dismiss-tags:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Assist panel ── */
|
||||
.assist-panel {
|
||||
@@ -237,32 +133,6 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.btn-proofread {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-proofread:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-proofread:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-close-assist {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.assist-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -342,28 +212,6 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-generate {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-generate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Streaming */
|
||||
.assist-streaming-label {
|
||||
@@ -419,14 +267,6 @@
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-toggle-view {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.diff-view {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -475,24 +315,6 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-accept {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-reject {
|
||||
padding: 0.4rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
@@ -537,7 +359,7 @@
|
||||
}
|
||||
.modal-btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -547,8 +369,8 @@
|
||||
z-index: 100;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
@@ -640,3 +462,96 @@
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editor button aliases.
|
||||
*
|
||||
* These names are used across six views, so they alias onto the shared variants
|
||||
* rather than every call site being rewritten — the class stays the app's, the
|
||||
* appearance comes from components.css. Same reasoning as .btn-small: a name
|
||||
* already in the templates is cheaper to point somewhere than to replace.
|
||||
*
|
||||
* They are @extend-shaped, which CSS lacks, so each carries the variant's own
|
||||
* declarations. That is the one duplication this migration cannot remove — but
|
||||
* it is duplication of a REFERENCE (a var()), not of a value, so a palette
|
||||
* change still moves everything at once.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
.btn-accept,
|
||||
.btn-generate,
|
||||
.btn-save {
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-accept:not(:disabled):hover,
|
||||
.btn-generate:not(:disabled):hover,
|
||||
.btn-save:not(:disabled):hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
.btn-clear,
|
||||
.btn-reject,
|
||||
.btn-proofread,
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-back:not(:disabled):hover,
|
||||
.btn-clear:not(:disabled):hover,
|
||||
.btn-reject:not(:disabled):hover,
|
||||
.btn-proofread:not(:disabled):hover,
|
||||
.btn-suggest-tags:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-delete:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* Shared geometry for every alias above. */
|
||||
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
|
||||
.btn-delete, .btn-dismiss-tags, .btn-proofread, .btn-suggest-tags {
|
||||
border-radius: var(--fs-radius-md);
|
||||
font-family: var(--fs-font-body);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||
color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
|
||||
.btn-delete {
|
||||
padding: var(--fs-space-2) var(--fs-space-4);
|
||||
font-size: var(--fs-size-label);
|
||||
}
|
||||
.btn-proofread, .btn-suggest-tags {
|
||||
padding: var(--fs-space-1) var(--fs-space-3);
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
.btn-dismiss-tags {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px var(--fs-space-1);
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-dismiss-tags:hover { color: var(--color-text); }
|
||||
|
||||
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
|
||||
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
|
||||
.btn-delete:disabled, .btn-proofread:disabled, .btn-suggest-tags:disabled,
|
||||
.btn-dismiss-tags:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
+331
-161
@@ -1,149 +1,319 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
||||
|
||||
/* ==========================================================================
|
||||
GENERATED FROM THE DESIGN SYSTEM — Scribe (design system 2), which inherits
|
||||
the FabledSword house style (design system 1).
|
||||
|
||||
Do not hand-edit the --fs-* block below. Edit the design system in the app
|
||||
and regenerate: /design-systems -> Master stylesheet -> Copy.
|
||||
|
||||
DARK IS THE BASE LAYER. The kit is dark-mode-first, so :root carries the dark
|
||||
palette and [data-theme="light"] overrides it. That is the inverse of how this
|
||||
file used to read, and it is deliberate: the light palette was never specified
|
||||
by any rule, so it is recorded as a departure rather than as the default.
|
||||
|
||||
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
|
||||
radius, and every derived colour — is stated once, because a value built with
|
||||
var() resolves where it is USED, not where it is written.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Light mode — warm parchment palette */
|
||||
--color-bg: #F5F1E8;
|
||||
--color-bg-secondary: #FBF8F0;
|
||||
--color-bg-card: #FBF8F0;
|
||||
--color-surface: #EFEAE0;
|
||||
--color-text: #14171A;
|
||||
--color-text-secondary: #5A5852;
|
||||
--color-text-muted: #9A9890;
|
||||
--color-border: #D9D6CE;
|
||||
--color-input-border: #D9D6CE;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.12);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #EBEDF0;
|
||||
--color-code-inline-bg: #EBEDF0;
|
||||
--color-table-stripe: rgba(20, 23, 26, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #EFEAE0;
|
||||
--color-input-bar-text: #14171A;
|
||||
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #D9D6CE;
|
||||
--color-bubble-user-text: #5A5852;
|
||||
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.08);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.12);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.20);
|
||||
/* accent */
|
||||
--fs-accent: #5B4A8A; /* Scribe's signature colour */
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent); /* Pill, tag and badge backgrounds */
|
||||
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
|
||||
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
|
||||
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
|
||||
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
|
||||
|
||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
/* action */
|
||||
--fs-action-primary: #4A5D3F; /* Save, Submit, Confirm */
|
||||
--fs-action-secondary: #8B7355; /* Non-destructive alternates */
|
||||
--fs-action-tertiary: var(--fs-border-color); /* Ghost / outline actions */
|
||||
--fs-action-destructive: var(--fs-destructive);
|
||||
--fs-action-primary-hover: color-mix(in srgb, var(--fs-action-primary) 88%, white);
|
||||
--fs-action-secondary-hover: color-mix(in srgb, var(--fs-action-secondary) 88%, white);
|
||||
--fs-action-destructive-hover: color-mix(in srgb, var(--fs-action-destructive) 88%, white);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
/* border */
|
||||
--fs-border-color: #3F4651; /* Borders, dividers and ghost outlines */
|
||||
--fs-border: 0.5px solid var(--fs-border-color);
|
||||
--fs-border-hover: 0.5px solid color-mix(in srgb, var(--fs-text-secondary) 30%, transparent);
|
||||
--fs-border-active: 2px solid var(--fs-accent); /* Selected card or active tab only */
|
||||
|
||||
/* editor */
|
||||
--fs-wikilink: var(--fs-accent);
|
||||
|
||||
/* elevation */
|
||||
--fs-shadow-1: 0 1px 0 rgba(0,0,0,0.4); /* Hairline lift */
|
||||
--fs-shadow-2: 0 4px 12px rgba(0,0,0,0.35); /* Dropdowns, popovers */
|
||||
--fs-shadow-3: 0 16px 40px rgba(0,0,0,0.5); /* Modals */
|
||||
|
||||
/* focus */
|
||||
--fs-focus-ring: 0 0 0 2px var(--fs-accent);
|
||||
|
||||
/* font */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif;
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif;
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
/* icon */
|
||||
--fs-icon-stroke: 1.5px; /* at 24px */
|
||||
--fs-icon-stroke-sm: 1px; /* at 16px */
|
||||
|
||||
/* layout */
|
||||
--fs-layout-page-max: 1200px;
|
||||
--fs-layout-page-pad: 1rem;
|
||||
--fs-layout-sidebar: 260px;
|
||||
--fs-layout-header: 52px;
|
||||
|
||||
/* motion */
|
||||
--fs-ease: cubic-bezier(0.2, 0.6, 0.2, 1); /* the one curve */
|
||||
--fs-dur-fast: 120ms;
|
||||
--fs-dur-base: 180ms;
|
||||
--fs-dur-slow: 280ms;
|
||||
|
||||
/* priority */
|
||||
--fs-priority-low: var(--fs-info);
|
||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
||||
--fs-priority-medium: var(--fs-warning);
|
||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
||||
--fs-priority-high: var(--fs-error);
|
||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
||||
|
||||
/* radius */
|
||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||
--fs-radius-md: 8px; /* buttons, inputs, small cards */
|
||||
--fs-radius-lg: 12px; /* cards, panels, modals */
|
||||
--fs-radius-xl: 16px; /* hero containers */
|
||||
--fs-radius-pill: 9999px;
|
||||
|
||||
/* semantic */
|
||||
--fs-success: var(--fs-action-primary);
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-error: #C04A1F;
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
||||
|
||||
/* space */
|
||||
--fs-space-1: 4px;
|
||||
--fs-space-2: 8px;
|
||||
--fs-space-3: 12px;
|
||||
--fs-space-4: 16px;
|
||||
--fs-space-5: 20px;
|
||||
--fs-space-6: 24px;
|
||||
--fs-space-7: 32px;
|
||||
--fs-space-8: 48px;
|
||||
--fs-space-9: 64px;
|
||||
--fs-space-10: 96px;
|
||||
|
||||
/* state */
|
||||
--fs-disabled-opacity: 0.5;
|
||||
--fs-overlay: rgba(0, 0, 0, 0.65);
|
||||
|
||||
/* status */
|
||||
--fs-status-todo: var(--fs-border-color);
|
||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
||||
--fs-status-in-progress: var(--fs-accent);
|
||||
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
|
||||
--fs-status-done: var(--fs-success);
|
||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
||||
--fs-overdue: var(--fs-error);
|
||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||
--fs-surface-raised: #1E2228; /* cards, raised elements */
|
||||
--fs-surface-hover: #2C313A; /* hovered surfaces */
|
||||
--fs-surface-code: var(--fs-surface-page);
|
||||
--fs-surface-code-inline: var(--fs-surface-raised);
|
||||
--fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent);
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
||||
--fs-text-secondary: #C2BFB4;
|
||||
--fs-text-tertiary: #9C9A92;
|
||||
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
|
||||
|
||||
/* type */
|
||||
--fs-size-display: 40px;
|
||||
--fs-size-h1: 32px;
|
||||
--fs-size-h2: 24px;
|
||||
--fs-size-h3: 18px;
|
||||
--fs-size-body: 15px;
|
||||
--fs-size-body-sm: 13px;
|
||||
--fs-size-label: 12px;
|
||||
--fs-size-code: 13px;
|
||||
--fs-size-tiny: 11px; /* the only ALL CAPS, the only non-default tracking */
|
||||
--fs-weight-regular: 400;
|
||||
--fs-weight-medium: 500; /* the heaviest the system goes */
|
||||
--fs-leading-heading: 1.3;
|
||||
--fs-leading-body: 1.5;
|
||||
--fs-leading-longform: 1.7;
|
||||
--fs-tracking-tiny: 0.08em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode — Obsidian / Iron / Pewter */
|
||||
--color-bg: #14171A;
|
||||
--color-bg-secondary: #1E2228;
|
||||
--color-bg-card: #1E2228;
|
||||
--color-surface: #2C313A;
|
||||
--color-text: #E8E4D8;
|
||||
--color-text-secondary: #C2BFB4;
|
||||
--color-text-muted: #9C9A92;
|
||||
--color-border: #3F4651;
|
||||
--color-input-border: #3F4651;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
||||
--color-tag-text: #5B4A8A;
|
||||
[data-theme="light"] {
|
||||
/* accent */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 20px color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||
|
||||
/* border */
|
||||
--fs-border-color: #D9D6CE;
|
||||
|
||||
/* state */
|
||||
--fs-overlay: rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #F5F1E8;
|
||||
--fs-surface-raised: #FBF8F0;
|
||||
--fs-surface-hover: #EFEAE0;
|
||||
--fs-surface-code: #EBEDF0;
|
||||
--fs-surface-code-inline: #EBEDF0;
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #14171A;
|
||||
--fs-text-secondary: #5A5852;
|
||||
--fs-text-tertiary: #9A9890;
|
||||
}
|
||||
|
||||
/* SUPERSEDES — write the token, not the literal.
|
||||
* #fff -> --fs-text-on-action
|
||||
* #ffffff -> --fs-text-on-action
|
||||
* white -> --fs-text-on-action
|
||||
* bold -> --fs-weight-medium
|
||||
* bolder -> --fs-weight-medium
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
|
||||
|
||||
These exist so ~55 components keep working while they migrate to --fs-*
|
||||
one at a time. Every one is a plain var() reference, which is what lets this
|
||||
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
|
||||
--color-bg follows, because the alias resolves at use time.
|
||||
|
||||
That is why this file lost 48 of its 60 dark-mode overrides — they were all
|
||||
restating relationships the aliases now express directly.
|
||||
|
||||
Removing this block is a rename sweep across the components, tracked
|
||||
separately. Nothing new should reference a --color-* name.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* surfaces */
|
||||
--color-bg: var(--fs-surface-page);
|
||||
--color-bg-secondary: var(--fs-surface-raised);
|
||||
--color-bg-card: var(--fs-surface-raised);
|
||||
--color-surface: var(--fs-surface-hover);
|
||||
--color-code-bg: var(--fs-surface-code);
|
||||
--color-code-inline-bg: var(--fs-surface-code-inline);
|
||||
--color-table-stripe: var(--fs-table-stripe);
|
||||
--color-overlay: var(--fs-overlay);
|
||||
|
||||
/* text */
|
||||
--color-text: var(--fs-text-primary);
|
||||
--color-text-secondary: var(--fs-text-secondary);
|
||||
--color-text-muted: var(--fs-text-tertiary);
|
||||
|
||||
/* lines */
|
||||
--color-border: var(--fs-border-color);
|
||||
--color-input-border: var(--fs-border-color);
|
||||
--focus-ring: var(--fs-focus-ring);
|
||||
|
||||
/* brand */
|
||||
--color-primary: var(--fs-accent);
|
||||
--color-primary-solid: var(--fs-accent);
|
||||
--color-primary-deep: var(--fs-accent-deep);
|
||||
--color-primary-faint: var(--fs-accent-faint);
|
||||
--color-primary-tint: var(--fs-accent-soft);
|
||||
--color-primary-wash: var(--fs-accent-wash);
|
||||
--color-tag-bg: var(--fs-accent-soft);
|
||||
--color-tag-text: var(--fs-accent);
|
||||
--color-wikilink: var(--fs-wikilink);
|
||||
--color-wikilink-bg: var(--fs-accent-soft);
|
||||
--gradient-cta: var(--fs-gradient-cta);
|
||||
--glow-cta: var(--fs-glow-cta);
|
||||
--glow-cta-hover: var(--fs-glow-cta-hover);
|
||||
|
||||
/* actions */
|
||||
--color-action-primary: var(--fs-action-primary);
|
||||
--color-action-primary-hover: var(--fs-action-primary-hover);
|
||||
--color-action-secondary: var(--fs-action-secondary);
|
||||
--color-action-secondary-hover: var(--fs-action-secondary-hover);
|
||||
--color-action-destructive: var(--fs-action-destructive);
|
||||
--color-action-destructive-hover: var(--fs-action-destructive-hover);
|
||||
|
||||
/* semantic */
|
||||
--color-success: var(--fs-success);
|
||||
--color-warning: var(--fs-warning);
|
||||
--color-danger: var(--fs-error);
|
||||
--color-overdue: var(--fs-overdue);
|
||||
--color-toast-success: var(--fs-success);
|
||||
--color-toast-error: var(--fs-error);
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.18);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #14171A;
|
||||
--color-code-inline-bg: #1E2228;
|
||||
--color-table-stripe: rgba(255, 255, 255, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #1E2228;
|
||||
--color-input-bar-text: #E8E4D8;
|
||||
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #3F4651;
|
||||
--color-bubble-user-text: #C2BFB4;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.10);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.14);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.22);
|
||||
|
||||
/* Action color set — identical across themes */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
/* task status + priority */
|
||||
--color-status-todo: var(--fs-status-todo);
|
||||
--color-status-todo-bg: var(--fs-status-todo-bg);
|
||||
--color-status-in-progress: var(--fs-status-in-progress);
|
||||
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
|
||||
--color-status-done: var(--fs-status-done);
|
||||
--color-status-done-bg: var(--fs-status-done-bg);
|
||||
--color-priority-low: var(--fs-priority-low);
|
||||
--color-priority-low-bg: var(--fs-priority-low-bg);
|
||||
--color-priority-medium: var(--fs-priority-medium);
|
||||
--color-priority-medium-bg: var(--fs-priority-medium-bg);
|
||||
--color-priority-high: var(--fs-priority-high);
|
||||
--color-priority-high-bg: var(--fs-priority-high-bg);
|
||||
|
||||
/* geometry */
|
||||
--radius-sm: var(--fs-radius-sm);
|
||||
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
|
||||
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
|
||||
--page-max-width: var(--fs-layout-page-max);
|
||||
--page-padding-x: var(--fs-layout-page-pad);
|
||||
--sidebar-width: var(--fs-layout-sidebar);
|
||||
--header-height: var(--fs-layout-header);
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Names components reference that were NEVER declared anywhere.
|
||||
|
||||
Each of these was reached for with a hardcoded fallback, so the page
|
||||
rendered — but the fallback was what rendered, always, and several were
|
||||
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
|
||||
to a brick that is not the oxblood, --color-status-cancelled to a grey from
|
||||
no palette in this system.
|
||||
|
||||
Wiring them to real tokens is the whole point of the exercise. Expect small
|
||||
visual shifts exactly where a fallback had drifted; that shift IS the fix.
|
||||
------------------------------------------------------------------ */
|
||||
--color-accent: var(--fs-accent);
|
||||
/* Foreground ON the accent, so it follows the accent's mode-independence,
|
||||
not the page text's. Pointing this at --fs-text-primary made it invert to
|
||||
obsidian on light — over a mid-tone accent, well under the AA floor. */
|
||||
--color-accent-fg: var(--fs-text-on-action);
|
||||
--color-hover: var(--fs-surface-hover);
|
||||
--color-bg-hover: var(--fs-surface-hover);
|
||||
--color-bg-tertiary: var(--fs-surface-hover);
|
||||
--color-surface-2: var(--fs-surface-hover);
|
||||
--color-surface-alt: var(--fs-surface-hover);
|
||||
--color-surface-raised: var(--fs-surface-raised);
|
||||
--color-input-bg: var(--fs-surface-page);
|
||||
--color-muted: var(--fs-text-tertiary);
|
||||
--color-destructive: var(--fs-destructive);
|
||||
--color-primary-bg: var(--fs-accent-soft);
|
||||
--color-status-cancelled: var(--fs-status-cancelled);
|
||||
--font-display: var(--fs-font-display);
|
||||
--font-mono: var(--fs-font-mono);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Base element styles
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -152,36 +322,36 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-family: var(--fs-font-body);
|
||||
font-feature-settings: "cv11";
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
line-height: var(--fs-leading-body);
|
||||
transition: background-color var(--fs-dur-base) var(--fs-ease),
|
||||
color var(--fs-dur-base) var(--fs-ease);
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-family: var(--fs-font-display);
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
line-height: var(--fs-leading-heading);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
font-family: var(--fs-font-body);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
line-height: var(--fs-leading-heading);
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-feature-settings: "liga", "calt";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 74, 138, 0.3);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-accent-wash);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -190,15 +360,15 @@ select:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
|
||||
button:not(:disabled):active,
|
||||
.btn:not(:disabled):active,
|
||||
[role="button"]:not(:disabled):active {
|
||||
transform: scale(0.97);
|
||||
transition: transform 0.08s ease;
|
||||
transition: transform 0.08s var(--fs-ease);
|
||||
}
|
||||
|
||||
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
|
||||
@@ -228,11 +398,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
background: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
@@ -240,14 +410,14 @@ button:not(:disabled):active,
|
||||
position: fixed;
|
||||
z-index: 150;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-primary);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--fs-size-body-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
box-shadow: var(--fs-shadow-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inline-assist-btn:hover {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
|
||||
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
@@ -48,6 +48,7 @@ router.afterEach(() => {
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,6 +64,16 @@ router.afterEach(() => {
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Design. An icon rather than a sixth primary nav link: it's a
|
||||
meta-surface like Trash and Settings, but hiding it entirely would
|
||||
defeat the point of having somewhere the design system is visible.
|
||||
Points at the RECORD, not the live-token view — the record is what
|
||||
you work with; the live view is the check on it, and it's a tab
|
||||
away. -->
|
||||
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
|
||||
<Palette :size="16" />
|
||||
</router-link>
|
||||
|
||||
<!-- Trash link -->
|
||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||
<Trash2 :size="16" />
|
||||
@@ -93,9 +104,11 @@ router.afterEach(() => {
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<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="/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>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sub-navigation for the Design surface.
|
||||
*
|
||||
* There are two pages here and they are halves of ONE thing: the record that
|
||||
* decides the styling, and what the browser is actually rendering from it. They
|
||||
* were briefly two top-level nav entries, which put the read-only diagnostic
|
||||
* first and buried the editable record under it — backwards, since the record
|
||||
* is the thing you work with and the live view is the check on it.
|
||||
*
|
||||
* A component rather than the same markup pasted into both views: two copies of
|
||||
* a tab bar diverge the moment a third tab appears, and that is the exact shape
|
||||
* of duplication this whole surface exists to make visible.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="design-tabs" aria-label="Design views">
|
||||
<router-link to="/design-systems" class="design-tab">Design system</router-link>
|
||||
<router-link to="/design" class="design-tab">Live tokens</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.design-tab {
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.design-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
|
||||
exact matching would drop the highlight on any future child route. */
|
||||
.design-tab.router-link-active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -281,7 +281,7 @@ onMounted(loadVersions);
|
||||
|
||||
<div class="history-footer">
|
||||
<button
|
||||
class="btn-restore"
|
||||
class="btn-primary"
|
||||
:disabled="!selectedVersion?.body"
|
||||
@click="restore"
|
||||
>Restore this version</button>
|
||||
@@ -393,19 +393,6 @@ onMounted(loadVersions);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-restore {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-restore:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
||||
.pin-badge {
|
||||
|
||||
@@ -188,11 +188,11 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
}
|
||||
.iap-btn-accept {
|
||||
background: var(--color-success, #22c55e);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.iap-btn-accept:hover { opacity: 0.85; }
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ onUnmounted(() => {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
|
||||
@@ -50,7 +50,7 @@ onMounted(() => store.fetchAll())
|
||||
<span class="notif-panel-title">Notifications</span>
|
||||
<button
|
||||
v-if="store.count > 0"
|
||||
class="btn-mark-all"
|
||||
class="btn-text"
|
||||
@click="store.markAll()"
|
||||
>Mark all read</button>
|
||||
</header>
|
||||
@@ -70,7 +70,7 @@ onMounted(() => store.fetchAll())
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
<button class="btn-text" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notif-empty">No unread notifications</div>
|
||||
@@ -108,21 +108,6 @@ onMounted(() => store.fetchAll())
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-mark-all:hover { text-decoration: underline; }
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notif-item {
|
||||
display: flex;
|
||||
@@ -148,22 +133,4 @@ onMounted(() => store.fetchAll())
|
||||
}
|
||||
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
|
||||
|
||||
.btn-notif-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.btn-notif-close:hover { color: var(--color-text); }
|
||||
|
||||
.notif-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,7 +90,7 @@ function goToPage(page: number) {
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ellipsis {
|
||||
|
||||
@@ -117,7 +117,7 @@ onMounted(async () => {
|
||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||
<header class="share-header">
|
||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
<button class="btn-text" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
</header>
|
||||
|
||||
<!-- Add share form -->
|
||||
@@ -184,7 +184,7 @@ onMounted(async () => {
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
<button class="btn-text" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
</li>
|
||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||
</ul>
|
||||
@@ -231,23 +231,6 @@ onMounted(async () => {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-close:hover { color: var(--color-text); }
|
||||
|
||||
.share-add {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.share-tabs {
|
||||
display: flex;
|
||||
@@ -268,7 +251,7 @@ onMounted(async () => {
|
||||
.share-tab.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
.share-target-form {
|
||||
@@ -339,11 +322,11 @@ onMounted(async () => {
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
@@ -394,23 +377,4 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-remove-share {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-remove-share:hover { color: var(--color-danger, #ef4444); }
|
||||
|
||||
.shares-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -163,7 +163,7 @@ async function confirmDelete() {
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="systems-toolbar">
|
||||
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
|
||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||
+ System
|
||||
</button>
|
||||
<label v-if="archivedSystems.length" class="archived-toggle">
|
||||
@@ -190,10 +190,10 @@ async function confirmDelete() {
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-confirm" :disabled="!newName.trim() || creating">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
</button>
|
||||
<button type="button" class="btn-cancel" @click="cancelCreate">Cancel</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="cancelCreate">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -211,7 +211,7 @@ async function confirmDelete() {
|
||||
<div v-else-if="!visibleSystems.length" class="systems-empty">
|
||||
<p class="empty-title">No systems yet</p>
|
||||
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
|
||||
<button v-if="!showCreate" class="btn-confirm" @click="openCreate">+ Create a system</button>
|
||||
<button v-if="!showCreate" class="btn-primary btn-compact" @click="openCreate">+ Create a system</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
@@ -241,10 +241,10 @@ async function confirmDelete() {
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-confirm" :disabled="!editName.trim() || savingEdit">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||
{{ savingEdit ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button type="button" class="btn-cancel" @click="cancelEdit">Cancel</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -387,51 +387,6 @@ async function confirmDelete() {
|
||||
.system-textarea { resize: vertical; }
|
||||
|
||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||
.btn-confirm {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-confirm:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||
.btn-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-cancel {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-cancel:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||
|
||||
/* ── List ─────────────────────────────────────────────────────── */
|
||||
.systems-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.system-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
transition: border-color 0.12s, box-shadow 0.15s;
|
||||
}
|
||||
.system-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
|
||||
}
|
||||
.system-card--archived { opacity: 0.6; }
|
||||
|
||||
.system-swatch {
|
||||
@@ -555,6 +510,6 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
|
||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||
</style>
|
||||
|
||||
@@ -122,8 +122,8 @@ onMounted(loadLogs);
|
||||
class="log-duration-input"
|
||||
placeholder="min"
|
||||
/>
|
||||
<button class="btn-log-save" @click="saveEdit(log)">Save</button>
|
||||
<button class="btn-log-cancel" @click="cancelEdit">Cancel</button>
|
||||
<button class="btn-primary btn-compact" @click="saveEdit(log)">Save</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -133,8 +133,8 @@ onMounted(loadLogs);
|
||||
{{ formatDuration(log.duration_minutes) }}
|
||||
</span>
|
||||
<div class="log-entry-actions">
|
||||
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
|
||||
<button class="btn-log-delete" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||
<button class="btn-text" @click="startEdit(log)" title="Edit">Edit</button>
|
||||
<button class="btn-text" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
|
||||
@@ -162,7 +162,7 @@ onMounted(loadLogs);
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn-log-submit"
|
||||
class="btn-primary btn-compact"
|
||||
@click="submitLog"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
>
|
||||
@@ -217,7 +217,7 @@ onMounted(loadLogs);
|
||||
|
||||
.log-duration-badge {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 99px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
@@ -230,34 +230,7 @@ onMounted(loadLogs);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-log-edit,
|
||||
.btn-log-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
padding: 0.1rem 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-log-edit:hover { color: var(--color-primary); }
|
||||
.btn-log-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
.log-content {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.log-content :deep(p) { margin: 0; }
|
||||
|
||||
.log-add {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.log-textarea {
|
||||
width: 100%;
|
||||
@@ -307,32 +280,6 @@ onMounted(loadLogs);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-log-submit,
|
||||
.btn-log-save {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-log-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-log-cancel {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,7 +36,7 @@ const toastStore = useToastStore();
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
min-width: 200px;
|
||||
@@ -54,7 +54,7 @@ const toastStore = useToastStore();
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.toast-close:hover {
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.toast--success {
|
||||
background: var(--color-toast-success);
|
||||
|
||||
@@ -236,12 +236,12 @@ function restore() {
|
||||
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.vh-btn-restore {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -314,14 +314,14 @@ defineExpose({ reload: loadProjectNotes });
|
||||
@keydown.enter="createNote"
|
||||
@keydown.escape="cancelNewNote"
|
||||
/>
|
||||
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
<button class="btn-primary btn-inline" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '…' : '+' }}
|
||||
</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rail-title">Notes</span>
|
||||
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
|
||||
<button class="btn-ghost btn-inline" @click="startNewNote" title="New note">+ New</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
type="search"
|
||||
aria-label="Search notes"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
<button v-if="searchQuery" class="btn-text btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||
@@ -358,12 +358,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
</div>
|
||||
<div class="note-row-actions" @click.stop>
|
||||
<template v-if="deletingId === note.id">
|
||||
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
<button class="btn-danger-outline btn-inline" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<button v-else class="btn-text" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -382,7 +382,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<WordCount :body="noteBody" />
|
||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||
<span v-if="saving" class="saving-txt">Saving…</span>
|
||||
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
<button class="btn-primary btn-compact" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -400,7 +400,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<div class="tag-row">
|
||||
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
|
||||
<button
|
||||
class="btn-suggest-tags"
|
||||
class="btn-ghost btn-compact"
|
||||
:disabled="tagSuggestions.suggestingTags.value"
|
||||
title="Auto-suggest tags from title and body"
|
||||
@click="tagSuggestions.fetchTagSuggestions()"
|
||||
@@ -417,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
@@ -429,8 +429,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
<button class="btn-ghost btn-inline" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-text" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
@@ -484,26 +484,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-new-note {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.rail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-search-input {
|
||||
flex: 1;
|
||||
@@ -517,17 +497,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.rail-search-input:focus { outline: none; }
|
||||
.rail-search-input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.btn-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-search-clear:hover { color: var(--color-text); }
|
||||
/* Sits inside the search field: no padding, and must not flex. */
|
||||
.btn-search-clear { padding: 0; flex-shrink: 0; }
|
||||
|
||||
.rail-state {
|
||||
padding: 1rem 0.65rem;
|
||||
@@ -617,99 +588,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
}
|
||||
.btn-cancel-delete:hover { color: var(--color-text); }
|
||||
|
||||
/* Inline new note */
|
||||
.new-note-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.new-note-input:focus { outline: none; }
|
||||
|
||||
.btn-confirm {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-confirm:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-cancel:hover { color: var(--color-text); }
|
||||
|
||||
/* ── Right editor pane ── */
|
||||
.note-editor-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Editor UI */
|
||||
.panel-header {
|
||||
@@ -731,23 +610,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
padding: 0.9rem 1.1rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-title-input {
|
||||
width: 100%;
|
||||
@@ -775,20 +637,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-suggest-tags:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
||||
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
@@ -817,21 +666,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-dismiss-suggestions {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.btn-dismiss-suggestions:hover { color: var(--color-text); }
|
||||
|
||||
.toolbar-row {
|
||||
padding: 0.3rem 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-suggest-strip {
|
||||
display: flex;
|
||||
@@ -865,21 +699,4 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
||||
|
||||
.btn-link-all {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
.btn-link-all:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.editor-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -232,7 +232,7 @@ defineExpose({ reload: loadAll });
|
||||
placeholder="New task..."
|
||||
@keydown.enter="addTask"
|
||||
/>
|
||||
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
<button class="btn-primary btn-inline btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state-msg">Loading...</div>
|
||||
@@ -293,18 +293,18 @@ defineExpose({ reload: loadAll });
|
||||
<Transition name="detail-fade">
|
||||
<div v-if="activeTask" class="task-detail">
|
||||
<div class="detail-header">
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
<button class="btn-danger-outline btn-inline btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<button v-else class="btn-text btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
<button class="btn-text btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||
@@ -396,17 +396,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.btn-add {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.28rem 0.55rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-add:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
||||
|
||||
.groups-scroll {
|
||||
flex: 1;
|
||||
@@ -534,17 +524,7 @@ defineExpose({ reload: loadAll });
|
||||
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
||||
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
|
||||
|
||||
.btn-edit-task {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-edit-task { margin-left: 0.25rem; }
|
||||
.btn-edit-task:hover { text-decoration: underline; }
|
||||
|
||||
.detail-body {
|
||||
@@ -566,51 +546,11 @@ defineExpose({ reload: loadAll });
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-delete-task {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.btn-delete-task { margin-left: 0.25rem; }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-delete-confirm {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-delete-confirm { margin-left: 0.25rem; }
|
||||
|
||||
.btn-delete-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.btn-delete-cancel:hover { color: var(--color-text); }
|
||||
|
||||
.detail-title {
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
@@ -679,15 +619,5 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
|
||||
/* Close detail button */
|
||||
.btn-close-detail {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.btn-close-detail:hover { color: var(--color-text); }
|
||||
.btn-close-detail { margin-left: 0.2rem; }
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./assets/theme.css";
|
||||
// After theme.css — it consumes the tokens declared there.
|
||||
import "./assets/components.css";
|
||||
import "./assets/prose.css";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -69,6 +69,26 @@ const router = createRouter({
|
||||
name: "note-edit",
|
||||
component: () => import("@/views/NoteEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets",
|
||||
name: "snippets",
|
||||
component: () => import("@/views/SnippetListView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/new",
|
||||
name: "snippet-new",
|
||||
component: () => import("@/views/SnippetEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/:id",
|
||||
name: "snippet-view",
|
||||
component: () => import("@/views/SnippetDetailView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/:id/edit",
|
||||
name: "snippet-edit",
|
||||
component: () => import("@/views/SnippetEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/graph",
|
||||
name: "graph",
|
||||
@@ -89,6 +109,20 @@ const router = createRouter({
|
||||
name: "rules",
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// Meta-surface, same family as /rules: it describes the app rather than
|
||||
// holding the operator's records.
|
||||
path: "/design",
|
||||
name: "design",
|
||||
component: () => import("@/views/DesignView.vue"),
|
||||
},
|
||||
{
|
||||
// The editable half of the same surface: /design is what the browser
|
||||
// renders, /design-systems is the record that ought to decide it.
|
||||
path: "/design-systems",
|
||||
name: "design-systems",
|
||||
component: () => import("@/views/DesignSystemsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
redirect: "/",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { System } from "@/api/systems";
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
export type NoteType = "note" | "process";
|
||||
export type NoteType = "note" | "process" | "snippet";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
||||
*
|
||||
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
||||
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
||||
* can assert on it. What's left here is set arithmetic over live token values,
|
||||
* which is the one thing the browser knows and the server doesn't.
|
||||
*
|
||||
* SCOPE, and it is a real limit rather than an omission. This compares the
|
||||
* rulebook against the TOKENS. It cannot see the third category of drift — a
|
||||
* literal hardcoded in a component where a token should be referenced (#2275,
|
||||
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
||||
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
||||
*
|
||||
* Catching it needs the component sources, which would mean bundling every SFC
|
||||
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
|
||||
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
|
||||
* matters: a drift report that silently omits a category invites the reader to
|
||||
* conclude the category is clean.
|
||||
*/
|
||||
import type { DesignToken } from "@/utils/designTokens";
|
||||
|
||||
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
||||
|
||||
export interface Expectation {
|
||||
kind: ExpectationKind;
|
||||
value: string;
|
||||
rule_id: number;
|
||||
rule_title: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface ExpectationResponse {
|
||||
rulebook_id: number | null;
|
||||
expectations: Expectation[];
|
||||
}
|
||||
|
||||
export type FindingStatus = "ok" | "missing" | "violated";
|
||||
|
||||
export interface Finding {
|
||||
expectation: Expectation;
|
||||
status: FindingStatus;
|
||||
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a colour for comparison — the client-side twin of
|
||||
* `normalize_hex` in services/design_system.py.
|
||||
*
|
||||
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
||||
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
||||
* spellings of one colour, and a comparison that misses any of them under-reports
|
||||
* rather than erroring. The rgb() case is browser-specific and therefore has no
|
||||
* server-side counterpart, which is exactly why it is handled here.
|
||||
*/
|
||||
export function normalizeColour(value: string): string | null {
|
||||
const raw = value.trim().toLowerCase();
|
||||
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
||||
if (hex) {
|
||||
let digits = hex[1];
|
||||
if (digits.length === 3 || digits.length === 4) {
|
||||
digits = digits.split("").map((c) => c + c).join("");
|
||||
}
|
||||
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
||||
}
|
||||
|
||||
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
|
||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||
if (rgb) {
|
||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const channels = parts.slice(0, 3).map((p) => Number(p));
|
||||
if (channels.some((n) => !Number.isFinite(n))) return null;
|
||||
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
||||
const base = `#${channels.map(hexOf).join("")}`;
|
||||
if (parts.length === 3) return base;
|
||||
const alpha = Number(parts[3]);
|
||||
if (!Number.isFinite(alpha) || alpha >= 1) return base;
|
||||
return `${base}${hexOf(alpha * 255)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
||||
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
||||
const index = new Map<string, string[]>();
|
||||
for (const token of tokens) {
|
||||
const colour = normalizeColour(token.value);
|
||||
if (!colour) continue;
|
||||
const names = index.get(colour);
|
||||
if (names) names.push(token.name);
|
||||
else index.set(colour, [token.name]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare claims against the live tokens.
|
||||
*
|
||||
* A `token` claim asks whether a custom property of that name exists.
|
||||
* A `color` claim asks whether any token resolves to that value.
|
||||
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
||||
*/
|
||||
export function compareToTokens(
|
||||
expectations: Expectation[],
|
||||
tokens: DesignToken[],
|
||||
): Finding[] {
|
||||
const names = new Set(tokens.map((t) => t.name));
|
||||
const colours = colourIndex(tokens);
|
||||
|
||||
return expectations.map((expectation) => {
|
||||
if (expectation.kind === "token") {
|
||||
const present = names.has(expectation.value);
|
||||
return {
|
||||
expectation,
|
||||
status: present ? "ok" : "missing",
|
||||
matches: present ? [expectation.value] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const matches = colours.get(expectation.value) ?? [];
|
||||
if (expectation.kind === "prohibited_color") {
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "violated" : "ok",
|
||||
matches,
|
||||
};
|
||||
}
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "ok" : "missing",
|
||||
matches,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface DriftSummary {
|
||||
ok: number;
|
||||
missing: number;
|
||||
violated: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarise(findings: Finding[]): DriftSummary {
|
||||
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
||||
for (const finding of findings) summary[finding.status] += 1;
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings worth leading with.
|
||||
*
|
||||
* A panel that opens with every row gets closed and never reopened — the same
|
||||
* principle the auto-inject menu is built on: a short list that gets read beats
|
||||
* a complete one that doesn't. Violations first (something is actively wrong),
|
||||
* then missing (something was never built), and `ok` rows are not "findings" at
|
||||
* all — they belong behind an expansion.
|
||||
*/
|
||||
export function rankFindings(findings: Finding[]): Finding[] {
|
||||
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
||||
return [...findings].sort((a, b) => {
|
||||
const byStatus = order[a.status] - order[b.status];
|
||||
if (byStatus !== 0) return byStatus;
|
||||
return a.expectation.rule_id - b.expectation.rule_id;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||
*
|
||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
||||
* against these, and the drift panel compares them to the design rulebook.
|
||||
*
|
||||
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||
* its VALUE is not: values contain nested parens, commas inside rgba(),
|
||||
* `var()` references to other tokens, multi-part shadows, and gradients — and
|
||||
* `theme.css` has all of those today. So we take the names from the source and
|
||||
* ask the BROWSER for every value.
|
||||
*
|
||||
* That is not just easier, it is more correct. getComputedStyle reports what
|
||||
* actually won the cascade, resolves `var()` chains, and — critically for this
|
||||
* milestone — reflects live overrides set on a container, which is exactly what
|
||||
* the preview surface needs (see #2261). Parsing the source would report what
|
||||
* the file says rather than what the user is looking at.
|
||||
*
|
||||
* It also means this module needs no unit tests to be trustworthy: the only
|
||||
* logic here is a name regex and a group lookup. The frontend has no test
|
||||
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
|
||||
* error-prone half in the browser rather than in our code is deliberate.
|
||||
*/
|
||||
import themeCss from "@/assets/theme.css?raw";
|
||||
|
||||
export type TokenGroup =
|
||||
| "color"
|
||||
| "radius"
|
||||
| "gradient"
|
||||
| "glow"
|
||||
| "focus"
|
||||
| "layout"
|
||||
| "other";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export interface DesignToken {
|
||||
/** Full custom-property name, including the leading `--`. */
|
||||
name: string;
|
||||
/** Coarse family, derived from the name prefix. */
|
||||
group: TokenGroup;
|
||||
/** Resolved value in the requested context, straight from the browser. */
|
||||
value: string;
|
||||
/** True when the declaration appears inside the dark block in source. */
|
||||
overriddenInDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
|
||||
*
|
||||
* The discriminator is the COLON, not the preceding character. A declaration is
|
||||
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
|
||||
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
|
||||
* actively wrong: an earlier version required the match to follow `{` or `;`,
|
||||
* which silently dropped every declaration that came after a comment —
|
||||
* including `--color-bg`, the first and most-used token in the file.
|
||||
*/
|
||||
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
||||
|
||||
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||
|
||||
/** The dark block's selector, as written in theme.css. */
|
||||
const DARK_SELECTOR = '[data-theme="dark"]';
|
||||
|
||||
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||
["--color-", "color"],
|
||||
["--radius-", "radius"],
|
||||
["--gradient-", "gradient"],
|
||||
["--glow-", "glow"],
|
||||
["--focus-", "focus"],
|
||||
["--page-", "layout"],
|
||||
["--sidebar-", "layout"],
|
||||
["--chat-", "layout"],
|
||||
];
|
||||
|
||||
export function groupFor(name: string): TokenGroup {
|
||||
for (const [prefix, group] of GROUP_PREFIXES) {
|
||||
if (name.startsWith(prefix)) return group;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
|
||||
export function tokenNames(css: string = themeCss): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
|
||||
const name = match[1];
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
||||
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
||||
const bare = css.replace(COMMENT, "");
|
||||
const start = bare.indexOf(DARK_SELECTOR);
|
||||
if (start === -1) return new Set();
|
||||
const open = bare.indexOf("{", start);
|
||||
const close = bare.indexOf("}", open);
|
||||
if (open === -1 || close === -1) return new Set();
|
||||
return new Set(tokenNames(bare.slice(open, close)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved value of every token in `host`'s context.
|
||||
*
|
||||
* Pass a container to read the tokens as they apply INSIDE it — which is how
|
||||
* the preview surface reads a scoped override without disturbing the page.
|
||||
* Defaults to the document root, i.e. the app-wide values.
|
||||
*/
|
||||
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||
const computed = getComputedStyle(host);
|
||||
const dark = darkOverriddenNames();
|
||||
return tokenNames().map((name) => ({
|
||||
name,
|
||||
group: groupFor(name),
|
||||
value: computed.getPropertyValue(name).trim(),
|
||||
overriddenInDark: dark.has(name),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tokens as they would resolve in a given mode, without touching the page.
|
||||
*
|
||||
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
|
||||
* mutated to take a reading.
|
||||
*
|
||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
||||
* function: light is declared on `:root` while dark is declared on
|
||||
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
||||
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
||||
* back. So reading "light" from inside a dark page returns the dark values —
|
||||
* the probe has nothing to match.
|
||||
*
|
||||
* Concretely: dark-inside-light previews work, light-inside-dark previews do
|
||||
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
||||
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
||||
* should treat a cross-mode read as best-effort.
|
||||
*/
|
||||
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||
const probe = document.createElement("div");
|
||||
probe.setAttribute("data-theme", mode);
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
try {
|
||||
return readTokens(probe);
|
||||
} finally {
|
||||
probe.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens grouped by family, preserving source order within each group. */
|
||||
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
|
||||
const out = new Map<TokenGroup, DesignToken[]>();
|
||||
for (const token of tokens) {
|
||||
const bucket = out.get(token.group);
|
||||
if (bucket) bucket.push(token);
|
||||
else out.set(token.group, [token]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens declared in the stylesheet that nothing references with `var()`.
|
||||
*
|
||||
* Dead tokens are drift too: `--chat-reading-width` and
|
||||
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
|
||||
* in the MCP-first pivot, and nothing has referenced them since. Takes the
|
||||
* corpus of source files to search as an argument so the caller decides what
|
||||
* "used" means — this module has no opinion about the project layout.
|
||||
*/
|
||||
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
|
||||
const haystack = sources.join("\n");
|
||||
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,545 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Design explorer — the gallery (milestone #251 step 3).
|
||||
*
|
||||
* Renders the design system against the tokens that are actually live, read at
|
||||
* runtime rather than parsed from source, so what you see here is what the app
|
||||
* is using right now.
|
||||
*
|
||||
* HONESTY RULE, and the reason parts of this page say "not implemented":
|
||||
* a gallery of hand-written look-alikes drifts from the app within a month and
|
||||
* then lies — which is the same failure this whole surface exists to catch. So
|
||||
* every specimen below is either a REAL component imported from the app, or a
|
||||
* real token read from the browser, or it is explicitly marked as missing.
|
||||
*
|
||||
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
|
||||
* in five `<style scoped>` blocks, all five drifted, and this page reported it
|
||||
* as a gap because drawing a look-alike would have made it a sixth copy.
|
||||
* `assets/components.css` is now the single definition (#2273), so the
|
||||
* specimens below are the app's real classes — they cannot drift from the app
|
||||
* without drifting the app itself.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { fetchDesignExpectations } from "@/api/design";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import {
|
||||
compareToTokens,
|
||||
rankFindings,
|
||||
summarise,
|
||||
type Expectation,
|
||||
type Finding,
|
||||
} from "@/utils/designDrift";
|
||||
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
||||
|
||||
const tokens = ref<DesignToken[]>([]);
|
||||
const expectations = ref<Expectation[]>([]);
|
||||
const designRulebookId = ref<number | null>(null);
|
||||
const driftLoaded = ref(false);
|
||||
const showCleanRows = ref(false);
|
||||
|
||||
/**
|
||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
||||
* which needs the app's stylesheets applied and the theme attribute set.
|
||||
*/
|
||||
onMounted(async () => {
|
||||
tokens.value = readTokens();
|
||||
try {
|
||||
const response = await fetchDesignExpectations();
|
||||
designRulebookId.value = response.rulebook_id;
|
||||
expectations.value = response.expectations;
|
||||
} catch {
|
||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
||||
// "no drift data" rather than taking the page down with it.
|
||||
designRulebookId.value = null;
|
||||
} finally {
|
||||
driftLoaded.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const findings = computed<Finding[]>(() =>
|
||||
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
||||
);
|
||||
const driftSummary = computed(() => summarise(findings.value));
|
||||
const visibleFindings = computed(() =>
|
||||
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
||||
);
|
||||
|
||||
const grouped = computed(() => groupTokens(tokens.value));
|
||||
|
||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
||||
const orderedGroups = computed(() =>
|
||||
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
|
||||
);
|
||||
|
||||
/** A token whose value reads as a colour is worth showing as a swatch. */
|
||||
function isColourish(value: string): boolean {
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
|
||||
const RULEBOOK_BUTTONS = [
|
||||
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
|
||||
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
|
||||
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
|
||||
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
|
||||
];
|
||||
|
||||
const TYPE_SPECIMENS = [
|
||||
{ token: "Display", spec: "40 / 500 / Fraunces" },
|
||||
{ token: "H1", spec: "32 / 500 / Fraunces" },
|
||||
{ token: "H2", spec: "24 / 500 / Fraunces" },
|
||||
{ token: "H3", spec: "18 / 500 / Inter" },
|
||||
{ token: "Body", spec: "15 / 400 / Inter" },
|
||||
{ token: "Body small", spec: "13 / 400 / Inter" },
|
||||
{ token: "Label", spec: "12 / 500 / Inter" },
|
||||
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
|
||||
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design-view">
|
||||
<DesignTabs />
|
||||
|
||||
<header class="design-header">
|
||||
<h1>Live tokens</h1>
|
||||
<p class="lede">
|
||||
The system as it actually is. Token values are read from the browser at
|
||||
runtime, so this page reflects the live cascade rather than what the
|
||||
stylesheet says. Components shown are the real ones — where a piece of
|
||||
the system has no shared implementation, it is marked missing rather
|
||||
than mocked up.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
||||
<section class="design-section">
|
||||
<h2>Rulebook drift</h2>
|
||||
|
||||
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
||||
|
||||
<div v-else-if="designRulebookId === null" class="gap-notice">
|
||||
<strong>No design rulebook designated.</strong>
|
||||
<p>
|
||||
This install hasn't said which rulebook describes its design system, so
|
||||
there is nothing to check the tokens against. Designate one in
|
||||
<router-link to="/settings">Settings</router-link> and this panel will
|
||||
compare every colour and token the rulebook names against what the
|
||||
stylesheet actually resolves to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="section-note">
|
||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
||||
claims in rulebook #{{ designRulebookId }}.
|
||||
</p>
|
||||
|
||||
<div class="gap-notice">
|
||||
<strong>This compares the rulebook against the TOKENS only.</strong>
|
||||
<p>
|
||||
A value hardcoded in a component — where a token should have been
|
||||
referenced — is invisible here, because the drift isn't in the tokens
|
||||
at all. Reading it would mean bundling every component's source into
|
||||
the app. That check belongs in CI and is tracked separately, so treat
|
||||
a clean panel as "the tokens agree", not "the app agrees".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!findings.length" class="muted">
|
||||
The rulebook names nothing this panel can check. Rules that state values
|
||||
— colours, token names — produce claims; rules that state judgement
|
||||
don't, by design.
|
||||
</p>
|
||||
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
||||
<span class="spec-name">
|
||||
<span
|
||||
v-if="finding.expectation.kind !== 'token'"
|
||||
class="swatch"
|
||||
:style="{ background: finding.expectation.value }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<code>{{ finding.expectation.value }}</code>
|
||||
</span>
|
||||
<span class="spec-detail">
|
||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
||||
<span v-if="finding.matches.length" class="matches">
|
||||
· {{ finding.matches.join(", ") }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="finding.status">
|
||||
{{ finding.status === "violated" ? "forbidden, but present"
|
||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="findings.length && driftSummary.ok"
|
||||
class="reveal-toggle"
|
||||
@click="showCleanRows = !showCleanRows"
|
||||
>
|
||||
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Real components: these are imported, not recreated. -->
|
||||
<section class="design-section">
|
||||
<h2>Components</h2>
|
||||
<p class="section-note">Imported from the app. What you see is what ships.</p>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Status badge</span>
|
||||
<div class="specimen-row">
|
||||
<StatusBadge status="todo" />
|
||||
<StatusBadge status="in_progress" />
|
||||
<StatusBadge status="done" />
|
||||
<StatusBadge status="cancelled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Priority badge</span>
|
||||
<div class="specimen-row">
|
||||
<PriorityBadge priority="low" />
|
||||
<PriorityBadge priority="medium" />
|
||||
<PriorityBadge priority="high" />
|
||||
<span class="muted">(<code>none</code> renders nothing, by design)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Tag pill</span>
|
||||
<div class="specimen-row">
|
||||
<TagPill tag="design-system" />
|
||||
<TagPill tag="dismissible" dismissible />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- No longer a gap: these are the app's real classes, from the shared
|
||||
sheet. Nothing here is a look-alike — change components.css and these
|
||||
specimens change with it, which is the only way this page stays true. -->
|
||||
<section class="design-section">
|
||||
<h2>Buttons</h2>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Save</button>
|
||||
<button class="btn-secondary">Detect</button>
|
||||
<button class="btn-ghost">Cancel</button>
|
||||
<button class="btn-danger">Delete</button>
|
||||
<button class="btn-primary" disabled>Disabled</button>
|
||||
</div>
|
||||
<p class="spec-caption">
|
||||
Three sizes, because the app has three kinds of button: a page action, a
|
||||
row action, and an affordance that sits inside a card without disturbing
|
||||
its rhythm.
|
||||
</p>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Default — page action</button>
|
||||
<button class="btn-primary btn-compact">Compact — row action</button>
|
||||
<button class="btn-primary btn-inline">Inline</button>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
|
||||
<span class="spec-name">{{ b.name }}</span>
|
||||
<span class="spec-detail">{{ b.spec }}</span>
|
||||
<span class="spec-status ok">shared</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
||||
<section class="design-section">
|
||||
<h2>Type scale</h2>
|
||||
<div class="gap-notice">
|
||||
<strong>Families load; the scale has no tokens.</strong>
|
||||
<p>
|
||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
||||
scale is not expressed as custom properties, so sizes and weights are
|
||||
set ad hoc per component. Listed here as specification, not as a live
|
||||
specimen — there is nothing to read.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
||||
<span class="spec-name">{{ t.token }}</span>
|
||||
<span class="spec-detail">{{ t.spec }}</span>
|
||||
<span class="spec-status missing">no token</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Tokens: entirely real, read live. -->
|
||||
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
|
||||
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
|
||||
<ul class="token-list">
|
||||
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
|
||||
<span
|
||||
v-if="isColourish(token.value)"
|
||||
class="swatch"
|
||||
:style="{ background: token.value }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<code class="token-value">{{ token.value || "—" }}</code>
|
||||
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
||||
mode-aware
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!tokens.length" class="muted">Reading tokens…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-view {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--page-padding-x) 4rem;
|
||||
}
|
||||
|
||||
.design-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 60ch;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.design-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.design-section h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.token-group-heading {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.section-note,
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Specimens -------------------------------------------------------------- */
|
||||
|
||||
.specimen {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.specimen-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.specimen-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Gaps ------------------------------------------------------------------- */
|
||||
|
||||
.spec-caption {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Layout only. The buttons inside style themselves from the shared sheet —
|
||||
adding any appearance rule here would recreate the copy this section
|
||||
just stopped being. */
|
||||
.button-specimens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-3);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice p {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.spec-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spec-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spec-name {
|
||||
min-width: 8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spec-detail {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spec-status {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.ok {
|
||||
background: var(--color-status-done-bg);
|
||||
color: var(--color-status-done);
|
||||
}
|
||||
|
||||
.spec-name .swatch {
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.matches {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reveal-toggle {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reveal-toggle:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Tokens ----------------------------------------------------------------- */
|
||||
|
||||
.token-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.swatch-none {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
var(--color-border) 3px,
|
||||
var(--color-border) 4px
|
||||
);
|
||||
}
|
||||
|
||||
.token-name {
|
||||
min-width: 16rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-flag {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.token-name {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@ async function handleSubmit() {
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
|
||||
{{ submitting ? "Sending..." : "Send Reset Link" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -137,24 +137,6 @@ async function handleSubmit() {
|
||||
.success-msg p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -27,6 +27,10 @@ interface KnowledgeItem {
|
||||
project_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Set only when another user owns this record — their suggestion, not one of
|
||||
// yours. Absent means it's yours.
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
// Task-specific
|
||||
status?: string;
|
||||
priority?: string;
|
||||
@@ -377,7 +381,7 @@ onUnmounted(() => {
|
||||
<option value="alpha">Alphabetical</option>
|
||||
<option value="type">By type</option>
|
||||
</select>
|
||||
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<button class="btn-ghost btn-compact" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
@@ -437,6 +441,11 @@ onUnmounted(() => {
|
||||
<div class="k-card-tags">
|
||||
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.shared"
|
||||
class="shared-tag"
|
||||
:title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`"
|
||||
>by {{ item.owner ?? "another user" }}</span>
|
||||
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,14 +463,14 @@ onUnmounted(() => {
|
||||
<span>Graph</span>
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
class="btn-text"
|
||||
@click="toggleGraphExpand"
|
||||
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
||||
>
|
||||
<ChevronLeft v-if="graphExpanded" :size="16" />
|
||||
<ChevronRight v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
|
||||
<button class="btn-text" @click="toggleGraph" title="Close graph">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -565,7 +574,7 @@ onUnmounted(() => {
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
@@ -710,26 +719,6 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.btn-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: transparent;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Card grid ───────────────────────────────────────────── */
|
||||
.card-grid {
|
||||
@@ -839,6 +828,16 @@ onUnmounted(() => {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-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 {
|
||||
font-size: 0.68rem;
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
.k-card-task {
|
||||
@@ -937,22 +936,6 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-icon-sm:hover { color: var(--color-text); }
|
||||
.graph-embed {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||
.graph-embed :deep(.graph-page) {
|
||||
height: 100%;
|
||||
|
||||
@@ -84,7 +84,7 @@ function loginWithOAuth() {
|
||||
<p class="forgot-link">
|
||||
<router-link to="/forgot-password">Forgot your password?</router-link>
|
||||
</p>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
|
||||
{{ submitting ? "Signing in..." : "Sign In" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -98,7 +98,7 @@ function loginWithOAuth() {
|
||||
|
||||
<button
|
||||
v-if="authStore.oauthEnabled"
|
||||
class="btn-oauth"
|
||||
class="btn-ghost btn-block"
|
||||
@click="loginWithOAuth"
|
||||
>
|
||||
Login with Authentik
|
||||
@@ -176,24 +176,6 @@ function loginWithOAuth() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -208,20 +190,6 @@ function loginWithOAuth() {
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-oauth {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-oauth:hover {
|
||||
background: var(--color-bg-hover, var(--color-border));
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -177,7 +177,7 @@ function clearFilters() {
|
||||
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
|
||||
<button
|
||||
v-if="category || search || dateFrom || dateTo"
|
||||
class="btn-clear"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear
|
||||
@@ -337,19 +337,6 @@ function clearFilters() {
|
||||
.filter-date {
|
||||
width: 140px;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-clear:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.loading-msg,
|
||||
|
||||
@@ -748,7 +748,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
|
||||
.btn-link-all:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
.link-suggest-list {
|
||||
display: flex;
|
||||
|
||||
@@ -194,22 +194,22 @@ async function convertToTask() {
|
||||
</div>
|
||||
<template v-else-if="store.currentNote">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">← Notes</router-link>
|
||||
<router-link to="/notes" class="btn-ghost">← Notes</router-link>
|
||||
<router-link
|
||||
:to="`/notes/${store.currentNote.id}/edit`"
|
||||
class="btn-edit"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="!store.currentNote.is_task"
|
||||
class="btn-convert"
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToTask"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Task" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent → project → milestone -->
|
||||
@@ -329,81 +329,10 @@ async function convertToTask() {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit: Moss action-primary — switching from view to edit is operating
|
||||
the software, not a brand moment. */
|
||||
.btn-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.note-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
|
||||
@@ -113,6 +113,45 @@ function truncate(text: string | null, max = 120): string {
|
||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
||||
}
|
||||
|
||||
// A card is a glance, not a report. Roundtable had ~35 milestones and its tile
|
||||
// ran several viewport-heights tall, which made the grid unreadable (#2391).
|
||||
const MAX_MILESTONE_BARS = 10;
|
||||
|
||||
interface MilestoneBar extends MilestoneSummary {
|
||||
/** Position in the FULL list, so a bar keeps its colour when another
|
||||
* milestone is added or finishes. Tying the palette to the visible index
|
||||
* would recolour the card every time work closed. */
|
||||
paletteIndex: number;
|
||||
}
|
||||
|
||||
/** Bars to draw per project, plus how many were withheld.
|
||||
*
|
||||
* Ordered OPEN WORK FIRST, newest first. Recency alone would be wrong here: a
|
||||
* long-running project's oldest milestones are usually its finished ones, so
|
||||
* showing 10 completed bars while hiding the 3 in flight is worse than showing
|
||||
* nothing. What the card is for is "what is happening", not "what happened".
|
||||
*
|
||||
* Computed once per load rather than called from the template — a helper in a
|
||||
* v-for is re-run on every render, and this one sorts.
|
||||
*/
|
||||
const milestoneBars = computed(() => {
|
||||
const byProject = new Map<number, { bars: MilestoneBar[]; hidden: number }>();
|
||||
for (const project of projects.value) {
|
||||
const all = project.summary?.milestone_summary ?? [];
|
||||
const indexed: MilestoneBar[] = all.map((ms, i) => ({ ...ms, paletteIndex: i }));
|
||||
const newestFirst = (a: MilestoneBar, b: MilestoneBar) => b.id - a.id;
|
||||
const ordered = [
|
||||
...indexed.filter((m) => m.pct < 100).sort(newestFirst),
|
||||
...indexed.filter((m) => m.pct >= 100).sort(newestFirst),
|
||||
];
|
||||
byProject.set(project.id, {
|
||||
bars: ordered.slice(0, MAX_MILESTONE_BARS),
|
||||
hidden: Math.max(0, ordered.length - MAX_MILESTONE_BARS),
|
||||
});
|
||||
}
|
||||
return byProject;
|
||||
});
|
||||
|
||||
function overallPct(project: Project): { total: number; pct: number } {
|
||||
const counts = project.summary?.task_counts;
|
||||
if (!counts) return { total: 0, pct: 0 };
|
||||
@@ -185,11 +224,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
<!-- Milestone progress bars -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
v-if="milestoneBars.get(project.id)?.bars.length"
|
||||
class="milestone-bars"
|
||||
>
|
||||
<div
|
||||
v-for="(ms, i) in project.summary.milestone_summary"
|
||||
v-for="ms in milestoneBars.get(project.id)!.bars"
|
||||
:key="ms.id"
|
||||
class="milestone-bar-row"
|
||||
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
|
||||
@@ -198,11 +237,23 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
<div class="milestone-bar-track">
|
||||
<div
|
||||
class="milestone-bar-fill"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(ms.paletteIndex) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
||||
</div>
|
||||
<!-- Say what is withheld. A list that simply stops reads as a
|
||||
rendering bug; a count reads as a summary. Plain text, not a
|
||||
link: the whole card already navigates to this project, and a
|
||||
link nested inside a clickable region is a trap for keyboard
|
||||
and screen-reader users. -->
|
||||
<span
|
||||
v-if="milestoneBars.get(project.id)!.hidden"
|
||||
class="milestone-more"
|
||||
>
|
||||
+{{ milestoneBars.get(project.id)!.hidden }}
|
||||
{{ milestoneBars.get(project.id)!.hidden === 1 ? 'more milestone' : 'more milestones' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
@@ -284,20 +335,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
/* Moss action-primary per Hybrid — list-view utility action,
|
||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
@@ -340,8 +377,8 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-primary); color: #fff; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-action-primary); border-radius: var(--radius-sm); color: var(--color-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
.skeleton-card {
|
||||
height: 140px;
|
||||
@@ -506,6 +543,14 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Deliberately quiet — it is a footnote about what is not shown, not another
|
||||
row competing with the bars above it. */
|
||||
.milestone-more {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-size-tiny);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -592,9 +637,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
|
||||
@@ -9,6 +9,11 @@ import { renderMarkdown } from "@/utils/markdown";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||
import SystemsSection from "@/components/SystemsSection.vue";
|
||||
import {
|
||||
fetchDesignSystems,
|
||||
setProjectDesignSystem,
|
||||
type DesignSystem,
|
||||
} from "@/api/designSystems";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Clock,
|
||||
@@ -41,6 +46,7 @@ interface Project {
|
||||
goal: string | null;
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
design_system_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -69,6 +75,12 @@ const toast = useToastStore();
|
||||
const tasksStore = useTasksStore();
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
|
||||
// Design system the project is styled from. Loaded separately because an
|
||||
// install with none is the ordinary case (rule #115) and the picker simply
|
||||
// doesn't render — a failed fetch must not take the project page with it.
|
||||
const designSystems = ref<DesignSystem[]>([]);
|
||||
const editDesignSystemId = ref<number | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const showStartPlanning = ref(false);
|
||||
@@ -175,6 +187,7 @@ async function loadProject() {
|
||||
editDescription.value = data.description ?? "";
|
||||
editGoal.value = data.goal ?? "";
|
||||
editStatus.value = data.status;
|
||||
editDesignSystemId.value = data.design_system_id ?? null;
|
||||
editDirty.value = false;
|
||||
milestones.value = data.summary?.milestone_summary ?? [];
|
||||
autoCollapseCompleted(milestones.value);
|
||||
@@ -336,8 +349,20 @@ onMounted(async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadDesignSystems();
|
||||
});
|
||||
|
||||
/** Populate the design-system picker. Swallows failure on purpose: with no
|
||||
* design systems the picker doesn't render at all, which is the ordinary state
|
||||
* for most installs — so this must never be able to break the project page. */
|
||||
async function loadDesignSystems() {
|
||||
try {
|
||||
designSystems.value = (await fetchDesignSystems()).design_systems;
|
||||
} catch {
|
||||
designSystems.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
@@ -345,28 +370,39 @@ watch(projectId, async () => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
|
||||
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
|
||||
() => {
|
||||
if (!project.value) return;
|
||||
editDirty.value =
|
||||
editTitle.value !== project.value.title ||
|
||||
editDescription.value !== (project.value.description ?? "") ||
|
||||
editGoal.value !== (project.value.goal ?? "") ||
|
||||
editStatus.value !== project.value.status;
|
||||
editStatus.value !== project.value.status ||
|
||||
editDesignSystemId.value !== (project.value.design_system_id ?? null);
|
||||
}
|
||||
);
|
||||
|
||||
async function saveProject() {
|
||||
if (!project.value || saving.value) return;
|
||||
// Bound once rather than re-read: the checks below straddle two awaits, and
|
||||
// `project.value` is a ref whose narrowing doesn't survive them.
|
||||
const current = project.value;
|
||||
if (!current || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
|
||||
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
|
||||
title: editTitle.value.trim(),
|
||||
description: editDescription.value.trim() || null,
|
||||
goal: editGoal.value.trim() || null,
|
||||
status: editStatus.value,
|
||||
});
|
||||
project.value = { ...project.value, ...updated };
|
||||
// The design-system pointer is its own endpoint (PUT, because clearing it
|
||||
// is a real outcome rather than an omission), so it saves separately —
|
||||
// only when it actually changed, to keep the common save at one request.
|
||||
if (editDesignSystemId.value !== (current.design_system_id ?? null)) {
|
||||
await setProjectDesignSystem(current.id, editDesignSystemId.value);
|
||||
updated.design_system_id = editDesignSystemId.value;
|
||||
}
|
||||
project.value = { ...current, ...updated };
|
||||
editDirty.value = false;
|
||||
toast.show("Project saved");
|
||||
} catch {
|
||||
@@ -399,7 +435,7 @@ async function confirmDelete() {
|
||||
|
||||
<!-- Nav bar -->
|
||||
<div class="page-header">
|
||||
<router-link to="/projects" class="btn-back">← Projects</router-link>
|
||||
<router-link to="/projects" class="btn-ghost">← Projects</router-link>
|
||||
<div class="page-header-actions">
|
||||
<template v-if="showStartPlanning">
|
||||
<input
|
||||
@@ -415,7 +451,7 @@ async function confirmDelete() {
|
||||
>
|
||||
Create plan
|
||||
</button>
|
||||
<button class="btn-share" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
||||
<button class="btn-secondary btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="project"
|
||||
@@ -428,7 +464,7 @@ async function confirmDelete() {
|
||||
<LayoutGrid :size="16" />
|
||||
Workspace
|
||||
</router-link>
|
||||
<button v-if="project && !showStartPlanning" class="btn-share" @click="showShare = true">Share</button>
|
||||
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -505,7 +541,14 @@ async function confirmDelete() {
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
|
||||
<div v-if="designSystems.length" class="edit-field">
|
||||
<label class="edit-label" for="project-design-system">Design system</label>
|
||||
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" @click="saveProject" :disabled="!editDirty || saving">
|
||||
{{ saving ? "Saving..." : "Save Changes" }}
|
||||
</button>
|
||||
</aside>
|
||||
@@ -538,7 +581,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="milestone-actions">
|
||||
<button v-if="!showNewMilestone" class="btn-add-milestone" @click="showNewMilestone = true">
|
||||
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
|
||||
+ Milestone
|
||||
</button>
|
||||
<div v-else class="new-milestone-row">
|
||||
@@ -550,10 +593,10 @@ async function confirmDelete() {
|
||||
@keydown.enter="createMilestone"
|
||||
@keydown.escape="showNewMilestone = false; newMilestoneTitle = ''"
|
||||
/>
|
||||
<button class="btn-ms-confirm" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
|
||||
<button class="btn-primary btn-compact" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
|
||||
{{ creatingMilestone ? "..." : "Add" }}
|
||||
</button>
|
||||
<button class="btn-ms-cancel" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
|
||||
<button class="btn-secondary btn-compact" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -813,80 +856,9 @@ async function confirmDelete() {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
|
||||
the project's "central feature moment" — entering the focused workspace
|
||||
is a Scribe-flavored action, not a plain operation. */
|
||||
.btn-workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); }
|
||||
|
||||
/* Share: Bronze action-secondary — alternate path */
|
||||
.btn-share {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Delete project: Oxblood action-destructive ghost — outline form since
|
||||
the actual confirm modal carries the filled destructive treatment */
|
||||
.btn-danger-outline {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
|
||||
/* ── Project identity header ─────────────────────────────────── */
|
||||
.project-header { margin-bottom: 1rem; }
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.project-title-input {
|
||||
flex: 1;
|
||||
font-size: 1.75rem;
|
||||
@@ -1019,30 +991,6 @@ async function confirmDelete() {
|
||||
.edit-textarea { resize: vertical; }
|
||||
|
||||
/* Save panel: Moss action-primary per Hybrid rule */
|
||||
.btn-save-panel {
|
||||
padding: 0.45rem 0.9rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* ── Content area ────────────────────────────────────────────── */
|
||||
.content-area { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tab-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1105,49 +1053,6 @@ async function confirmDelete() {
|
||||
}
|
||||
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
||||
.btn-ms-confirm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-ms-cancel {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* ── Milestone group ─────────────────────────────────────────── */
|
||||
.milestone-group {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
.milestone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.milestone-header.clickable { cursor: pointer; }
|
||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||
|
||||
@@ -1181,7 +1086,7 @@ async function confirmDelete() {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||
.ms-plan-actions .btn-primary { background: var(--color-action-primary); color: var(--fs-text-on-action); border-color: var(--color-action-primary); }
|
||||
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
|
||||
|
||||
@@ -1350,8 +1255,8 @@ async function confirmDelete() {
|
||||
line-height: 1;
|
||||
}
|
||||
.task-card:hover .task-advance-btn { opacity: 1; }
|
||||
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
|
||||
.task-advance-btn:hover { background: var(--color-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: var(--fs-text-on-action); }
|
||||
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.priority-dot {
|
||||
@@ -1432,7 +1337,7 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
|
||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||
|
||||
/* ── Skeleton ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -144,7 +144,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Creating Account..." : "Create Account" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -247,24 +247,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -117,7 +117,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Creating account..." : "Create Account" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -216,24 +216,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -88,7 +88,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Resetting..." : "Reset Password" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -195,24 +195,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
+147
-241
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
|
||||
import { listRulebooks } from "@/api/rulebooks";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
@@ -22,6 +23,20 @@ const retentionSaved = ref(false);
|
||||
const kbInjectEnabled = ref(true);
|
||||
const kbInjectThreshold = ref("0.55");
|
||||
const kbInjectTopK = ref("3");
|
||||
const kbWritePathEnabled = ref(true);
|
||||
// The write-path arm's OWN threshold, stricter than auto-inject's 0.55 above:
|
||||
// code embeddings sit on a much higher similarity floor than prose, so 0.55 let
|
||||
// unrelated code through (#2223). Shares top-k, not the threshold.
|
||||
const kbWritePathThreshold = ref("0.68");
|
||||
// Near-duplicate report floor. Deliberately looser than the 0.90 write-time
|
||||
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
||||
// suggests a merge the operator reviews (services/dedup.py).
|
||||
const kbDuplicateThreshold = ref("0.82");
|
||||
// Which rulebook describes this install's design system, for the /design drift
|
||||
// panel. Empty = none designated, which is the normal state for a fresh install
|
||||
// rather than a misconfiguration — the panel explains itself when unset.
|
||||
const designRulebookId = ref("");
|
||||
const designRulebooks = ref<{ id: number; title: string }[]>([]);
|
||||
const savingKbInject = ref(false);
|
||||
const kbInjectSaved = ref(false);
|
||||
|
||||
@@ -67,8 +82,17 @@ async function saveRetention() {
|
||||
async function saveKbInject() {
|
||||
const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0));
|
||||
const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1)));
|
||||
// `|| 0.82` not `|| 0`: an unparseable value here should fall back to the
|
||||
// default, not to 0 — a 0 floor would report every snippet as a duplicate of
|
||||
// every other one.
|
||||
const dupT = Math.min(1, Math.max(0, Number(kbDuplicateThreshold.value) || 0.82));
|
||||
// Same `|| default` reasoning as dupT: falling back to 0 would surface every
|
||||
// snippet in the corpus on every edit, which is the failure this knob fixes.
|
||||
const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68));
|
||||
kbInjectThreshold.value = String(t);
|
||||
kbInjectTopK.value = String(k);
|
||||
kbDuplicateThreshold.value = String(dupT);
|
||||
kbWritePathThreshold.value = String(wpT);
|
||||
savingKbInject.value = true;
|
||||
kbInjectSaved.value = false;
|
||||
try {
|
||||
@@ -76,6 +100,15 @@ async function saveKbInject() {
|
||||
kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false',
|
||||
kb_autoinject_threshold: String(t),
|
||||
kb_autoinject_top_k: String(k),
|
||||
// Its own switch AND its own threshold (shares only the ceiling) — see
|
||||
// WRITEPATH_DEFAULT_THRESHOLD in services/plugin_context.py for the
|
||||
// measurements that split them.
|
||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||
kb_writepath_threshold: String(wpT),
|
||||
kb_duplicate_threshold: String(dupT),
|
||||
// Empty string DELETES the setting (see routes/settings.py), which is
|
||||
// exactly right for "no design rulebook" — absent rather than zero.
|
||||
design_rulebook_id: designRulebookId.value,
|
||||
});
|
||||
kbInjectSaved.value = true;
|
||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||
@@ -459,6 +492,21 @@ onMounted(async () => {
|
||||
if (allSettings.kb_autoinject_top_k !== undefined) {
|
||||
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||||
}
|
||||
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
|
||||
if (allSettings.kb_writepath_threshold !== undefined) {
|
||||
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
|
||||
}
|
||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
||||
}
|
||||
designRulebookId.value = allSettings.design_rulebook_id ?? "";
|
||||
// Best-effort: the picker degrades to "none available" rather than blocking
|
||||
// the whole settings page if rulebooks can't be listed.
|
||||
try {
|
||||
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
|
||||
} catch {
|
||||
designRulebooks.value = [];
|
||||
}
|
||||
if (allSettings.notify_task_reminders !== undefined) {
|
||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||
}
|
||||
@@ -1117,7 +1165,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
||||
<button class="btn-primary" @click="saveTimezone" :disabled="savingTimezone">
|
||||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1142,7 +1190,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveRetention" :disabled="savingRetention">
|
||||
<button class="btn-primary" @click="saveRetention" :disabled="savingRetention">
|
||||
{{ savingRetention ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="retentionSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1193,8 +1241,79 @@ function formatUserDate(iso: string): string {
|
||||
/>
|
||||
<p class="field-hint">Ceiling on titles surfaced at once (1–10).</p>
|
||||
</div>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="kbWritePathEnabled" />
|
||||
Also surface prior art when Claude writes code
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Checks the file Claude is about to write or edit against your recorded
|
||||
snippets — what's already kept at that path, and what resembles the code
|
||||
being written — so a helper you already have is offered before it's
|
||||
rewritten. Uses the ceiling above with its own threshold below, and never
|
||||
blocks the edit. Off = prior art surfaces only on your own prompts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-writepath-threshold">Prior-art confidence threshold (0–1)</label>
|
||||
<input
|
||||
id="kb-writepath-threshold"
|
||||
v-model="kbWritePathThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Stricter than the prompt threshold above on purpose. Any two pieces of
|
||||
code look somewhat alike — shared keywords, indentation, structure — so
|
||||
resemblance scores start higher for code than for prose, and a bar tuned
|
||||
for prompts flags unrelated code as prior art. Lower this if genuine
|
||||
duplicates go unnoticed; raise it if you're being offered snippets that
|
||||
have nothing to do with what's being written. Snippets recorded at the
|
||||
exact file are always shown regardless — those are prior art by
|
||||
location, not by resemblance.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="design-rulebook">Design-system rulebook</label>
|
||||
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
|
||||
<option value="">None — don't check for design drift</option>
|
||||
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
|
||||
{{ rb.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Which rulebook describes how this app should look. Once set, the
|
||||
<router-link to="/design">Design</router-link> page compares every colour
|
||||
and token your rules name against what the stylesheet actually resolves
|
||||
to, and reports where they disagree. Leave it as None if your rules
|
||||
don't describe a design system — nothing else depends on this.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
|
||||
<input
|
||||
id="kb-duplicate-threshold"
|
||||
v-model="kbDuplicateThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
How alike two snippets must be before the Snippets page suggests merging
|
||||
them. Lower = more suggestions, more false pairs. Looser than the 0.90
|
||||
used to block a duplicate at creation, because this only proposes a merge
|
||||
you review — it never acts on its own.
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveKbInject" :disabled="savingKbInject">
|
||||
<button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject">
|
||||
{{ savingKbInject ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1242,7 +1361,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
class="btn-primary"
|
||||
@click="changeEmail"
|
||||
:disabled="changingEmail || !emailPassword"
|
||||
>
|
||||
@@ -1290,7 +1409,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
class="btn-primary"
|
||||
@click="changePassword"
|
||||
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
||||
>
|
||||
@@ -1346,7 +1465,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1372,7 +1491,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1382,7 +1501,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
|
||||
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1414,7 +1533,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1443,7 +1562,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveNotifications" :disabled="savingNotifications">
|
||||
<button class="btn-primary" @click="saveNotifications" :disabled="savingNotifications">
|
||||
{{ savingNotifications ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1470,7 +1589,7 @@ function formatUserDate(iso: string): string {
|
||||
placeholder="Enter a search query..."
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
||||
<button class="btn-primary" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
||||
{{ searchLoading ? "Searching..." : "Search" }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1772,7 +1891,7 @@ function formatUserDate(iso: string): string {
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
|
||||
<button class="btn-primary" @click="saveBaseUrl" :disabled="savingBaseUrl">
|
||||
{{ savingBaseUrl ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1797,7 +1916,7 @@ function formatUserDate(iso: string): string {
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
|
||||
<button class="btn-primary" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
|
||||
{{ savingMarketplaceUrl ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1827,10 +1946,10 @@ function formatUserDate(iso: string): string {
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveDbMaintenance" :disabled="savingDbMaint">
|
||||
<button class="btn-primary" @click="saveDbMaintenance" :disabled="savingDbMaint">
|
||||
{{ savingDbMaint ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button class="btn-save btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
|
||||
<button class="btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
|
||||
{{ runningDbMaint ? "Running..." : "Run now" }}
|
||||
</button>
|
||||
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1921,7 +2040,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom: 1.25rem;">
|
||||
<button class="btn-save" @click="saveSmtp" :disabled="savingSmtp">
|
||||
<button class="btn-primary" @click="saveSmtp" :disabled="savingSmtp">
|
||||
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
|
||||
</button>
|
||||
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1935,7 +2054,7 @@ function formatUserDate(iso: string): string {
|
||||
placeholder="test@example.com"
|
||||
class="input"
|
||||
/>
|
||||
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
||||
<button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
||||
{{ sendingTest ? "Sending..." : "Send Test" }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1960,7 +2079,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">When closed, new users can only be added by an administrator.</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
class="btn-primary btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
@@ -1981,7 +2100,7 @@ function formatUserDate(iso: string): string {
|
||||
required
|
||||
:disabled="sendingInvite"
|
||||
/>
|
||||
<button type="submit" class="btn-save" :disabled="sendingInvite || !inviteEmail.trim()">
|
||||
<button type="submit" class="btn-primary" :disabled="sendingInvite || !inviteEmail.trim()">
|
||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -2003,7 +2122,7 @@ function formatUserDate(iso: string): string {
|
||||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button class="btn-delete" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
|
||||
<button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
|
||||
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
||||
</button>
|
||||
</td>
|
||||
@@ -2042,13 +2161,13 @@ function formatUserDate(iso: string): string {
|
||||
<span class="you-label">You</span>
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button class="btn-confirm-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">
|
||||
<button class="btn-danger btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="btn-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
|
||||
<button class="btn-ghost btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2186,10 +2305,10 @@ function formatUserDate(iso: string): string {
|
||||
<span v-if="g.description" class="group-desc">{{ g.description }}</span>
|
||||
</div>
|
||||
<div class="group-card-actions">
|
||||
<button class="btn-sm" @click="toggleGroupExpand(g)">
|
||||
<button class="btn-ghost btn-compact" @click="toggleGroupExpand(g)">
|
||||
{{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
|
||||
</button>
|
||||
<button class="btn-sm btn-danger-sm" @click="deleteGroupConfirm(g)">Delete</button>
|
||||
<button class="btn-danger-outline btn-compact" @click="deleteGroupConfirm(g)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2219,7 +2338,7 @@ function formatUserDate(iso: string): string {
|
||||
<li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
|
||||
<span class="member-name">{{ m.username }}</span>
|
||||
<span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
|
||||
<button class="btn-sm btn-danger-sm" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
|
||||
<button class="btn-danger-outline btn-compact" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
|
||||
</li>
|
||||
<li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
|
||||
</ul>
|
||||
@@ -2445,82 +2564,6 @@ function formatUserDate(iso: string): string {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Save: Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
|
||||
/* Danger outline (Invalidate sessions, Clear observations, etc.):
|
||||
Oxblood action-destructive ghost — fills on hover */
|
||||
.btn-danger-outline {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: none;
|
||||
color: var(--color-action-destructive);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover:not(:disabled) {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Filled destructive: Oxblood action-destructive */
|
||||
.btn-danger {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Secondary: Bronze action-secondary — alternate paths (Detect, Test,
|
||||
Refresh, Add slot, etc.). Outline form for visual lightness. */
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* DB maintenance last-run summary */
|
||||
.db-maint-last { margin-top: 1rem; }
|
||||
.db-maint-last-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.db-maint-table-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -2566,7 +2609,7 @@ function formatUserDate(iso: string): string {
|
||||
.db-health-table tr.dh-warn td:first-child code { color: var(--color-warning); }
|
||||
.btn-warn:hover:not(:disabled) {
|
||||
background: var(--color-warning);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
.saved-msg {
|
||||
@@ -2866,68 +2909,6 @@ function formatUserDate(iso: string): string {
|
||||
}
|
||||
.you-label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||
.btn-delete:disabled { opacity: 0.4; cursor: default; }
|
||||
/* Two-stage destructive: Confirm = Oxblood filled, Cancel = Bronze ghost */
|
||||
.btn-confirm-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-right: 0.25rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-cancel-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
|
||||
/* Toggle (Open/Close registration, etc.): Open = Moss, Close = Pewter ghost */
|
||||
.btn-toggle {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-toggle-open { background: var(--color-action-primary); color: #fff; }
|
||||
.btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-toggle-close {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); }
|
||||
.loading-msg, .empty-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* Logs panel */
|
||||
.stats-section { padding: 1rem 1.25rem; }
|
||||
@@ -3029,20 +3010,6 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
/* ── Groups tab ──────────────────────────────────────────────── */
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-primary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
@@ -3125,32 +3092,6 @@ function formatUserDate(iso: string): string {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-danger-sm:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||
|
||||
.group-members-panel {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.members-search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.member-search-wrap {
|
||||
flex: 1;
|
||||
@@ -3272,7 +3213,7 @@ function formatUserDate(iso: string): string {
|
||||
}
|
||||
.unit-btn.active {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.unit-btn:hover:not(.active) {
|
||||
color: var(--color-text);
|
||||
@@ -3663,21 +3604,6 @@ function formatUserDate(iso: string): string {
|
||||
text-align: right;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-remove-slot {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-remove-slot:hover {
|
||||
color: var(--color-danger, #e05555);
|
||||
border-color: var(--color-danger, #e05555);
|
||||
}
|
||||
.blend-actions {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@@ -3731,24 +3657,4 @@ function formatUserDate(iso: string): string {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-danger-outline {
|
||||
padding: 0.45rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover:not(:disabled) {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
|
||||
@keyframes va-dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
getSnippet,
|
||||
deleteSnippet,
|
||||
unmergeSnippet,
|
||||
type Snippet,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
|
||||
const snippet = ref<Snippet | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
const id = computed(() => Number(route.params.id));
|
||||
|
||||
const canWrite = computed(() => {
|
||||
const p = snippet.value?.permission;
|
||||
return p === undefined || p === "owner" || p === "edit" || p === "admin";
|
||||
});
|
||||
|
||||
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
||||
|
||||
// What this record absorbed. Merge keeps the target's fields and trashes the
|
||||
// sources, so this line is the only visible trace that the variants existed.
|
||||
const mergedFrom = computed(() => snippet.value?.snippet.merged_from ?? []);
|
||||
|
||||
function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
|
||||
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
|
||||
}
|
||||
|
||||
// Un-merge (#2165)
|
||||
const unmerging = ref<number | null>(null);
|
||||
|
||||
/** Only entries carrying what the source contributed can be reversed exactly.
|
||||
* Without that, subtraction would be a guess that could strip call sites the
|
||||
* survivor owns in its own right — so the control isn't offered. */
|
||||
function canUnmerge(entry: { locations?: unknown[]; tags?: unknown[] }): boolean {
|
||||
return canWrite.value && (!!entry.locations?.length || !!entry.tags?.length);
|
||||
}
|
||||
|
||||
async function doUnmerge(sourceId: number) {
|
||||
unmerging.value = sourceId;
|
||||
try {
|
||||
await unmergeSnippet(id.value, sourceId);
|
||||
toast.show(`#${sourceId} pulled back out and restored`);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
// 409 carries the reason the record's state makes it impossible — show it
|
||||
// rather than a generic failure, since it's the actionable part.
|
||||
const detail = (e as { body?: { error?: string } }).body?.error;
|
||||
toast.show(detail || `Couldn't un-merge #${sourceId}`, "error");
|
||||
} finally {
|
||||
unmerging.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
snippet.value = await getSnippet(id.value);
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { status?: number }).status;
|
||||
error.value = status === 404
|
||||
? "This snippet couldn't be found."
|
||||
: "Couldn't load this snippet.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function copyCode() {
|
||||
const code = snippet.value?.snippet.code ?? "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
toast.show("Code copied");
|
||||
} catch {
|
||||
toast.show("Couldn't copy — select and copy manually", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await deleteSnippet(id.value);
|
||||
toast.show("Snippet deleted");
|
||||
router.push("/snippets");
|
||||
} catch {
|
||||
toast.show("Failed to delete snippet", "error");
|
||||
} finally {
|
||||
showDeleteConfirm.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippet-detail">
|
||||
<router-link to="/snippets" class="back-link">← Snippets</router-link>
|
||||
|
||||
<div v-if="loading" class="state-msg">Fetching the snippet…</div>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<template v-else-if="snippet">
|
||||
<div class="detail-header">
|
||||
<h1 class="snippet-name">{{ snippet.snippet.name }}</h1>
|
||||
<div class="header-actions" v-if="canWrite">
|
||||
<button class="btn-ghost" @click="router.push(`/snippets/${id}/edit`)">Edit</button>
|
||||
<button class="btn-danger" @click="showDeleteConfirm = true">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="snippet.shared" class="shared-notice">
|
||||
Shared by <strong>{{ snippet.owner ?? "another user" }}</strong> — their
|
||||
suggestion, not one of your own records. Worth weighing on its merits
|
||||
before you build on it.
|
||||
</p>
|
||||
|
||||
<p v-if="snippet.snippet.when_to_use" class="when-to-use">
|
||||
{{ snippet.snippet.when_to_use }}
|
||||
</p>
|
||||
|
||||
<dl class="meta-grid">
|
||||
<template v-if="snippet.snippet.signature">
|
||||
<dt>Signature</dt>
|
||||
<dd><code>{{ snippet.snippet.signature }}</code></dd>
|
||||
</template>
|
||||
<template v-if="locations.length">
|
||||
<dt>{{ locations.length > 1 ? "Locations" : "Location" }}</dt>
|
||||
<dd class="location-list">
|
||||
<div v-for="(loc, i) in locations" :key="i" class="location">
|
||||
<code v-for="(p, j) in locParts(loc)" :key="j">{{ p }}</code>
|
||||
</div>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="snippet.snippet.language">
|
||||
<dt>Language</dt>
|
||||
<dd>{{ snippet.snippet.language }}</dd>
|
||||
</template>
|
||||
<template v-if="mergedFrom.length">
|
||||
<dt>Merged from</dt>
|
||||
<dd class="merged-from">
|
||||
<span v-for="m in mergedFrom" :key="m.id" class="merged-entry">
|
||||
<span>#{{ m.id }}</span>
|
||||
<button
|
||||
v-if="canUnmerge(m)"
|
||||
class="unmerge-btn"
|
||||
:disabled="unmerging === m.id"
|
||||
:title="`Pull #${m.id} back out: restore it and remove the ${(m.locations ?? []).length} location(s) it contributed`"
|
||||
@click="doUnmerge(m.id)"
|
||||
>
|
||||
{{ unmerging === m.id ? "…" : "un-merge" }}
|
||||
</button>
|
||||
<!-- Says why rather than hiding the control: a disabled thing with
|
||||
no explanation reads as a bug. -->
|
||||
<span
|
||||
v-else-if="canWrite"
|
||||
class="unmerge-na"
|
||||
:title="`This merge predates per-source provenance, so what #${m.id} contributed isn't recorded. Restore it from the trash and adjust both records by hand — subtracting a guess could strip call sites this record genuinely owns.`"
|
||||
>
|
||||
(not reversible)
|
||||
</span>
|
||||
</span>
|
||||
<span class="merged-hint">
|
||||
folded in here — the originals are in the trash
|
||||
</span>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="code-block">
|
||||
<div class="code-bar">
|
||||
<span class="code-lang">{{ snippet.snippet.language || "code" }}</span>
|
||||
<button class="btn-ghost btn-copy" @click="copyCode">Copy</button>
|
||||
</div>
|
||||
<pre><code>{{ snippet.snippet.code }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div v-if="snippet.tags.length" class="tag-row">
|
||||
<span v-for="t in snippet.tags" :key="t" class="tag-pill">{{ t }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
title="Delete snippet"
|
||||
:message="`Delete “${snippet?.snippet.name}”? This can be restored from the trash.`"
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snippet-detail {
|
||||
max-width: 820px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.snippet-name {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-size: 1.4rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.when-to-use {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Shown only for a record another user owns. Deliberately above the code: the
|
||||
provenance has to be read before the implementation is trusted. */
|
||||
.shared-notice {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-left: 3px solid var(--color-text-muted);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.meta-grid dt {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.meta-grid dd {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.meta-grid code,
|
||||
.tag-row + * code {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-size: 0.82rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
.location-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.location {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.merged-from {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.merged-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.merged-entry {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.unmerge-btn {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.unmerge-btn:hover:not(:disabled) {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
.unmerge-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.unmerge-na {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
/* Cursor cues that the explanation is in the tooltip. */
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.code-lang {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-copy {
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.code-block code {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.tag-pill {
|
||||
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);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.15rem 0;
|
||||
}
|
||||
.meta-grid dd {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,582 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
getSnippet,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
type SnippetInput,
|
||||
type SnippetLocation,
|
||||
} from "@/api/snippets";
|
||||
import { ApiError } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
const systemsStore = useSystemsStore();
|
||||
|
||||
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||||
const isEditing = computed(() => editId.value !== null);
|
||||
|
||||
// All-string scalar state (tags/locations handled separately) so v-model and
|
||||
// .trim() never meet an undefined.
|
||||
interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
language: string;
|
||||
signature: string;
|
||||
when_to_use: string;
|
||||
}
|
||||
|
||||
const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" });
|
||||
|
||||
const form = ref<FormState>({
|
||||
name: "",
|
||||
code: "",
|
||||
language: "",
|
||||
signature: "",
|
||||
when_to_use: "",
|
||||
});
|
||||
// A snippet that unified several one-offs carries several locations; a fresh one
|
||||
// starts with a single blank row.
|
||||
const locations = ref<SnippetLocation[]>([blankLocation()]);
|
||||
|
||||
function addLocation() {
|
||||
locations.value.push(blankLocation());
|
||||
}
|
||||
function removeLocation(i: number) {
|
||||
locations.value.splice(i, 1);
|
||||
}
|
||||
const tagsText = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const systemIds = ref<number[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
const nameRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Systems belong to a project, so the picker only has anything to offer once
|
||||
// one is chosen — and it repopulates when the project changes.
|
||||
const projectSystems = computed(() =>
|
||||
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
|
||||
);
|
||||
|
||||
async function loadSystems() {
|
||||
if (!projectId.value) return;
|
||||
try {
|
||||
await systemsStore.fetchSystems(projectId.value);
|
||||
} catch {
|
||||
/* non-fatal — the systems picker just won't populate */
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, (next, prev) => {
|
||||
// Moving to another project invalidates system ids from the old one.
|
||||
if (prev !== null && next !== prev) systemIds.value = [];
|
||||
loadSystems();
|
||||
});
|
||||
|
||||
const canSave = computed(
|
||||
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
|
||||
);
|
||||
|
||||
// A near-duplicate blocked on create: the same reusable thing is already
|
||||
// recorded. Recording it twice is what merge then has to undo, so the editor
|
||||
// offers the existing record before it offers to save anyway.
|
||||
interface DuplicateHit {
|
||||
existing_id: number;
|
||||
existing_title: string;
|
||||
}
|
||||
const duplicate = ref<DuplicateHit | null>(null);
|
||||
const forceCreate = ref(false);
|
||||
|
||||
function duplicateFrom(err: unknown): DuplicateHit | null {
|
||||
if (!(err instanceof ApiError) || err.status !== 409) return null;
|
||||
const body = err.body as Record<string, unknown>;
|
||||
if (!body.duplicate) return null;
|
||||
return {
|
||||
existing_id: Number(body.existing_id),
|
||||
existing_title: String(body.existing_title ?? "an existing snippet"),
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!isEditing.value) {
|
||||
// Recording from a project page carries the project through, so the snippet
|
||||
// lands where the work is instead of unfiled.
|
||||
if (route.query.projectId) {
|
||||
projectId.value = Number(route.query.projectId);
|
||||
await loadSystems();
|
||||
}
|
||||
await nextTick();
|
||||
nameRef.value?.focus();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const s = await getSnippet(editId.value!);
|
||||
const f = s.snippet;
|
||||
form.value = {
|
||||
name: f.name,
|
||||
code: f.code,
|
||||
language: f.language,
|
||||
signature: f.signature,
|
||||
when_to_use: f.when_to_use,
|
||||
};
|
||||
locations.value = f.locations?.length
|
||||
? f.locations.map((l) => ({ ...l }))
|
||||
: [blankLocation()];
|
||||
// The stored tag list carries language + "snippet" markers, which the
|
||||
// backend re-derives on save — show only the caller's extra tags here.
|
||||
tagsText.value = s.tags
|
||||
.filter((t) => t !== "snippet" && t !== f.language)
|
||||
.join(", ");
|
||||
projectId.value = s.project_id ?? null;
|
||||
systemIds.value = (s.systems ?? []).map((sys) => sys.id);
|
||||
await loadSystems();
|
||||
} catch {
|
||||
loadError.value = "Couldn't load this snippet to edit.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
function parseTags(): string[] {
|
||||
return tagsText.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function cleanLocations(): SnippetLocation[] {
|
||||
return locations.value
|
||||
.map((l) => ({ repo: l.repo.trim(), path: l.path.trim(), symbol: l.symbol.trim() }))
|
||||
.filter((l) => l.repo || l.path || l.symbol);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return;
|
||||
saving.value = true;
|
||||
const payload: SnippetInput = {
|
||||
name: form.value.name.trim(),
|
||||
code: form.value.code,
|
||||
language: form.value.language.trim(),
|
||||
signature: form.value.signature.trim(),
|
||||
when_to_use: form.value.when_to_use.trim(),
|
||||
locations: cleanLocations(),
|
||||
tags: parseTags(),
|
||||
project_id: projectId.value,
|
||||
system_ids: systemIds.value,
|
||||
};
|
||||
if (forceCreate.value) payload.force = true;
|
||||
try {
|
||||
const result = isEditing.value
|
||||
? await updateSnippet(editId.value!, payload)
|
||||
: await createSnippet(payload);
|
||||
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
|
||||
router.push(`/snippets/${result.id}`);
|
||||
} catch (err) {
|
||||
// A blocked near-duplicate isn't a failure — it's the record telling you
|
||||
// this already exists. Offer the existing one rather than a red toast.
|
||||
const dup = duplicateFrom(err);
|
||||
if (dup) {
|
||||
duplicate.value = dup;
|
||||
saving.value = false;
|
||||
return;
|
||||
}
|
||||
toast.show("Failed to save snippet", "error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnyway() {
|
||||
duplicate.value = null;
|
||||
forceCreate.value = true;
|
||||
await save();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEditing.value) router.push(`/snippets/${editId.value}`);
|
||||
else router.push("/snippets");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippet-editor" @keydown.ctrl.s.prevent="save" @keydown.meta.s.prevent="save">
|
||||
<router-link :to="isEditing ? `/snippets/${editId}` : '/snippets'" class="back-link">
|
||||
← {{ isEditing ? "Back to snippet" : "Snippets" }}
|
||||
</router-link>
|
||||
|
||||
<h1>{{ isEditing ? "Edit snippet" : "New snippet" }}</h1>
|
||||
|
||||
<div v-if="loading" class="state-msg">Loading…</div>
|
||||
<p v-else-if="loadError" class="error-msg">{{ loadError }}</p>
|
||||
|
||||
<form v-else class="form" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label for="sn-name">Name <span class="required">*</span></label>
|
||||
<input
|
||||
id="sn-name"
|
||||
ref="nameRef"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input mono"
|
||||
placeholder="useDebouncedRef"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-when">When to reach for it</label>
|
||||
<input
|
||||
id="sn-when"
|
||||
v-model="form.when_to_use"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Debounce a reactive ref that updates too often"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
<p class="hint">Shown in the recall menu — keep it sharp.</p>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="sn-lang">Language</label>
|
||||
<input
|
||||
id="sn-lang"
|
||||
v-model="form.language"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="typescript"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sn-sig">Signature</label>
|
||||
<input
|
||||
id="sn-sig"
|
||||
v-model="form.signature"
|
||||
type="text"
|
||||
class="input mono"
|
||||
placeholder="useDebouncedRef(value, ms)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="location-set">
|
||||
<legend>
|
||||
Locations
|
||||
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||||
</legend>
|
||||
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
||||
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||
<button
|
||||
type="button"
|
||||
class="loc-remove"
|
||||
aria-label="Remove location"
|
||||
title="Remove location"
|
||||
@click="removeLocation(i)"
|
||||
>×</button>
|
||||
</div>
|
||||
<button type="button" class="loc-add" @click="addLocation">+ Add location</button>
|
||||
</fieldset>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-code">Code <span class="required">*</span></label>
|
||||
<textarea
|
||||
id="sn-code"
|
||||
v-model="form.code"
|
||||
class="input mono code-area"
|
||||
rows="14"
|
||||
spellcheck="false"
|
||||
placeholder="Paste the reusable implementation…"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-tags">Tags</label>
|
||||
<input
|
||||
id="sn-tags"
|
||||
v-model="tagsText"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="composable, ui (comma-separated)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
<p class="hint">Extra tags. Language and “snippet” are added automatically.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-project">Project</label>
|
||||
<ProjectSelector id="sn-project" v-model="projectId" />
|
||||
<p class="hint">
|
||||
Snippets surface proactively within their project; search reaches across all of them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId" class="field">
|
||||
<span class="field-label">Systems</span>
|
||||
<div v-if="projectSystems.length" class="systems">
|
||||
<label v-for="s in projectSystems" :key="s.id" class="system-opt">
|
||||
<input type="checkbox" :value="s.id" v-model="systemIds" />
|
||||
<span>{{ s.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-else class="hint">No systems in this project yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="duplicate" class="duplicate" role="alert">
|
||||
<p class="duplicate-title">This may already be recorded</p>
|
||||
<p class="duplicate-body">
|
||||
A similar snippet exists —
|
||||
<router-link :to="`/snippets/${duplicate.existing_id}`">
|
||||
{{ duplicate.existing_title }}
|
||||
</router-link
|
||||
>. Reuse or edit that one rather than keeping two copies of the same thing.
|
||||
</p>
|
||||
<div class="duplicate-actions">
|
||||
<button type="button" class="btn-secondary" @click="saveAnyway">
|
||||
Record it anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" :disabled="!canSave">
|
||||
{{ saving ? "Saving…" : isEditing ? "Save changes" : "Create snippet" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snippet-editor {
|
||||
max-width: 760px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.snippet-editor h1 {
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.field-row.three {
|
||||
grid-template-columns: 1fr 1.4fr 1fr;
|
||||
}
|
||||
.field label,
|
||||
.location-set legend {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
.hint-inline {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.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);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
}
|
||||
.code-area {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.location-set {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.location-set legend {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
.loc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.loc-remove {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.loc-remove:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.loc-add {
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.loc-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.loc-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.systems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.system-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.system-opt input {
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duplicate {
|
||||
display: flex;
|
||||
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-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.duplicate-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.duplicate-body {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.duplicate-body a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.duplicate-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.field-row,
|
||||
.field-row.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -645,7 +645,7 @@ useEditorGuards(dirty, save);
|
||||
@focus="onParentFocus"
|
||||
@blur="hideParentDropdown"
|
||||
/>
|
||||
<button v-if="parentId" class="btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
||||
<button v-if="parentId" class="btn-text btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
||||
</div>
|
||||
<div v-if="showParentDropdown" class="parent-dropdown">
|
||||
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
||||
@@ -666,7 +666,7 @@ useEditorGuards(dirty, save);
|
||||
<div v-if="isEditing" class="subtasks-section">
|
||||
<div class="subtasks-header">
|
||||
<span class="subtasks-label">Sub-tasks</span>
|
||||
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
|
||||
<button class="btn-text" @click="addingSubTask = !addingSubTask">+ Add</button>
|
||||
</div>
|
||||
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
|
||||
<template v-else>
|
||||
@@ -686,8 +686,8 @@ useEditorGuards(dirty, save);
|
||||
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
|
||||
autofocus
|
||||
/>
|
||||
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||||
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
||||
<button class="btn-primary btn-compact" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||||
<button class="btn-ghost btn-compact" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -921,23 +921,6 @@ useEditorGuards(dirty, save);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.btn-add-subtask {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
padding: 0.1rem 0.2rem;
|
||||
}
|
||||
.btn-add-subtask:hover { opacity: 0.8; }
|
||||
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
.subtask-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
||||
.subtask-title {
|
||||
font-size: 0.83rem;
|
||||
@@ -968,33 +951,6 @@ useEditorGuards(dirty, save);
|
||||
font-family: inherit;
|
||||
}
|
||||
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.btn-subtask-confirm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-subtask-cancel {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Streaming preview */
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -1125,24 +1081,4 @@ useEditorGuards(dirty, save);
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: normal;
|
||||
}
|
||||
.btn-reconsolidate {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: normal;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.btn-reconsolidate:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-reconsolidate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -263,29 +263,29 @@ const subTaskProgress = computed(() => {
|
||||
<div class="toolbar">
|
||||
<router-link
|
||||
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
||||
class="btn-back"
|
||||
class="btn-ghost"
|
||||
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
||||
<router-link
|
||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||
class="btn-edit"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="advanceLabel"
|
||||
class="btn-advance"
|
||||
class="btn-primary"
|
||||
@click="advanceStatus"
|
||||
>
|
||||
{{ advanceLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-convert"
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToNote"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent task → project → milestone -->
|
||||
@@ -475,83 +475,6 @@ const subTaskProgress = computed(() => {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit + Advance: Moss action-primary — both are "operating the software"
|
||||
workflow actions, not brand moments. */
|
||||
.btn-edit,
|
||||
.btn-advance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover,
|
||||
.btn-advance:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.task-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
|
||||
@@ -25,7 +25,7 @@ onMounted(() => store.fetchTrash());
|
||||
<h1>Trash</h1>
|
||||
<button
|
||||
v-if="store.batches.length"
|
||||
class="btn-empty"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="empty"
|
||||
>Empty trash</button>
|
||||
</header>
|
||||
@@ -52,8 +52,8 @@ onMounted(() => store.fetchTrash());
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-actions">
|
||||
<button class="btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
||||
<button class="btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
||||
<button class="btn-ghost btn-compact btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
||||
<button class="btn-ghost btn-compact btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -64,25 +64,11 @@ onMounted(() => store.fetchTrash());
|
||||
.trash-page { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
|
||||
.trash-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.trash-header h1 { font-family: Fraunces, serif; font-style: italic; margin: 0; }
|
||||
.btn-empty {
|
||||
background: none; border: 1px solid var(--color-border, #2a2a2e);
|
||||
color: inherit; border-radius: 6px; padding: 0.4rem 0.8rem; cursor: pointer;
|
||||
}
|
||||
.btn-empty:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
||||
.trash-note { opacity: 0.7; font-size: 0.9em; margin: 0.5rem 0 1.5rem; }
|
||||
.trash-loading, .trash-empty { opacity: 0.6; font-style: italic; padding: 2rem 0; }
|
||||
.batch-list { list-style: none; padding: 0; margin: 0; }
|
||||
.batch {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 1rem; padding: 0.85rem 1rem; margin-bottom: 0.5rem;
|
||||
background: var(--color-surface, #18181b); border-radius: 8px;
|
||||
border-left: 2px solid var(--color-border, #2a2a2e);
|
||||
}
|
||||
.batch-summary { font-weight: 500; }
|
||||
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
|
||||
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
|
||||
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
|
||||
.btn-restore:hover { border-color: var(--color-primary, #6366f1); color: var(--color-primary, #6366f1); }
|
||||
.btn-purge:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
||||
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
|
||||
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||
</style>
|
||||
|
||||
@@ -167,7 +167,7 @@ function formatDate(iso: string): string {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
class="btn-primary btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
@@ -190,7 +190,7 @@ function formatDate(iso: string): string {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-invite"
|
||||
class="btn-primary"
|
||||
:disabled="sendingInvite || !inviteEmail.trim()"
|
||||
>
|
||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||
@@ -216,7 +216,7 @@ function formatDate(iso: string): string {
|
||||
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
class="btn-delete"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="revokeInvitation(inv.id)"
|
||||
:disabled="revokingId !== null"
|
||||
>
|
||||
@@ -262,17 +262,17 @@ function formatDate(iso: string): string {
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button
|
||||
class="btn-confirm-delete"
|
||||
class="btn-danger btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="btn-delete"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
@@ -328,24 +328,6 @@ function formatDate(iso: string): string {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-invite {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-invite:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-invite:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.invite-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -380,26 +362,8 @@ function formatDate(iso: string): string {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-toggle {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-toggle-open {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-toggle-open:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* The one genuine override: 'close registration' must NOT read as the
|
||||
primary action it sits on. Scoped, so it beats the shared variant. */
|
||||
.btn-toggle-close {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
@@ -478,54 +442,6 @@ function formatDate(iso: string): string {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-confirm-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.btn-confirm-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-cancel-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-cancel-delete:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.registration-row {
|
||||
|
||||
@@ -1,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), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.12",
|
||||
"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",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
+15
-4
@@ -7,8 +7,13 @@ instance into a first-class Claude Code extension:
|
||||
rulebook (the `scribe` server).
|
||||
- **Session-start push channel** — a `SessionStart` hook injects your always-on
|
||||
rules + active-project context so Scribe surfaces *without being asked*.
|
||||
- **Universal process-skills** — brainstorm, systematic-debugging, TDD,
|
||||
writing-plans, verification, receiving-code-review (replaces superpowers).
|
||||
- **Prior-art recall on writes** — a `PreToolUse` hook on Write/Edit checks the
|
||||
file about to be written against your recorded snippets (what's kept at that
|
||||
path, and what resembles the code) and offers them before the helper is
|
||||
rewritten. Titles only, never blocks the edit.
|
||||
- **Universal process-skills** — using-scribe, writing-plans,
|
||||
systematic-debugging, verification, brainstorming, reusing-code (record and
|
||||
recall reusable code as snippets). Replaces superpowers.
|
||||
- **Your Scribe Processes as skills** — saved Processes are synced into local
|
||||
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
|
||||
stub fetches the live procedure via `get_process`. Refreshed each session and
|
||||
@@ -41,6 +46,11 @@ On install you'll be asked for:
|
||||
- `hooks/hooks.json` → SessionStart hook (`hooks/scribe_session_context.sh`),
|
||||
**fail-open**: if Scribe is unreachable it injects nothing and never blocks
|
||||
the session.
|
||||
- `hooks/hooks.json` → PreToolUse hook on `Write|Edit`
|
||||
(`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.
|
||||
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`
|
||||
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
||||
@@ -51,5 +61,6 @@ On install you'll be asked for:
|
||||
|
||||
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
|
||||
pick up changes.
|
||||
- The session-start hook needs only a **read**-scoped key; the MCP tools need
|
||||
**write** scope to create/update.
|
||||
- The session-start, auto-inject and prior-art hooks need only a **read**-scoped
|
||||
key; the MCP tools need **write** scope to create/update. Every hook is a GET
|
||||
for that reason — a read key cannot POST.
|
||||
|
||||
@@ -23,6 +23,17 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_prior_art.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
# static floor here. If the instance is unconfigured/unreachable, or anything
|
||||
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
||||
#
|
||||
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code:
|
||||
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
||||
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
|
||||
# with the userConfig key UPPERCASED (see #2198 — reading the lowercase spelling
|
||||
# silently disables this hook, and silence is indistinguishable from "nothing
|
||||
# cleared the threshold"):
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
#
|
||||
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
||||
@@ -32,8 +35,8 @@ event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_c
|
||||
# Nothing to retrieve against.
|
||||
[ -n "$prompt" ] || exit 0
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
@@ -41,15 +44,23 @@ case "$token" in *'${'*) token="" ;; esac
|
||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||
|
||||
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
||||
q=$(printf '%s' "$prompt" | cut -c1-2000)
|
||||
q_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || exit 0
|
||||
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
|
||||
# long multi-line prompt sailed past the budget entirely. Same defect as the
|
||||
# prior-art hook's code cap; this copy was missed when that one was fixed, and
|
||||
# scripts/check_plugin.py caught it.
|
||||
q=$(printf '%s' "$prompt" | head -c 2000)
|
||||
# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as
|
||||
# several lines joined by raw newlines and the request died. Single-line prompts
|
||||
# worked, which is why this looked healthy — the long, substantial prompts most
|
||||
# worth retrieving against were exactly the ones silently dropped. -s slurps.
|
||||
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||
|
||||
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
|
||||
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe plugin — PreToolUse write-path trigger (prior-art recall).
|
||||
#
|
||||
# Auto-inject (scribe_autoinject.sh) fires on the operator's prompt. The moment
|
||||
# reuse is actually lost is later: when the AGENT decides mid-task to write a
|
||||
# helper. This hook fires there — on Write/Edit — and asks the operator's Scribe
|
||||
# instance what prior art is already recorded for the target file: a snippet at
|
||||
# that path or in its directory, plus snippets resembling the code about to be
|
||||
# written. Titles + ids only, never bodies.
|
||||
#
|
||||
# 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
|
||||
# recall aid must not be able to stop the operator's work.
|
||||
#
|
||||
# Config (same as the other hooks), exported to the hook by Claude Code with the
|
||||
# userConfig key UPPERCASED (see #2198 — the lowercase spelling reads as empty
|
||||
# and this hook then exits 0 in silence, looking exactly like "no prior art"):
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
|
||||
event=$(cat 2>/dev/null || true)
|
||||
file_path=$(printf '%s' "$event" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0
|
||||
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
||||
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
||||
|
||||
[ -n "$file_path" ] || exit 0
|
||||
|
||||
# The code about to be written. Write and Edit name this field differently, and
|
||||
# the names have changed across Claude Code versions — take whichever is present
|
||||
# rather than betting on one shape.
|
||||
code=$(printf '%s' "$event" | jq -r '
|
||||
.tool_input.content // .tool_input.file_content //
|
||||
.tool_input.new_string // .tool_input.new_str // empty' 2>/dev/null) || code=""
|
||||
|
||||
# Skip formats that hold prose or data rather than reusable code. Purely to
|
||||
# avoid a pointless round-trip — the server would return nothing for these
|
||||
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
||||
# often exactly the thing worth reusing.
|
||||
case "$file_path" in
|
||||
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
||||
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
||||
# because the local arm below needs the repo root and needs no server at all.
|
||||
lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true)
|
||||
[ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
rel_path="$file_path"
|
||||
if [ -n "$repo_root" ]; then
|
||||
case "$file_path" in
|
||||
"$repo_root"/*) rel_path="${file_path#"$repo_root"/}" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
|
||||
# of the codebase, so a helper nobody thought to record is invisible to them —
|
||||
# which is how `.btn-primary` came to be defined four times, in four scoped
|
||||
# stylesheets, already diverged. It was never a snippet, so no threshold and no
|
||||
# query rewrite could ever have surfaced it.
|
||||
#
|
||||
# This arm closes that by asking the only question the record cannot answer,
|
||||
# in the only place that can: the hook already runs on the developer's machine,
|
||||
# inside the repo, holding the code about to be written. No index, no storage,
|
||||
# no staleness, and no server — it deliberately runs even on an install that
|
||||
# has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none.
|
||||
# ---------------------------------------------------------------------------
|
||||
local_lines=""
|
||||
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
||||
# kind<TAB>name for each thing this payload DEFINES.
|
||||
names=$(printf '%s' "$code" | awk '
|
||||
match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t);
|
||||
if (t != "") print "css\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) {
|
||||
t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) {
|
||||
t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t);
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
' 2>/dev/null | sort -u | head -12) || names=""
|
||||
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written, which would
|
||||
# otherwise always match itself on an Edit.
|
||||
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
|
||||
done <<< "$names"
|
||||
fi
|
||||
|
||||
local_context=""
|
||||
if [ -n "$local_lines" ]; then
|
||||
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
|
||||
fi
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
|
||||
# arm above already ran and may have something to say.
|
||||
if [ -z "$url" ] || [ -z "$token" ]; then
|
||||
if [ -n "$local_context" ]; then
|
||||
jq -n --arg c "$local_context" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cap the code sent as the semantic query. The embedder truncates at its own
|
||||
# token limit well before this, so a bigger slice buys no extra signal — and the
|
||||
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
|
||||
# plugin hook works with a read key).
|
||||
# `head -c`, not `cut -c1-1200`: cut is line-oriented and caps each line
|
||||
# separately, so a 400-line edit sailed past the "1200 char" budget entirely and
|
||||
# built a URL from the whole payload. head -c caps the total, which is the point.
|
||||
q=$(printf '%s' "$code" | head -c 1200)
|
||||
|
||||
# `-sRr`, not `-rR`: jq -R reads input LINE BY LINE, so a multi-line payload came
|
||||
# back as several separately-encoded lines joined by raw newlines — an invalid
|
||||
# URL that made curl fail, and this hook then exited 0 in silence. -s slurps the
|
||||
# whole input into one string first. Newlines are exactly what code contains, so
|
||||
# this hook could never have worked without it (issue #2198 / #2082).
|
||||
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
|
||||
|
||||
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||
repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
|
||||
# Per-session dedup, in its own file rather than sharing auto-inject's. Each
|
||||
# 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.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
idfile=""
|
||||
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"
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${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=""
|
||||
|
||||
context=""
|
||||
if [ -n "$body" ]; then
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
||||
# Remember what was surfaced so it isn't shown again this session.
|
||||
if [ -n "$idfile" ] && [ -n "$context" ]; then
|
||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Local first. It answers "this already EXISTS", which is a stronger claim than
|
||||
# "this resembles something recorded" — and it is the one the recorded arms are
|
||||
# structurally unable to make.
|
||||
combined="$local_context"
|
||||
if [ -n "$context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${context}"
|
||||
fi
|
||||
[ -n "$combined" ] || exit 0
|
||||
|
||||
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
||||
jq -n --arg c "$combined" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
exit 0
|
||||
@@ -4,16 +4,18 @@
|
||||
# Tier 1 (STATIC, always fires, no auth, no network): injects a bundled
|
||||
# behavioral mandate (scribe_static_context.md) so a fresh session knows to
|
||||
# reach for Scribe — record work, recall before acting — even when the instance
|
||||
# is unreachable OR the API token never reached this hook. The latter is a known
|
||||
# Claude Code gap: sensitive userConfig values aren't always exported to the
|
||||
# hook subprocess, so the dynamic tier can silently get nothing. The static tier
|
||||
# is the load-bearing floor that does not depend on the key or the network.
|
||||
# is unreachable or unconfigured. The static tier is the load-bearing floor that
|
||||
# does not depend on the key or the network.
|
||||
#
|
||||
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
||||
# for always-on rules + active-project context and appends it. Config comes from
|
||||
# the plugin's userConfig, exported to hooks as:
|
||||
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so
|
||||
# the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the
|
||||
# lowercase spelling silently yields nothing — that was issue #2198, and it
|
||||
# disabled the dynamic tier, auto-inject, and the write-path trigger at once.
|
||||
# The active project is resolved server-side from the working repo's git remote
|
||||
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
|
||||
#
|
||||
@@ -25,20 +27,25 @@
|
||||
# can't make the model flush, and can't know the in-flight task ids; the durable
|
||||
# path is record-as-you-go + this post-compaction reload.)
|
||||
#
|
||||
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in
|
||||
# hooks.json — sensitive values are kept in the keychain and never spliced into
|
||||
# a hook command line, so the placeholder arrives unexpanded. The harness env
|
||||
# vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for
|
||||
# the settings.json dogfooding path.
|
||||
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a
|
||||
# shell-form hooks.json command — Claude Code rejects that outright (splicing a
|
||||
# configured value into a shell command line would let the shell run whatever it
|
||||
# contains). The env vars above are the supported channel; SCRIBE_URL /
|
||||
# SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
#
|
||||
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session. A *failed*
|
||||
# dynamic fetch is surfaced as a short status line (not swallowed). A fully
|
||||
# unconfigured install (no url AND no token) is the intended static-only mode
|
||||
# and stays quiet.
|
||||
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session, and every
|
||||
# way it can come up empty produces a short status line — failed fetch, missing
|
||||
# token, and missing-everything alike. Nothing about the credential path is
|
||||
# allowed to fail quietly; see the #2198 comment at the status block below.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
|
||||
|
||||
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
|
||||
# with CDPATH empty, so an operator whose CDPATH happens to contain a matching
|
||||
# directory name can't send us somewhere else — and `cd` won't echo the resolved
|
||||
# path into our output. shellcheck can't tell that idiom from `CDPATH=cd`.
|
||||
# shellcheck disable=SC1007
|
||||
here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
|
||||
|
||||
# SessionStart delivers a JSON event on stdin; `source` is startup|resume|compact|clear.
|
||||
@@ -55,8 +62,8 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
|
||||
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
||||
|
||||
# --- 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:-}}
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
|
||||
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
|
||||
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
|
||||
@@ -71,7 +78,7 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
||||
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||
q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && q="?repo=${enc}"
|
||||
fi
|
||||
body=$(curl -fsS --max-time 8 \
|
||||
@@ -80,9 +87,16 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||
# Endpoint configured but token absent: the signature of the known Claude Code
|
||||
# userConfig export gap (sensitive values not always reaching the hook).
|
||||
status="> ⚠️ Scribe: live context disabled this session — the API token did not reach this hook (a known Claude Code plugin-config gap). Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
|
||||
# install", which made issue #2198 invisible for weeks: a *casing* bug here
|
||||
# (reading CLAUDE_PLUGIN_OPTION_api_token when Claude Code exports the key
|
||||
# UPPERCASED) looks identical to never having configured the plugin, and
|
||||
# silently disabled auto-inject and the write-path trigger too. It is not a
|
||||
# benign state — the plugin prompts for both values at enable time, so if
|
||||
# neither reached the hook, something is wrong. Say so.
|
||||
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
fi
|
||||
|
||||
[ -n "$dyn" ] && append "$dyn"
|
||||
|
||||
@@ -37,6 +37,11 @@ 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.
|
||||
- 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
|
||||
|
||||
@@ -18,14 +18,16 @@
|
||||
# FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's
|
||||
# safe as a second SessionStart hook). On any fetch failure it exits without
|
||||
# touching existing stubs — a transient outage must not wipe the user's skills.
|
||||
# Config mirrors the context hook (CLAUDE_PLUGIN_OPTION_* / SCRIBE_* override).
|
||||
# Config mirrors the context hook: CLAUDE_PLUGIN_OPTION_API_ENDPOINT /
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN (userConfig key UPPERCASED by Claude Code — see
|
||||
# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded `${...}` placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# Reusing code — recall before you rebuild
|
||||
|
||||
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 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
|
||||
|
||||
- About to write a utility, hook, formatter, adapter, or a reusable component?
|
||||
**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
|
||||
helper you need here was quite possibly written somewhere else. Narrow with
|
||||
`project_id` only when you specifically want this project's own.
|
||||
- **About to edit an existing file? Ask by place, not just by meaning.**
|
||||
`list_snippets(repo="…", path="…")` answers "what canonical helpers are already
|
||||
recorded here?" — `path` matches the exact file or anything beneath it, so
|
||||
`path="frontend/src"` covers the whole tree. Cheaper and sharper than a
|
||||
wording search when you already know where the code is going, and it catches
|
||||
the helper you'd otherwise duplicate a few lines down. `symbol="…"` narrows
|
||||
further, and all three must match the same recorded location. Combine with `q`
|
||||
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.
|
||||
- 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
|
||||
that a snippet is already recorded for the file you just wrote or edited, open
|
||||
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.
|
||||
|
||||
## The moment you build something reusable — record it
|
||||
|
||||
- Just wrote (or noticed) a helper, hook, pattern, or component worth repeating?
|
||||
Record it with `create_snippet` while it's fresh:
|
||||
- **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
|
||||
of the title, so it's what a later recall menu shows — make it earn the pull.
|
||||
- **language**, **signature**, and **location** (`repo` / `path` / `symbol`)
|
||||
so the recorded copy points back at the canonical source.
|
||||
- **project_id** / **system_ids** to associate it with the work it belongs to.
|
||||
- Record the *reference* implementation, not every call site — one good entry
|
||||
per reusable thing. If it already exists, `update_snippet` it instead of
|
||||
recording a second copy (the create gate will flag a near-duplicate anyway).
|
||||
|
||||
## A shared snippet is a suggestion, not a standard
|
||||
|
||||
Scribe is multi-user, so a search can return snippets other people own. Those
|
||||
come back marked `shared: true` with an `owner`.
|
||||
|
||||
- Read one as **that person's suggestion**, not as the way things are done here.
|
||||
Judge the code on its merits before reaching for it.
|
||||
- Say whose it is when you propose it — "there's a snippet from *alex* that does
|
||||
this" — so the operator can weigh the source, not just the code.
|
||||
- Don't treat it as the house pattern, and don't build on it at scale, without
|
||||
the operator agreeing to adopt it.
|
||||
- Snippets shared directly with the operator only appear when you search for
|
||||
them, never in a plain `list_snippets` — so anything ambient is genuinely
|
||||
theirs.
|
||||
|
||||
## Keep the record honest
|
||||
|
||||
A recorded snippet is offered as prior art on every matching turn, so a wrong
|
||||
one costs more than a missing one.
|
||||
|
||||
- Details gone stale — a renamed symbol, a moved file, a signature that's
|
||||
changed? Fix it with `update_snippet`. Passing an **empty string** clears a
|
||||
field, so a wrong signature or location can be removed, not just written over.
|
||||
- Recorded something that turned out not to be reusable, or that no longer
|
||||
exists? Retire it with `delete_snippet` — it goes to the trash and can be
|
||||
restored. Don't leave it competing for attention.
|
||||
|
||||
## Found the same thing in several places — unify it
|
||||
|
||||
When you notice the same reusable thing recorded (or written) as several
|
||||
one-offs, don't leave the duplicates competing in recall — **merge them**.
|
||||
`merge_snippets(canonical_id, [other_ids])` keeps one canonical record, folds in
|
||||
the others' call sites as locations, and retires the duplicates to the trash.
|
||||
The result is a single entry that shows every place the thing is used — which is
|
||||
exactly the signal that it was worth consolidating. This is the cure the create
|
||||
gate only hints at when it blocks a near-duplicate.
|
||||
|
||||
## 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.
|
||||
@@ -64,11 +64,13 @@ Two constraints on *how* that's achieved:
|
||||
3. **Update over duplicate.** When recording, prefer updating an existing
|
||||
note/rule/task over creating a new one. Search first; revise what's there.
|
||||
|
||||
4. **Plans live in Scribe.** For non-trivial work call `start_planning(project_id,
|
||||
title)` FIRST — it creates a milestone whose `body` holds the design; each
|
||||
step is its own task under that milestone (`create_task(milestone_id=...)`),
|
||||
progress goes in work-logs (`add_task_log`). Read it back with `get_milestone`.
|
||||
Do not write plans/specs to local `.md` files.
|
||||
4. **When you plan, plan in Scribe.** Work with an *arc* — several steps toward
|
||||
one goal — gets a plan, and a plan is a milestone: `start_planning(project_id,
|
||||
title)` creates one whose `body` holds the design, each step is its own task
|
||||
under it (`create_task(milestone_id=...)`), progress goes in work-logs
|
||||
(`add_task_log`). Work without an arc (a fix, a one-file change, a question)
|
||||
is just a task — don't wrap it in a milestone. Either way, do not write
|
||||
plans/specs to local `.md` files. See the **writing-plans** skill.
|
||||
|
||||
5. **Keep state honest.** Set a task `in_progress` when you start it, `done` the
|
||||
moment it's complete; log progress as you go.
|
||||
@@ -102,7 +104,7 @@ shared homes general:
|
||||
norms that bind *every* project. Cross-project standards only.
|
||||
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
|
||||
reusable, *themed* module of general rules that binds only projects that opt
|
||||
in (e.g. a design system → visual apps). Themed, but still project-agnostic.
|
||||
in (e.g. a review checklist → every service). Themed, but project-agnostic.
|
||||
- **Project rule** (`create_project_rule`) — anything specific to one project
|
||||
(its files, paths, quirks).
|
||||
|
||||
@@ -112,6 +114,26 @@ project rule; a standard a category shares → subscribed rulebook; a universal
|
||||
norm → always-on rulebook. Never put project-specific detail in a shared
|
||||
rulebook — it leaks to every other project that gets it.
|
||||
|
||||
**First ask whether it's a rule at all.** A rule is prose you have to remember
|
||||
and apply; Scribe's other entities are structure a tool can resolve and check.
|
||||
Visual standards belong in a **design system**, not a rulebook — a token can be
|
||||
inherited, resolved per mode, rendered to a stylesheet and diffed against code,
|
||||
and none of that survives being written as a rule. A repeatable procedure is a
|
||||
**process**; reusable code is a **snippet**. Reach for a rule when the thing
|
||||
really is a standing instruction about how to work.
|
||||
|
||||
## Building UI: the project's design system binds
|
||||
|
||||
`enter_project` returns a `design_system` when the project has one, with the
|
||||
guidance **chain-merged** — the house style it inherits plus its own departures
|
||||
from it. Treat it the way you treat a rule.
|
||||
|
||||
Before writing a colour, size, radius, weight or duration by hand, reach for a
|
||||
token: `resolve_design_system(id)` for the values, or
|
||||
`get_design_system_stylesheet(id)` for the rendered sheet. A literal is a value
|
||||
stated outside the system, so it can never follow a palette change — and
|
||||
nothing will tell you it drifted.
|
||||
|
||||
## Other Scribe process-skills
|
||||
|
||||
This plugin also ships focused process-skills — writing-plans, systematic
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: writing-plans
|
||||
description: Use before starting any non-trivial or multi-step piece of work — produce a clear plan BEFORE diving in. Triggers when the user asks you to plan, design an approach, scope an effort, or tackle work big enough to need ordered steps. The plan lives in a Scribe milestone (via start_planning), not a local file.
|
||||
description: Use when a piece of work has an arc — several steps toward one goal, worth tracking as a unit — and you want the approach reviewable before you start. Triggers when the user asks you to plan, design an approach, or scope an effort, or when work is about to sprawl across several steps. Not for single-step work. The plan lives in a Scribe milestone (via start_planning), not a local file.
|
||||
---
|
||||
|
||||
# Writing plans
|
||||
@@ -9,12 +9,30 @@ A plan is **how** you'll execute a chunk of work — the design plus an ordered
|
||||
set of steps — written *before* you start, so the approach is reviewable and the
|
||||
work stays trackable.
|
||||
|
||||
## Start the plan in Scribe, not a file
|
||||
## First decide whether this work wants a plan
|
||||
|
||||
For non-trivial work, call **`start_planning(project_id, title)` FIRST** —
|
||||
before any design or implementation. It creates a **milestone** (the plan
|
||||
container) seeded with a design template and returns the milestone id plus the
|
||||
project's applicable rules. The plan lives in that milestone:
|
||||
A plan lives in a milestone, and **a milestone earns its place when the work has
|
||||
an arc**: several steps, one shared goal, a beginning and an end worth tracking
|
||||
as a unit. That is the whole test, and it is a judgment about the *shape* of the
|
||||
work — not about its size, difficulty, or importance.
|
||||
|
||||
Plenty of real work has no arc. A bug fix, a one-file change, a question
|
||||
answered, a setting changed. For those, a milestone is a container with one
|
||||
thing in it: the ceremony costs more than it records, and it leaves the project
|
||||
with milestones that never meant anything. **Use a task instead** — set it
|
||||
`in_progress`, record what you find with `add_task_log`, set it `done`. That is
|
||||
a complete, honest record of work that didn't need a plan.
|
||||
|
||||
Some projects are milestone-shaped and some are a flat task list. Read the
|
||||
project you are in rather than imposing a shape on it.
|
||||
|
||||
## When it does: start the plan in Scribe, not a file
|
||||
|
||||
Call **`start_planning(project_id, title)`** before designing or implementing —
|
||||
so the milestone exists to write into, rather than being backfilled from work
|
||||
already done. It creates a **milestone** (the plan container) seeded with a
|
||||
design template and returns the milestone id plus the project's applicable
|
||||
rules. The plan lives in that milestone:
|
||||
|
||||
- The **design/intent** goes in the milestone `body` — edit it with
|
||||
`update_milestone(milestone_id, body=...)`.
|
||||
|
||||
+5
-1
@@ -19,7 +19,11 @@ dependencies = [
|
||||
"caldav>=1.3",
|
||||
"icalendar>=5.0",
|
||||
"APScheduler>=3.10,<4.0",
|
||||
"mcp[cli]>=1.0",
|
||||
# Capped below 2.0: that release removed `mcp.server.fastmcp`, which
|
||||
# src/scribe/mcp/server.py imports to build the whole tool surface. The
|
||||
# ceiling is a real incompatibility, not caution — lift it in the same
|
||||
# change that ports server.py to the 2.x API.
|
||||
"mcp[cli]>=1.0,<2",
|
||||
"fastembed>=0.4",
|
||||
"pgvector>=0.3",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the frontend's CSS against the tokens its stylesheet declares.
|
||||
|
||||
The gap this closes: `services/design_stylesheet.check_code_against_tokens` has
|
||||
always been able to answer "does this code use the sheet correctly?", but the
|
||||
only thing ever fed to it was recorded SNIPPETS. The app's own components — where
|
||||
sixteen unresolvable references were found living quietly (#2319) — were checked
|
||||
by nothing at all.
|
||||
|
||||
That was structural rather than an oversight. The drift panel runs in the browser
|
||||
and cannot read source files, and the server has no repo access. CI is the only
|
||||
place holding both the component sources and the ability to run the check, and it
|
||||
only became cheap once `theme.css` became a generated artifact — so the source of
|
||||
truth is a local file, with no network and no credentials.
|
||||
|
||||
INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should
|
||||
be called or which literals are discouraged. Both come from the stylesheet: the
|
||||
declarations, and the `SUPERSEDES` block the generator emits. Point it at a
|
||||
different install's sheet and it checks that install's rules.
|
||||
|
||||
Two severities, and the split is deliberate:
|
||||
|
||||
FAIL an unresolvable `var()` reference. Currently zero, so this is a ratchet
|
||||
that holds a line already reached rather than a backlog that keeps CI
|
||||
red. It also cannot false-positive: either the name is declared or it
|
||||
is not.
|
||||
REPORT superseded literals and raw colour literals. Hundreds today, so gating
|
||||
on them would mean a permanently failing job that everyone learns to
|
||||
ignore — which is worse than no check.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
# A declaration is `--name:`; a reference is `var(--name)` or `var(--name, …)`.
|
||||
DECLARATION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
|
||||
REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
|
||||
SUPERSEDES_LINE = re.compile(r"^\s*\*\s*(\S+)\s*->\s*(--[A-Za-z0-9_-]+)\s*$")
|
||||
HEX_LITERAL = re.compile(r"#[0-9a-fA-F]{3,8}\b")
|
||||
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
|
||||
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
||||
|
||||
|
||||
def declared_tokens(sheet: str) -> set[str]:
|
||||
"""Every custom property the stylesheet declares.
|
||||
|
||||
Anchored on the colon alone. Anchoring on `{` or `;` instead silently drops
|
||||
every declaration that follows a comment — a mistake made once already, which
|
||||
lost `--color-bg` and 2 others without erroring.
|
||||
"""
|
||||
return set(DECLARATION.findall(sheet))
|
||||
|
||||
|
||||
def superseded_literals(sheet: str) -> dict[str, str]:
|
||||
"""`{literal: token}` from the generator's SUPERSEDES block, lowercased."""
|
||||
out: dict[str, str] = {}
|
||||
for line in sheet.splitlines():
|
||||
match = SUPERSEDES_LINE.match(line)
|
||||
if match:
|
||||
out[match.group(1).lower()] = match.group(2)
|
||||
return out
|
||||
|
||||
|
||||
def _literal_pattern(literal: str) -> re.Pattern:
|
||||
"""Match a literal without matching a longer one containing it.
|
||||
|
||||
`#fff` must not fire inside `#ffffff`: different colours, and a finding on
|
||||
the wrong one sends someone to change correct code.
|
||||
"""
|
||||
return re.compile(
|
||||
r"(?<![0-9A-Za-z_#-])" + re.escape(literal) + r"(?![0-9A-Za-z_-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def style_source(path: pathlib.Path) -> str:
|
||||
"""The CSS in a file — `<style>` blocks for an SFC, the whole of a .css.
|
||||
|
||||
Comments are stripped, and that is load-bearing rather than tidy. A comment
|
||||
EXPLAINING a rule mentions the very literal the rule forbids: this file's own
|
||||
stylesheet documents why it avoids `#fff`, and the first run of this checker
|
||||
reported that explanation as a violation. A checker that flags the
|
||||
documentation of a rule teaches people to stop documenting rules.
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
css = "\n".join(STYLE_BLOCK.findall(text)) if path.suffix == ".vue" else text
|
||||
return CSS_COMMENT.sub(" ", css)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
|
||||
parser.add_argument("--root", default="frontend/src")
|
||||
parser.add_argument(
|
||||
"--report-literals", action="store_true",
|
||||
help="also list raw colour literals (advisory, never fails)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
sheet_path = pathlib.Path(args.sheet)
|
||||
if not sheet_path.is_file():
|
||||
print(f"error: stylesheet not found: {sheet_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
sheet = sheet_path.read_text()
|
||||
declared = declared_tokens(sheet)
|
||||
supersedes = superseded_literals(sheet)
|
||||
print(f"{sheet_path}: {len(declared)} tokens declared, "
|
||||
f"{len(supersedes)} superseded literals recorded\n")
|
||||
|
||||
root = pathlib.Path(args.root)
|
||||
sources = sorted(
|
||||
[p for p in root.rglob("*.vue")] + [p for p in root.rglob("*.css")]
|
||||
)
|
||||
|
||||
unresolved: list[tuple[pathlib.Path, str]] = []
|
||||
superseded_hits: list[tuple[pathlib.Path, str, str]] = []
|
||||
literal_count = 0
|
||||
|
||||
for path in sources:
|
||||
if path == sheet_path:
|
||||
continue
|
||||
css = style_source(path)
|
||||
if not css.strip():
|
||||
continue
|
||||
|
||||
# A component may legitimately declare a local custom property; a
|
||||
# reference to it is not unresolved.
|
||||
local = set(DECLARATION.findall(css))
|
||||
for name in sorted(set(REFERENCE.findall(css))):
|
||||
if name not in declared and name not in local:
|
||||
unresolved.append((path, name))
|
||||
|
||||
for literal, token in supersedes.items():
|
||||
if _literal_pattern(literal).search(css):
|
||||
superseded_hits.append((path, literal, token))
|
||||
|
||||
literal_count += len(HEX_LITERAL.findall(css))
|
||||
|
||||
if unresolved:
|
||||
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
|
||||
print(" These render as the fallback if given one, or as nothing at all.")
|
||||
print(" Either way nothing errors, which is why they survive.\n")
|
||||
for path, name in unresolved:
|
||||
print(f" {path}: {name}")
|
||||
print()
|
||||
else:
|
||||
print("OK — every var() reference resolves to a declared token.\n")
|
||||
|
||||
if superseded_hits:
|
||||
print(f"REPORT — {len(superseded_hits)} superseded literal(s). "
|
||||
"The sheet says what to write instead:")
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for path, literal, token in superseded_hits:
|
||||
key = (str(path), literal)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
print(f" {path}: {literal} -> {token}")
|
||||
print()
|
||||
|
||||
if args.report_literals:
|
||||
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
|
||||
print(" Advisory: a literal is a value stated outside the system, so it "
|
||||
"cannot follow a palette change.\n")
|
||||
|
||||
return 1 if unresolved else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+414
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Guards for `plugin/` — the one part of this repo that ships straight to users.
|
||||
|
||||
WHY THIS EXISTS. `plugin/` is not built into the Docker image. Installs fetch it
|
||||
from this git repo via `.claude-plugin/marketplace.json`, so a push IS the
|
||||
release for plugin content: no build, no gate, immediately fetchable. Two
|
||||
separate defects have reached a live install through that path:
|
||||
|
||||
- #2198 — all four hook scripts were inert (wrong env-var case, line-oriented
|
||||
`jq -rR`, line-oriented `cut -c`). No CI ran, because `plugin/**` wasn't in
|
||||
the workflow's `paths:` filter at all.
|
||||
- #2209 — the fix for #2198 shipped to `main` and still couldn't reach an
|
||||
install, because `plugin.json`'s version wasn't bumped and the installer
|
||||
compares versions to decide whether to refresh its cache.
|
||||
|
||||
The rule for the second one was already written down and was still missed. A
|
||||
written rule that depends on being remembered during a long session is not a
|
||||
control; this is.
|
||||
|
||||
shellcheck and jq are NOT in `ci-python` (verified against CI-runner's Dockerfile
|
||||
and scripts/install-common.sh, not from memory — rule #37). CI installs both
|
||||
per-job, which is what CI-runner's own docs/process.md prescribes for a dep with
|
||||
a single consumer: "If only one project needs the dep, prefer that project
|
||||
installing it per-job in their workflow — at least until a second consumer
|
||||
arrives." Promotion into the image is filed as an issue there rather than
|
||||
assumed here.
|
||||
|
||||
Both are optional at runtime: without shellcheck the lint step is SKIPPED and
|
||||
says so, and without jq the smoke test is skipped. A skipped check announces
|
||||
itself loudly, because a check that quietly no-ops is the failure mode this
|
||||
whole file exists to prevent.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_plugin.py # all checks
|
||||
python3 scripts/check_plugin.py --no-version # skip the bump check
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLUGIN_DIR = ROOT / "plugin"
|
||||
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
||||
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
||||
|
||||
# Paths whose contents reach an install. Keep in step with the workflow's
|
||||
# `paths:` filter — a path that ships but isn't checked here is the gap again.
|
||||
SHIPPED = ("plugin", ".claude-plugin")
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
failures.append(msg)
|
||||
print(f"FAIL {msg}")
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
print(f"ok {msg}")
|
||||
|
||||
|
||||
def skip(msg: str) -> None:
|
||||
# Loud on purpose. A check that quietly does nothing is indistinguishable
|
||||
# from a check that passed — the exact confusion that let #2198 survive.
|
||||
print(f"SKIP {msg}")
|
||||
|
||||
|
||||
def hook_scripts() -> list[Path]:
|
||||
return sorted(HOOKS_DIR.glob("*.sh"))
|
||||
|
||||
|
||||
# --- syntax ----------------------------------------------------------------
|
||||
|
||||
def check_syntax() -> None:
|
||||
"""`bash -n` every hook. Catches nothing subtle, costs nothing, and a syntax
|
||||
error here means a hook that silently never runs."""
|
||||
for script in hook_scripts():
|
||||
proc = subprocess.run(
|
||||
["bash", "-n", str(script)], capture_output=True, text=True
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
fail(f"{script.relative_to(ROOT)}: bash -n — {proc.stderr.strip()}")
|
||||
else:
|
||||
ok(f"{script.relative_to(ROOT)}: syntax")
|
||||
|
||||
|
||||
# --- known-bad patterns ----------------------------------------------------
|
||||
|
||||
# Each entry: (compiled pattern, short label, why it's wrong).
|
||||
# These are the exact classes from #2198. They are deliberately specific — a
|
||||
# broad shell linter belongs in the image, not hand-rolled here.
|
||||
PATTERNS: list[tuple[re.Pattern, str, str]] = [
|
||||
(
|
||||
re.compile(r"CLAUDE_PLUGIN_OPTION_[a-z]"),
|
||||
"lowercase userConfig env var",
|
||||
"Claude Code exports userConfig to hooks as CLAUDE_PLUGIN_OPTION_<KEY> "
|
||||
"with the key UPPERCASED. The lowercase spelling reads as empty and the "
|
||||
"hook then does nothing, silently.",
|
||||
),
|
||||
(
|
||||
# -R without -s: reads input line by line, so a multi-line payload is
|
||||
# encoded per line and joined with raw newlines. The class is a-r + t-z
|
||||
# (i.e. every letter EXCEPT `s`) so `-rR` is caught and `-sRr` is not —
|
||||
# an earlier a-q spelling silently excluded `r` and missed the real
|
||||
# defect, which is exactly the flag combination that shipped.
|
||||
re.compile(r"jq\s+-(?:[a-rt-zA-Z]*R[a-rt-zA-Z]*)\s"),
|
||||
"line-oriented jq -R",
|
||||
"jq -R reads input LINE BY LINE. Encoding a multi-line payload that way "
|
||||
"produces separate encoded lines joined by raw newlines — an invalid "
|
||||
"URL. Use -s (slurp) as well, e.g. `jq -sRr '@uri'`.",
|
||||
),
|
||||
(
|
||||
re.compile(r"\|\s*cut\s+-c"),
|
||||
"line-oriented cut for a payload cap",
|
||||
"cut -c truncates EACH LINE, so it does not cap total size. Use "
|
||||
"`head -c N` to bound a payload.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def check_patterns() -> None:
|
||||
for script in hook_scripts():
|
||||
text = script.read_text(encoding="utf-8", errors="replace")
|
||||
rel = script.relative_to(ROOT)
|
||||
hits = 0
|
||||
for line_no, line in enumerate(text.splitlines(), 1):
|
||||
# A line that only *documents* the trap is fine — several hooks now
|
||||
# carry a comment naming the wrong form so the next reader knows.
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
for pattern, label, why in PATTERNS:
|
||||
if pattern.search(line):
|
||||
hits += 1
|
||||
fail(f"{rel}:{line_no}: {label}\n {line.strip()}\n {why}")
|
||||
if not hits:
|
||||
ok(f"{rel}: no known-bad patterns")
|
||||
|
||||
|
||||
# --- the version bump ------------------------------------------------------
|
||||
|
||||
# --- shellcheck ------------------------------------------------------------
|
||||
|
||||
def check_shellcheck() -> None:
|
||||
"""Real shell linting, where available.
|
||||
|
||||
The hand-rolled patterns above only know the bugs that already happened.
|
||||
This is what catches the next one.
|
||||
"""
|
||||
exe = shutil.which("shellcheck")
|
||||
if not exe:
|
||||
skip("shellcheck not installed — install it to lint the hooks properly")
|
||||
return
|
||||
for script in hook_scripts():
|
||||
proc = subprocess.run(
|
||||
[exe, "--severity=warning", "--shell=bash", str(script)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
rel = script.relative_to(ROOT)
|
||||
if proc.returncode != 0:
|
||||
fail(f"{rel}: shellcheck\n{proc.stdout.strip()}")
|
||||
else:
|
||||
ok(f"{rel}: shellcheck")
|
||||
|
||||
|
||||
# --- the fail-open contract ------------------------------------------------
|
||||
|
||||
# Every hook promises never to break the operator's session: unconfigured or
|
||||
# unreachable, it exits 0. Three of them additionally promise SILENCE, because
|
||||
# they are pure enrichment. scribe_session_context.sh is the exception by
|
||||
# design — it always emits a static behavioural floor that needs no credentials
|
||||
# and no network, so "silent" would be the wrong assertion for it.
|
||||
#
|
||||
# This is the contract that made #2198 invisible for weeks, so it is worth
|
||||
# pinning: the bug and the healthy no-results case look identical from outside.
|
||||
# Pinning it does NOT make the failure visible; it makes sure the fail-open
|
||||
# behaviour is deliberate rather than accidental.
|
||||
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
|
||||
# The prior-art hook's local arm (#2280) fires with no credentials, so the
|
||||
# silence assertion below needs a name the repo genuinely lacks. Two traps,
|
||||
# both hit while writing this:
|
||||
# - `def f` matched real code, so the hook spoke and "silent" was asserting
|
||||
# the wrong thing;
|
||||
# - spelling the replacement out in full put `def <name>(` INTO this file,
|
||||
# so the smoke event defined the very symbol it claimed was absent.
|
||||
# Concatenating keeps the contiguous string out of the source.
|
||||
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
|
||||
|
||||
SMOKE_EVENTS: dict[str, str] = {
|
||||
"scribe_autoinject.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
|
||||
),
|
||||
"scribe_prior_art.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
|
||||
"tool_input": {"file_path": "src/x.py",
|
||||
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
|
||||
),
|
||||
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
||||
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
||||
}
|
||||
|
||||
# The one hook that legitimately produces output with no credentials.
|
||||
STATIC_FLOOR = "scribe_session_context.sh"
|
||||
|
||||
|
||||
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("SCRIBE_", "CLAUDE_PLUGIN_OPTION_"))}
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
["bash", str(script)], input=event, capture_output=True, text=True,
|
||||
env=env, timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def check_fail_open() -> None:
|
||||
if not shutil.which("jq"):
|
||||
# Without jq every hook bails at its first line, so this would pass
|
||||
# while exercising nothing. Say so rather than bank a green tick.
|
||||
skip("jq not installed — the hooks would exit at line 1, so this "
|
||||
"check would pass without testing anything")
|
||||
return
|
||||
|
||||
scenarios = [
|
||||
("unconfigured", {}),
|
||||
# Connection refused immediately — exercises the unreachable-instance
|
||||
# path without waiting on a real network timeout.
|
||||
("unreachable", {"SCRIBE_URL": "http://127.0.0.1:1", "SCRIBE_TOKEN": "x"}),
|
||||
]
|
||||
for script in hook_scripts():
|
||||
rel = script.relative_to(ROOT)
|
||||
event = SMOKE_EVENTS.get(script.name)
|
||||
if event is None:
|
||||
skip(f"{rel}: no smoke event defined")
|
||||
continue
|
||||
for label, env_extra in scenarios:
|
||||
try:
|
||||
proc = _run_hook(script, event, env_extra)
|
||||
except subprocess.TimeoutExpired:
|
||||
fail(f"{rel} [{label}]: hung — a hook must never block a session")
|
||||
continue
|
||||
if proc.returncode != 0:
|
||||
fail(f"{rel} [{label}]: exited {proc.returncode}, must be 0 — "
|
||||
f"a recall aid may never fail the operator's action")
|
||||
continue
|
||||
out = proc.stdout.strip()
|
||||
if script.name == STATIC_FLOOR:
|
||||
# Emits its bundled static tier regardless; that floor is the
|
||||
# whole point of the two-tier design.
|
||||
if not out:
|
||||
fail(f"{rel} [{label}]: emitted nothing — the static "
|
||||
f"behavioural floor must survive having no credentials")
|
||||
else:
|
||||
ok(f"{rel} [{label}]: exit 0, static floor present")
|
||||
elif out:
|
||||
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
|
||||
f" {out[:200]}")
|
||||
else:
|
||||
ok(f"{rel} [{label}]: exit 0, silent")
|
||||
|
||||
|
||||
def check_local_prior_art_needs_no_instance() -> None:
|
||||
"""The prior-art hook's local arm must answer with no credentials (#2280).
|
||||
|
||||
The other arms ask Scribe what was RECORDED. This one asks the repo what
|
||||
EXISTS, which needs no instance — and that is the whole reason it catches
|
||||
the case the recorded arms structurally cannot: a helper nobody thought to
|
||||
record. If it ever silently starts depending on configuration, it stops
|
||||
covering that case and nothing else would notice.
|
||||
|
||||
Paired with the silence assertion in check_fail_open, which uses a symbol
|
||||
that cannot exist. Together they pin both halves: silent when there is
|
||||
nothing to say, and speaking when there is — both with no instance at all.
|
||||
"""
|
||||
script = HOOKS_DIR / "scribe_prior_art.sh"
|
||||
if not script.is_file() or not shutil.which("jq"):
|
||||
skip("prior-art local arm: hook or jq missing")
|
||||
return
|
||||
|
||||
# A definition this repo really does contain, written into a DIFFERENT file
|
||||
# so the self-match exclusion doesn't suppress it.
|
||||
event = json.dumps({
|
||||
"session_id": "smoke", "cwd": ".", "tool_name": "Write",
|
||||
"tool_input": {
|
||||
"file_path": "scripts/_probe_not_real.py",
|
||||
"content": "def check_local_prior_art_needs_no_instance():\n pass\n",
|
||||
},
|
||||
})
|
||||
try:
|
||||
proc = _run_hook(script, event, {}) # NO credentials, on purpose
|
||||
except subprocess.TimeoutExpired:
|
||||
fail("prior-art local arm: hung")
|
||||
return
|
||||
if proc.returncode != 0:
|
||||
fail(f"prior-art local arm: exited {proc.returncode}, must be 0")
|
||||
elif "already defined" not in proc.stdout:
|
||||
fail("prior-art local arm: found nothing for a symbol this repo "
|
||||
"defines, with no credentials — the arm that needs no instance "
|
||||
"has stopped working, and the recorded arms cannot cover for it")
|
||||
else:
|
||||
ok("prior-art local arm: answers with no instance configured")
|
||||
|
||||
|
||||
def _git(*args: str) -> tuple[int, str]:
|
||||
proc = subprocess.run(
|
||||
["git", *args], capture_output=True, text=True, cwd=ROOT
|
||||
)
|
||||
return proc.returncode, (proc.stdout or proc.stderr).strip()
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
if ref is None:
|
||||
try:
|
||||
return json.loads(MANIFEST.read_text()).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
rel = MANIFEST.relative_to(ROOT).as_posix()
|
||||
code, out = _git("show", f"{ref}:{rel}")
|
||||
if code != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(out).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def check_version_bump(base: str = "origin/main") -> None:
|
||||
"""If shipped plugin content differs from `base`, the version must too.
|
||||
|
||||
Stated against the BASE BRANCH rather than the last commit on purpose. A
|
||||
per-commit rule would demand a bump from every commit in a batch; what
|
||||
actually matters is that whatever reaches an install carries a version the
|
||||
installer can tell apart from the one already cached. One bump per batch,
|
||||
which is also how a human would do it.
|
||||
"""
|
||||
code, _ = _git("rev-parse", "--verify", base)
|
||||
if code != 0:
|
||||
# Do NOT pass silently — a check that quietly no-ops is how this class
|
||||
# of bug survives in the first place.
|
||||
fail(
|
||||
f"cannot resolve {base}, so the version-bump check could not run. "
|
||||
f"Fetch it first — `git fetch --depth=1 origin main:refs/remotes/"
|
||||
f"origin/main` is enough, since this diffs two trees and needs no "
|
||||
f"common ancestor — or pass --no-version deliberately."
|
||||
)
|
||||
return
|
||||
|
||||
code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
|
||||
if code != 0:
|
||||
fail(f"git diff against {base} failed: {changed}")
|
||||
return
|
||||
if not changed.strip():
|
||||
ok(f"no shipped plugin changes against {base} — version bump not required")
|
||||
return
|
||||
|
||||
here, there = manifest_version(), manifest_version(base)
|
||||
if here is None:
|
||||
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
||||
return
|
||||
if there is None:
|
||||
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
||||
return
|
||||
if here == there:
|
||||
files = "\n ".join(changed.splitlines())
|
||||
fail(
|
||||
f"plugin content changed but the manifest version is still {here}.\n"
|
||||
f" The installer compares versions to decide whether to refresh "
|
||||
f"its cache, so an unchanged version means these edits reach the repo "
|
||||
f"and stop there — the marketplace clone updates, the cache that "
|
||||
f"actually executes does not (issue #2209).\n"
|
||||
f" Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
|
||||
f" Changed:\n {files}"
|
||||
)
|
||||
else:
|
||||
ok(f"plugin content changed and version moved {there} -> {here}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--no-version", action="store_true",
|
||||
help="skip the manifest version-bump check")
|
||||
parser.add_argument("--base", default="origin/main",
|
||||
help="branch the version bump is measured against")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not HOOKS_DIR.is_dir():
|
||||
print(f"FAIL no hooks directory at {HOOKS_DIR}")
|
||||
return 1
|
||||
|
||||
check_syntax()
|
||||
check_patterns()
|
||||
check_shellcheck()
|
||||
check_fail_open()
|
||||
check_local_prior_art_needs_no_instance()
|
||||
if not args.no_version:
|
||||
check_version_bump(args.base)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{len(failures)} problem(s) found.")
|
||||
return 1
|
||||
print("All plugin checks passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -26,9 +26,12 @@ from scribe.routes.profile import profile_bp
|
||||
from scribe.routes.knowledge import knowledge_bp
|
||||
from scribe.routes.rulebooks import rulebooks_bp
|
||||
from scribe.routes.plugin import plugin_bp
|
||||
from scribe.routes.design import design_bp
|
||||
from scribe.routes.design_systems import design_systems_bp
|
||||
from scribe.routes.trash import trash_bp
|
||||
from scribe.routes.dashboard import dashboard_bp
|
||||
from scribe.routes.systems import systems_bp
|
||||
from scribe.routes.snippets import snippets_bp
|
||||
from scribe.mcp import mount_mcp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
@@ -88,9 +91,12 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(knowledge_bp)
|
||||
app.register_blueprint(rulebooks_bp)
|
||||
app.register_blueprint(plugin_bp)
|
||||
app.register_blueprint(design_bp)
|
||||
app.register_blueprint(design_systems_bp)
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(systems_bp)
|
||||
app.register_blueprint(snippets_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
@@ -168,6 +174,15 @@ def create_app() -> Quart:
|
||||
await backfill_note_embeddings()
|
||||
except Exception:
|
||||
logger.warning("Embedding backfill failed", exc_info=True)
|
||||
# Snippets written before migration 0070 have no `notes.data` mirror,
|
||||
# and the location reverse lookup queries that column — an unfilled
|
||||
# row would read as "no snippet here" rather than as a gap. Separate
|
||||
# try block so neither backfill can skip the other.
|
||||
try:
|
||||
from scribe.services.snippets import backfill_snippet_data
|
||||
await backfill_snippet_data()
|
||||
except Exception:
|
||||
logger.warning("Snippet data backfill failed", exc_info=True)
|
||||
|
||||
asyncio.create_task(_delayed_backfill())
|
||||
|
||||
|
||||
+70
-18
@@ -33,14 +33,27 @@ What each part is for, and when to reach for it:
|
||||
- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of
|
||||
work. The design/intent lives in the milestone `body`; each step is its own
|
||||
child task (create_task(milestone_id=...)), tracked with status + work-logs —
|
||||
NOT a checkbox buried in the body. Start one with start_planning when
|
||||
beginning non-trivial work, before you dive in; read it back with
|
||||
get_milestone (body + steps). (The old kind=plan task is retired — some
|
||||
historical plan-tasks still exist and remain readable, but don't create new
|
||||
ones.)
|
||||
NOT a checkbox buried in the body. Create one with start_planning when the
|
||||
work has an arc (same test as a milestone, above) and you want the approach
|
||||
reviewable before you start; read it back with get_milestone (body + steps).
|
||||
Work without an arc is a task, not a plan. (The old kind=plan task is retired
|
||||
— some historical plan-tasks still exist and remain readable, but don't
|
||||
create new ones.)
|
||||
- Note: durable free-form knowledge — reference material, decisions, logs of
|
||||
what happened.
|
||||
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
|
||||
- Design system: the visual standards a project's UI is built from — design
|
||||
tokens (name + value per mode) plus the prose a token table cannot hold
|
||||
(aesthetic, voice, what is out of scope). Systems INHERIT: a child holds only
|
||||
what it changes and the chain supplies the rest, so a family's house style and
|
||||
one app's departures from it are the same structure at two depths. A project
|
||||
points at one with set_project_design_system, and enter_project then hands it
|
||||
back with the guidance chain-merged. Treat it as binding for UI work: reach
|
||||
for a token (resolve_design_system / get_design_system_stylesheet) before
|
||||
writing a colour, size, radius or duration by hand. Do NOT record a design
|
||||
system as a rulebook — rules are for behaviour, and tokens kept as prose
|
||||
cannot be resolved, inherited, rendered to a stylesheet, or checked against
|
||||
code.
|
||||
- System: a per-project, reusable, self-describing subsystem/area. Associate any
|
||||
record (note, task, issue) with it via system_ids so research, build-work, and
|
||||
fixes for the same area line up, and recurring problem-spots surface. Manage
|
||||
@@ -143,8 +156,8 @@ right altitude:
|
||||
subscribe_project_to_rulebook) — a reusable, THEMED module of general
|
||||
rules that binds only the projects which subscribe. Its rules must make
|
||||
sense for every project that could subscribe, never one specific project
|
||||
(e.g. a design-system rulebook: design-specific but project-agnostic — no
|
||||
rule names a single app).
|
||||
(e.g. a code-review checklist, or a compliance regime a category of
|
||||
projects shares — no rule names a single app).
|
||||
- Project rule (create_project_rule) — anything specific to ONE project.
|
||||
Both rulebook tiers are SHARED, so their rules stay general; the difference
|
||||
between them is REACH (all projects vs opt-in by theme), not generality. Rule
|
||||
@@ -152,6 +165,15 @@ of thumb: names a specific project's files/paths/quirks -> project rule; a
|
||||
standard a CATEGORY of projects shares -> subscribed rulebook; a universal
|
||||
norm -> always-on rulebook. Coordinate with the operator on which home fits.
|
||||
|
||||
Before writing a rule, check whether another entity already models the thing.
|
||||
A rule is prose an agent must remember and apply; the other entities are
|
||||
structure a tool can resolve, render and check. Visual standards are a DESIGN
|
||||
SYSTEM, not a rulebook — a token can be inherited, resolved per mode, rendered
|
||||
to a stylesheet and diffed against code, and none of that survives being
|
||||
written as a rule. A repeatable procedure is a PROCESS. Reusable code is a
|
||||
SNIPPET. Reach for a rule when the thing genuinely is a standing instruction
|
||||
about how to work, and nothing else can hold it.
|
||||
|
||||
One thing NOT to do: don't bridge Scribe into a session by writing to the
|
||||
host's native memory. Rules are pull-only, so a fresh session won't reach for
|
||||
them unless its always-loaded context says to — but the bridge for that is the
|
||||
@@ -184,17 +206,13 @@ adopting or creating — never do either silently, and never guess a project int
|
||||
existence. Once a project is in scope, the enter_project handshake and the
|
||||
host-memory pointer step above both apply.
|
||||
|
||||
A plan is a MILESTONE, and Scribe is the canonical home for it. When you begin
|
||||
non-trivial work, call start_planning(project_id, title) FIRST — before any
|
||||
brainstorming, design, or plan-writing skill runs. start_planning creates the
|
||||
milestone, seeds its `body` with the design template, returns the project's
|
||||
applicable_rules, and gives you the milestone id you'll write into. Put the
|
||||
design/intent in the milestone body via update_milestone(milestone_id, body=...);
|
||||
create each step as a child task with create_task(milestone_id=...) and track it
|
||||
with status + add_task_log — do NOT list steps as checkboxes in the body. Read
|
||||
the plan back with get_milestone (body + steps). If a habit tells you to save a
|
||||
plan or spec to a local `.md` file, that's superseded here: the milestone is the
|
||||
record, not a local file.
|
||||
When work DOES get a plan, Scribe is the plan's canonical home: it is a
|
||||
milestone (see the Plan entry above), created with start_planning and written
|
||||
into with update_milestone + child tasks. If a habit tells you to save a plan or
|
||||
spec to a local `.md` file, that's superseded here — the milestone is the
|
||||
record, not a file on disk. Whether a given piece of work wants a plan at all is
|
||||
a separate question, answered by the arc test above and by the writing-plans
|
||||
skill; these instructions do not mandate one.
|
||||
|
||||
Deletes are recoverable: every delete_* tool moves the entity (and its
|
||||
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
||||
@@ -209,6 +227,37 @@ get_process(name) and follow the returned prompt verbatim, including any
|
||||
"clarify first" steps it contains. Author a new one with create_process(title,
|
||||
body); edit with update_process.
|
||||
|
||||
Scribe also stores Snippets — reusable functions/components recorded once for
|
||||
recall (note_type "snippet"): a name, language, signature, canonical location
|
||||
(repo · path · symbol), a one-line "when to reach for it", and the code. They
|
||||
are ordinary embedded notes, so a recorded snippet also surfaces through the
|
||||
same search + proactive recall as everything else. Two reflexes: (1) before you
|
||||
write a new helper/utility/component, search first (list_snippets(q=...) or
|
||||
search) — reuse the prior art with get_snippet(id) instead of re-deriving a
|
||||
one-off; (2) the moment you build or notice something reusable, record it with
|
||||
create_snippet(name, code, when_to_use, language, signature, repo, path, symbol,
|
||||
project_id, system_ids) so a later session is offered it. Make when_to_use sharp
|
||||
— it becomes the title, which is what a recall menu shows. Edit an existing one
|
||||
with update_snippet rather than recording a second copy; when the same reusable
|
||||
thing already exists as several one-offs, unify them into one canonical record
|
||||
with merge_snippets (it folds every call site in as a location and trashes the
|
||||
duplicates). Keep the record honest: a snippet whose details have gone stale can
|
||||
be corrected with update_snippet (an empty string clears a field), and one that
|
||||
is wrong or obsolete should be retired with delete_snippet — a bad snippet keeps
|
||||
being offered as prior art, which costs more than none at all.
|
||||
|
||||
Scribe is multi-user, so some records belong to other people. Anything another
|
||||
user owns comes back marked `shared: true` with an `owner`. Treat a shared
|
||||
record as THAT PERSON'S SUGGESTION, never as the operator's settled practice:
|
||||
weigh it on its merits, attribute it when you reference it, and ask before
|
||||
adopting it or acting on it. This matters most for a shared Process — do not run
|
||||
one as written; describe what it would do and get the operator's go-ahead.
|
||||
Records shared directly with the operator are also deliberately search-only:
|
||||
they surface when you look for them (pass a query), not in plain lists, so
|
||||
nobody else's material arrives unasked. Editing another user's record needs an
|
||||
editor or admin share from them; a read-only share is refused, and the right
|
||||
answer is usually to record the operator's own version rather than to push.
|
||||
|
||||
When developing Scribe itself, honor its multi-user sharing ACL: scope every
|
||||
read and mutation of user data by owner + shares — never assume a single
|
||||
operator. "Works for one user" is not done.
|
||||
@@ -225,6 +274,9 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||
"list_always_on_rules", "search",
|
||||
"get_system", "list_systems", "list_system_records",
|
||||
# Reports on the snippet corpus. Reads only — the merge it suggests is a
|
||||
# separate, explicitly-called write.
|
||||
"find_duplicate_snippets",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
milestones, notes, processes, projects, recent, repos, rulebooks, search, systems, tags, tasks, trash,
|
||||
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
|
||||
systems, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,9 +18,11 @@ def register_all(mcp) -> None:
|
||||
projects.register(mcp)
|
||||
milestones.register(mcp)
|
||||
systems.register(mcp)
|
||||
design_systems.register(mcp)
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
repos.register(mcp)
|
||||
processes.register(mcp)
|
||||
snippets.register(mcp)
|
||||
rulebooks.register(mcp)
|
||||
trash.register(mcp)
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Design system + token MCP tools — wrappers over services/design_systems.py.
|
||||
|
||||
A design system is a stylesheet held as records: a named set of tokens with an
|
||||
optional parent, so a family system carries the house style and an app system
|
||||
carries only what it changes. Precedence by name along the parent chain IS the
|
||||
CSS cascade, which is why "what does this app alter?" is a plain list rather
|
||||
than a diff.
|
||||
|
||||
Parity with the REST surface is a rule, not a nicety (see
|
||||
`tests/test_routes_design_systems.py`): an agent and a browser are two callers
|
||||
of one service.
|
||||
|
||||
Sentinels, matching the milestone/task tool conventions:
|
||||
- title="" / description="" / etc. -> "leave unchanged" on update
|
||||
- parent_id / design_system_id: 0 = leave unchanged, -1 = clear, positive = set
|
||||
(three states, because clearing a parent is a real operation and not the
|
||||
same as omitting the argument)
|
||||
- order_index=-1 -> "leave unchanged" (0 is a valid order_index)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import design_systems as ds_svc
|
||||
from scribe.services.design_systems import DesignSystemCycle
|
||||
|
||||
|
||||
async def create_design_system(
|
||||
title: str,
|
||||
description: str = "",
|
||||
guidance: str = "",
|
||||
parent_id: int = 0,
|
||||
) -> dict:
|
||||
"""Create a design system, optionally inheriting from another.
|
||||
|
||||
Args:
|
||||
title: What this system is — a house style, or one app within it
|
||||
(required).
|
||||
description: What it covers and when it applies.
|
||||
guidance: The narrative a token table cannot hold — aesthetic, voice and
|
||||
tone, what is deliberately out of scope. Markdown, free-form.
|
||||
parent_id: Inherit from this system — it holds the defaults this one
|
||||
overrides. Omit (0) for a top-level "family" system, which is what
|
||||
a first design system usually is.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
system = await ds_svc.create_design_system(
|
||||
uid,
|
||||
title=title,
|
||||
description=description or None,
|
||||
guidance=guidance or None,
|
||||
parent_id=parent_id or None,
|
||||
)
|
||||
if system is None:
|
||||
raise ValueError(f"parent design system {parent_id} not found or not writable")
|
||||
return system.to_dict()
|
||||
|
||||
|
||||
async def list_design_systems() -> dict:
|
||||
"""List your design systems. An empty list is normal — most installs have none."""
|
||||
uid = current_user_id()
|
||||
rows = await ds_svc.list_design_systems(uid)
|
||||
return {"design_systems": [s.to_dict() for s in rows]}
|
||||
|
||||
|
||||
async def get_design_system(design_system_id: int) -> dict:
|
||||
"""Fetch a design system plus its OWN tokens — i.e. what it changes.
|
||||
|
||||
For what it actually resolves to once inheritance is applied, use
|
||||
`resolve_design_system`. The two answer different questions and a system
|
||||
that overrides nothing has an empty token list but a full resolved set.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
system = await ds_svc.get_design_system(uid, design_system_id)
|
||||
if system is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
tokens = await ds_svc.list_tokens(uid, design_system_id)
|
||||
return {
|
||||
"design_system": system.to_dict(),
|
||||
"tokens": [t.to_dict() for t in tokens],
|
||||
}
|
||||
|
||||
|
||||
async def resolve_design_system(design_system_id: int) -> dict:
|
||||
"""The EFFECTIVE token set — everything inherited, with this system's on top.
|
||||
|
||||
Each token carries `origin_by_mode` (which system supplied each mode's
|
||||
value) and `contributions` (every system that offered one, deepest first —
|
||||
so entry 0 won and the rest were shadowed). Reach for this when you need to
|
||||
know what a value actually IS; reach for `get_design_system` when you need
|
||||
to know what this system CHANGES.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
resolved = await ds_svc.resolve_design_system(uid, design_system_id)
|
||||
if resolved is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
return {
|
||||
"design_system_id": design_system_id,
|
||||
"tokens": [t.to_dict() for t in resolved],
|
||||
}
|
||||
|
||||
|
||||
async def update_design_system(
|
||||
design_system_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
guidance: str = "",
|
||||
parent_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update a design system.
|
||||
|
||||
Args:
|
||||
design_system_id: The system to update.
|
||||
title: New title, or "" to leave unchanged.
|
||||
description: New description, or "" to leave unchanged.
|
||||
guidance: New guidance prose, or "" to leave unchanged.
|
||||
parent_id: 0 = leave unchanged, -1 = clear (make this a top-level
|
||||
family system), positive = inherit from that system. A parent that
|
||||
already inherits from this system is refused — that would be a loop.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
if title:
|
||||
fields["title"] = title
|
||||
if description:
|
||||
fields["description"] = description
|
||||
if guidance:
|
||||
fields["guidance"] = guidance
|
||||
if parent_id:
|
||||
fields["parent_id"] = None if parent_id == -1 else parent_id
|
||||
try:
|
||||
system = await ds_svc.update_design_system(uid, design_system_id, **fields)
|
||||
except DesignSystemCycle as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if system is None:
|
||||
raise ValueError(f"design system {design_system_id} not found or not writable")
|
||||
return system.to_dict()
|
||||
|
||||
|
||||
async def delete_design_system(design_system_id: int) -> dict:
|
||||
"""Soft-delete a design system (recoverable).
|
||||
|
||||
Systems that inherited from it become top-level systems keeping their own
|
||||
tokens — deleting a family does not delete the apps under it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not await ds_svc.delete_design_system(uid, design_system_id):
|
||||
raise ValueError(f"design system {design_system_id} not found or not writable")
|
||||
return {"message": f"Design system {design_system_id} deleted."}
|
||||
|
||||
|
||||
async def get_design_system_stylesheet(
|
||||
design_system_id: int,
|
||||
root_selector: str = ":root",
|
||||
) -> dict:
|
||||
"""The master CSS sheet a design system generates.
|
||||
|
||||
Purpose tokens only — this sheet declares what values MEAN and styles no
|
||||
elements. Components (buttons, tables, input schemes) are SNIPPETS that
|
||||
reference these names, so a value is stated once and reused rather than
|
||||
restated per element. Reach for this when you need the tokens a snippet is
|
||||
allowed to use.
|
||||
|
||||
Also returns `valueless` (tokens the system names but has no value for) and
|
||||
`duplicates` (values declared under more than one name — a deliberate alias,
|
||||
or one idea recorded twice).
|
||||
|
||||
Args:
|
||||
design_system_id: The system to render.
|
||||
root_selector: Selector for the base layer. Defaults to `:root`; pass a
|
||||
container selector to scope the sheet to a preview region.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
result = await ds_svc.stylesheet_for_system(uid, design_system_id, root_selector)
|
||||
if result is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
return result
|
||||
|
||||
|
||||
async def check_snippets_against_design_system(
|
||||
design_system_id: int,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Which recorded snippets disagree with a design system's sheet.
|
||||
|
||||
Snippets are the component layer — buttons, tables, input schemes — and they
|
||||
are supposed to use the tags the sheet declares. Three findings per snippet,
|
||||
each currently silent in the codebase:
|
||||
|
||||
unknown `var(--x)` where the system has no `--x`. Renders as
|
||||
NOTHING: no error, no failing test, just an element
|
||||
that quietly isn't styled.
|
||||
superseded_literals a literal the sheet says to stop writing, paired with
|
||||
the token to write instead.
|
||||
local_definitions custom properties the snippet mints for itself rather
|
||||
than using shared ones — the bloat a shared sheet
|
||||
exists to prevent.
|
||||
|
||||
Snippets with nothing to report are omitted. Reach for this before writing
|
||||
or reviewing component CSS.
|
||||
|
||||
Args:
|
||||
design_system_id: The system whose sheet is authoritative.
|
||||
project_id: Narrow to one project, or 0 for every project.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
result = await ds_svc.check_snippets_against_system(
|
||||
uid, design_system_id, project_id
|
||||
)
|
||||
if result is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_design_token(
|
||||
design_system_id: int,
|
||||
name: str,
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
rationale: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> dict:
|
||||
"""Add a token to a design system.
|
||||
|
||||
Args:
|
||||
design_system_id: The system that owns this token.
|
||||
name: The custom-property name, e.g. "--surface-page" (required).
|
||||
Name it for its PURPOSE, not its value: a name like "--obsidian"
|
||||
or "--button-bg" stops being true the moment the value or the
|
||||
element changes.
|
||||
value_by_mode: Values keyed by mode, e.g.
|
||||
{"base": "#14171a", "light": "#f7f5ef"}. Use "base" for the value
|
||||
that applies when no mode is more specific; a token that is not
|
||||
mode-dependent needs only "base". In a system WITH a parent, an
|
||||
omitted mode is inherited rather than blanked.
|
||||
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
||||
this system's own vocabulary is.
|
||||
purpose: What the token is for, e.g. "page background, deepest
|
||||
surface".
|
||||
rationale: WHY it is this value — a different question from purpose.
|
||||
"Deliberately the same value as the primary action colour" is a
|
||||
rationale; "page background, deepest surface" is a purpose.
|
||||
supersedes: Literal values this token should be used INSTEAD OF, e.g.
|
||||
["#fff", "#ffffff"]. This is how a design system records what a
|
||||
prohibition was trying to say — not "white is banned" but "write
|
||||
this token instead". Declare it rather than expecting it to be
|
||||
inferred: a superseded literal and the token's own value are
|
||||
usually different values, so nothing can connect them by matching.
|
||||
order_index: Display position within its group.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
token = await ds_svc.create_token(
|
||||
uid,
|
||||
design_system_id=design_system_id,
|
||||
name=name,
|
||||
value_by_mode=value_by_mode,
|
||||
group_name=group_name or None,
|
||||
purpose=purpose or None,
|
||||
rationale=rationale or None,
|
||||
supersedes=supersedes,
|
||||
order_index=order_index,
|
||||
)
|
||||
if token is None:
|
||||
raise ValueError(f"design system {design_system_id} not found or not writable")
|
||||
return token.to_dict()
|
||||
|
||||
|
||||
async def list_design_tokens(design_system_id: int) -> dict:
|
||||
"""A design system's OWN tokens — its override set, not its effective set."""
|
||||
uid = current_user_id()
|
||||
rows = await ds_svc.list_tokens(uid, design_system_id)
|
||||
return {"tokens": [t.to_dict() for t in rows]}
|
||||
|
||||
|
||||
async def update_design_token(
|
||||
token_id: int,
|
||||
name: str = "",
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
rationale: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = -1,
|
||||
) -> dict:
|
||||
"""Update a token. Empty/None args leave a field unchanged.
|
||||
|
||||
`value_by_mode` and `supersedes` REPLACE their whole value rather than
|
||||
merging into it, so send every entry you want the token to keep. Pass `[]`
|
||||
to clear `supersedes` entirely.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
if name:
|
||||
fields["name"] = name
|
||||
if value_by_mode is not None:
|
||||
fields["value_by_mode"] = value_by_mode
|
||||
if group_name:
|
||||
fields["group_name"] = group_name
|
||||
if purpose:
|
||||
fields["purpose"] = purpose
|
||||
if rationale:
|
||||
fields["rationale"] = rationale
|
||||
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
|
||||
# superseded literal) and would otherwise be unreachable.
|
||||
if supersedes is not None:
|
||||
fields["supersedes"] = supersedes
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
token = await ds_svc.update_token(uid, token_id, **fields)
|
||||
if token is None:
|
||||
raise ValueError(f"design token {token_id} not found or not writable")
|
||||
return token.to_dict()
|
||||
|
||||
|
||||
async def delete_design_token(token_id: int) -> dict:
|
||||
"""Soft-delete a token (recoverable).
|
||||
|
||||
In a system with a parent this restores inheritance: the token stops being
|
||||
overridden here and resolves to the parent's value again.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not await ds_svc.delete_token(uid, token_id):
|
||||
raise ValueError(f"design token {token_id} not found or not writable")
|
||||
return {"message": f"Design token {token_id} deleted."}
|
||||
|
||||
|
||||
async def set_project_design_system(project_id: int, design_system_id: int = 0) -> dict:
|
||||
"""Point a project at a design system.
|
||||
|
||||
Args:
|
||||
project_id: The project to style.
|
||||
design_system_id: The system it uses, or -1 to clear it. Pointing at a
|
||||
system only requires READ access to it — consuming a design system
|
||||
is not changing it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
target = None if design_system_id == -1 else design_system_id
|
||||
ok = await ds_svc.set_project_design_system(uid, project_id, target)
|
||||
if not ok:
|
||||
raise ValueError(
|
||||
f"project {project_id} not writable, or design system "
|
||||
f"{design_system_id} not found"
|
||||
)
|
||||
return {"project_id": project_id, "design_system_id": target}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
create_design_system,
|
||||
list_design_systems,
|
||||
get_design_system,
|
||||
resolve_design_system,
|
||||
update_design_system,
|
||||
delete_design_system,
|
||||
get_design_system_stylesheet,
|
||||
check_snippets_against_design_system,
|
||||
create_design_token,
|
||||
list_design_tokens,
|
||||
update_design_token,
|
||||
delete_design_token,
|
||||
set_project_design_system,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -14,10 +14,12 @@ Sentinel conventions (inherited from existing fable-mcp tools):
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.note_usage import record_pulled
|
||||
|
||||
|
||||
async def list_notes(
|
||||
@@ -57,12 +59,22 @@ async def get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Scribe note by its ID.
|
||||
|
||||
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
||||
A note another user shared with you also carries `shared`, `owner` and
|
||||
`permission` — read it as their suggestion, not as settled practice you set.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.get_note(uid, note_id)
|
||||
if note is None:
|
||||
loaded = await notes_svc.get_note_for_user(uid, note_id)
|
||||
note = loaded[0] if loaded else None
|
||||
if note is None or note.deleted_at is not None:
|
||||
raise ValueError(f"note {note_id} not found")
|
||||
return note.to_dict()
|
||||
out = note.to_dict()
|
||||
out.update(await access_svc.describe_provenance(uid, note))
|
||||
# Records the pull for ANY note kind, not just snippets: the auto-inject
|
||||
# menu surfaces notes, tasks and processes too, so restricting this to
|
||||
# snippets would leave those permanently at zero pulls and make them look
|
||||
# like dead weight next to snippets that merely had a counter (#2085).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
|
||||
return out
|
||||
|
||||
|
||||
async def create_note(
|
||||
|
||||
@@ -7,6 +7,7 @@ get_process is the fire mechanism: it returns the full prompt for Claude to run.
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import knowledge as knowledge_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
|
||||
@@ -19,15 +20,24 @@ async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
||||
tag: Filter to a single tag (optional).
|
||||
limit: Max results (1-100).
|
||||
|
||||
Returns {"processes": [{id, title, tags, preview}], "total": int}.
|
||||
Returns {"processes": [{id, title, tags, preview}], "total": int}. An entry
|
||||
marked `shared: true` with an `owner` is another person's procedure — treat
|
||||
it as a suggestion to raise with the operator, not as their own practice.
|
||||
|
||||
Searching (passing `q`) reaches processes shared directly with the operator;
|
||||
the plain list deliberately doesn't, so someone else's procedure never
|
||||
arrives unasked.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
items, total = await knowledge_svc.query_knowledge(
|
||||
user_id=uid, note_type="process", tags=[tag] if tag else [],
|
||||
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
|
||||
)
|
||||
labelled = await access_svc.label_shared_items(uid, items)
|
||||
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
||||
"preview": it.get("snippet", "")} for it in items]
|
||||
"preview": it.get("snippet", ""),
|
||||
**({"shared": True, "owner": it.get("owner")} if it.get("shared") else {})}
|
||||
for it in labelled]
|
||||
return {"processes": procs, "total": total}
|
||||
|
||||
|
||||
@@ -56,6 +66,13 @@ async def get_process(name_or_id: str) -> dict:
|
||||
Resolution: numeric id → exact (case-insensitive) title → substring. On an
|
||||
ambiguous substring match, the best (most-recent) match is returned with an
|
||||
`other_matches` list so you can disambiguate with the operator.
|
||||
|
||||
IF THE RESULT IS MARKED `shared: true`, DO NOT FOLLOW IT VERBATIM. It is
|
||||
another person's procedure (see `owner`), not one the operator wrote or
|
||||
adopted. Summarise what it would do and get their go-ahead first. The
|
||||
follow-it-as-written contract above applies only to the operator's own
|
||||
processes — a shared one is a proposal, and running it unasked would put
|
||||
someone else's judgement in charge of this session.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
|
||||
@@ -64,17 +81,28 @@ async def get_process(name_or_id: str) -> dict:
|
||||
out = note.to_dict()
|
||||
if candidates:
|
||||
out["other_matches"] = candidates
|
||||
out.update(await access_svc.describe_provenance(uid, note))
|
||||
return out
|
||||
|
||||
|
||||
async def update_process(process_id: int, title: str = "", body: str = "",
|
||||
tags: list[str] | None = None) -> dict:
|
||||
"""Update a stored process. Only provided fields change — empty title/body
|
||||
leave that field unchanged; pass tags to replace the tag set."""
|
||||
leave that field unchanged; pass tags to replace the tag set.
|
||||
|
||||
Editing another user's process requires an editor or admin share from them; a
|
||||
read-only share is not enough and says so rather than claiming not-found.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.get_note(uid, process_id)
|
||||
if note is None or note.note_type != "process":
|
||||
loaded = await notes_svc.get_note_for_user(uid, process_id)
|
||||
note = loaded[0] if loaded else None
|
||||
if note is None or note.note_type != "process" or note.deleted_at is not None:
|
||||
raise ValueError(f"process {process_id} not found")
|
||||
if not await access_svc.can_write_note(uid, process_id):
|
||||
raise ValueError(
|
||||
f"process {process_id} is shared with you read-only — ask its owner "
|
||||
f"for edit access, or save your own copy with create_process"
|
||||
)
|
||||
fields: dict = {}
|
||||
if title.strip():
|
||||
fields["title"] = title.strip()
|
||||
@@ -82,8 +110,13 @@ async def update_process(process_id: int, title: str = "", body: str = "",
|
||||
fields["body"] = body
|
||||
if tags is not None:
|
||||
fields["tags"] = tags
|
||||
updated = await notes_svc.update_note(uid, process_id, **fields)
|
||||
return updated.to_dict()
|
||||
# As the owner — update_note is owner-scoped and the write is authorised above.
|
||||
updated = await notes_svc.update_note(note.user_id, process_id, **fields)
|
||||
if updated is None:
|
||||
raise ValueError(f"process {process_id} not found")
|
||||
out = updated.to_dict()
|
||||
out.update(await access_svc.describe_provenance(uid, updated))
|
||||
return out
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
|
||||
@@ -17,6 +17,7 @@ keeps working.
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import design_systems as design_systems_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
@@ -53,7 +54,13 @@ async def enter_project(project_id: int) -> dict:
|
||||
|
||||
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
||||
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
||||
open_tasks, recent_notes.
|
||||
open_tasks, recent_notes, design_system.
|
||||
|
||||
`design_system` is null unless the project points at one. When present it
|
||||
carries the chain-merged guidance (the house style AND this project's
|
||||
departures from it) plus a summary of the token set — treat it as binding
|
||||
for any UI you write, and pull the values with resolve_design_system or
|
||||
get_design_system_stylesheet before reaching for a literal.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
project = await projects_svc.get_project(uid, project_id)
|
||||
@@ -74,9 +81,17 @@ async def enter_project(project_id: int) -> dict:
|
||||
uid, is_task=False, project_id=project_id,
|
||||
sort="updated_at", limit=5,
|
||||
)
|
||||
# A project need not have one, and most installs won't — null is ordinary
|
||||
# here, not a missing prerequisite.
|
||||
design_system = None
|
||||
if project.design_system_id:
|
||||
design_system = await design_systems_svc.design_context(
|
||||
uid, project.design_system_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"project": project.to_dict(),
|
||||
"design_system": design_system,
|
||||
"milestone_summary": milestone_summary,
|
||||
"applicable_rules": applicable["rules"],
|
||||
"project_rules": applicable.get("project_rules", []),
|
||||
|
||||
@@ -45,7 +45,8 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
Two ways a rulebook reaches projects, set by its always_on flag (toggle via
|
||||
update_rulebook):
|
||||
- always_on = true -> binds EVERY one of your projects automatically.
|
||||
Use for universal cross-project norms (e.g. "FabledSword family").
|
||||
Use for universal cross-project norms that apply across every
|
||||
project, not just one.
|
||||
- always_on = false -> binds only projects that subscribe
|
||||
(subscribe_project_to_rulebook). Use for a THEMED body of rules a
|
||||
category of projects shares (e.g. a design system that visual apps
|
||||
@@ -54,7 +55,7 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
to any single project. Project-specific rules go in create_project_rule.
|
||||
|
||||
Args:
|
||||
title: Rulebook name (e.g. "FabledSword family").
|
||||
title: Rulebook name.
|
||||
description: Optional short description of what this rulebook covers.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import time
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services.access import owner_names_for
|
||||
from scribe.services.embeddings import DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes
|
||||
from scribe.services.retrieval_telemetry import record_retrieval
|
||||
|
||||
@@ -42,6 +43,10 @@ async def search(
|
||||
Returns:
|
||||
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
||||
"total": int}
|
||||
|
||||
A result marked `shared: true` with an `owner` belongs to another user —
|
||||
that person's suggestion, not the operator's own record or settled practice.
|
||||
Weigh it on its merits and say whose it is when you use it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
limit = max(1, min(limit, 50))
|
||||
@@ -50,6 +55,9 @@ async def search(
|
||||
raw = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task,
|
||||
project_id=project_id or None,
|
||||
# An explicit search reaches everything the operator may read, including
|
||||
# records shared with them one-to-one.
|
||||
scope="read",
|
||||
)
|
||||
record_retrieval(
|
||||
user_id=uid, source="mcp_search", query=q,
|
||||
@@ -57,6 +65,9 @@ async def search(
|
||||
project_id=project_id or None, is_task=is_task, results=raw,
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
)
|
||||
owners = await owner_names_for(
|
||||
{int(note.user_id) for _s, note in raw if note.user_id != uid}
|
||||
)
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
@@ -66,6 +77,10 @@ async def search(
|
||||
"is_task": bool(note.is_task),
|
||||
"tags": list(note.tags or []),
|
||||
"similarity": float(score),
|
||||
**(
|
||||
{"shared": True, "owner": owners.get(int(note.user_id))}
|
||||
if note.user_id != uid else {}
|
||||
),
|
||||
}
|
||||
for score, note in raw
|
||||
],
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Snippet MCP tools: record reusable functions/components for later recall.
|
||||
|
||||
A snippet is a Note with note_type='snippet' (see services/snippets.py). Because
|
||||
snippets are ordinary embedded notes, once recorded they surface through the same
|
||||
semantic search + title-first auto-inject as everything else — so a reusable
|
||||
thing recorded once can be recalled before it's re-written as a one-off.
|
||||
|
||||
The tools wrap services/snippets.py, mirroring the note/process tools (dedup gate
|
||||
on create, System association passthrough).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
|
||||
async def list_snippets(
|
||||
q: str = "", tag: str = "", limit: int = 50, project_id: int = 0,
|
||||
repo: str = "", path: str = "", symbol: str = "", verification: str = "",
|
||||
) -> dict:
|
||||
"""List recorded snippets (reusable functions/components).
|
||||
|
||||
Two ways to ask, usable together: by MEANING (`q` — "what do I need this code
|
||||
to do?") and by PLACE (`repo`/`path`/`symbol` — "what canonical helpers
|
||||
already live in the file I'm about to edit?"). Reach for the place form
|
||||
before you write or change code in a file: it is the cheap way to find prior
|
||||
art you'd otherwise duplicate a few lines down.
|
||||
|
||||
Args:
|
||||
q: Free-text search across name + body (optional). Matches on meaning as
|
||||
well as wording, so describe what you need the code to DO.
|
||||
tag: Filter to a single tag, e.g. a language like "python" (optional).
|
||||
limit: Max results (1-100).
|
||||
project_id: Narrow to one project. 0 (default) searches every project —
|
||||
usually what you want, since a helper you need here may well have
|
||||
been written somewhere else.
|
||||
repo: Narrow to snippets recorded in this repo, matched exactly — the
|
||||
same repo string used when recording, e.g. "Scribe".
|
||||
path: Narrow to snippets recorded at this path. Matches the exact file
|
||||
OR anything beneath it, so "frontend/src" finds
|
||||
"frontend/src/lib/x.ts" as well as itself.
|
||||
symbol: Narrow to snippets recorded under this symbol name, exactly.
|
||||
verification: Narrow on the drift check (see verify_snippet).
|
||||
"attention" is the one to reach for — everything whose recorded
|
||||
location or code no longer checks out, plus everything whose verdict
|
||||
expired because the snippet was edited after it was checked. Also
|
||||
accepts "ok", "unverified", "drifted", or a specific failure:
|
||||
"missing", "moved", "changed".
|
||||
|
||||
`repo`/`path`/`symbol` must all match the SAME recorded location, so a
|
||||
snippet that lives in repo A and, separately, at path B in another repo is
|
||||
not returned for repo=A + path=B.
|
||||
|
||||
Returns {"snippets": [{id, title, tags, preview, usage}], "total": int}. The
|
||||
title reads "name — when to reach for it"; open one in full with
|
||||
get_snippet(id).
|
||||
|
||||
`usage` is {surfaced_count, pull_count, last_surfaced_at, last_pulled_at}:
|
||||
how often the entry has been put in front of an agent versus actually
|
||||
opened. Treat a high surfaced_count with a zero pull_count as a prompt to
|
||||
fix the record — usually its "when to reach for it" doesn't say when — or to
|
||||
delete it. Such an entry is not harmless: it takes a slot in every future
|
||||
auto-inject menu and crowds out something useful.
|
||||
|
||||
An entry marked `shared: true` with an `owner` belongs to someone else — one
|
||||
person's suggestion, not settled practice here. Weigh it on its merits and
|
||||
attribute it when you use it. Searching (passing `q`) also reaches snippets
|
||||
shared directly with the operator; browsing without a query deliberately
|
||||
does not, so those stay out of ambient results until asked for.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
items, total = await snippets_svc.list_snippets(
|
||||
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
|
||||
project_id=project_id or None,
|
||||
repo=repo, path=path, symbol=symbol, verification=verification,
|
||||
)
|
||||
labeled = await access_svc.label_shared_items(uid, items)
|
||||
usage = await usage_for_notes([int(it["id"]) for it in labeled])
|
||||
for it in labeled:
|
||||
it["usage"] = usage.get(int(it["id"]), empty_usage())
|
||||
return {"snippets": labeled, "total": total}
|
||||
|
||||
|
||||
async def create_snippet(
|
||||
name: str,
|
||||
code: str,
|
||||
language: str = "",
|
||||
signature: str = "",
|
||||
when_to_use: str = "",
|
||||
repo: str = "",
|
||||
path: str = "",
|
||||
symbol: str = "",
|
||||
locations: list[dict] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
system_ids: list[int] | None = None,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""Record a reusable function/component so future sessions can RECALL it
|
||||
instead of writing a fresh one-off.
|
||||
|
||||
Reach for this the moment you build (or notice) something reusable: a helper,
|
||||
a hook, a component, a pattern worth repeating. Recording it once makes it
|
||||
surface automatically when a similar problem comes up later. Before writing a
|
||||
new utility, search first — a snippet may already exist.
|
||||
|
||||
Args:
|
||||
name: Short name of the function/component, e.g. "useDebouncedRef".
|
||||
code: The code itself (required).
|
||||
language: Language/format, e.g. "python", "vue", "sql". Becomes a tag and
|
||||
the code-fence language.
|
||||
signature: One-line signature/interface, e.g. "debounce(fn, ms) -> fn".
|
||||
when_to_use: One line on when to reach for it — this becomes part of the
|
||||
title, so it's what a recall menu shows. Keep it sharp.
|
||||
repo/path/symbol: Canonical location of the reference implementation.
|
||||
locations: Several locations at once, as [{"repo","path","symbol"}, ...],
|
||||
when you already know the thing lives in more than one place. Takes
|
||||
precedence over the single repo/path/symbol shorthand.
|
||||
tags: Extra plain-string tags (language + "snippet" are added for you).
|
||||
project_id: Associate with a project (0 = no project). Snippets surface
|
||||
proactively within their project; search finds them across projects.
|
||||
system_ids: Ids of the project's Systems to associate this snippet with.
|
||||
force: Bypass the near-duplicate gate (see below).
|
||||
|
||||
Returns the created snippet (including a parsed `snippet` field), OR — when a
|
||||
near-duplicate snippet already exists and force is false — {"duplicate": true,
|
||||
"existing_id": ..., "message": ...} and nothing is created. When that happens
|
||||
and it really is the same reusable thing found in another place, prefer
|
||||
merge_snippets(existing_id, [new...]) — or record then merge — to unify them
|
||||
into ONE canonical record (which then carries every call site as a location),
|
||||
rather than forcing a second copy with force=true.
|
||||
"""
|
||||
if not (name or "").strip() or not (code or "").strip():
|
||||
raise ValueError("create_snippet requires a non-empty name and code")
|
||||
uid = current_user_id()
|
||||
|
||||
title = snippets_svc.compose_title(name, when_to_use)
|
||||
body = snippets_svc.compose_body(
|
||||
code=code, language=language, signature=signature,
|
||||
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
||||
locations=locations,
|
||||
)
|
||||
if not force:
|
||||
dup = await dedup_svc.find_duplicate_note(
|
||||
uid, title, body, project_id=project_id or None,
|
||||
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
||||
)
|
||||
if dup is not None:
|
||||
return dedup_svc.duplicate_response(dup, "snippet")
|
||||
|
||||
note = await snippets_svc.create_snippet(
|
||||
uid, name=name, code=code, language=language, signature=signature,
|
||||
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
|
||||
locations=locations, tags=tags, project_id=project_id or None,
|
||||
)
|
||||
if system_ids:
|
||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
if system_ids:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
async def get_snippet(snippet_id: int) -> dict:
|
||||
"""Fetch a snippet by id — the full record: code, signature, location, and a
|
||||
parsed `snippet` field of its structured parts.
|
||||
|
||||
If the record belongs to someone else it carries `shared: true` with the
|
||||
`owner` and your `permission`. Read that as ONE PERSON'S SUGGESTION, not as
|
||||
established practice here: judge it on its merits, say whose it is when you
|
||||
reference it, and don't adopt it as the house pattern without checking.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note = await snippets_svc.get_snippet(uid, snippet_id)
|
||||
if note is None:
|
||||
raise ValueError(f"snippet {snippet_id} not found")
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
# A "pull" is an explicit open, so it's recorded HERE rather than in
|
||||
# snippets_svc.get_snippet — the service is also reached by update/merge
|
||||
# paths, and counting those would inflate exactly the number that is
|
||||
# supposed to mean "someone chose to look at this" (#2085).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_snippet")
|
||||
return data
|
||||
|
||||
|
||||
async def unmerge_snippet(survivor_id: int, source_id: int) -> dict:
|
||||
"""Reverse ONE source out of a merged snippet — the inverse of merge_snippets.
|
||||
|
||||
Restores the source record and strips exactly what it contributed from the
|
||||
survivor: the locations and tags it ADDED at merge time, never the ones the
|
||||
survivor already had. Reach for it when a merge turns out to have unified two
|
||||
things that only looked alike.
|
||||
|
||||
Also the fix for a half-undone merge. Restoring a merged-in source from the
|
||||
trash by hand brings the record back but leaves the survivor still claiming
|
||||
its call sites, so both records claim the same places and the reverse lookup
|
||||
reads the duplicate claims as real. Running this on an already-restored
|
||||
source repairs that: it skips the restore and does the subtraction.
|
||||
|
||||
Args:
|
||||
survivor_id: The snippet that absorbed the other.
|
||||
source_id: The snippet to pull back out of it.
|
||||
|
||||
Returns {"survivor": {...}, "restored": {...}}.
|
||||
|
||||
Refuses, with the reason, when: the survivor has no record of absorbing that
|
||||
id; the source was purged from the trash; or the merge predates per-source
|
||||
provenance, in which case what it contributed isn't known and subtracting a
|
||||
guess could strip call sites the survivor genuinely owns — restore it from
|
||||
the trash and adjust both records by hand instead.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
try:
|
||||
result = await snippets_svc.unmerge_snippet(uid, survivor_id, source_id)
|
||||
except snippets_svc.UnmergeError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if result is None:
|
||||
raise ValueError(f"snippet {survivor_id} not found")
|
||||
survivor, restored = result
|
||||
return {
|
||||
"survivor": snippets_svc.snippet_to_dict(survivor),
|
||||
"restored": snippets_svc.snippet_to_dict(restored) if restored else None,
|
||||
}
|
||||
|
||||
|
||||
async def find_duplicate_snippets(threshold: float = 0.0) -> dict:
|
||||
"""Find snippets already recorded that look like duplicates of each other.
|
||||
|
||||
The create gate PREVENTS a new duplicate and merge_snippets CURES one you
|
||||
point it at — this is the missing third piece: it FINDS the ones already in
|
||||
the record, so nobody has to notice them by hand.
|
||||
|
||||
Results are grouped into candidate merge SETS, not just pairs. Grouping is
|
||||
transitive: if A resembles B and B resembles C, all three land in one set
|
||||
even when A and C don't directly clear the bar. That mirrors what merge does
|
||||
(it folds every source into one survivor), but it means a chain of mild
|
||||
resemblances can rope in a member that isn't really alike — so read a set as
|
||||
a proposal and check the members before acting.
|
||||
|
||||
Reports only YOUR snippets. merge_snippets requires one owner across the
|
||||
whole set, so surfacing someone else's would propose a merge that can't be
|
||||
performed.
|
||||
|
||||
Acting on a group: pick the best record as the canonical target, then
|
||||
`merge_snippets(target_id, [other ids])`. Merge unions the fields and folds
|
||||
every source's location in, so the survivor is findable at all their call
|
||||
sites; the sources are trashed, recoverably. Prefer as target the one with
|
||||
the clearest "when to reach for it" — merge keeps the target's title.
|
||||
|
||||
Args:
|
||||
threshold: Similarity floor, 0-1. 0 (default) uses the configured
|
||||
setting. Raise it if the report is noisy, lower it to catch more.
|
||||
|
||||
Returns {"groups": [{"note_ids", "snippets", "top_score"}], "pairs",
|
||||
"threshold"}. An empty `groups` means nothing resembles anything else that
|
||||
closely — the common and desirable case.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
return await dedup_svc.find_duplicate_snippets(
|
||||
uid, threshold=threshold if threshold > 0 else None
|
||||
)
|
||||
|
||||
|
||||
async def verify_snippet(
|
||||
snippet_id: int, status: str, detail: str = "", path: str = "",
|
||||
) -> dict:
|
||||
"""Record whether a snippet's recorded location and code still match source.
|
||||
|
||||
YOU do the checking — Scribe has no copy of the repo and deliberately never
|
||||
gets one. This tool only remembers your verdict so it becomes queryable and
|
||||
so the operator can see what has rotted.
|
||||
|
||||
The procedure, once per snippet you're checking:
|
||||
1. `get_snippet(id)` — read its `snippet.locations` and `snippet.code`.
|
||||
2. Does the recorded path still exist in the working tree? If not →
|
||||
status="missing".
|
||||
3. Does the recorded symbol still appear in that file? If not →
|
||||
status="moved" (the file is there, the thing isn't).
|
||||
4. Does the source still match the recorded code, allowing for formatting?
|
||||
Judge whether it still does the same thing — an added parameter or a
|
||||
changed branch is "changed"; a reindent is not. If it diverged →
|
||||
status="changed".
|
||||
5. All three hold → status="ok".
|
||||
|
||||
Put what you actually found in `detail` ("renamed to parse_location_str",
|
||||
"moved to services/knowledge.py"). It's what makes the record fixable later
|
||||
by someone who wasn't here, so write it for them, not as a status echo.
|
||||
|
||||
A verdict expires automatically if the snippet is edited afterwards: it is
|
||||
stamped with a hash of the code it was checked against, so it can never go
|
||||
on vouching for code nobody checked. Re-verify after fixing a record.
|
||||
|
||||
Args:
|
||||
snippet_id: The snippet you checked.
|
||||
status: "ok" | "missing" | "moved" | "changed".
|
||||
detail: What you found — free text, shown to the operator.
|
||||
path: The path you actually checked, if it differs from the recorded
|
||||
one (e.g. you found the symbol at its new home). Defaults to the
|
||||
recorded path.
|
||||
|
||||
Requires write access: a verdict changes how the record is presented, so
|
||||
being able to read a snippet someone shared with you doesn't let you mark
|
||||
it broken.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note = await snippets_svc.record_verification(
|
||||
uid, snippet_id, status=status, detail=detail, path=path,
|
||||
)
|
||||
if note is None:
|
||||
raise ValueError(
|
||||
f"snippet {snippet_id} not found, or you don't have write access to it"
|
||||
)
|
||||
return snippets_svc.snippet_to_dict(note)
|
||||
|
||||
|
||||
async def update_snippet(
|
||||
snippet_id: int,
|
||||
name: str | None = None,
|
||||
code: str | None = None,
|
||||
language: str | None = None,
|
||||
signature: str | None = None,
|
||||
when_to_use: str | None = None,
|
||||
repo: str | None = None,
|
||||
path: str | None = None,
|
||||
symbol: str | None = None,
|
||||
locations: list[dict] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
system_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Update a snippet. Only the fields you pass change.
|
||||
|
||||
An omitted field is left alone; an EMPTY STRING clears it — so a stale
|
||||
signature, a wrong "when to use", or an obsolete location can be removed, not
|
||||
just overwritten. A snippet that surfaces in recall with wrong details is
|
||||
worse than none, so correcting downward has to be possible.
|
||||
|
||||
Args:
|
||||
locations: Replace the whole location set, as [{"repo","path","symbol"},
|
||||
...]. Pass [] to clear every location. The single repo/path/symbol
|
||||
args instead overlay onto the FIRST location, leaving the rest.
|
||||
tags: Replaces the extra-tag set (language + "snippet" are re-derived).
|
||||
project_id: 0 leaves it unchanged, -1 detaches it from its project, a
|
||||
positive id moves it.
|
||||
|
||||
Editing someone else's snippet requires an editor or admin share from them.
|
||||
A read-only share is refused with a message saying so — record your own
|
||||
version instead of trying to force it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if project_id == 0:
|
||||
project = snippets_svc.UNSET
|
||||
elif project_id < 0:
|
||||
project = None
|
||||
else:
|
||||
project = project_id
|
||||
|
||||
try:
|
||||
note = await snippets_svc.update_snippet(
|
||||
uid, snippet_id,
|
||||
name=name, code=code, language=language,
|
||||
signature=signature, when_to_use=when_to_use,
|
||||
repo=repo, path=path, symbol=symbol,
|
||||
locations=locations, tags=tags, project_id=project,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
# Readable but not writable — surface the real reason, not "not found".
|
||||
raise ValueError(str(exc)) from exc
|
||||
if note is None:
|
||||
raise ValueError(f"snippet {snippet_id} not found")
|
||||
if system_ids is not None:
|
||||
await systems_svc.set_record_systems(note.user_id, snippet_id, system_ids)
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
if system_ids is not None:
|
||||
data["systems"] = [
|
||||
s.to_dict()
|
||||
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
async def delete_snippet(snippet_id: int) -> dict:
|
||||
"""Retire a snippet you recorded — it moves to the trash and is recoverable.
|
||||
|
||||
Reach for this when a snippet is wrong, obsolete, or was never worth keeping.
|
||||
A recorded snippet is offered as prior art on every matching turn, so a bad
|
||||
one costs more than a missing one. If instead it's a duplicate of something
|
||||
that should survive, prefer merge_snippets — that keeps the call sites.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not await snippets_svc.delete_snippet(uid, snippet_id):
|
||||
raise ValueError(f"snippet {snippet_id} not found")
|
||||
return {"deleted": True, "id": snippet_id}
|
||||
|
||||
|
||||
async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
||||
"""Unify duplicate/variant snippets INTO one canonical record — the cure for
|
||||
the same reusable thing recorded as several one-offs.
|
||||
|
||||
Keeps the target as the canonical: its name, when-to-use, signature, language
|
||||
and code win. The sources' locations and extra tags are folded in — so the
|
||||
survivor ends up carrying EVERY call site as a location (which is itself the
|
||||
"this is duplicated N times" signal) — and the source records are moved to the
|
||||
trash (recoverable). The survivor is re-embedded so recall stops surfacing the
|
||||
now-merged duplicates.
|
||||
|
||||
The survivor records what it absorbed as `merged_from` (in its `snippet`
|
||||
fields and as a "Merged from: #ids" line in the body), so a variant that got
|
||||
folded in leaves a trace outside the trash. It accumulates across merges.
|
||||
|
||||
Reversible: each entry also records what that source contributed, so
|
||||
`unmerge_snippet(target_id, source_id)` can restore it and strip exactly
|
||||
those locations back off — never the ones the target already had.
|
||||
|
||||
Args:
|
||||
target_id: The snippet to keep (the canonical record).
|
||||
source_ids: Snippet ids to fold into the target and retire. Ids that
|
||||
aren't your snippets are skipped; target_id in the list is ignored.
|
||||
|
||||
Returns the merged canonical snippet (with a parsed `snippet` field) plus
|
||||
`merged_ids` — the source ids actually merged and trashed.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
ids = [s for s in (source_ids or []) if s != target_id]
|
||||
if not ids:
|
||||
raise ValueError("merge_snippets requires at least one other source_id")
|
||||
try:
|
||||
result = await snippets_svc.merge_snippets(uid, target_id, ids)
|
||||
except PermissionError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if result is None:
|
||||
raise ValueError(f"snippet {target_id} not found")
|
||||
note, merged_ids = result
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
data["merged_ids"] = merged_ids
|
||||
return data
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_snippets, create_snippet, get_snippet, update_snippet,
|
||||
delete_snippet, merge_snippets, verify_snippet, find_duplicate_snippets,
|
||||
unmerge_snippet,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -19,6 +19,7 @@ Sentinels (preserved from existing fable-mcp):
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import planning as planning_svc
|
||||
@@ -26,6 +27,7 @@ from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import task_logs as task_logs_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.note_usage import record_pulled
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
@@ -68,17 +70,21 @@ async def get_task(task_id: int) -> dict:
|
||||
kind=plan tasks, the response also includes applicable_rules +
|
||||
subscribed_rulebooks from the task's project's rulebook subscriptions (new
|
||||
plans are milestones — use get_milestone for those).
|
||||
|
||||
A task another user shared with you also carries `shared`, `owner` and
|
||||
`permission` — it's their work item, not one you took on.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.get_note(uid, task_id)
|
||||
if note is None:
|
||||
loaded = await notes_svc.get_note_for_user(uid, task_id)
|
||||
note = loaded[0] if loaded else None
|
||||
if note is None or note.deleted_at is not None:
|
||||
raise ValueError(f"task {task_id} not found")
|
||||
data = note.to_dict()
|
||||
parent_title = None
|
||||
if note.parent_id:
|
||||
parent = await notes_svc.get_note(uid, note.parent_id)
|
||||
if parent is not None:
|
||||
parent_title = parent.title
|
||||
parent_loaded = await notes_svc.get_note_for_user(uid, note.parent_id)
|
||||
if parent_loaded is not None:
|
||||
parent_title = parent_loaded[0].title
|
||||
data["parent_title"] = parent_title
|
||||
|
||||
# Legacy kind=plan tasks predate milestone-as-plan; still surface their
|
||||
@@ -93,6 +99,15 @@ async def get_task(task_id: int) -> dict:
|
||||
data["project_rules"] = applicable.get("project_rules", [])
|
||||
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
# Same reasoning as get_note's record_pulled, and this is the tool where it
|
||||
# matters MOST: auto-inject ranks kind-blind over a corpus that is
|
||||
# overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without
|
||||
# this the surfaced→pulled loop was open exactly where the volume is — every
|
||||
# surfaced task counted as never-pulled because the tool that opens one didn't
|
||||
# say so, driving auto-inject's measured pull-through toward zero for its own
|
||||
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_task")
|
||||
return data
|
||||
|
||||
|
||||
@@ -261,6 +276,12 @@ async def add_task_log(task_id: int, content: str) -> dict:
|
||||
async def start_planning(project_id: int, title: str) -> dict:
|
||||
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
|
||||
|
||||
Reach for this when the work has an ARC — several steps toward one goal,
|
||||
worth tracking as a unit. Work without one (a fix, a one-file change, a
|
||||
question answered) is a task, not a plan: create_task, drive its status, and
|
||||
record progress with add_task_log. A milestone holding a single step is
|
||||
ceremony, and it leaves the project with a plan that never meant anything.
|
||||
|
||||
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
|
||||
template (Goal/Approach/Verification) under the given project, and the call
|
||||
returns it together with the project's applicable Rulebook rules and brief
|
||||
|
||||
@@ -27,6 +27,7 @@ from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
|
||||
from scribe.models.invitation import InvitationToken # noqa: E402, F401
|
||||
from scribe.models.embedding import NoteEmbedding # noqa: E402, F401
|
||||
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
|
||||
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
|
||||
from scribe.models.project import Project # noqa: E402, F401
|
||||
from scribe.models.milestone import Milestone # noqa: E402, F401
|
||||
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||
@@ -42,3 +43,4 @@ from scribe.models.rulebook import ( # noqa: E402, F401
|
||||
)
|
||||
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
||||
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
||||
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Design systems — a stylesheet held as records, inherited family -> app.
|
||||
|
||||
A DesignSystem is a named set of design tokens with an OPTIONAL parent, and that
|
||||
single self-FK is the whole model. A system with no parent is a family system; a
|
||||
system WITH one holds only what it changes. "What does this app alter?" is
|
||||
therefore `list its tokens` — nothing to compute, nothing to diff — which is why
|
||||
inheritance won over a flat family-plus-loose-overrides shape.
|
||||
|
||||
Resolution walks the chain and lets the deepest system win by token name. That
|
||||
is the CSS cascade rather than an analogy to it, which is why the storage model
|
||||
and the stylesheet model come out the same shape.
|
||||
|
||||
`parent_id` also replaces two things the rulebook model needs to express the same
|
||||
idea: an `always_on` flag (a family system is simply one with no parent) and a
|
||||
subscription join table (a project points at ONE system, and the chain supplies
|
||||
the rest). Less schema for more structure.
|
||||
"""
|
||||
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, Text, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "design_systems"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
owner_user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The narrative a token table cannot hold: aesthetic, voice and tone, what
|
||||
# is deliberately out of scope. Free-form markdown rather than a column per
|
||||
# category — a schema with `voice`/`aesthetic`/`scope` columns would bake one
|
||||
# rulebook's table of contents into every install.
|
||||
guidance: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# SET NULL, not CASCADE: deleting a family system must not delete every app
|
||||
# system that inherited from it. Orphaning turns each child into a root that
|
||||
# still holds its own overrides — recoverable. A cascade would destroy data
|
||||
# the operator never asked to touch.
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("design_systems.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"owner_user_id": self.owner_user_id,
|
||||
"title": self.title,
|
||||
"description": self.description or "",
|
||||
"guidance": self.guidance or "",
|
||||
"parent_id": self.parent_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""One custom property in one system: its name, and its value per mode.
|
||||
|
||||
`value_by_mode` is a JSONB map of mode -> value, e.g.
|
||||
`{"base": "#f7f5ef", "dark": "#14171a"}`. Three reasons it beats a pair of
|
||||
`value_light` / `value_dark` columns here:
|
||||
|
||||
- **Absence means one thing.** In a child system an unset mode means
|
||||
"inherit"; in a root it would have to mean "not mode-dependent". With
|
||||
columns those are both NULL and the resolver cannot tell them apart. With
|
||||
a map, resolution is `{**parent_map, **child_map}` at every level —
|
||||
one rule, no special case for roots.
|
||||
- **Per-mode overrides are already real.** A palette rule in this operator's
|
||||
own kit deepens one accent on light backgrounds for contrast while
|
||||
leaving the dark value alone. Mode is a second override axis, not a
|
||||
second column.
|
||||
- **Nothing filters tokens by value in SQL.** Drift comparison resolves the
|
||||
set first and compares in the client; the importer diffs in Python. The
|
||||
queryability columns would buy is for a query no caller makes.
|
||||
|
||||
The cost is real — a third mode is data rather than schema, so the DB will
|
||||
not reject a typo'd mode key. That is the trade taken.
|
||||
|
||||
NOT NULL with a `{}` default deliberately: a JSONB column otherwise has two
|
||||
empty states (SQL NULL and JSON null) and code has to test for both.
|
||||
"""
|
||||
__tablename__ = "design_tokens"
|
||||
__table_args__ = (
|
||||
# Partial unique: a name is unique among LIVE tokens in a system, so a
|
||||
# trashed token doesn't block recreating the same name. Two live rows
|
||||
# named `--fs-obsidian` in one system is a duplicate definition, and the
|
||||
# cascade would pick between them arbitrarily.
|
||||
Index(
|
||||
"uq_token_per_design_system", "design_system_id", "name",
|
||||
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
design_system_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("design_systems.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
# Named for what it holds rather than the bare word `values`, which is
|
||||
# reserved in SQL — the same reason `group_name` is not `group`.
|
||||
value_by_mode: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
|
||||
)
|
||||
# `group` is a reserved word in SQL; `group_name` throughout — model, column
|
||||
# and payload — so no layer has to remember which spelling it is on.
|
||||
# Free text, not a CHECK enum: groupings are the design system's own
|
||||
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
||||
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# WHY this token is this value — a different question from `purpose`, which
|
||||
# is what it is FOR. "Deliberately the same value as the primary action
|
||||
# colour" is a rationale; "page background, deepest surface" is a purpose.
|
||||
# Design guidance carries the first routinely and a token row had nowhere to
|
||||
# put it.
|
||||
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
|
||||
# "#ffffff"] on a text-on-action token.
|
||||
#
|
||||
# This is how a design system records the thing a prohibition was trying to
|
||||
# say. "Pure white is never text" is the shadow of a positive fact — some
|
||||
# other colour IS the text colour — and a system that stores what things ARE
|
||||
# has no row for a ban.
|
||||
# Recording the replacement keeps the check and makes it actionable: a
|
||||
# finding can name what to write instead of merely objecting.
|
||||
#
|
||||
# It has to be DECLARED rather than inferred, because the superseded literal
|
||||
# and the token's own value are usually different colours entirely. No
|
||||
# value-matching rule could ever connect them.
|
||||
#
|
||||
# Consumed by the source lint (#2277), not by the drift panel: these
|
||||
# literals live in component CSS, which the panel cannot see and says so.
|
||||
supersedes: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"design_system_id": self.design_system_id,
|
||||
"name": self.name,
|
||||
"value_by_mode": self.value_by_mode or {},
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"rationale": self.rationale,
|
||||
"supersedes": self.supersedes or [],
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -69,6 +69,13 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
# notes keep the 'work' default and ignore it. Orthogonal to note_type
|
||||
# (which is the note/entity axis).
|
||||
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
||||
# Queryable structured fields for typed records — currently snippets, whose
|
||||
# name/language/signature/locations live here so they can be INDEXED. The
|
||||
# body keeps the same facts in readable markdown and remains what gets
|
||||
# embedded; this is a mirror for querying, not the source of truth for
|
||||
# display. NULL on every row written before migration 0070, so readers fall
|
||||
# back to parsing the body (see services/snippets.snippet_fields).
|
||||
data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||
@@ -79,6 +86,9 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
Index("ix_notes_milestone_id", "milestone_id"),
|
||||
Index("ix_notes_note_type", "note_type"),
|
||||
Index("ix_notes_arose_from_id", "arose_from_id"),
|
||||
# Containment queries into `data` — e.g. which snippets name a given
|
||||
# repo/path in their locations. See migration 0070.
|
||||
Index("ix_notes_data_gin", "data", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
|
||||
SURFACED = "surfaced"
|
||||
PULLED = "pulled"
|
||||
|
||||
|
||||
class NoteUsageEvent(Base):
|
||||
"""One row per time a note was SURFACED to the agent, or PULLED in full.
|
||||
|
||||
Answers the question RetrievalLog cannot: not "what did the ranker return
|
||||
and with what scores" but "did anyone ever actually open this?" A snippet
|
||||
nobody opens is not neutral — it competes for the injection budget on every
|
||||
future turn and dilutes the menu — so the surfaced:pulled ratio is what
|
||||
makes dead weight visible and prunable.
|
||||
|
||||
WHY A SEPARATE TABLE FROM RetrievalLog. RetrievalLog is one row per *call*,
|
||||
keyed on the score distribution it exists to capture; folding un-scored
|
||||
events into it would corrupt exactly the distribution threshold tuning reads
|
||||
(see the KNOWN GAP note this closes in plugin_context.build_write_path_hint).
|
||||
This table is one row per *note per event*, which is the grain the usage
|
||||
readout needs and the grain RetrievalLog's JSONB `result_ids` can't be
|
||||
indexed at. The two are complements: RetrievalLog tunes the threshold, this
|
||||
tunes the corpus.
|
||||
|
||||
WHY NOT IN-SESSION CORRELATION. The original framing was "correlate
|
||||
result_ids against a later get_note in the same session". There is no
|
||||
session identity server-side — the MCP endpoint is stateless and hooks send
|
||||
no session id — and introducing one would mean threading an opaque,
|
||||
client-supplied token through every read path for a signal that does not
|
||||
need it. Two independent counters answer the question without it: a note
|
||||
surfaced 40 times and never pulled is dead weight regardless of which
|
||||
sessions those events fell in.
|
||||
|
||||
Deliberately FK-free on user_id and note_id (mirrors RetrievalLog/AppLog):
|
||||
telemetry outlives the row it describes, and deleting a note should not
|
||||
erase the evidence that it was surfaced 40 times and never once opened.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_usage_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
note_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 'surfaced' | 'pulled'
|
||||
event: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Which surface produced it: 'auto_inject' | 'write_path_place' |
|
||||
# 'write_path_semantic' | 'mcp_get_snippet' | 'mcp_get_note' | 'rest_note'.
|
||||
# Kept granular so the place arm and the semantic arm can be compared —
|
||||
# that comparison is the whole reason the place arm needed logging at all.
|
||||
source: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
# The readout is always "these note ids, split by event" — a covering
|
||||
# composite beats separate single-column indexes for it.
|
||||
Index("ix_note_usage_note_event", "note_id", "event"),
|
||||
Index("ix_note_usage_created_at", "created_at"),
|
||||
Index("ix_note_usage_user_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"user_id": self.user_id,
|
||||
"note_id": self.note_id,
|
||||
"event": self.event,
|
||||
"source": self.source,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import enum
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import TimestampMixin, SoftDeleteMixin
|
||||
@@ -21,6 +21,12 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
goal: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(Text, default="active")
|
||||
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
||||
# The design system this project's UI is built from, or NULL. NULL is the
|
||||
# ordinary state, not a degraded one — most installs have no design system
|
||||
# at all and nothing may assume one exists.
|
||||
design_system_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -31,6 +37,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"goal": self.goal,
|
||||
"status": self.status,
|
||||
"color": self.color,
|
||||
"design_system_id": self.design_system_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Design-system surface — what the rulebook expects of the stylesheet.
|
||||
|
||||
The client owns the other half of the comparison: it reads live token values from
|
||||
the browser (see `utils/designTokens.ts`), which is the only place they exist
|
||||
resolved. This endpoint supplies the claims to check them against.
|
||||
"""
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services import design_rulebook_import as design_svc
|
||||
|
||||
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
|
||||
|
||||
|
||||
@design_bp.get("/expectations")
|
||||
@login_required
|
||||
async def get_expectations():
|
||||
"""Checkable claims from the rulebook this install designated as its design system.
|
||||
|
||||
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
|
||||
|
||||
`rulebook_id: null` is the NORMAL case, not an error — an install that has
|
||||
not designated a design rulebook has nothing to compare against, and the
|
||||
client shows an explanatory empty state (rule #115). Distinguishing it from
|
||||
"designated but empty" is why the id is returned alongside the list.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
result = await design_svc.design_expectations(uid)
|
||||
return jsonify(result.as_dict())
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Design system + token REST endpoints (milestone #254 step 4).
|
||||
|
||||
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
|
||||
Design systems are owner-scoped top-level records rather than project-scoped
|
||||
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
|
||||
closer shape.
|
||||
|
||||
Two failures this layer has to keep apart, which is why the service raises for
|
||||
one and returns None for the other:
|
||||
|
||||
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
|
||||
from this system" is a correctable mistake and the caller needs to be told
|
||||
which one they made.
|
||||
- None -> 404, covering both "no such system" and "not yours". Conflating
|
||||
those two IS the intent: distinguishing them would confirm the existence of
|
||||
records the caller may not see.
|
||||
"""
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import login_required
|
||||
from scribe.services import design_systems as ds_svc
|
||||
from scribe.services.design_systems import DesignSystemCycle
|
||||
|
||||
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
def _not_found(what: str = "design system"):
|
||||
return jsonify({"error": f"{what} not found"}), 404
|
||||
|
||||
|
||||
# ── Design systems ──────────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.get("/design-systems")
|
||||
@login_required
|
||||
async def list_design_systems():
|
||||
"""The caller's design systems. An empty list is the ordinary state for an
|
||||
install that has never made one, not an error."""
|
||||
rows = await ds_svc.list_design_systems(_uid())
|
||||
return jsonify({"design_systems": [s.to_dict() for s in rows]})
|
||||
|
||||
|
||||
@design_systems_bp.post("/design-systems")
|
||||
@login_required
|
||||
async def create_design_system():
|
||||
data = await request.get_json() or {}
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
system = await ds_svc.create_design_system(
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
description=data.get("description") or None,
|
||||
guidance=data.get("guidance") or None,
|
||||
parent_id=data.get("parent_id"),
|
||||
)
|
||||
if system is None:
|
||||
return jsonify({"error": "parent design system not found"}), 404
|
||||
return jsonify(system.to_dict()), 201
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def get_design_system(design_system_id: int):
|
||||
system = await ds_svc.get_design_system(_uid(), design_system_id)
|
||||
if system is None:
|
||||
return _not_found()
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def update_design_system(design_system_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items() if k in ("title", "description", "guidance")
|
||||
}
|
||||
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
|
||||
# which a `if data.get("parent_id")` filter would silently drop.
|
||||
if "parent_id" in data:
|
||||
fields["parent_id"] = data["parent_id"]
|
||||
try:
|
||||
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
|
||||
except DesignSystemCycle as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if system is None:
|
||||
return _not_found()
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def delete_design_system(design_system_id: int):
|
||||
if not await ds_svc.delete_design_system(_uid(), design_system_id):
|
||||
return _not_found()
|
||||
return "", 204
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
|
||||
@login_required
|
||||
async def resolve_design_system(design_system_id: int):
|
||||
"""The EFFECTIVE token set — everything inherited, with this system's on top.
|
||||
|
||||
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
|
||||
this returns what it ends up being. Both are real questions and answering
|
||||
only one would make the other a client-side computation.
|
||||
"""
|
||||
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
|
||||
if resolved is None:
|
||||
return _not_found()
|
||||
return jsonify({
|
||||
"design_system_id": design_system_id,
|
||||
"tokens": [t.to_dict() for t in resolved],
|
||||
})
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/stylesheet")
|
||||
@login_required
|
||||
async def get_design_system_stylesheet(design_system_id: int):
|
||||
"""The master CSS sheet this design system generates.
|
||||
|
||||
JSON by default (the UI wants the reuse report alongside the CSS); add
|
||||
`?format=css` for the raw stylesheet as `text/css`, which is what a build
|
||||
step or a `curl` wants.
|
||||
|
||||
`?root=` overrides the base selector — a container-scoped preview cannot use
|
||||
`:root`, so the generator takes it as a parameter.
|
||||
"""
|
||||
root = (request.args.get("root") or ":root").strip() or ":root"
|
||||
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root)
|
||||
if result is None:
|
||||
return _not_found()
|
||||
if request.args.get("format") == "css":
|
||||
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/snippet-check")
|
||||
@login_required
|
||||
async def check_snippets_against_system(design_system_id: int):
|
||||
"""Which recorded snippets disagree with this system's sheet.
|
||||
|
||||
`?project_id=` narrows to one project; omit it to check every project, which
|
||||
is usually right — a component recorded elsewhere still has to use the same
|
||||
tags.
|
||||
"""
|
||||
project_id = request.args.get("project_id", type=int) or 0
|
||||
result = await ds_svc.check_snippets_against_system(
|
||||
_uid(), design_system_id, project_id
|
||||
)
|
||||
if result is None:
|
||||
return _not_found()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
|
||||
@login_required
|
||||
async def list_design_tokens(design_system_id: int):
|
||||
"""This system's OWN tokens — its override set, not its effective set."""
|
||||
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
|
||||
return _not_found()
|
||||
rows = await ds_svc.list_tokens(_uid(), design_system_id)
|
||||
return jsonify({"tokens": [t.to_dict() for t in rows]})
|
||||
|
||||
|
||||
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
|
||||
@login_required
|
||||
async def create_design_token(design_system_id: int):
|
||||
data = await request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
token = await ds_svc.create_token(
|
||||
user_id=_uid(),
|
||||
design_system_id=design_system_id,
|
||||
name=name,
|
||||
value_by_mode=data.get("value_by_mode"),
|
||||
group_name=data.get("group_name") or None,
|
||||
purpose=data.get("purpose") or None,
|
||||
rationale=data.get("rationale") or None,
|
||||
supersedes=data.get("supersedes"),
|
||||
order_index=data.get("order_index") or 0,
|
||||
)
|
||||
if token is None:
|
||||
return _not_found()
|
||||
return jsonify(token.to_dict()), 201
|
||||
|
||||
|
||||
@design_systems_bp.patch("/design-tokens/<int:token_id>")
|
||||
@login_required
|
||||
async def update_design_token(token_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in (
|
||||
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||
"supersedes", "order_index",
|
||||
)
|
||||
}
|
||||
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
||||
if token is None:
|
||||
return _not_found("design token")
|
||||
return jsonify(token.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-tokens/<int:token_id>")
|
||||
@login_required
|
||||
async def delete_design_token(token_id: int):
|
||||
if not await ds_svc.delete_token(_uid(), token_id):
|
||||
return _not_found("design token")
|
||||
return "", 204
|
||||
|
||||
|
||||
# ── The project pointer ─────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.put("/projects/<int:project_id>/design-system")
|
||||
@login_required
|
||||
async def set_project_design_system(project_id: int):
|
||||
"""Point a project at a design system. `{"design_system_id": null}` clears it.
|
||||
|
||||
PUT rather than PATCH: this sets one field to exactly what is sent, and
|
||||
clearing it is a first-class outcome rather than an omission.
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
ok = await ds_svc.set_project_design_system(
|
||||
_uid(), project_id, data.get("design_system_id")
|
||||
)
|
||||
if not ok:
|
||||
return _not_found("project or design system")
|
||||
return jsonify({"project_id": project_id,
|
||||
"design_system_id": data.get("design_system_id")})
|
||||
@@ -5,6 +5,7 @@ from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import parse_pagination
|
||||
from scribe.services.access import label_shared_items
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,7 +55,9 @@ async def list_knowledge():
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
# Mark rows another user owns: this feed can be mixed-ownership, and an
|
||||
# unmarked card reads as one the viewer wrote.
|
||||
"items": await label_shared_items(uid, items),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
@@ -116,7 +119,9 @@ async def get_knowledge_batch():
|
||||
|
||||
from scribe.services.knowledge import get_knowledge_by_ids
|
||||
items = await get_knowledge_by_ids(uid, ids)
|
||||
return jsonify({"items": items})
|
||||
# The scrolling feed hydrates its cards here, not from the list route, so the
|
||||
# ownership markers have to be applied on this path too.
|
||||
return jsonify({"items": await label_shared_items(uid, items)})
|
||||
|
||||
|
||||
@knowledge_bp.route("/tags", methods=["GET"])
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
@@ -113,9 +111,6 @@ async def create_note_route():
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
||||
return jsonify(note.to_dict()), 201
|
||||
|
||||
|
||||
@@ -221,9 +216,6 @@ async def update_note_route(note_id: int):
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@@ -259,9 +251,6 @@ async def patch_note_route(note_id: int):
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
|
||||
@@ -99,6 +99,53 @@ async def autoinject_retrieve():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@plugin_bp.get("/prior-art")
|
||||
@login_required
|
||||
async def write_path_prior_art():
|
||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||
|
||||
Answers "what is already recorded for the file about to be written?" — by
|
||||
place (a snippet at this path or in its directory) and by meaning (snippets
|
||||
resembling the code about to be written). Titles only, gated, and empty most
|
||||
of the time. See services.plugin_context.build_write_path_hint.
|
||||
|
||||
Query:
|
||||
path (str) — the target file, REPO-RELATIVE, matching the
|
||||
convention snippet locations are recorded in. An
|
||||
absolute path simply won't match anything.
|
||||
code (optional) — the code about to be written; the semantic query.
|
||||
Omit it and only the location arm runs.
|
||||
repo (optional) — working repo remote; resolved to the bound project
|
||||
to scope the search, exactly as /retrieve does. It
|
||||
is NOT used as the location `repo` filter — see the
|
||||
service docstring for why those two differ.
|
||||
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
||||
exclude_ids (opt) — comma-separated ids already surfaced this session.
|
||||
"""
|
||||
path = (request.args.get("path") or "").strip()
|
||||
code = request.args.get("code") or ""
|
||||
try:
|
||||
project_id = int(request.args.get("project_id", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
project_id = 0
|
||||
|
||||
repo = (request.args.get("repo") or "").strip()
|
||||
if repo and not project_id:
|
||||
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
|
||||
if resolved:
|
||||
project_id = resolved
|
||||
|
||||
exclude_ids = [
|
||||
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
|
||||
if p.strip().isdigit()
|
||||
]
|
||||
|
||||
result = await plugin_ctx_svc.build_write_path_hint(
|
||||
g.user.id, path, code=code, project_id=project_id, exclude_ids=exclude_ids
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@plugin_bp.get("/processes")
|
||||
@login_required
|
||||
async def process_manifest():
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Project management routes."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -13,6 +12,7 @@ from scribe.services.projects import (
|
||||
delete_project,
|
||||
get_project,
|
||||
get_project_for_user,
|
||||
get_project_summaries,
|
||||
get_project_summary,
|
||||
list_projects_for_user,
|
||||
update_project,
|
||||
@@ -31,16 +31,28 @@ async def list_projects_route():
|
||||
include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
|
||||
projects = await list_projects_for_user(uid, status=status)
|
||||
if include_summary:
|
||||
# Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls
|
||||
async def _attach(project_dict: dict) -> dict:
|
||||
# Batched: four queries plus two, in two sessions, for ALL projects.
|
||||
# This replaced an asyncio.gather over a per-project summary that opened
|
||||
# its own session and then one more per milestone — ~250 concurrent
|
||||
# checkouts against a pool of 15, all waiting out the 30s timeout and
|
||||
# starving every other route on the instance (#2384).
|
||||
#
|
||||
# Grouped by OWNER because a shared project's counts belong to its
|
||||
# owner's records, matching what the per-project path passed.
|
||||
by_owner: dict[int, list[dict]] = {}
|
||||
for p in projects:
|
||||
by_owner.setdefault(p.get("user_id") or uid, []).append(p)
|
||||
for owner_uid, owned in by_owner.items():
|
||||
try:
|
||||
owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict()
|
||||
summary = await get_project_summary(owner_uid, project_dict["id"])
|
||||
project_dict["summary"] = summary
|
||||
summaries = await get_project_summaries(
|
||||
owner_uid, [p["id"] for p in owned]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return project_dict
|
||||
projects = list(await asyncio.gather(*[_attach(p) for p in projects]))
|
||||
logger.warning("Project summaries failed", exc_info=True)
|
||||
continue
|
||||
for p in owned:
|
||||
if p["id"] in summaries:
|
||||
p["summary"] = summaries[p["id"]]
|
||||
return jsonify({"projects": projects})
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import time
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.services.access import owner_names_for
|
||||
from scribe.services.embeddings import semantic_search_notes
|
||||
from scribe.services.retrieval_telemetry import record_retrieval
|
||||
|
||||
@@ -36,7 +37,9 @@ async def search_route():
|
||||
|
||||
t0 = time.perf_counter()
|
||||
results = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD
|
||||
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
||||
# The user typed this, so it reaches everything they may read.
|
||||
scope="read",
|
||||
)
|
||||
record_retrieval(
|
||||
user_id=uid, source="rest_search", query=q,
|
||||
@@ -44,6 +47,9 @@ async def search_route():
|
||||
project_id=None, is_task=is_task, results=results,
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
)
|
||||
owners = await owner_names_for(
|
||||
{int(note.user_id) for _s, note in results if note.user_id != uid}
|
||||
)
|
||||
return jsonify({
|
||||
"results": [
|
||||
{
|
||||
@@ -53,6 +59,10 @@ async def search_route():
|
||||
"is_task": note.is_task,
|
||||
"tags": note.tags or [],
|
||||
"similarity": score,
|
||||
**(
|
||||
{"shared": True, "owner": owners.get(int(note.user_id))}
|
||||
if note.user_id != uid else {}
|
||||
),
|
||||
}
|
||||
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
|
||||
],
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""REST routes for snippets — reusable functions/components recorded for recall.
|
||||
|
||||
A snippet is a note with note_type='snippet' (see services/snippets.py). These
|
||||
routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the
|
||||
agent-facing surface. Both go through services/snippets.py, so the
|
||||
serialize/parse contract and embedding-on-create live in one place (DRY).
|
||||
|
||||
ACL (rule #78): reads/writes of a single snippet resolve through the share-aware
|
||||
`get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so
|
||||
a shared editor isn't rejected by the owner-scoped service — mirroring
|
||||
routes/notes.py. The list is owner-scoped, matching the note-browse surface.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.routes.utils import not_found, parse_pagination
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
|
||||
from scribe.services.access import (
|
||||
can_write_note,
|
||||
describe_provenance,
|
||||
label_shared_items,
|
||||
)
|
||||
from scribe.services.notes import get_note_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets")
|
||||
|
||||
# Fields the create/update payload may carry, mapped straight to the service.
|
||||
_STR_FIELDS = ("name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol")
|
||||
|
||||
|
||||
async def _load_snippet(uid: int, snippet_id: int):
|
||||
"""Share-aware resolve of a snippet by id → (note, permission) or None if it
|
||||
isn't accessible or isn't a snippet."""
|
||||
result = await get_note_for_user(uid, snippet_id)
|
||||
if result is None:
|
||||
return None
|
||||
note, permission = result
|
||||
if note.note_type != snippets_svc.SNIPPET_NOTE_TYPE:
|
||||
return None
|
||||
return note, permission
|
||||
|
||||
|
||||
@snippets_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_snippets_route():
|
||||
uid = get_current_user_id()
|
||||
q = request.args.get("q") or None
|
||||
tag = request.args.get("tag", "")
|
||||
try:
|
||||
project_id = int(request.args.get("project_id", 0) or 0) or None
|
||||
except (TypeError, ValueError):
|
||||
project_id = None
|
||||
# Reverse lookup — "what already lives here?" Empty args are ignored, so the
|
||||
# plain list is unchanged.
|
||||
repo = request.args.get("repo", "")
|
||||
path = request.args.get("path", "")
|
||||
symbol = request.args.get("symbol", "")
|
||||
# Drift check (#2086). "attention" is what the UI's filter chip sends.
|
||||
verification = request.args.get("verification", "")
|
||||
limit, offset = parse_pagination()
|
||||
items, total = await snippets_svc.list_snippets(
|
||||
uid, q=q, tag=tag, limit=limit, offset=offset, project_id=project_id,
|
||||
repo=repo, path=path, symbol=symbol, verification=verification,
|
||||
)
|
||||
# Mark rows owned by someone else so the UI can show whose they are — an
|
||||
# unmarked row in your own list reads as one you recorded and vetted.
|
||||
items = await label_shared_items(uid, items)
|
||||
# One aggregate for the whole page — a per-row lookup here would be N+1 by
|
||||
# construction. Every row gets the key, zero-filled, so the UI renders
|
||||
# "never pulled" rather than having to treat a missing field as a state.
|
||||
usage = await usage_for_notes([int(it["id"]) for it in items])
|
||||
for it in items:
|
||||
it["usage"] = usage.get(int(it["id"]), empty_usage())
|
||||
return jsonify({"snippets": items, "total": total})
|
||||
|
||||
|
||||
@snippets_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_snippet_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
code = (data.get("code") or "").strip()
|
||||
if not name or not code:
|
||||
return jsonify({"error": "name and code are required"}), 400
|
||||
project_id = data.get("project_id") or None
|
||||
|
||||
# Same near-duplicate gate the MCP create path applies: recording the same
|
||||
# reusable thing twice is what merge then has to undo, so catch it here too.
|
||||
# `force` is the deliberate override once the operator has seen the warning.
|
||||
if not data.get("force"):
|
||||
dup = await dedup_svc.find_duplicate_note(
|
||||
uid,
|
||||
snippets_svc.compose_title(name, data.get("when_to_use", "")),
|
||||
snippets_svc.compose_body(
|
||||
code=data.get("code", ""),
|
||||
language=data.get("language", ""),
|
||||
signature=data.get("signature", ""),
|
||||
when_to_use=data.get("when_to_use", ""),
|
||||
repo=data.get("repo", ""),
|
||||
path=data.get("path", ""),
|
||||
symbol=data.get("symbol", ""),
|
||||
locations=data.get("locations"),
|
||||
),
|
||||
project_id=project_id,
|
||||
is_task=False,
|
||||
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
||||
)
|
||||
if dup is not None:
|
||||
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
|
||||
|
||||
note = await snippets_svc.create_snippet(
|
||||
uid,
|
||||
name=name,
|
||||
code=data.get("code", ""),
|
||||
language=data.get("language", ""),
|
||||
signature=data.get("signature", ""),
|
||||
when_to_use=data.get("when_to_use", ""),
|
||||
repo=data.get("repo", ""),
|
||||
path=data.get("path", ""),
|
||||
symbol=data.get("symbol", ""),
|
||||
locations=data.get("locations"),
|
||||
tags=data.get("tags"),
|
||||
project_id=project_id,
|
||||
)
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
|
||||
out = snippets_svc.snippet_to_dict(note)
|
||||
out["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_snippet_route(snippet_id: int):
|
||||
uid = get_current_user_id()
|
||||
loaded = await _load_snippet(uid, snippet_id)
|
||||
if loaded is None:
|
||||
return not_found("Snippet")
|
||||
note, permission = loaded
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
data["permission"] = permission
|
||||
# Read the association as the OWNER: a shared reader isn't scoped to the
|
||||
# owner's project, so their own id would come back empty (mirrors the
|
||||
# write-as-owner pattern this module already uses).
|
||||
data["systems"] = [
|
||||
s.to_dict()
|
||||
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
|
||||
]
|
||||
data.update(await describe_provenance(uid, note))
|
||||
data["usage"] = (await usage_for_notes([snippet_id])).get(
|
||||
snippet_id, empty_usage()
|
||||
)
|
||||
# Opening the detail view IS a pull — the operator chose to look. Tagged
|
||||
# apart from the MCP sources so "the agent reused it" and "a human read it"
|
||||
# stay distinguishable; they mean different things for pruning (#2085).
|
||||
record_pulled(user_id=uid, note_id=snippet_id, source="rest_snippet")
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_snippet_route(snippet_id: int):
|
||||
uid = get_current_user_id()
|
||||
loaded = await _load_snippet(uid, snippet_id)
|
||||
if loaded is None:
|
||||
return not_found("Snippet")
|
||||
note, _ = loaded
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
owner_uid = note.user_id
|
||||
data = await request.get_json() or {}
|
||||
|
||||
# Partial update: only keys present in the payload change (the service
|
||||
# treats None as "leave unchanged"). Empty strings ARE applied — the form
|
||||
# sends the full field set, so a cleared field is an intentional clear.
|
||||
kwargs = {k: data[k] for k in _STR_FIELDS if k in data}
|
||||
if "locations" in data:
|
||||
kwargs["locations"] = data["locations"]
|
||||
if "tags" in data:
|
||||
kwargs["tags"] = data["tags"]
|
||||
if "project_id" in data:
|
||||
# A present-but-empty project_id is a deliberate detach, not "unchanged"
|
||||
# — the service distinguishes the two via its UNSET sentinel.
|
||||
kwargs["project_id"] = data["project_id"] or None
|
||||
|
||||
updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs)
|
||||
if updated is None:
|
||||
return not_found("Snippet")
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(owner_uid, snippet_id, data["system_ids"])
|
||||
out = snippets_svc.snippet_to_dict(updated)
|
||||
out["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(owner_uid, snippet_id)
|
||||
]
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>/unmerge", methods=["POST"])
|
||||
@login_required
|
||||
async def unmerge_snippet_route(snippet_id: int):
|
||||
"""Pull one source back out of a merged survivor. Body: {"source_id": int}.
|
||||
|
||||
Also the repair for a half-undone merge: restoring a source from the trash by
|
||||
hand leaves the survivor still claiming its call sites, and running this on an
|
||||
already-restored source strips them."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
source_id = data.get("source_id")
|
||||
if not isinstance(source_id, int):
|
||||
return jsonify({"error": "source_id (int) is required"}), 400
|
||||
try:
|
||||
result = await snippets_svc.unmerge_snippet(uid, snippet_id, source_id)
|
||||
except PermissionError as exc:
|
||||
return jsonify({"error": str(exc)}), 403
|
||||
except snippets_svc.UnmergeError as exc:
|
||||
# 409, not 400: the request is well-formed, the record's state is what
|
||||
# makes it impossible — and the message says which.
|
||||
return jsonify({"error": str(exc)}), 409
|
||||
if result is None:
|
||||
return not_found("Snippet")
|
||||
survivor, restored = result
|
||||
return jsonify({
|
||||
"survivor": snippets_svc.snippet_to_dict(survivor),
|
||||
"restored": snippets_svc.snippet_to_dict(restored) if restored else None,
|
||||
})
|
||||
|
||||
|
||||
@snippets_bp.route("/duplicates", methods=["GET"])
|
||||
@login_required
|
||||
async def duplicate_snippets_route():
|
||||
"""Near-duplicate snippets already recorded, grouped into merge candidates.
|
||||
|
||||
Registered ABOVE the `/<int:snippet_id>` routes on purpose — Quart matches
|
||||
an int converter before a static segment either way, but keeping the literal
|
||||
path first makes the precedence obvious to the next person reading this."""
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
threshold = float(request.args.get("threshold", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
threshold = 0.0
|
||||
return jsonify(await dedup_svc.find_duplicate_snippets(
|
||||
uid, threshold=threshold if threshold > 0 else None
|
||||
))
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>/verify", methods=["POST"])
|
||||
@login_required
|
||||
async def verify_snippet_route(snippet_id: int):
|
||||
"""Record a drift-check verdict. Body: {"status": ..., "detail", "path"}.
|
||||
|
||||
The CHECK itself runs wherever the code is — an agent with the working tree
|
||||
— because Scribe has no checkout and shouldn't have one. This endpoint just
|
||||
stores what was found. It's here for parity with the MCP tool and so the UI
|
||||
can clear a stale marker after the operator fixes a record by hand."""
|
||||
uid = get_current_user_id()
|
||||
if await _load_snippet(uid, snippet_id) is None:
|
||||
return not_found("Snippet")
|
||||
# Distinguished from not-found deliberately: the service returns None for
|
||||
# both, and telling a shared reader "no such snippet" about one they can
|
||||
# plainly see is a confusing lie.
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
data = await request.get_json() or {}
|
||||
status = (data.get("status") or "").strip()
|
||||
if not status:
|
||||
return jsonify({"error": "status is required"}), 400
|
||||
try:
|
||||
updated = await snippets_svc.record_verification(
|
||||
uid, snippet_id,
|
||||
status=status,
|
||||
detail=data.get("detail") or "",
|
||||
path=data.get("path") or "",
|
||||
)
|
||||
except ValueError as exc:
|
||||
# An unknown status — reject it rather than storing a value the filter
|
||||
# would then never match.
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if updated is None:
|
||||
return not_found("Snippet")
|
||||
return jsonify(snippets_svc.snippet_to_dict(updated))
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>/merge", methods=["POST"])
|
||||
@login_required
|
||||
async def merge_snippet_route(snippet_id: int):
|
||||
"""Unify source snippets into this one (the canonical target). Body:
|
||||
{"source_ids": [int, ...]}. Requires write on the target and every source,
|
||||
and all must share the target's owner (cross-owner merge is out of scope)."""
|
||||
uid = get_current_user_id()
|
||||
loaded = await _load_snippet(uid, snippet_id)
|
||||
if loaded is None:
|
||||
return not_found("Snippet")
|
||||
target, _ = loaded
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
data = await request.get_json() or {}
|
||||
raw = data.get("source_ids") or []
|
||||
source_ids = [s for s in raw if isinstance(s, int) and s != snippet_id]
|
||||
if not source_ids:
|
||||
return jsonify({"error": "source_ids (non-empty list of other snippet ids) is required"}), 400
|
||||
|
||||
owner_uid = target.user_id
|
||||
for sid in source_ids:
|
||||
sloaded = await _load_snippet(uid, sid)
|
||||
if sloaded is None:
|
||||
return not_found(f"Snippet {sid}")
|
||||
snote, _ = sloaded
|
||||
if snote.user_id != owner_uid:
|
||||
return jsonify({"error": "can only merge snippets with the same owner"}), 400
|
||||
if not await can_write_note(uid, sid):
|
||||
return jsonify({"error": f"Permission denied for snippet {sid}"}), 403
|
||||
|
||||
result = await snippets_svc.merge_snippets(owner_uid, snippet_id, source_ids)
|
||||
if result is None:
|
||||
return not_found("Snippet")
|
||||
note, merged_ids = result
|
||||
out = snippets_svc.snippet_to_dict(note)
|
||||
out["merged_ids"] = merged_ids
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_snippet_route(snippet_id: int):
|
||||
uid = get_current_user_id()
|
||||
loaded = await _load_snippet(uid, snippet_id)
|
||||
if loaded is None:
|
||||
return not_found("Snippet")
|
||||
note, _ = loaded
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
if not await snippets_svc.delete_snippet(note.user_id, snippet_id):
|
||||
return not_found("Snippet")
|
||||
return "", 204
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -8,10 +7,8 @@ from scribe.models.note import TaskPriority, TaskStatus
|
||||
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from scribe.services.access import can_write_note
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
from scribe.services.notes import (
|
||||
create_note,
|
||||
get_note,
|
||||
get_note_for_user,
|
||||
list_notes,
|
||||
update_note,
|
||||
@@ -150,9 +147,6 @@ async def create_task_route():
|
||||
)
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, task.id, data["system_ids"])
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
|
||||
out = task.to_dict()
|
||||
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task.id)]
|
||||
return jsonify(out), 201
|
||||
@@ -187,8 +181,10 @@ async def get_task_route(task_id: int):
|
||||
data = task.to_dict()
|
||||
data["permission"] = permission
|
||||
if task.parent_id:
|
||||
parent = await get_note(uid, task.parent_id)
|
||||
data["parent_title"] = parent.title if parent else None
|
||||
# Share-aware like the task itself: a shared subtask whose parent is also
|
||||
# shared would otherwise render as an orphan with no parent title.
|
||||
parent = await get_note_for_user(uid, task.parent_id)
|
||||
data["parent_title"] = parent[0].title if parent else None
|
||||
data["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
|
||||
return jsonify(data)
|
||||
|
||||
@@ -254,9 +250,6 @@ async def update_task_route(task_id: int):
|
||||
return not_found("Task")
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, task_id, data["system_ids"])
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
|
||||
out = task.to_dict()
|
||||
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
|
||||
return jsonify(out)
|
||||
|
||||
@@ -9,13 +9,16 @@ Permission rank: owner > admin > editor > viewer
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.design_system import DesignSystem
|
||||
from scribe.models.group import GroupMembership
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.share import NoteShare, ProjectShare
|
||||
from scribe.models.user import User
|
||||
from scribe.services.design_cascade import ancestry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -161,3 +164,277 @@ async def can_read_note(user_id: int, note_id: int) -> bool:
|
||||
async def can_write_note(user_id: int, note_id: int) -> bool:
|
||||
perm = await get_note_permission(user_id, note_id)
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Design-system permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_design_system_permission(
|
||||
user_id: int, design_system_id: int
|
||||
) -> str | None:
|
||||
"""Effective permission on a design system, or None.
|
||||
|
||||
Two ways in, and the asymmetry between them is the point:
|
||||
|
||||
- **Owning it** grants "owner" — full read and write.
|
||||
- **Reaching it through a project you can see** grants "viewer", and only
|
||||
ever "viewer". Being an editor on a shared project must NOT confer the
|
||||
right to rewrite the family system that project inherits from: one
|
||||
project's collaborator would be editing tokens every other project in
|
||||
the family resolves through. Editing a design system stays the owner's
|
||||
act, and it is the same reasoning that keeps a rulebook owner-scoped.
|
||||
|
||||
Reachability follows the parent chain UPWARD. Rendering a project's UI means
|
||||
resolving its whole chain, so read access to a system implies read access to
|
||||
its ancestors — otherwise a shared project would resolve to a truncated
|
||||
cascade and silently render with the wrong values.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
if system.owner_user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
shared_project_ids = select(ProjectShare.project_id).where(
|
||||
or_(
|
||||
ProjectShare.shared_with_user_id == user_id,
|
||||
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
|
||||
)
|
||||
)
|
||||
entry_points = set(
|
||||
(
|
||||
await session.execute(
|
||||
select(Project.design_system_id).where(
|
||||
Project.design_system_id.is_not(None),
|
||||
Project.deleted_at.is_(None),
|
||||
or_(
|
||||
Project.user_id == user_id,
|
||||
Project.id.in_(shared_project_ids),
|
||||
),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
if not entry_points:
|
||||
return None
|
||||
|
||||
# One narrow query for the whole forest's shape. Design systems are a
|
||||
# handful of rows per install — a family and one per app — so walking
|
||||
# from each entry point in memory beats a recursive CTE per check.
|
||||
parents = dict(
|
||||
(
|
||||
await session.execute(
|
||||
select(DesignSystem.id, DesignSystem.parent_id).where(
|
||||
DesignSystem.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
for entry in entry_points:
|
||||
if design_system_id in ancestry(entry, parents):
|
||||
return "viewer"
|
||||
return None
|
||||
|
||||
|
||||
async def can_read_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
return (await get_design_system_permission(user_id, design_system_id)) is not None
|
||||
|
||||
|
||||
async def can_write_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
perm = await get_design_system_permission(user_id, design_system_id)
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set-based visibility (for LIST queries)
|
||||
#
|
||||
# Two scopes, deliberately different (decision note 2094):
|
||||
#
|
||||
# readable_* — everything the ACL permits, including a record reachable ONLY
|
||||
# through a direct or group note share. For EXPLICIT acts: a
|
||||
# search the caller typed, or a fetch by id.
|
||||
# browsable_* — the caller's own records plus anything in a project they have
|
||||
# access to. For PASSIVE surfaces: browse lists, facet counts,
|
||||
# the process→skill manifest.
|
||||
#
|
||||
# The split is a trust boundary, not an optimisation. Anything that appears
|
||||
# unasked — in your own list, your own counts, or as a skill installed on your
|
||||
# machine — reads as material you endorsed. A one-off record someone shared with
|
||||
# you has not earned that standing, so it waits until you go looking for it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def notes_visibility_clause(user_id: int, scope: str = "own"):
|
||||
"""The one place a retrieval declares how far it may see.
|
||||
|
||||
Every path that returns notes picks a scope by what KIND of act it is:
|
||||
|
||||
"own" — the caller's records only. For machinery whose answer must not
|
||||
depend on other people: the near-duplicate gate can't block a
|
||||
create because a stranger wrote something similar, and can't
|
||||
point at a record the caller cannot edit.
|
||||
"browse" — own + project-reachable. For passive surfaces, where an
|
||||
unrequested record would read as endorsed.
|
||||
"read" — everything the ACL permits. For explicit acts: a typed search,
|
||||
a fetch by id.
|
||||
|
||||
Defaults to the narrowest, so a new caller that forgets to choose is wrong in
|
||||
the safe direction.
|
||||
"""
|
||||
if scope == "own":
|
||||
return Note.user_id == user_id
|
||||
if scope == "browse":
|
||||
return browsable_notes_clause(user_id)
|
||||
if scope == "read":
|
||||
return readable_notes_clause(user_id)
|
||||
raise ValueError(f"unknown note scope {scope!r} (own | browse | read)")
|
||||
|
||||
|
||||
async def owner_names_for(user_ids: set[int]) -> dict[int, str]:
|
||||
"""{user_id: username} in one query. Empty input costs nothing.
|
||||
|
||||
Fails soft: on a lookup error the caller gets no names and renders "another
|
||||
user" instead. Losing an attribution is a cosmetic downgrade, and the part
|
||||
that matters — that the record ISN'T the caller's — comes from comparing
|
||||
owner ids, not from this. Failing the whole search over a username would be
|
||||
the worse trade.
|
||||
"""
|
||||
if not user_ids:
|
||||
return {}
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(User.id, User.username).where(User.id.in_(user_ids))
|
||||
)
|
||||
).all()
|
||||
return {uid: name for uid, name in rows}
|
||||
except Exception:
|
||||
logger.warning("Owner-name lookup failed; falling back to unnamed "
|
||||
"attribution", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def _my_group_ids(user_id: int):
|
||||
"""The caller's group ids as a SUBQUERY, not a fetched list.
|
||||
|
||||
Keeping it in SQL is what makes the clause builders below pure functions —
|
||||
no session, no await, nothing for a caller's unit test to mock — and it folds
|
||||
the membership lookup into the one statement the caller was already running.
|
||||
"""
|
||||
return select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
||||
|
||||
|
||||
def readable_notes_clause(user_id: int):
|
||||
"""A SQLAlchemy WHERE predicate matching every note this user may READ.
|
||||
|
||||
`get_note_permission` answers "may I read THIS note?" one row at a time,
|
||||
which a list query can't use — checking per row is O(n) round-trips. This is
|
||||
the same resolution expressed as set membership so it can go straight into a
|
||||
`select(Note).where(...)`:
|
||||
|
||||
1. ownership
|
||||
2. a direct note share
|
||||
3. a group note share
|
||||
4. inheritance from a shared project
|
||||
|
||||
Keep the two in step: a rule added here belongs in `get_note_permission`
|
||||
too, or a note becomes findable but not openable (or the reverse — which is
|
||||
the bug this was written to fix).
|
||||
"""
|
||||
groups = _my_group_ids(user_id)
|
||||
|
||||
shared_note_ids = select(NoteShare.note_id).where(
|
||||
or_(
|
||||
NoteShare.shared_with_user_id == user_id,
|
||||
NoteShare.shared_with_group_id.in_(groups),
|
||||
)
|
||||
)
|
||||
shared_project_ids = select(ProjectShare.project_id).where(
|
||||
or_(
|
||||
ProjectShare.shared_with_user_id == user_id,
|
||||
ProjectShare.shared_with_group_id.in_(groups),
|
||||
)
|
||||
)
|
||||
|
||||
return or_(
|
||||
Note.user_id == user_id,
|
||||
Note.id.in_(shared_note_ids),
|
||||
Note.project_id.in_(shared_project_ids),
|
||||
)
|
||||
|
||||
|
||||
async def describe_provenance(user_id: int, note) -> dict:
|
||||
"""Provenance for a record being handed to a caller: `{}` when it's theirs,
|
||||
otherwise `{"shared": True, "owner": <username>, "permission": <perm>}`.
|
||||
|
||||
Anything reaching an agent or a UI from another person has to say so. A
|
||||
shared record is one person's suggestion, not a standard the caller adopted,
|
||||
and without a marker the two are indistinguishable — the reader would assume
|
||||
their own past self wrote it and treat it as settled practice.
|
||||
"""
|
||||
if note is None or note.user_id == user_id:
|
||||
return {}
|
||||
async with async_session() as session:
|
||||
owner = await session.get(User, note.user_id)
|
||||
return {
|
||||
"shared": True,
|
||||
"owner": owner.username if owner else None,
|
||||
"permission": await get_note_permission(user_id, note.id),
|
||||
}
|
||||
|
||||
|
||||
async def label_shared_items(user_id: int, items: list[dict]) -> list[dict]:
|
||||
"""Mark the entries in a list payload that belong to someone else.
|
||||
|
||||
Each foreign item gains `shared: True` and `owner: <username>`; the caller's
|
||||
own items are left untouched so an all-mine list stays noise-free. Usernames
|
||||
are resolved in one query per distinct owner, not one per row.
|
||||
|
||||
Lists are where provenance matters most: an unmarked row in your own list
|
||||
reads as something you recorded and vetted.
|
||||
"""
|
||||
foreign = {
|
||||
it["user_id"] for it in items
|
||||
if it.get("user_id") is not None and it["user_id"] != user_id
|
||||
}
|
||||
if not foreign:
|
||||
return items
|
||||
names = await owner_names_for(foreign)
|
||||
for it in items:
|
||||
owner_id = it.get("user_id")
|
||||
if owner_id is not None and owner_id != user_id:
|
||||
it["shared"] = True
|
||||
it["owner"] = names.get(owner_id)
|
||||
return items
|
||||
|
||||
|
||||
def browsable_notes_clause(user_id: int):
|
||||
"""A WHERE predicate for PASSIVE surfaces: the caller's own notes, plus
|
||||
notes in a project they can reach (owned or shared).
|
||||
|
||||
Deliberately narrower than `readable_notes_clause` — it omits records the
|
||||
caller can read *only* via a direct or group note share. Those stay
|
||||
search-only, so a one-off someone handed them never drifts into a browse
|
||||
list, a facet count, or the skill manifest as though it were their own.
|
||||
|
||||
Project membership is the seam because it's a standing, mutual context: if
|
||||
you're on the project, its content is your working material. A note with no
|
||||
project that you don't own therefore never matches — `NULL IN (...)` is not
|
||||
true — which is exactly the intent.
|
||||
"""
|
||||
shared_project_ids = select(ProjectShare.project_id).where(
|
||||
or_(
|
||||
ProjectShare.shared_with_user_id == user_id,
|
||||
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
|
||||
)
|
||||
)
|
||||
owned_project_ids = select(Project.id).where(Project.user_id == user_id)
|
||||
|
||||
return or_(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id.in_(shared_project_ids),
|
||||
Note.project_id.in_(owned_project_ids),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,10 @@ from scribe.models.milestone import Milestone
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.note_draft import NoteDraft
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
from scribe.models.rulebook import (
|
||||
Rule,
|
||||
Rulebook,
|
||||
@@ -18,6 +21,7 @@ from scribe.models.rulebook import (
|
||||
project_topic_suppressions,
|
||||
)
|
||||
from scribe.models.setting import Setting
|
||||
from scribe.models.system import RecordSystem, System
|
||||
from scribe.models.task_log import TaskLog
|
||||
from scribe.models.user import User
|
||||
|
||||
@@ -26,17 +30,44 @@ logger = logging.getLogger(__name__)
|
||||
# Backup format version. v3 (2026-06) added rulebooks/topics/rules + their
|
||||
# project subscription/suppression join tables. v4 (2026-07) dropped events
|
||||
# when the calendar surface was retired — old v3 events are skipped on restore.
|
||||
# v5 (2026-08) added the six tables that had accumulated outside the backup
|
||||
# entirely (#2293), and the coverage guard that stops the seventh.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 4
|
||||
BACKUP_VERSION = 5
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
# what tests/test_services_backup.py asserts against Base.metadata.
|
||||
#
|
||||
# The point is the ABSENCE case. A new table gets a model and a migration, both
|
||||
# of which fail loudly if wrong, and then silently never gets a backup section:
|
||||
# no error, no warning, and a restore that reports success. Naming the coverage
|
||||
# explicitly turns "someone forgot" into a failing test (#2293).
|
||||
_BACKED_UP = [
|
||||
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
||||
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
||||
"project_rulebook_subscriptions", "project_rule_suppressions",
|
||||
"project_topic_suppressions",
|
||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"note_usage_events", "repo_bindings",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
|
||||
# embeddings are derived (regenerated from note bodies); api_keys are sensitive
|
||||
# credentials; the rest are transient/operational.
|
||||
# note_embeddings are derived (regenerated from note bodies); api_keys are
|
||||
# sensitive credentials; retrieval_logs is observational telemetry that nothing
|
||||
# reads for correctness and that grows per query; the rest are
|
||||
# transient/operational.
|
||||
#
|
||||
# REAL table names, deliberately. This list used to read "embeddings",
|
||||
# "invitations", "password_resets" — none of which are tables — so it looked
|
||||
# like coverage while naming nothing the schema could confirm.
|
||||
_NOT_INCLUDED = [
|
||||
"groups", "group_memberships", "project_shares", "note_shares",
|
||||
"api_keys", "embeddings", "app_logs", "notifications", "invitations",
|
||||
"password_resets", "user_profiles",
|
||||
"api_keys", "note_embeddings", "app_logs", "notifications",
|
||||
"invitation_tokens", "password_reset_tokens", "user_profiles",
|
||||
"retrieval_logs",
|
||||
]
|
||||
|
||||
|
||||
@@ -60,12 +91,73 @@ def _topic_suppression_rows(rows) -> list[dict]:
|
||||
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
||||
|
||||
|
||||
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
||||
# same reason: CI has no database, so a serialiser that is a plain function is
|
||||
# one that can actually be tested.
|
||||
|
||||
def _system_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
|
||||
"name": r.name, "description": r.description, "color": r.color,
|
||||
"status": r.status, "order_index": r.order_index,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _record_system_rows(rows) -> list[dict]:
|
||||
return [{"note_id": r.note_id, "system_id": r.system_id} for r in rows]
|
||||
|
||||
|
||||
def _design_system_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": r.id, "owner_user_id": r.owner_user_id, "title": r.title,
|
||||
"description": r.description, "guidance": r.guidance,
|
||||
"parent_id": r.parent_id,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _design_token_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": r.id, "design_system_id": r.design_system_id, "name": r.name,
|
||||
"value_by_mode": r.value_by_mode or {},
|
||||
"group_name": r.group_name, "purpose": r.purpose,
|
||||
"rationale": r.rationale, "supersedes": r.supersedes or [],
|
||||
"order_index": r.order_index,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _usage_event_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"user_id": r.user_id, "note_id": r.note_id, "event": r.event,
|
||||
"source": r.source,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _repo_binding_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def export_full_backup() -> dict:
|
||||
"""Export all data as a version-3 JSON backup."""
|
||||
"""Export all data as a version-5 JSON backup."""
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
projects = (await session.execute(select(Project))).scalars().all()
|
||||
@@ -77,6 +169,18 @@ async def export_full_backup() -> dict:
|
||||
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
systems = (await session.execute(select(System))).scalars().all()
|
||||
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
||||
# Parent-first, so a restore can resolve parent_id as it goes rather
|
||||
# than needing a second pass — the self-FK is the only ordering
|
||||
# constraint in this payload.
|
||||
design_systems = (await session.execute(
|
||||
select(DesignSystem).order_by(DesignSystem.parent_id.nullsfirst(),
|
||||
DesignSystem.id)
|
||||
)).scalars().all()
|
||||
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
||||
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
||||
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
|
||||
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||
rules = (await session.execute(select(Rule))).scalars().all()
|
||||
@@ -244,11 +348,17 @@ async def export_full_backup() -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"systems": _system_rows(systems),
|
||||
"record_systems": _record_system_rows(record_systems),
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
}
|
||||
|
||||
|
||||
async def export_user_backup(user_id: int) -> dict:
|
||||
"""Export a single user's data as a version-3 JSON backup."""
|
||||
"""Export a single user's data as a version-5 JSON backup."""
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
projects = (await session.execute(
|
||||
@@ -274,6 +384,32 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
settings = (await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id)
|
||||
)).scalars().all()
|
||||
systems = (await session.execute(
|
||||
select(System).where(System.user_id == user_id)
|
||||
)).scalars().all()
|
||||
system_ids = [sy.id for sy in systems]
|
||||
note_ids = [n.id for n in notes]
|
||||
# Scoped by the user's SYSTEMS, not their notes: a shared note carrying
|
||||
# this user's system tag belongs in their backup, and a note of theirs
|
||||
# tagged with someone else's system does not — that row is the other
|
||||
# user's to keep.
|
||||
record_systems = (await session.execute(
|
||||
select(RecordSystem).where(RecordSystem.system_id.in_(system_ids))
|
||||
)).scalars().all() if system_ids else []
|
||||
design_systems = (await session.execute(
|
||||
select(DesignSystem).where(DesignSystem.owner_user_id == user_id)
|
||||
.order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id)
|
||||
)).scalars().all()
|
||||
ds_ids = [d.id for d in design_systems]
|
||||
design_tokens = (await session.execute(
|
||||
select(DesignToken).where(DesignToken.design_system_id.in_(ds_ids))
|
||||
)).scalars().all() if ds_ids else []
|
||||
usage_events = (await session.execute(
|
||||
select(NoteUsageEvent).where(NoteUsageEvent.note_id.in_(note_ids))
|
||||
)).scalars().all() if note_ids else []
|
||||
repo_bindings = (await session.execute(
|
||||
select(RepoBinding).where(RepoBinding.user_id == user_id)
|
||||
)).scalars().all()
|
||||
rulebooks = (await session.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
||||
)).scalars().all()
|
||||
@@ -455,6 +591,12 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"systems": _system_rows(systems),
|
||||
"record_systems": _record_system_rows(record_systems),
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
}
|
||||
|
||||
|
||||
@@ -556,6 +698,8 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -814,6 +958,106 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
stats["topic_suppressions"] += 1
|
||||
|
||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||
# payload restores without them rather than failing on an absent key.
|
||||
|
||||
# 15. Systems
|
||||
system_id_map: dict[int, int] = {}
|
||||
for sy_data in data.get("systems", []):
|
||||
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(sy_data.get("project_id", 0))
|
||||
if mapped_uid is None or mapped_pid is None:
|
||||
continue
|
||||
system = System(
|
||||
user_id=mapped_uid, project_id=mapped_pid,
|
||||
name=sy_data.get("name", ""),
|
||||
description=sy_data.get("description"),
|
||||
color=sy_data.get("color"),
|
||||
status=sy_data.get("status", "active"),
|
||||
order_index=sy_data.get("order_index", 0),
|
||||
)
|
||||
session.add(system)
|
||||
await session.flush()
|
||||
system_id_map[sy_data["id"]] = system.id
|
||||
stats["systems"] += 1
|
||||
|
||||
# 16. Record↔system links
|
||||
for rs in data.get("record_systems", []):
|
||||
mapped_nid = note_id_map.get(rs.get("note_id", 0))
|
||||
mapped_sid = system_id_map.get(rs.get("system_id", 0))
|
||||
if mapped_nid is None or mapped_sid is None:
|
||||
continue
|
||||
session.add(RecordSystem(note_id=mapped_nid, system_id=mapped_sid))
|
||||
stats["record_systems"] += 1
|
||||
|
||||
# 17. Design systems. The export orders these parent-first, so a
|
||||
# parent's new id is always in the map by the time a child needs it —
|
||||
# no second pass, and a child whose parent is missing lands as a root
|
||||
# rather than failing the whole restore.
|
||||
design_system_id_map: dict[int, int] = {}
|
||||
for ds_data in data.get("design_systems", []):
|
||||
mapped_uid = user_id_map.get(ds_data.get("owner_user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
design = DesignSystem(
|
||||
owner_user_id=mapped_uid,
|
||||
title=ds_data.get("title", ""),
|
||||
description=ds_data.get("description"),
|
||||
guidance=ds_data.get("guidance"),
|
||||
parent_id=design_system_id_map.get(ds_data.get("parent_id") or 0),
|
||||
)
|
||||
session.add(design)
|
||||
await session.flush()
|
||||
design_system_id_map[ds_data["id"]] = design.id
|
||||
stats["design_systems"] += 1
|
||||
|
||||
# 18. Design tokens
|
||||
for t_data in data.get("design_tokens", []):
|
||||
mapped_dsid = design_system_id_map.get(t_data.get("design_system_id", 0))
|
||||
if mapped_dsid is None:
|
||||
continue
|
||||
session.add(DesignToken(
|
||||
design_system_id=mapped_dsid,
|
||||
name=t_data.get("name", ""),
|
||||
value_by_mode=t_data.get("value_by_mode") or {},
|
||||
group_name=t_data.get("group_name"),
|
||||
purpose=t_data.get("purpose"),
|
||||
rationale=t_data.get("rationale"),
|
||||
supersedes=t_data.get("supersedes") or [],
|
||||
order_index=t_data.get("order_index", 0),
|
||||
))
|
||||
stats["design_tokens"] += 1
|
||||
|
||||
# 19. Usage events. Kept because pull-through is the evidence base for
|
||||
# whether recall works at all, and it is only ever accumulated — a
|
||||
# restore that dropped it would silently reset that measurement to zero
|
||||
# while everything still looked fine.
|
||||
for ev in data.get("note_usage_events", []):
|
||||
mapped_nid = note_id_map.get(ev.get("note_id", 0))
|
||||
if mapped_nid is None:
|
||||
continue
|
||||
session.add(NoteUsageEvent(
|
||||
user_id=user_id_map.get(ev.get("user_id") or 0),
|
||||
note_id=mapped_nid,
|
||||
event=ev.get("event", ""),
|
||||
source=ev.get("source", ""),
|
||||
created_at=_dt(ev.get("created_at")),
|
||||
))
|
||||
stats["note_usage_events"] += 1
|
||||
|
||||
# 20. Repo bindings — small, but losing them means every bound repo
|
||||
# quietly stops loading its project at session start.
|
||||
for rb_data in data.get("repo_bindings", []):
|
||||
mapped_uid = user_id_map.get(rb_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(rb_data.get("project_id", 0))
|
||||
if mapped_uid is None or mapped_pid is None:
|
||||
continue
|
||||
session.add(RepoBinding(
|
||||
user_id=mapped_uid, project_id=mapped_pid,
|
||||
repo_key=rb_data.get("repo_key", ""),
|
||||
))
|
||||
stats["repo_bindings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2/v3 backup: %s", stats)
|
||||
|
||||
@@ -26,11 +26,17 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.embedding import NoteEmbedding
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services import embeddings as embeddings_svc
|
||||
# Imported rather than redeclared: no service imports this module (the create
|
||||
# gate is called from the routes/tools layer), so there is no cycle to dodge,
|
||||
# and a second copy of the constant is a thing to drift.
|
||||
from scribe.services.snippets import SNIPPET_NOTE_TYPE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -124,6 +130,11 @@ async def find_duplicate_note(
|
||||
user_id, query, project_id=project_id, is_task=is_task,
|
||||
orphan_only=(project_id is None),
|
||||
limit=3, threshold=_SEMANTIC_THRESHOLD,
|
||||
# Owner-only, deliberately: this gate BLOCKS a create and tells the
|
||||
# caller to update the match instead. Matching someone else's record
|
||||
# would refuse their write and point them at something they may not
|
||||
# be able to edit.
|
||||
scope="own",
|
||||
)
|
||||
for score, note in hits:
|
||||
# semantic_search_notes doesn't filter note_type — enforce it here so
|
||||
@@ -134,6 +145,174 @@ async def find_duplicate_note(
|
||||
return None
|
||||
|
||||
|
||||
# --- corpus-wide near-duplicate report (#2088) -------------------------------
|
||||
# The gate above PREVENTS a new duplicate; merge_snippets CURES one you point it
|
||||
# at. Neither FINDS the duplicates already sitting in the record — someone had to
|
||||
# notice them by hand, which is the exact failure the Drafter exists to remove.
|
||||
#
|
||||
# WHY OWN SNIPPETS ONLY. merge_snippets requires every record to share the
|
||||
# target's owner (cross-owner merge is out of scope), so a report that surfaced
|
||||
# someone else's snippet would propose a merge that cannot be performed. The
|
||||
# scope here is set by what the operator can actually act on, not by what they
|
||||
# can see.
|
||||
#
|
||||
# WHY A LOWER THRESHOLD THAN THE GATE. The gate BLOCKS a write at 0.90 and has to
|
||||
# be unforgiving of noise. This report only makes a suggestion the operator
|
||||
# reviews, so it can afford to be looser and catch the pairs the gate lets
|
||||
# through — which are precisely the ones that accumulated. It is a setting rather
|
||||
# than a constant (rule #25) because the right value depends on how uniform a
|
||||
# corpus is, and nobody can guess that from here.
|
||||
|
||||
DUPLICATE_THRESHOLD_KEY = "kb_duplicate_threshold"
|
||||
DUPLICATE_DEFAULT_THRESHOLD = 0.82
|
||||
# Hard cap on returned pairs. A pathologically uniform corpus is O(n²) pairs, and
|
||||
# a report nobody can read is not a report.
|
||||
_MAX_DUPLICATE_PAIRS = 200
|
||||
|
||||
|
||||
async def get_duplicate_threshold(user_id: int) -> float:
|
||||
"""The user's near-duplicate similarity floor, clamped to [0, 1]."""
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
try:
|
||||
value = float(await get_setting(
|
||||
user_id, DUPLICATE_THRESHOLD_KEY, str(DUPLICATE_DEFAULT_THRESHOLD)
|
||||
))
|
||||
except (TypeError, ValueError):
|
||||
value = DUPLICATE_DEFAULT_THRESHOLD
|
||||
return min(1.0, max(0.0, value))
|
||||
|
||||
|
||||
def group_pairs(pairs: list[tuple[int, int, float]]) -> list[list[int]]:
|
||||
"""Collapse similar-pairs into candidate merge SETS (connected components).
|
||||
|
||||
Pure and synchronous so the grouping rule is testable without a database.
|
||||
|
||||
Transitive on purpose: if A~B and B~C, all three land in one set even when
|
||||
A and C fall below the threshold. That matches what merge does — it folds
|
||||
every source into one survivor — and it avoids handing the operator three
|
||||
overlapping pairs to reconcile by hand, which is the chore being removed.
|
||||
The cost is that a chain of mild resemblances can rope in a pair that isn't
|
||||
really alike; the operator sees the members and picks, so a set is a
|
||||
proposal, never an action.
|
||||
"""
|
||||
parent: dict[int, int] = {}
|
||||
|
||||
def find(x: int) -> int:
|
||||
parent.setdefault(x, x)
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
def union(a: int, b: int) -> None:
|
||||
ra, rb = find(a), find(b)
|
||||
if ra != rb:
|
||||
parent[rb] = ra
|
||||
|
||||
for left, right, _score in pairs:
|
||||
union(left, right)
|
||||
|
||||
groups: dict[int, list[int]] = {}
|
||||
for node in parent:
|
||||
groups.setdefault(find(node), []).append(node)
|
||||
# Biggest clusters first — the most tangled thing is the most worth fixing.
|
||||
# Ids ascending within a set so the output is stable across runs.
|
||||
return sorted((sorted(g) for g in groups.values() if len(g) > 1),
|
||||
key=lambda g: (-len(g), g[0]))
|
||||
|
||||
|
||||
async def find_duplicate_snippets(
|
||||
user_id: int, *, threshold: float | None = None, limit: int = _MAX_DUPLICATE_PAIRS
|
||||
) -> dict:
|
||||
"""Near-duplicate snippets already in the record, grouped into merge sets.
|
||||
|
||||
One indexed self-join over `note_embeddings` rather than an N² Python scan:
|
||||
pgvector's cosine distance is the same operator semantic search uses, so a
|
||||
similarity floor is a distance ceiling and the work stays in Postgres.
|
||||
|
||||
Returns {"groups": [{"note_ids": [...], "snippets": [...], "top_score": f}],
|
||||
"pairs": [...], "threshold": f}. Fail-open (an empty report) like the rest of
|
||||
this module — a suggestion feature must not be able to break the page it
|
||||
decorates.
|
||||
"""
|
||||
floor = await get_duplicate_threshold(user_id) if threshold is None else threshold
|
||||
floor = min(1.0, max(0.0, floor))
|
||||
max_distance = min(2.0, max(0.0, 1.0 - floor))
|
||||
|
||||
left = aliased(NoteEmbedding, name="left_emb")
|
||||
right = aliased(NoteEmbedding, name="right_emb")
|
||||
left_note = aliased(Note, name="left_note")
|
||||
right_note = aliased(Note, name="right_note")
|
||||
distance = left.embedding.cosine_distance(right.embedding)
|
||||
|
||||
pairs: list[tuple[int, int, float]] = []
|
||||
try:
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(left.note_id, right.note_id, distance.label("distance"))
|
||||
.select_from(left)
|
||||
# `<` not `!=`: each unordered pair exactly once, and it drops
|
||||
# the self-pair (distance 0) that would otherwise dominate.
|
||||
.join(right, left.note_id < right.note_id)
|
||||
.join(left_note, left_note.id == left.note_id)
|
||||
.join(right_note, right_note.id == right.note_id)
|
||||
.where(
|
||||
left_note.note_type == SNIPPET_NOTE_TYPE,
|
||||
right_note.note_type == SNIPPET_NOTE_TYPE,
|
||||
left_note.deleted_at.is_(None),
|
||||
right_note.deleted_at.is_(None),
|
||||
# Owner-scoped on both sides — see the note above on why the
|
||||
# report is bounded by what merge can actually act on.
|
||||
left_note.user_id == user_id,
|
||||
right_note.user_id == user_id,
|
||||
distance <= max_distance,
|
||||
)
|
||||
.order_by(distance.asc())
|
||||
.limit(max(1, limit))
|
||||
)
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
pairs = [(int(a), int(b), round(1.0 - float(d), 4)) for a, b, d in rows]
|
||||
except Exception:
|
||||
logger.warning("Near-duplicate snippet scan failed", exc_info=True)
|
||||
return {"groups": [], "pairs": [], "threshold": floor}
|
||||
|
||||
if not pairs:
|
||||
return {"groups": [], "pairs": [], "threshold": floor}
|
||||
|
||||
best: dict[tuple[int, int], float] = {(a, b): s for a, b, s in pairs}
|
||||
grouped = group_pairs(pairs)
|
||||
|
||||
# Titles for presentation. One fetch for every id in the report.
|
||||
ids = sorted({n for g in grouped for n in g})
|
||||
titles: dict[int, str] = {}
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Note.id, Note.title).where(Note.id.in_(ids))
|
||||
)).all()
|
||||
titles = {int(i): t for i, t in rows}
|
||||
except Exception:
|
||||
logger.debug("duplicate report titles unavailable", exc_info=True)
|
||||
|
||||
groups = []
|
||||
for members in grouped:
|
||||
scores = [
|
||||
s for (a, b), s in best.items() if a in members and b in members
|
||||
]
|
||||
groups.append({
|
||||
"note_ids": members,
|
||||
"snippets": [
|
||||
{"id": nid, "title": titles.get(nid, "")} for nid in members
|
||||
],
|
||||
# The strongest resemblance in the set — how confident the suggestion
|
||||
# is, and what the list sorts on.
|
||||
"top_score": max(scores) if scores else floor,
|
||||
})
|
||||
groups.sort(key=lambda g: (-g["top_score"], g["note_ids"][0]))
|
||||
return {"groups": groups, "pairs": pairs, "threshold": floor}
|
||||
|
||||
|
||||
async def find_duplicate_rule(
|
||||
title: str,
|
||||
topic_id: int | None = None,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""The design-system cascade, as pure functions over already-loaded rows.
|
||||
|
||||
Deliberately free of every database and service import — including
|
||||
`services/access.py`, which needs `ancestry` to answer "can this caller read
|
||||
this system?" and would otherwise form an import cycle with the service that
|
||||
needs `access` back. A module that imports nothing can be imported by both.
|
||||
|
||||
Being pure is also what makes the cascade rule testable without a database:
|
||||
these take a plain `{id: parent_id}` map, so a test states the shape of a
|
||||
hierarchy in one literal instead of building one.
|
||||
"""
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The mode key that applies when no more specific one does. Emitted CSS puts it
|
||||
# on the base selector and every other key on a mode selector, mirroring how a
|
||||
# stylesheet is actually written: light on `:root`, dark layered over it.
|
||||
BASE_MODE = "base"
|
||||
|
||||
|
||||
def ancestry(system_id: int, parents: Mapping[int, int | None]) -> list[int]:
|
||||
"""The inheritance chain from `system_id` up to its root, nearest first.
|
||||
|
||||
Includes `system_id` itself at index 0, because resolution wants
|
||||
deepest-to-shallowest and the system being resolved is the deepest link.
|
||||
|
||||
A system missing from `parents` terminates the chain rather than raising: a
|
||||
parent whose row was soft-deleted or filtered out is a truncated chain, not
|
||||
a failed request.
|
||||
|
||||
The visited-set is defensive, not the primary guard — writes already refuse
|
||||
to create a cycle (`would_cycle`). It is here because a loop introduced by a
|
||||
direct DB edit or a future bug must degrade to a truncated chain instead of
|
||||
spinning forever. Truncation shows up in the result; a hang shows up as an
|
||||
outage.
|
||||
"""
|
||||
chain: list[int] = []
|
||||
seen: set[int] = set()
|
||||
current: int | None = system_id
|
||||
while current is not None and current not in seen:
|
||||
seen.add(current)
|
||||
chain.append(current)
|
||||
current = parents.get(current)
|
||||
return chain
|
||||
|
||||
|
||||
def would_cycle(
|
||||
system_id: int,
|
||||
proposed_parent_id: int | None,
|
||||
parents: Mapping[int, int | None],
|
||||
) -> bool:
|
||||
"""Would making `proposed_parent_id` the parent of `system_id` close a loop?
|
||||
|
||||
True when the proposed parent IS the system, or already inherits from it.
|
||||
|
||||
The walk goes UP from the proposed parent, which is the cheap direction —
|
||||
each system has at most one parent, so the chain is a line. Asking the
|
||||
equivalent downward question ("is the proposed parent among my
|
||||
descendants?") would mean searching a whole forest for the same answer.
|
||||
|
||||
`proposed_parent_id=None` clears the parent and can never cycle.
|
||||
"""
|
||||
if proposed_parent_id is None:
|
||||
return False
|
||||
if proposed_parent_id == system_id:
|
||||
return True
|
||||
return system_id in ancestry(proposed_parent_id, parents)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution — flattening a chain into an effective token set
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Contribution:
|
||||
"""One system's offer for one token in one mode."""
|
||||
system_id: int
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedToken:
|
||||
"""A token after the cascade, carrying the whole argument rather than the verdict.
|
||||
|
||||
`contributions` holds every system that supplied a value, per mode, DEEPEST
|
||||
FIRST — so `[0]` is the winner and `[1:]` are what it shadowed. Storing the
|
||||
contest rather than a winner plus a separate provenance field means there is
|
||||
nothing to keep in sync: "which system supplied this?" and "what did it
|
||||
override?" are both reads of the same list, and they cannot disagree.
|
||||
|
||||
Provenance is per MODE, not per token, because overriding is. A system that
|
||||
deepens one accent for light backgrounds while leaving dark alone owns the
|
||||
base value and inherits the dark one, and a token-level "overridden here"
|
||||
flag would have to lie about one of them.
|
||||
"""
|
||||
name: str
|
||||
contributions: dict[str, tuple[Contribution, ...]]
|
||||
group_name: str | None
|
||||
purpose: str | None
|
||||
rationale: str | None
|
||||
supersedes: tuple[str, ...]
|
||||
order_index: int
|
||||
|
||||
@property
|
||||
def value_by_mode(self) -> dict[str, str]:
|
||||
"""The effective value for each mode — the winner of each contest."""
|
||||
return {mode: entries[0].value for mode, entries in self.contributions.items()}
|
||||
|
||||
@property
|
||||
def origin_by_mode(self) -> dict[str, int]:
|
||||
"""Which system supplied each mode's effective value."""
|
||||
return {mode: entries[0].system_id for mode, entries in self.contributions.items()}
|
||||
|
||||
def value_for(self, mode: str) -> str | None:
|
||||
"""The value to render in `mode`, falling back to the base mode.
|
||||
|
||||
This is the read rule the storage shape implies: a token that is not
|
||||
mode-dependent carries only `base`, and asking it for "dark" must yield
|
||||
the base value rather than nothing.
|
||||
"""
|
||||
entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE)
|
||||
return entries[0].value if entries else None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Payload shape for both surfaces — and it carries the SHADOWED entries.
|
||||
|
||||
Serialising only the winner would throw away the provenance at the last
|
||||
step, which is the one thing this type exists to preserve. `contributions`
|
||||
is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so
|
||||
a client renders without re-deriving anything, and cannot derive it
|
||||
differently.
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"rationale": self.rationale,
|
||||
"supersedes": list(self.supersedes),
|
||||
"order_index": self.order_index,
|
||||
"value_by_mode": self.value_by_mode,
|
||||
"origin_by_mode": self.origin_by_mode,
|
||||
"contributions": {
|
||||
mode: [
|
||||
{"system_id": c.system_id, "value": c.value} for c in entries
|
||||
]
|
||||
for mode, entries in self.contributions.items()
|
||||
},
|
||||
}
|
||||
|
||||
def is_overridden_in(self, system_id: int) -> bool:
|
||||
"""Does `system_id` win any mode of this token AND shadow something?
|
||||
|
||||
The distinction the UI needs: a token this system introduced is not an
|
||||
override, and a token it merely inherits is not either.
|
||||
"""
|
||||
return any(
|
||||
len(entries) > 1 and entries[0].system_id == system_id
|
||||
for entries in self.contributions.values()
|
||||
)
|
||||
|
||||
|
||||
def _sort_key(token: ResolvedToken) -> tuple:
|
||||
# Ungrouped tokens sort last rather than first: a design system that has
|
||||
# started grouping should read as its groups, with the not-yet-filed
|
||||
# remainder at the end.
|
||||
return (token.group_name is None, token.group_name or "", token.order_index, token.name)
|
||||
|
||||
|
||||
def resolve_tokens(
|
||||
system_id: int,
|
||||
parents: Mapping[int, int | None],
|
||||
tokens_by_system: Mapping[int, Sequence],
|
||||
) -> list[ResolvedToken]:
|
||||
"""Flatten a system's inheritance chain into its effective token set.
|
||||
|
||||
Walks from `system_id` up to the root and applies tokens by name, deepest
|
||||
winning. That is the CSS cascade — precedence by name along a parent chain —
|
||||
rather than an analogy to it, which is why the storage model and the
|
||||
stylesheet model came out the same shape.
|
||||
|
||||
`tokens_by_system` maps a system id to its own token rows. Any object with
|
||||
`.name`, `.value_by_mode`, `.group_name`, `.purpose` and `.order_index` will
|
||||
do, so a test can state a hierarchy in literals and the service can pass ORM
|
||||
rows to the same function.
|
||||
|
||||
The result includes tokens the system never mentions — inheriting one is
|
||||
what puts it in the effective set. A system with no tokens of its own
|
||||
resolves to its parent's set entire, which is the correct answer for an app
|
||||
that has not departed from the family yet.
|
||||
|
||||
Merging is per (name, MODE): a child that supplies only a dark value
|
||||
overrides only dark and keeps inheriting base. Metadata (`group_name`,
|
||||
`purpose`, `order_index`) cascades separately by the same deepest-wins rule,
|
||||
since a child overriding a value routinely leaves the family's description
|
||||
of what the token is FOR untouched — and inheriting it beats blanking it.
|
||||
"""
|
||||
chain = ancestry(system_id, parents)
|
||||
|
||||
contributions: dict[str, dict[str, list[Contribution]]] = {}
|
||||
metadata: dict[str, dict[str, object]] = {}
|
||||
|
||||
# Deepest first, so the first contribution seen for a (name, mode) wins and
|
||||
# every later one is a shadowed ancestor appended behind it.
|
||||
for depth_system_id in chain:
|
||||
for token in tokens_by_system.get(depth_system_id) or ():
|
||||
per_mode = contributions.setdefault(token.name, {})
|
||||
for mode, value in (token.value_by_mode or {}).items():
|
||||
per_mode.setdefault(mode, []).append(
|
||||
Contribution(system_id=depth_system_id, value=value)
|
||||
)
|
||||
|
||||
meta = metadata.setdefault(
|
||||
token.name,
|
||||
{
|
||||
"group_name": None, "purpose": None, "rationale": None,
|
||||
"supersedes": None, "order_index": None,
|
||||
},
|
||||
)
|
||||
for field in ("group_name", "purpose", "rationale"):
|
||||
if meta[field] is None:
|
||||
meta[field] = getattr(token, field, None)
|
||||
# order_index alone treats 0 as UNSTATED rather than "first",
|
||||
# because 0 is the column default. Reading it as a real value would
|
||||
# let any child override drag its token to the top of the group and
|
||||
# lose the family's ordering — a visible reshuffle in return for a
|
||||
# change that only touched a colour.
|
||||
if not meta["order_index"]:
|
||||
meta["order_index"] = getattr(token, "order_index", 0) or None
|
||||
# `supersedes` cascades on EMPTINESS, not on None: a child that
|
||||
# overrides a colour and says nothing about which literals it
|
||||
# replaces should keep the family's declaration, and an empty list
|
||||
# is what "said nothing" looks like once the column is NOT NULL.
|
||||
# A child that states its own list replaces the whole thing.
|
||||
if not meta["supersedes"]:
|
||||
meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None
|
||||
|
||||
resolved = [
|
||||
ResolvedToken(
|
||||
name=name,
|
||||
contributions={
|
||||
mode: tuple(entries) for mode, entries in per_mode.items()
|
||||
},
|
||||
group_name=metadata[name]["group_name"],
|
||||
purpose=metadata[name]["purpose"],
|
||||
rationale=metadata[name]["rationale"],
|
||||
supersedes=metadata[name]["supersedes"] or (),
|
||||
order_index=metadata[name]["order_index"] or 0,
|
||||
)
|
||||
for name, per_mode in contributions.items()
|
||||
]
|
||||
return sorted(resolved, key=_sort_key)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Design-system expectations — turning rulebook prose into checkable claims.
|
||||
|
||||
Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
|
||||
against what the stylesheet and components actually DO. This module owns the
|
||||
first half: reading a rulebook's rules and extracting the claims that can be
|
||||
mechanically checked.
|
||||
|
||||
WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
|
||||
is the entire check — and this is the one genuinely fiddly piece of the feature.
|
||||
Extraction happens here where pytest can assert on it; the comparison itself is
|
||||
set arithmetic and stays in the browser, where the live token values are.
|
||||
|
||||
WHY NOT NLP. Rule statements are prose written for humans, and they should stay
|
||||
that way — they are read by people far more often than they are parsed. So this
|
||||
extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
|
||||
property names a rule mentions. Everything subtler (padding scales, type ramps)
|
||||
needs a rule author to opt into a structured form, which is deliberately left for
|
||||
when someone wants it rather than invented up front.
|
||||
|
||||
RULE #115. Nothing here assumes a design rulebook exists, or that it is this
|
||||
operator's. An install designates one; an install that hasn't gets an empty
|
||||
result and a panel that explains itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Which rulebook describes this install's design system. A plain setting rather
|
||||
# than a column: no migration, discoverable in the Settings UI (rule #25), and
|
||||
# honest about being a per-install choice rather than a property of the rulebook.
|
||||
DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
|
||||
|
||||
# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
|
||||
_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
|
||||
|
||||
# A custom-property name as written in prose, including the slash shorthand the
|
||||
# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
|
||||
_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
|
||||
|
||||
# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
|
||||
_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
|
||||
|
||||
# Negation markers. Checked PER SENTENCE, which is the whole trick — see
|
||||
# _extract_from_sentence.
|
||||
_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Expectation:
|
||||
"""One mechanically-checkable claim a rule makes."""
|
||||
|
||||
kind: str # "token" | "color" | "prohibited_color"
|
||||
value: str # "--fs-obsidian" | "#14171a"
|
||||
rule_id: int
|
||||
rule_title: str
|
||||
context: str # the sentence it came from, for showing your work
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"value": self.value,
|
||||
"rule_id": self.rule_id,
|
||||
"rule_title": self.rule_title,
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpectationSet:
|
||||
rulebook_id: int | None = None
|
||||
expectations: list[Expectation] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"rulebook_id": self.rulebook_id,
|
||||
"expectations": [e.as_dict() for e in self.expectations],
|
||||
}
|
||||
|
||||
|
||||
def normalize_hex(value: str) -> str | None:
|
||||
"""Fold a hex colour to a comparable form, or None if it isn't one.
|
||||
|
||||
Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
|
||||
code writes `#fff`, and those must compare equal or the single largest drift
|
||||
finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
|
||||
|
||||
Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
|
||||
same colour, and silently dropping the alpha would invent equality.
|
||||
"""
|
||||
match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
|
||||
if not match:
|
||||
return None
|
||||
digits = match.group(1).lower()
|
||||
if len(digits) in (3, 4):
|
||||
digits = "".join(c * 2 for c in digits)
|
||||
if len(digits) not in (6, 8):
|
||||
return None
|
||||
return f"#{digits}"
|
||||
|
||||
|
||||
def expand_token_shorthand(raw: str) -> list[str]:
|
||||
"""`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
|
||||
|
||||
The rulebook writes token families in a slash shorthand, and both forms it
|
||||
uses expand correctly under one rule: take everything up to and including the
|
||||
LAST hyphen of the first segment as the prefix, then append each alternative.
|
||||
|
||||
--fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
|
||||
--fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
|
||||
--fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
|
||||
|
||||
A name with no slash is returned as-is.
|
||||
"""
|
||||
if "/" not in raw:
|
||||
return [raw]
|
||||
head, *rest = raw.split("/")
|
||||
cut = head.rfind("-")
|
||||
if cut <= 1: # no hyphen beyond the leading `--`
|
||||
return [head, *rest]
|
||||
prefix = head[: cut + 1]
|
||||
return [head, *[f"{prefix}{part}" for part in rest if part]]
|
||||
|
||||
|
||||
def _is_negated(sentence: str) -> bool:
|
||||
return any(marker in sentence.lower() for marker in _NEGATIONS)
|
||||
|
||||
|
||||
def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
|
||||
"""Claims in ONE sentence, with negation scoped to that sentence.
|
||||
|
||||
Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
|
||||
|
||||
"Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
|
||||
Pure white #FFFFFF is NEVER used as text color."
|
||||
|
||||
Three colours the palette REQUIRES and one it FORBIDS, in one statement.
|
||||
Detecting negation across the whole statement would mark all four as
|
||||
forbidden; detecting it per sentence gets all four right.
|
||||
"""
|
||||
out: list[Expectation] = []
|
||||
negated = _is_negated(sentence)
|
||||
|
||||
for match in _HEX.finditer(sentence):
|
||||
value = normalize_hex(match.group(0))
|
||||
if not value:
|
||||
continue
|
||||
out.append(Expectation(
|
||||
kind="prohibited_color" if negated else "color",
|
||||
value=value,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
# Token names are not negated in practice — a rule says which tokens should
|
||||
# exist, never which must not — so they are recorded as expectations
|
||||
# regardless. If that ever changes, it needs its own kind rather than
|
||||
# borrowing the colour one.
|
||||
for match in _TOKEN.finditer(sentence):
|
||||
for name in expand_token_shorthand(match.group(1)):
|
||||
out.append(Expectation(
|
||||
kind="token",
|
||||
value=name,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def extract_expectations(rules: list[Rule]) -> list[Expectation]:
|
||||
"""Every checkable claim across a set of rules, deduped on (kind, value).
|
||||
|
||||
First occurrence wins so the reported rule is the one that introduced the
|
||||
claim, which is usually the most specific place to send a reader.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[Expectation] = []
|
||||
for rule in rules:
|
||||
text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""]))
|
||||
for sentence in _SENTENCE_SPLIT.split(text):
|
||||
if not sentence.strip():
|
||||
continue
|
||||
for expectation in _extract_from_sentence(sentence, rule):
|
||||
key = (expectation.kind, expectation.value)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(expectation)
|
||||
return out
|
||||
|
||||
|
||||
async def get_design_rulebook_id(user_id: int) -> int | None:
|
||||
"""The rulebook this install designated as its design system, if any."""
|
||||
raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
async def design_expectations(user_id: int) -> ExpectationSet:
|
||||
"""Checkable claims from the designated design rulebook.
|
||||
|
||||
Returns an empty set when no rulebook is designated — the normal case for
|
||||
any install but the one that set it up (rule #115). The caller shows an
|
||||
explanatory empty state rather than treating this as an error.
|
||||
"""
|
||||
rulebook_id = await get_design_rulebook_id(user_id)
|
||||
if rulebook_id is None:
|
||||
return ExpectationSet()
|
||||
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
|
||||
try:
|
||||
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
|
||||
except Exception:
|
||||
logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
|
||||
return ExpectationSet(rulebook_id=rulebook_id)
|
||||
|
||||
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user