Compare commits
83
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 |
+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,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`);
|
||||
@@ -20,9 +20,13 @@ export interface SnippetFields {
|
||||
path: string;
|
||||
symbol: string;
|
||||
locations: SnippetLocation[];
|
||||
/** Ids of 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. */
|
||||
merged_from: number[];
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -46,6 +50,29 @@ export interface Snippet {
|
||||
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. */
|
||||
@@ -61,6 +88,16 @@ export interface SnippetListItem {
|
||||
* them, not one of your own. Absent means it's yours. */
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
/** The recorded language, when one was given. Projected from the `data`
|
||||
* mirror so lists can show it without parsing the body — and so a prior-art
|
||||
* hit can be flagged as being in a DIFFERENT language than the file being
|
||||
* written, which is a shape to adapt rather than code to paste. */
|
||||
language?: string;
|
||||
/** Always present from the backend, zero-filled for records with no events. */
|
||||
usage?: SnippetUsage;
|
||||
/** Present on the detail record; the list feed carries it when a check has
|
||||
* been recorded. */
|
||||
verification?: SnippetVerification;
|
||||
}
|
||||
|
||||
/** Create/update payload — discrete fields the backend serializes into the
|
||||
@@ -91,6 +128,8 @@ export async function listSnippets(
|
||||
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();
|
||||
@@ -100,6 +139,7 @@ export async function listSnippets(
|
||||
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}` : ""}`);
|
||||
}
|
||||
@@ -123,6 +163,45 @@ 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(
|
||||
|
||||
@@ -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();
|
||||
@@ -64,6 +64,16 @@ router.afterEach(() => {
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Design. An icon rather than a sixth primary nav link: it's a
|
||||
meta-surface like Trash and Settings, but hiding it entirely would
|
||||
defeat the point of having somewhere the design system is visible.
|
||||
Points at the RECORD, not the live-token view — the record is what
|
||||
you work with; the live view is the check on it, and it's a tab
|
||||
away. -->
|
||||
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
|
||||
<Palette :size="16" />
|
||||
</router-link>
|
||||
|
||||
<!-- Trash link -->
|
||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||
<Trash2 :size="16" />
|
||||
@@ -98,6 +108,7 @@ router.afterEach(() => {
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -109,6 +109,20 @@ const router = createRouter({
|
||||
name: "rules",
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// Meta-surface, same family as /rules: it describes the app rather than
|
||||
// holding the operator's records.
|
||||
path: "/design",
|
||||
name: "design",
|
||||
component: () => import("@/views/DesignView.vue"),
|
||||
},
|
||||
{
|
||||
// The editable half of the same surface: /design is what the browser
|
||||
// renders, /design-systems is the record that ought to decide it.
|
||||
path: "/design-systems",
|
||||
name: "design-systems",
|
||||
component: () => import("@/views/DesignSystemsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
redirect: "/",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -381,7 +381,7 @@ onUnmounted(() => {
|
||||
<option value="alpha">Alphabetical</option>
|
||||
<option value="type">By type</option>
|
||||
</select>
|
||||
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<button class="btn-ghost btn-compact" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
@@ -463,14 +463,14 @@ onUnmounted(() => {
|
||||
<span>Graph</span>
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
class="btn-text"
|
||||
@click="toggleGraphExpand"
|
||||
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
||||
>
|
||||
<ChevronLeft v-if="graphExpanded" :size="16" />
|
||||
<ChevronRight v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
|
||||
<button class="btn-text" @click="toggleGraph" title="Close graph">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -574,7 +574,7 @@ onUnmounted(() => {
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
@@ -719,26 +719,6 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.btn-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: transparent;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Card grid ───────────────────────────────────────────── */
|
||||
.card-grid {
|
||||
@@ -956,22 +936,6 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-icon-sm:hover { color: var(--color-text); }
|
||||
.graph-embed {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||
.graph-embed :deep(.graph-page) {
|
||||
height: 100%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+133
-245
@@ -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";
|
||||
@@ -23,6 +24,19 @@ 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);
|
||||
|
||||
@@ -68,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 {
|
||||
@@ -77,9 +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, but deliberately the same threshold/ceiling — see
|
||||
// WRITEPATH_ENABLED_KEY in services/plugin_context.py.
|
||||
// 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);
|
||||
@@ -464,6 +493,20 @@ onMounted(async () => {
|
||||
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";
|
||||
}
|
||||
@@ -1122,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>
|
||||
@@ -1147,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>
|
||||
@@ -1207,12 +1250,70 @@ function formatUserDate(iso: string): string {
|
||||
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 same threshold and ceiling above, and never blocks the
|
||||
edit. Off = prior art surfaces only on your own prompts.
|
||||
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>
|
||||
@@ -1260,7 +1361,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
class="btn-primary"
|
||||
@click="changeEmail"
|
||||
:disabled="changingEmail || !emailPassword"
|
||||
>
|
||||
@@ -1308,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"
|
||||
>
|
||||
@@ -1364,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>
|
||||
@@ -1390,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>
|
||||
@@ -1400,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>
|
||||
@@ -1432,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>
|
||||
@@ -1461,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>
|
||||
@@ -1488,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>
|
||||
@@ -1790,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>
|
||||
@@ -1815,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>
|
||||
@@ -1845,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>
|
||||
@@ -1939,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>
|
||||
@@ -1953,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>
|
||||
@@ -1978,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"
|
||||
@@ -1999,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>
|
||||
@@ -2021,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>
|
||||
@@ -2060,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>
|
||||
@@ -2204,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>
|
||||
|
||||
@@ -2237,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>
|
||||
@@ -2463,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;
|
||||
@@ -2584,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 {
|
||||
@@ -2884,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; }
|
||||
@@ -3047,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%;
|
||||
@@ -3143,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;
|
||||
@@ -3290,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);
|
||||
@@ -3681,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;
|
||||
}
|
||||
@@ -3749,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>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getSnippet, deleteSnippet, type Snippet } from "@/api/snippets";
|
||||
import {
|
||||
getSnippet,
|
||||
deleteSnippet,
|
||||
unmergeSnippet,
|
||||
type Snippet,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
@@ -31,6 +36,32 @@ 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;
|
||||
@@ -117,7 +148,27 @@ async function confirmDelete() {
|
||||
<template v-if="mergedFrom.length">
|
||||
<dt>Merged from</dt>
|
||||
<dd class="merged-from">
|
||||
<span v-for="mid in mergedFrom" :key="mid">#{{ mid }}</span>
|
||||
<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>
|
||||
@@ -198,34 +249,6 @@ async function confirmDelete() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-danger {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: none;
|
||||
background: var(--color-action-destructive, #6B2118);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.when-to-use {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
@@ -297,6 +320,34 @@ async function confirmDelete() {
|
||||
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);
|
||||
|
||||
@@ -572,37 +572,6 @@ function cancel() {
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.field-row,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { listSnippets, mergeSnippets, type SnippetListItem } from "@/api/snippets";
|
||||
import {
|
||||
findDuplicateSnippets,
|
||||
listSnippets,
|
||||
mergeSnippets,
|
||||
type DuplicateGroup,
|
||||
type SnippetListItem,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -34,6 +40,15 @@ function toggleLocationFilter() {
|
||||
if (!showLocationFilter.value && locationActive.value) clearLocation();
|
||||
}
|
||||
|
||||
// Drift check (#2086) — "attention" is everything whose recorded location or
|
||||
// code no longer checks out, plus everything whose verdict expired because the
|
||||
// snippet was edited since it was checked.
|
||||
const needsAttentionOnly = ref(false);
|
||||
function toggleAttention() {
|
||||
needsAttentionOnly.value = !needsAttentionOnly.value;
|
||||
loadSnippets();
|
||||
}
|
||||
|
||||
// Multi-select → merge
|
||||
const selectMode = ref(false);
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
@@ -41,13 +56,54 @@ const showMergeModal = ref(false);
|
||||
const canonicalId = ref<number | null>(null);
|
||||
const merging = ref(false);
|
||||
|
||||
const selectedList = computed(() =>
|
||||
snippets.value.filter((s) => selectedIds.value.has(s.id)),
|
||||
);
|
||||
// Near-duplicate report (#2088). Loaded on demand, not with the list: it's a
|
||||
// pairwise scan and most visits to this page aren't a tidy-up.
|
||||
const duplicateGroups = ref<DuplicateGroup[]>([]);
|
||||
const dupLoading = ref(false);
|
||||
const dupChecked = ref(false);
|
||||
// Set while merging a SUGGESTED group. The report reaches the whole corpus, so
|
||||
// its members need not all be on the current page — see selectedList.
|
||||
const reviewingGroup = ref<DuplicateGroup | null>(null);
|
||||
|
||||
/** The records the merge modal acts on.
|
||||
*
|
||||
* Normally that's the selection filtered against what's on screen. But a
|
||||
* suggested group is corpus-wide: filtering it by the current page would render
|
||||
* an incomplete set AND silently narrow what doMerge folds in, since it derives
|
||||
* its source ids from this list. When a group is under review it is the
|
||||
* authority. */
|
||||
const selectedList = computed<{ id: number; title: string }[]>(() => {
|
||||
if (reviewingGroup.value) return reviewingGroup.value.snippets;
|
||||
return snippets.value.filter((s) => selectedIds.value.has(s.id));
|
||||
});
|
||||
|
||||
async function loadDuplicates() {
|
||||
dupLoading.value = true;
|
||||
try {
|
||||
const data = await findDuplicateSnippets();
|
||||
duplicateGroups.value = data.groups;
|
||||
dupChecked.value = true;
|
||||
} catch {
|
||||
toast.show("Couldn't check for duplicates", "error");
|
||||
} finally {
|
||||
dupLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand a suggested group to the existing merge flow, pre-selected. The operator
|
||||
* still picks which record survives and confirms — the report proposes, it
|
||||
* never merges. */
|
||||
function reviewGroup(group: DuplicateGroup) {
|
||||
reviewingGroup.value = group;
|
||||
selectedIds.value = new Set(group.note_ids);
|
||||
canonicalId.value = group.note_ids[0] ?? null;
|
||||
showMergeModal.value = true;
|
||||
}
|
||||
|
||||
function exitSelectMode() {
|
||||
selectMode.value = false;
|
||||
selectedIds.value = new Set();
|
||||
reviewingGroup.value = null;
|
||||
}
|
||||
function toggleSelectMode() {
|
||||
if (selectMode.value) exitSelectMode();
|
||||
@@ -68,6 +124,15 @@ function openMerge() {
|
||||
canonicalId.value = selectedList.value[0]?.id ?? null;
|
||||
showMergeModal.value = true;
|
||||
}
|
||||
/** Dismiss the modal. Clears the reviewed group too — leaving it set would keep
|
||||
* selectedList pinned to a corpus-wide set the operator has walked away from. */
|
||||
function closeMerge() {
|
||||
showMergeModal.value = false;
|
||||
if (reviewingGroup.value) {
|
||||
reviewingGroup.value = null;
|
||||
selectedIds.value = new Set();
|
||||
}
|
||||
}
|
||||
async function doMerge() {
|
||||
const target = canonicalId.value;
|
||||
if (target == null) return;
|
||||
@@ -78,8 +143,13 @@ async function doMerge() {
|
||||
await mergeSnippets(target, sources);
|
||||
toast.show(`Merged ${sources.length} snippet${sources.length > 1 ? "s" : ""} in`);
|
||||
showMergeModal.value = false;
|
||||
const wasSuggested = reviewingGroup.value !== null;
|
||||
exitSelectMode();
|
||||
await loadSnippets();
|
||||
// The merged-away records are gone, so a stale report would keep offering
|
||||
// them. Re-run it rather than clearing, so the operator can work through
|
||||
// several groups without re-triggering the scan each time.
|
||||
if (wasSuggested && dupChecked.value) await loadDuplicates();
|
||||
} catch {
|
||||
toast.show("Failed to merge snippets", "error");
|
||||
} finally {
|
||||
@@ -96,6 +166,7 @@ async function loadSnippets() {
|
||||
repo: locRepo.value.trim() || undefined,
|
||||
path: locPath.value.trim() || undefined,
|
||||
symbol: locSymbol.value.trim() || undefined,
|
||||
verification: needsAttentionOnly.value ? "attention" : undefined,
|
||||
});
|
||||
snippets.value = data.snippets;
|
||||
} catch {
|
||||
@@ -126,6 +197,79 @@ function splitTitle(title: string): { name: string; when: string } {
|
||||
function languageOf(tags: string[]): string {
|
||||
return tags.find((t) => t && t !== "snippet") ?? "";
|
||||
}
|
||||
|
||||
/** A snippet that has been offered repeatedly and never opened. The threshold
|
||||
* is 3 rather than 1 because one or two surfacings is noise — the record may
|
||||
* simply not have come up in a relevant context yet. */
|
||||
function isDeadWeight(s: SnippetListItem): boolean {
|
||||
const u = s.usage;
|
||||
return !!u && u.pull_count === 0 && u.surfaced_count >= 3;
|
||||
}
|
||||
|
||||
/** Short badge text, or "" to render nothing. A record nobody has surfaced yet
|
||||
* gets no badge at all: "0 / 0" would read as a verdict when it's an absence
|
||||
* of evidence. */
|
||||
function usageBadge(s: SnippetListItem): string {
|
||||
const u = s.usage;
|
||||
if (!u || u.surfaced_count === 0) return "";
|
||||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||
}
|
||||
|
||||
/** Short label for the drift verdict, or "" when there's nothing to say.
|
||||
* An expired verdict is reported as "unchecked" whatever it used to say —
|
||||
* it was about code that is no longer in the record. */
|
||||
function driftBadge(s: SnippetListItem): string {
|
||||
const v = s.verification;
|
||||
if (!v) return "";
|
||||
if (!v.current) return "unchecked since edit";
|
||||
// Record<string, string>, not an object literal: `status` is a union that
|
||||
// includes "ok" and "unverified", and a literal would have to enumerate every
|
||||
// member just to say "nothing to show for these".
|
||||
const labels: Record<string, string> = {
|
||||
missing: "path gone",
|
||||
moved: "symbol moved",
|
||||
changed: "code drifted",
|
||||
};
|
||||
return labels[v.status] ?? "";
|
||||
}
|
||||
|
||||
function driftTitle(s: SnippetListItem): string {
|
||||
const v = s.verification;
|
||||
if (!v) return "";
|
||||
const when = v.checked_at
|
||||
? `Checked ${new Date(v.checked_at).toLocaleDateString()}`
|
||||
: "Checked";
|
||||
if (!v.current) {
|
||||
return (
|
||||
`${when}, but the snippet has been edited since — that verdict was about ` +
|
||||
`code this record no longer holds. Re-verify it.`
|
||||
);
|
||||
}
|
||||
const reasons: Record<string, string> = {
|
||||
missing: "the recorded path no longer exists",
|
||||
moved: "the file is there but the symbol isn't in it",
|
||||
changed: "the source no longer matches the recorded code",
|
||||
};
|
||||
const what = reasons[v.status] ?? "";
|
||||
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||||
}
|
||||
|
||||
function usageTitle(s: SnippetListItem): string {
|
||||
const u = s.usage;
|
||||
if (!u) return "";
|
||||
const last = u.last_pulled_at
|
||||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||||
: "Never opened.";
|
||||
const verdict = isDeadWeight(s)
|
||||
? " Offered repeatedly without ever being opened — consider rewriting its" +
|
||||
" “when to reach for it” so it says when, or deleting it. It takes a slot" +
|
||||
" in every future auto-inject menu."
|
||||
: "";
|
||||
return (
|
||||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||||
`${u.pull_count}×. ${last}${verdict}`
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -167,6 +311,47 @@ function languageOf(tags: string[]): string {
|
||||
a screen reader too. -->
|
||||
{{ locationActive ? "Location · filtering" : "Location" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost"
|
||||
:class="{ 'filter-on': needsAttentionOnly }"
|
||||
:aria-pressed="needsAttentionOnly"
|
||||
title="Show only snippets whose recorded location or code no longer checks out — including ones edited since they were last verified"
|
||||
@click="toggleAttention"
|
||||
>
|
||||
{{ needsAttentionOnly ? "Needs attention · filtering" : "Needs attention" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost"
|
||||
:disabled="dupLoading"
|
||||
title="Look for snippets already recorded that resemble each other closely enough to be worth merging"
|
||||
@click="loadDuplicates"
|
||||
>
|
||||
{{ dupLoading ? "Checking…" : "Find duplicates" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Near-duplicate report. Only ever a proposal — merging is a separate,
|
||||
confirmed act, and the operator chooses which record survives. -->
|
||||
<div v-if="dupChecked && !dupLoading" class="dup-panel">
|
||||
<p v-if="!duplicateGroups.length" class="dup-empty">
|
||||
No near-duplicates found. Nothing recorded resembles anything else closely
|
||||
enough to be worth merging.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="dup-head">
|
||||
{{ duplicateGroups.length }} possible duplicate{{ duplicateGroups.length > 1 ? " sets" : " set" }}
|
||||
— review each before merging; a set is a suggestion, not a verdict.
|
||||
</p>
|
||||
<div v-for="(g, i) in duplicateGroups" :key="i" class="dup-group">
|
||||
<div class="dup-members">
|
||||
<span v-for="s in g.snippets" :key="s.id" class="dup-member">
|
||||
{{ splitTitle(s.title).name }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
||||
<button class="btn-ghost dup-action" @click="reviewGroup(g)">Review & merge</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
|
||||
@@ -209,20 +394,27 @@ function languageOf(tags: string[]): string {
|
||||
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
||||
<div class="empty-icon">❭_</div>
|
||||
<p class="empty-title">
|
||||
{{ locationActive
|
||||
? "Nothing kept at that location yet"
|
||||
: search.trim()
|
||||
? "No snippets match your search"
|
||||
: "No snippets kept yet" }}
|
||||
{{ needsAttentionOnly
|
||||
? "Everything checks out"
|
||||
: locationActive
|
||||
? "Nothing kept at that location yet"
|
||||
: search.trim()
|
||||
? "No snippets match your search"
|
||||
: "No snippets kept yet" }}
|
||||
</p>
|
||||
<p class="empty-sub">
|
||||
{{ locationActive
|
||||
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
|
||||
: search.trim()
|
||||
? "Try a different term, or clear the search."
|
||||
: "Record a reusable function or component and it will be offered back to you later." }}
|
||||
{{ needsAttentionOnly
|
||||
? "No snippet has drifted from its recorded location or code — as far as anything has been checked. Snippets nobody has verified yet don't appear here."
|
||||
: locationActive
|
||||
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
|
||||
: search.trim()
|
||||
? "Try a different term, or clear the search."
|
||||
: "Record a reusable function or component and it will be offered back to you later." }}
|
||||
</p>
|
||||
<button v-if="locationActive" class="empty-action" @click="clearLocation">
|
||||
<button v-if="needsAttentionOnly" class="empty-action" @click="toggleAttention">
|
||||
Show all snippets
|
||||
</button>
|
||||
<button v-else-if="locationActive" class="empty-action" @click="clearLocation">
|
||||
Clear location filter
|
||||
</button>
|
||||
<button
|
||||
@@ -261,6 +453,17 @@ function languageOf(tags: string[]): string {
|
||||
</p>
|
||||
<div class="card-footer">
|
||||
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
||||
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||||
{{ driftBadge(s) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="usageBadge(s)"
|
||||
class="usage-tag"
|
||||
:class="{ 'usage-dead': isDeadWeight(s) }"
|
||||
:title="usageTitle(s)"
|
||||
>
|
||||
{{ usageBadge(s) }}
|
||||
</span>
|
||||
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
|
||||
by {{ s.owner ?? "another user" }}
|
||||
</span>
|
||||
@@ -279,7 +482,7 @@ function languageOf(tags: string[]): string {
|
||||
|
||||
<!-- Merge modal -->
|
||||
<teleport to="body">
|
||||
<div v-if="showMergeModal" class="modal-overlay" @click.self="showMergeModal = false">
|
||||
<div v-if="showMergeModal" class="modal-overlay" @click.self="closeMerge">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Merge snippets">
|
||||
<h3 class="modal-title">Merge snippets</h3>
|
||||
<p class="modal-desc">
|
||||
@@ -299,7 +502,7 @@ function languageOf(tags: string[]): string {
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="showMergeModal = false">Cancel</button>
|
||||
<button class="modal-btn" @click="closeMerge">Cancel</button>
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
:disabled="merging || canonicalId == null"
|
||||
@@ -340,21 +543,6 @@ function languageOf(tags: string[]): string {
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-bottom: 1.25rem;
|
||||
@@ -466,17 +654,17 @@ function languageOf(tags: string[]): string {
|
||||
.empty-action {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border: 1px solid var(--color-action-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
color: var(--color-action-primary);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.empty-action:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
.skeleton-grid,
|
||||
@@ -576,27 +764,97 @@ function languageOf(tags: string[]): string {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Near-duplicate report */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-alt, var(--color-surface));
|
||||
}
|
||||
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dup-empty {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
/* Long snippet names must not push the row into a horizontal scroll. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dup-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Drift is a stronger signal than dead weight: the record may be actively
|
||||
misleading, not merely unused. Danger tone, and it sits first in the footer. */
|
||||
.drift-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
|
||||
color: var(--color-danger, #b91c1c);
|
||||
}
|
||||
|
||||
.usage-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||
than the danger one, because the record isn't broken, just unearned. */
|
||||
.usage-tag.usage-dead {
|
||||
background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
|
||||
color: var(--color-warning, #b45309);
|
||||
}
|
||||
|
||||
/* Header + select-mode */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-ghost {
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Selected card = 2px accent border per the design system (featured/active). */
|
||||
.snippet-card.selected {
|
||||
@@ -643,10 +901,6 @@ function languageOf(tags: string[]): string {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.select-bar .btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Merge modal */
|
||||
.modal-overlay {
|
||||
@@ -733,7 +987,7 @@ function languageOf(tags: string[]): string {
|
||||
.modal-btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
|
||||
@@ -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, 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.18",
|
||||
"version": "0.1.22",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
# 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:
|
||||
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
||||
# 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
|
||||
|
||||
@@ -46,16 +48,9 @@ case "$file_path" in
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → silent. Prior-art recall is pure enrichment.
|
||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||
|
||||
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
||||
# an absolute one would simply match nothing.
|
||||
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
||||
# because the local arm below needs the repo root and needs no server at all.
|
||||
lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true)
|
||||
[ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
@@ -66,20 +61,106 @@ if [ -n "$repo_root" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
|
||||
# of the codebase, so a helper nobody thought to record is invisible to them —
|
||||
# which is how `.btn-primary` came to be defined four times, in four scoped
|
||||
# stylesheets, already diverged. It was never a snippet, so no threshold and no
|
||||
# query rewrite could ever have surfaced it.
|
||||
#
|
||||
# This arm closes that by asking the only question the record cannot answer,
|
||||
# in the only place that can: the hook already runs on the developer's machine,
|
||||
# inside the repo, holding the code about to be written. No index, no storage,
|
||||
# no staleness, and no server — it deliberately runs even on an install that
|
||||
# has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none.
|
||||
# ---------------------------------------------------------------------------
|
||||
local_lines=""
|
||||
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
||||
# kind<TAB>name for each thing this payload DEFINES.
|
||||
names=$(printf '%s' "$code" | awk '
|
||||
match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t);
|
||||
if (t != "") print "css\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) {
|
||||
t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) {
|
||||
t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t);
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
' 2>/dev/null | sort -u | head -12) || names=""
|
||||
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written, which would
|
||||
# otherwise always match itself on an Edit.
|
||||
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
|
||||
done <<< "$names"
|
||||
fi
|
||||
|
||||
local_context=""
|
||||
if [ -n "$local_lines" ]; then
|
||||
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
|
||||
fi
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
|
||||
# arm above already ran and may have something to say.
|
||||
if [ -z "$url" ] || [ -z "$token" ]; then
|
||||
if [ -n "$local_context" ]; then
|
||||
jq -n --arg c "$local_context" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cap the code sent as the semantic query. The embedder truncates at its own
|
||||
# token limit well before this, so a bigger slice buys no extra signal — and the
|
||||
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
|
||||
# plugin hook works with a read key).
|
||||
q=$(printf '%s' "$code" | cut -c1-1200)
|
||||
# `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)
|
||||
|
||||
path_enc=$(printf '%s' "$rel_path" | jq -rR '@uri' 2>/dev/null) || exit 0
|
||||
code_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || code_enc=""
|
||||
# `-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 -rR '@uri' 2>/dev/null) || enc=""
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
|
||||
@@ -100,20 +181,32 @@ if [ -n "$session_id" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
||||
# finding that needed no instance to produce.
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
||||
[ -n "$body" ] || exit 0
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body=""
|
||||
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
||||
[ -n "$context" ] || exit 0
|
||||
|
||||
# Remember what was surfaced so it isn't shown again this session.
|
||||
if [ -n "$idfile" ]; then
|
||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||
context=""
|
||||
if [ -n "$body" ]; then
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
||||
# Remember what was surfaced so it isn't shown again this session.
|
||||
if [ -n "$idfile" ] && [ -n "$context" ]; then
|
||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Local first. It answers "this already EXISTS", which is a stronger claim than
|
||||
# "this resembles something recorded" — and it is the one the recorded arms are
|
||||
# structurally unable to make.
|
||||
combined="$local_context"
|
||||
if [ -n "$context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${context}"
|
||||
fi
|
||||
[ -n "$combined" ] || exit 0
|
||||
|
||||
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
||||
jq -n --arg c "$context" \
|
||||
jq -n --arg c "$combined" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
exit 0
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +26,8 @@ from scribe.routes.profile import profile_bp
|
||||
from scribe.routes.knowledge import knowledge_bp
|
||||
from scribe.routes.rulebooks import rulebooks_bp
|
||||
from scribe.routes.plugin import plugin_bp
|
||||
from scribe.routes.design import design_bp
|
||||
from scribe.routes.design_systems import design_systems_bp
|
||||
from scribe.routes.trash import trash_bp
|
||||
from scribe.routes.dashboard import dashboard_bp
|
||||
from scribe.routes.systems import systems_bp
|
||||
@@ -89,6 +91,8 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(knowledge_bp)
|
||||
app.register_blueprint(rulebooks_bp)
|
||||
app.register_blueprint(plugin_bp)
|
||||
app.register_blueprint(design_bp)
|
||||
app.register_blueprint(design_systems_bp)
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(systems_bp)
|
||||
|
||||
+39
-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
|
||||
@@ -256,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, snippets, systems, tags, tasks, trash,
|
||||
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
|
||||
systems, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +18,7 @@ def register_all(mcp) -> None:
|
||||
projects.register(mcp)
|
||||
milestones.register(mcp)
|
||||
systems.register(mcp)
|
||||
design_systems.register(mcp)
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
repos.register(mcp)
|
||||
|
||||
@@ -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)
|
||||
@@ -19,6 +19,7 @@ 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(
|
||||
@@ -68,6 +69,11 @@ async def get_note(note_id: int) -> dict:
|
||||
raise ValueError(f"note {note_id} not found")
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -14,12 +14,13 @@ 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 = "",
|
||||
repo: str = "", path: str = "", symbol: str = "", verification: str = "",
|
||||
) -> dict:
|
||||
"""List recorded snippets (reusable functions/components).
|
||||
|
||||
@@ -43,13 +44,27 @@ async def list_snippets(
|
||||
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}], "total": int}. The title
|
||||
reads "name — when to reach for it"; open one in full with get_snippet(id).
|
||||
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
|
||||
@@ -61,12 +76,13 @@ async def list_snippets(
|
||||
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,
|
||||
repo=repo, path=path, symbol=symbol, verification=verification,
|
||||
)
|
||||
return {
|
||||
"snippets": await access_svc.label_shared_items(uid, items),
|
||||
"total": total,
|
||||
}
|
||||
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(
|
||||
@@ -166,9 +182,144 @@ async def get_snippet(snippet_id: int) -> dict:
|
||||
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,
|
||||
@@ -265,6 +416,10 @@ async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
||||
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
|
||||
@@ -292,6 +447,7 @@ async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_snippets, create_snippet, get_snippet, update_snippet,
|
||||
delete_snippet, merge_snippets,
|
||||
delete_snippet, merge_snippets, verify_snippet, find_duplicate_snippets,
|
||||
unmerge_snippet,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -27,6 +27,7 @@ from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import task_logs as task_logs_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.note_usage import record_pulled
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
@@ -99,6 +100,14 @@ async def get_task(task_id: int) -> dict:
|
||||
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
# Same reasoning as get_note's record_pulled, and this is the tool where it
|
||||
# matters MOST: auto-inject ranks kind-blind over a corpus that is
|
||||
# overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without
|
||||
# this the surfaced→pulled loop was open exactly where the volume is — every
|
||||
# surfaced task counted as never-pulled because the tool that opens one didn't
|
||||
# say so, driving auto-inject's measured pull-through toward zero for its own
|
||||
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_task")
|
||||
return data
|
||||
|
||||
|
||||
@@ -267,6 +276,12 @@ async def add_task_log(task_id: int, content: str) -> dict:
|
||||
async def start_planning(project_id: int, title: str) -> dict:
|
||||
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
|
||||
|
||||
Reach for this when the work has an ARC — several steps toward one goal,
|
||||
worth tracking as a unit. Work without one (a fix, a one-file change, a
|
||||
question answered) is a task, not a plan: create_task, drive its status, and
|
||||
record progress with add_task_log. A milestone holding a single step is
|
||||
ceremony, and it leaves the project with a plan that never meant anything.
|
||||
|
||||
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
|
||||
template (Goal/Approach/Verification) under the given project, and the call
|
||||
returns it together with the project's applicable Rulebook rules and brief
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")})
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,
|
||||
@@ -61,14 +62,22 @@ async def list_snippets_route():
|
||||
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,
|
||||
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})
|
||||
|
||||
|
||||
@@ -148,6 +157,13 @@ async def get_snippet_route(snippet_id: int):
|
||||
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)
|
||||
|
||||
|
||||
@@ -189,6 +205,92 @@ async def update_snippet_route(snippet_id: int):
|
||||
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):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -8,7 +7,6 @@ from scribe.models.note import TaskPriority, TaskStatus
|
||||
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from scribe.services.access import can_write_note
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
from scribe.services.notes import (
|
||||
create_note,
|
||||
get_note_for_user,
|
||||
@@ -149,9 +147,6 @@ async def create_task_route():
|
||||
)
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, task.id, data["system_ids"])
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
|
||||
out = task.to_dict()
|
||||
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task.id)]
|
||||
return jsonify(out), 201
|
||||
@@ -255,9 +250,6 @@ async def update_task_route(task_id: int):
|
||||
return not_found("Task")
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, task_id, data["system_ids"])
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
|
||||
out = task.to_dict()
|
||||
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
|
||||
return jsonify(out)
|
||||
|
||||
@@ -12,11 +12,13 @@ import logging
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.design_system import DesignSystem
|
||||
from scribe.models.group import GroupMembership
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.share import NoteShare, ProjectShare
|
||||
from scribe.models.user import User
|
||||
from scribe.services.design_cascade import ancestry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,6 +166,88 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Design-system permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_design_system_permission(
|
||||
user_id: int, design_system_id: int
|
||||
) -> str | None:
|
||||
"""Effective permission on a design system, or None.
|
||||
|
||||
Two ways in, and the asymmetry between them is the point:
|
||||
|
||||
- **Owning it** grants "owner" — full read and write.
|
||||
- **Reaching it through a project you can see** grants "viewer", and only
|
||||
ever "viewer". Being an editor on a shared project must NOT confer the
|
||||
right to rewrite the family system that project inherits from: one
|
||||
project's collaborator would be editing tokens every other project in
|
||||
the family resolves through. Editing a design system stays the owner's
|
||||
act, and it is the same reasoning that keeps a rulebook owner-scoped.
|
||||
|
||||
Reachability follows the parent chain UPWARD. Rendering a project's UI means
|
||||
resolving its whole chain, so read access to a system implies read access to
|
||||
its ancestors — otherwise a shared project would resolve to a truncated
|
||||
cascade and silently render with the wrong values.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
if system.owner_user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
shared_project_ids = select(ProjectShare.project_id).where(
|
||||
or_(
|
||||
ProjectShare.shared_with_user_id == user_id,
|
||||
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
|
||||
)
|
||||
)
|
||||
entry_points = set(
|
||||
(
|
||||
await session.execute(
|
||||
select(Project.design_system_id).where(
|
||||
Project.design_system_id.is_not(None),
|
||||
Project.deleted_at.is_(None),
|
||||
or_(
|
||||
Project.user_id == user_id,
|
||||
Project.id.in_(shared_project_ids),
|
||||
),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
if not entry_points:
|
||||
return None
|
||||
|
||||
# One narrow query for the whole forest's shape. Design systems are a
|
||||
# handful of rows per install — a family and one per app — so walking
|
||||
# from each entry point in memory beats a recursive CTE per check.
|
||||
parents = dict(
|
||||
(
|
||||
await session.execute(
|
||||
select(DesignSystem.id, DesignSystem.parent_id).where(
|
||||
DesignSystem.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
for entry in entry_points:
|
||||
if design_system_id in ancestry(entry, parents):
|
||||
return "viewer"
|
||||
return None
|
||||
|
||||
|
||||
async def can_read_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
return (await get_design_system_permission(user_id, design_system_id)) is not None
|
||||
|
||||
|
||||
async def can_write_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
perm = await get_design_system_permission(user_id, design_system_id)
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set-based visibility (for LIST queries)
|
||||
#
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -139,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))
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Render a design system's resolved tokens as its master CSS sheet.
|
||||
|
||||
Pure, like `design_cascade` and for the same reasons: no database import, so the
|
||||
rendering rule is testable without one and the preview surface can reuse it.
|
||||
|
||||
WHAT THIS SHEET IS, AND DELIBERATELY IS NOT
|
||||
-------------------------------------------
|
||||
It declares **purpose tokens only** — custom properties, grouped by what they
|
||||
mean. It contains no rules for elements or classes: no `.btn-primary`, no
|
||||
`table`, no `input`.
|
||||
|
||||
That is the point rather than a limitation. A sheet that styled every element
|
||||
would restate the same handful of values once per element and grow with the UI;
|
||||
a sheet of purpose-named values states each once and is reused. Components —
|
||||
buttons, tables, input schemes — live as SNIPPETS that reference these names and
|
||||
carry prose about the idea, which is a surface that already exists and already
|
||||
has recall, locations, drift checks and merge.
|
||||
|
||||
So the division is: this sheet says what the values MEAN; snippets say what
|
||||
things LOOK LIKE, in terms of those values. A token named after an element
|
||||
(`--button-bg`) is the smell that the two have been mixed — it multiplies
|
||||
with every new element, where a purpose name (`--action-primary`) is reused.
|
||||
|
||||
SAFETY
|
||||
------
|
||||
Values are interpolated into a stylesheet, and design systems are shareable
|
||||
records (rule #47). A value of `red; } body { display: none` in a system someone
|
||||
shared with you would otherwise inject CSS into your page. Everything rendered
|
||||
here is validated or dropped — never escaped-and-hoped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
BASE_MODE = "base"
|
||||
|
||||
# A custom property name, strictly. Anything else is dropped rather than
|
||||
# sanitised: a name is an identifier, and a "cleaned up" identifier is a
|
||||
# different token than the one the operator recorded.
|
||||
_VALID_NAME = re.compile(r"^--[A-Za-z0-9_-]+$")
|
||||
|
||||
# Characters that would end the declaration, open a block, start an at-rule, or
|
||||
# begin a tag. A value containing any of them is not a value.
|
||||
_UNSAFE_VALUE = re.compile(r"[{};@<>]|\*/|/\*|\n|\r")
|
||||
|
||||
|
||||
def is_valid_token_name(name: str) -> bool:
|
||||
return bool(_VALID_NAME.match((name or "").strip()))
|
||||
|
||||
|
||||
def safe_value(value: str) -> str | None:
|
||||
"""A CSS value that cannot escape its declaration, or None.
|
||||
|
||||
Rejects rather than strips. A partially-sanitised value is a value the
|
||||
operator did not write, and silently rendering a different colour than the
|
||||
record holds is worse than rendering none — the sheet's whole claim is that
|
||||
it IS the record.
|
||||
"""
|
||||
candidate = (value or "").strip()
|
||||
if not candidate or _UNSAFE_VALUE.search(candidate):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def safe_comment(text: str) -> str:
|
||||
"""Comment text that cannot close its comment or break the line."""
|
||||
return re.sub(r"\*/|/\*|[\n\r]", " ", (text or "")).strip()
|
||||
|
||||
|
||||
def selector_for_mode(mode: str, root_selector: str = ":root") -> str:
|
||||
"""Which selector a mode's declarations belong under.
|
||||
|
||||
`base` gets the caller's root selector; every other mode gets a bare
|
||||
attribute selector, matching the convention already in the codebase.
|
||||
|
||||
The root selector is a PARAMETER because a container-scoped preview cannot
|
||||
use `:root` — #251 recorded that mode scoping is one-way (light lives on
|
||||
`:root`, dark layers over it), so a generator that hardcoded `:root` could
|
||||
not serve a preview at all.
|
||||
"""
|
||||
if mode == BASE_MODE:
|
||||
return root_selector
|
||||
safe_mode = re.sub(r"[^A-Za-z0-9_-]", "", mode)
|
||||
return f'[data-theme="{safe_mode}"]' if safe_mode else root_selector
|
||||
|
||||
|
||||
def _grouped(tokens: Sequence) -> list[tuple[str | None, list]]:
|
||||
"""Tokens by group, preserving the order they arrive in (already sorted)."""
|
||||
groups: dict[str | None, list] = {}
|
||||
for token in tokens:
|
||||
groups.setdefault(getattr(token, "group_name", None), []).append(token)
|
||||
return list(groups.items())
|
||||
|
||||
|
||||
def _modes_present(tokens: Sequence) -> list[str]:
|
||||
"""Every mode any token declares, base first then the rest alphabetically."""
|
||||
modes = {
|
||||
mode
|
||||
for token in tokens
|
||||
for mode in (getattr(token, "value_by_mode", None) or {})
|
||||
}
|
||||
rest = sorted(modes - {BASE_MODE})
|
||||
return ([BASE_MODE] if BASE_MODE in modes else []) + rest
|
||||
|
||||
|
||||
def render_stylesheet(
|
||||
tokens: Sequence,
|
||||
*,
|
||||
root_selector: str = ":root",
|
||||
title: str = "",
|
||||
design_system_id: int | None = None,
|
||||
) -> str:
|
||||
"""The master sheet for a resolved token set.
|
||||
|
||||
One block per mode. Within a block, only the tokens that declare a value for
|
||||
that mode — so a mode block is an override layer, exactly as the storage
|
||||
model has it.
|
||||
|
||||
Tokens with no value at all are emitted as COMMENTED-OUT declarations in
|
||||
their group, not dropped. The rulebook named them, so their absence is a
|
||||
finding, and a commented line puts that finding where the reader is already
|
||||
looking.
|
||||
"""
|
||||
header = [
|
||||
"/*",
|
||||
f" * {safe_comment(title) or 'Design system'} — generated stylesheet",
|
||||
]
|
||||
if design_system_id is not None:
|
||||
header.append(f" * Source: design system {int(design_system_id)}.")
|
||||
header += [
|
||||
" *",
|
||||
" * Purpose tokens only. This sheet declares what values MEAN; it styles",
|
||||
" * no elements. Buttons, tables and input schemes live as snippets that",
|
||||
" * reference these names, so each value is stated once and reused rather",
|
||||
" * than restated per element.",
|
||||
" *",
|
||||
" * Generated — edit the design system, not this file.",
|
||||
" */",
|
||||
"",
|
||||
]
|
||||
|
||||
lines = list(header)
|
||||
valueless = [
|
||||
t for t in tokens
|
||||
if is_valid_token_name(getattr(t, "name", ""))
|
||||
and not (getattr(t, "value_by_mode", None) or {})
|
||||
]
|
||||
|
||||
for mode in _modes_present(tokens):
|
||||
block: list[str] = []
|
||||
for group, group_tokens in _grouped(tokens):
|
||||
entries: list[str] = []
|
||||
for token in group_tokens:
|
||||
name = (getattr(token, "name", "") or "").strip()
|
||||
if not is_valid_token_name(name):
|
||||
continue
|
||||
raw = (getattr(token, "value_by_mode", None) or {}).get(mode)
|
||||
if raw is None:
|
||||
continue
|
||||
value = safe_value(str(raw))
|
||||
if value is None:
|
||||
entries.append(
|
||||
f" /* {name}: value rejected — not a safe CSS value */"
|
||||
)
|
||||
continue
|
||||
# Purpose first — what the token is FOR is what a reader of the
|
||||
# stylesheet needs. Rationale is the fallback so a token that
|
||||
# only carries the why still says something.
|
||||
purpose = safe_comment(
|
||||
getattr(token, "purpose", "")
|
||||
or getattr(token, "rationale", "")
|
||||
or ""
|
||||
)
|
||||
comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else ""
|
||||
entries.append(f" {name}: {value};{comment}")
|
||||
if entries:
|
||||
if group:
|
||||
block.append(f" /* {safe_comment(group)} */")
|
||||
block.extend(entries)
|
||||
block.append("")
|
||||
|
||||
# Declared-but-valueless tokens belong to the base layer: they have no
|
||||
# mode to sit under, and repeating them per mode would triple the noise.
|
||||
if mode == BASE_MODE and valueless:
|
||||
block.append(" /* Declared by the design system, no value set yet: */")
|
||||
block.extend(f" /* {t.name}: ; */" for t in valueless)
|
||||
block.append("")
|
||||
|
||||
if not block:
|
||||
continue
|
||||
lines.append(f"{selector_for_mode(mode, root_selector)} {{")
|
||||
lines.extend(block[:-1] if block[-1] == "" else block)
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
|
||||
# A trailing, machine-readable record of what this system says to write
|
||||
# INSTEAD of a given literal.
|
||||
#
|
||||
# The sheet carries its own supersedes declarations so that any consumer has
|
||||
# them — notably a CI check, which has the component sources but no database.
|
||||
# Hardcoding the mapping in a checker would bake one install's palette into
|
||||
# the tool; reading it from the sheet keeps the checker instance-agnostic and
|
||||
# keeps this file the single source.
|
||||
replacements = [
|
||||
(literal, getattr(token, "name", ""))
|
||||
for token in tokens
|
||||
for literal in (getattr(token, "supersedes", None) or ())
|
||||
if is_valid_token_name(getattr(token, "name", ""))
|
||||
]
|
||||
if replacements:
|
||||
lines.append("/* SUPERSEDES — write the token, not the literal.")
|
||||
for literal, name in replacements:
|
||||
lines.append(f" * {safe_comment(str(literal))} -> {name}")
|
||||
lines.append(" */")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def duplicate_values(tokens: Sequence) -> dict[str, list[str]]:
|
||||
"""Values declared by more than one token, mapped to the names declaring them.
|
||||
|
||||
Two names for one value are either a deliberate alias or the same idea
|
||||
recorded twice — the token-level form of the duplicated-definition shape.
|
||||
Reported rather than refused: a design system legitimately aligns colours on
|
||||
purpose (one palette entry defined as equal to another), and a generator
|
||||
that rejected that
|
||||
would be wrong about the operator's intent.
|
||||
|
||||
Compares the BASE value only. Two tokens agreeing in one mode and diverging
|
||||
in another are not duplicates of each other — they are a near-miss, which is
|
||||
a different and less interesting finding.
|
||||
"""
|
||||
by_value: dict[str, list[str]] = {}
|
||||
for token in tokens:
|
||||
name = getattr(token, "name", "")
|
||||
base = (getattr(token, "value_by_mode", None) or {}).get(BASE_MODE)
|
||||
if not name or not base:
|
||||
continue
|
||||
by_value.setdefault(str(base).strip().lower(), []).append(name)
|
||||
return {value: names for value, names in by_value.items() if len(names) > 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading a sheet from the other side: does this code use it correctly?
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# "The snippets use the tags from the sheet" is a verifiable relation, and
|
||||
# nothing checked it before. Three questions, each a different failure:
|
||||
#
|
||||
# var(--x) where no --x exists -> renders as NOTHING. No error, no test
|
||||
# failure, no visual clue beyond the thing
|
||||
# silently not being styled.
|
||||
# a superseded literal in code -> the value the sheet said to stop writing,
|
||||
# and the sheet knows what to write instead.
|
||||
# --x: declared inside a snippet -> a component minting its own token is the
|
||||
# bloat a shared sheet exists to prevent.
|
||||
#
|
||||
# The first is not hypothetical: `--color-accent` was used throughout a new view
|
||||
# in this codebase and does not exist.
|
||||
|
||||
_VAR_REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
|
||||
_LOCAL_DEFINITION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
|
||||
|
||||
|
||||
def referenced_tokens(code: str) -> set[str]:
|
||||
"""Every custom property the code reads through `var()`."""
|
||||
return set(_VAR_REFERENCE.findall(code or ""))
|
||||
|
||||
|
||||
def defined_tokens(code: str) -> set[str]:
|
||||
"""Every custom property the code declares itself.
|
||||
|
||||
Excludes names it also reads: `--x: var(--x, fallback)` is a redeclaration
|
||||
of something the sheet owns, which the unknown-reference check already
|
||||
covers more precisely.
|
||||
"""
|
||||
return set(_LOCAL_DEFINITION.findall(code or "")) - referenced_tokens(code or "")
|
||||
|
||||
|
||||
def _literal_pattern(literal: str) -> re.Pattern:
|
||||
"""Match a literal value without matching a longer one that contains it.
|
||||
|
||||
`#fff` must not match inside `#ffffff` — they are different colours, and a
|
||||
finding that fired on the wrong one would send someone to change code that
|
||||
was already correct.
|
||||
"""
|
||||
escaped = re.escape(literal)
|
||||
lead = r"(?<![0-9A-Za-z_#-])"
|
||||
trail = r"(?![0-9A-Za-z_-])" if literal.startswith("#") else r"(?![0-9A-Za-z_-])"
|
||||
return re.compile(lead + escaped + trail, re.IGNORECASE)
|
||||
|
||||
|
||||
def check_code_against_tokens(code: str, tokens) -> dict:
|
||||
"""What this code gets wrong about that token set.
|
||||
|
||||
`tokens` is any sequence with `.name`, `.value_by_mode` and `.supersedes` —
|
||||
resolved tokens, or stored ones.
|
||||
|
||||
Reports rather than scores. Every finding here has a legitimate exception:
|
||||
a snippet may target a system it isn't being checked against, and a literal
|
||||
may be deliberate in a context the token doesn't cover. What it removes is
|
||||
the SILENCE — all three currently fail with no signal at all.
|
||||
"""
|
||||
known = {getattr(t, "name", "") for t in tokens}
|
||||
referenced = referenced_tokens(code)
|
||||
|
||||
superseded: list[dict] = []
|
||||
for token in tokens:
|
||||
for literal in getattr(token, "supersedes", None) or ():
|
||||
if _literal_pattern(str(literal)).search(code or ""):
|
||||
superseded.append({
|
||||
"literal": literal,
|
||||
"use_instead": getattr(token, "name", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"used": sorted(referenced & known),
|
||||
"unknown": sorted(referenced - known),
|
||||
"superseded_literals": superseded,
|
||||
"local_definitions": sorted(defined_tokens(code)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derivation — tokens whose value is a formula over other tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)` needs
|
||||
# NO special storage: it is a value like any other, and the browser resolves the
|
||||
# `var()` at use time. Change `--fs-accent` and every derived token shifts with
|
||||
# it, in every mode, from one declaration.
|
||||
#
|
||||
# That last part is the real win. A derived token declared once in the base layer
|
||||
# follows its source through dark mode automatically, because `var()` resolves in
|
||||
# whatever context it is used rather than where it is written. Storing a computed
|
||||
# literal instead would need one row per mode AND would silently stop tracking
|
||||
# the source the moment the source changed.
|
||||
#
|
||||
# What derivation DOES need is the check below. A formula pointing at a token
|
||||
# that does not exist is invalid-at-computed-value-time: the browser drops the
|
||||
# declaration and the element falls back to inheritance or nothing. Silent, like
|
||||
# everything else in this family.
|
||||
|
||||
|
||||
def token_dependencies(tokens) -> dict[str, set[str]]:
|
||||
"""Each token name mapped to the token names its own values reference."""
|
||||
deps: dict[str, set[str]] = {}
|
||||
for token in tokens:
|
||||
name = getattr(token, "name", "")
|
||||
if not name:
|
||||
continue
|
||||
refs: set[str] = set()
|
||||
for value in (getattr(token, "value_by_mode", None) or {}).values():
|
||||
refs |= referenced_tokens(str(value))
|
||||
deps[name] = refs - {name}
|
||||
return deps
|
||||
|
||||
|
||||
def _find_cycles(deps: dict[str, set[str]]) -> list[list[str]]:
|
||||
"""Derivation loops, each reported once as the names involved.
|
||||
|
||||
CSS degrades a loop to invalid-at-computed-value-time rather than hanging, so
|
||||
this is about telling the operator, not about protecting the renderer. A
|
||||
token that quietly resolves to nothing is the failure worth naming.
|
||||
"""
|
||||
cycles: list[list[str]] = []
|
||||
seen_cycles: set[frozenset] = set()
|
||||
|
||||
def walk(node: str, path: list[str], visiting: set[str]) -> None:
|
||||
for dep in sorted(deps.get(node, ())):
|
||||
if dep in visiting:
|
||||
loop = path[path.index(dep):]
|
||||
key = frozenset(loop)
|
||||
if loop and key not in seen_cycles:
|
||||
seen_cycles.add(key)
|
||||
cycles.append(loop)
|
||||
continue
|
||||
if dep in deps:
|
||||
walk(dep, path + [dep], visiting | {dep})
|
||||
|
||||
for name in sorted(deps):
|
||||
walk(name, [name], {name})
|
||||
return cycles
|
||||
|
||||
|
||||
def derivation_report(tokens) -> dict:
|
||||
"""Which tokens are formulas, and which of those are broken.
|
||||
|
||||
`derived` name -> the tokens it is computed from
|
||||
`unknown_refs` name -> references that resolve to no token in this system.
|
||||
The browser drops such a declaration entirely; nothing errors.
|
||||
`cycles` derivation loops, which resolve to nothing for the same reason
|
||||
"""
|
||||
deps = token_dependencies(tokens)
|
||||
known = set(deps)
|
||||
|
||||
derived = {name: sorted(refs) for name, refs in deps.items() if refs}
|
||||
unknown = {
|
||||
name: sorted(refs - known)
|
||||
for name, refs in deps.items()
|
||||
if refs - known
|
||||
}
|
||||
return {
|
||||
"derived": derived,
|
||||
"unknown_refs": unknown,
|
||||
"cycles": _find_cycles(deps),
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
"""Design system + token persistence, and the guard on the parent chain.
|
||||
|
||||
Access goes through `services/access.py` (rule #78), where reaching a system via
|
||||
a project you can see grants READ but never write — see
|
||||
`get_design_system_permission` for why that asymmetry exists.
|
||||
|
||||
The cascade itself lives in `services/design_cascade.py` as pure functions. This
|
||||
module is the part that needs a database: loading the shape of the hierarchy,
|
||||
refusing writes that would break it, and storing tokens.
|
||||
|
||||
Nothing here seeds or implies a default system. An install with no design
|
||||
systems is an ordinary install, and every caller must handle an empty list as
|
||||
the normal case rather than a missing prerequisite.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import access
|
||||
from scribe.services.design_stylesheet import (
|
||||
check_code_against_tokens,
|
||||
derivation_report,
|
||||
duplicate_values,
|
||||
render_stylesheet,
|
||||
)
|
||||
from scribe.services.design_cascade import (
|
||||
ResolvedToken,
|
||||
ancestry,
|
||||
resolve_tokens,
|
||||
would_cycle,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DesignSystemCycle(ValueError):
|
||||
"""A parent assignment that would close an inheritance loop.
|
||||
|
||||
Raised rather than returned as None because the two outcomes need different
|
||||
answers: None already means "not found, or not yours", and a caller that
|
||||
conflated them would show "no such design system" for what is really "that
|
||||
parent is one of its own descendants". Routes map this to a 400.
|
||||
"""
|
||||
|
||||
|
||||
async def _parent_map(session, owner_user_id: int) -> dict[int, int | None]:
|
||||
"""`{id: parent_id}` for one owner's live systems — the hierarchy's shape.
|
||||
|
||||
Scoped to the OWNER of the systems, not the caller, and the distinction is
|
||||
load-bearing on the read path: a caller reading through a shared project
|
||||
does not own any link in the chain, and a caller-scoped map would hand them
|
||||
an empty forest and a cascade truncated to one system. They would get a page
|
||||
that renders with plausible wrong values and no error anywhere.
|
||||
|
||||
Owner-scoping is also the constraint on parenting: a chain may only be built
|
||||
from systems its owner controls, or someone else's delete or re-parent would
|
||||
silently restyle your app.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DesignSystem.id, DesignSystem.parent_id).where(
|
||||
DesignSystem.owner_user_id == owner_user_id,
|
||||
DesignSystem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
# --- design systems ---------------------------------------------------------
|
||||
|
||||
async def create_design_system(
|
||||
user_id: int,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
guidance: str | None = None,
|
||||
parent_id: int | None = None,
|
||||
) -> DesignSystem | None:
|
||||
"""Create a system, with or without a parent.
|
||||
|
||||
Returns None when `parent_id` names a system the caller may not write —
|
||||
which, per the ACL, means one they do not own.
|
||||
"""
|
||||
if parent_id is not None and not await access.can_write_design_system(
|
||||
user_id, parent_id
|
||||
):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
system = DesignSystem(
|
||||
owner_user_id=user_id,
|
||||
title=title.strip(),
|
||||
description=description,
|
||||
guidance=guidance,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
session.add(system)
|
||||
await session.commit()
|
||||
await session.refresh(system)
|
||||
return system
|
||||
|
||||
|
||||
async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem | None:
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
if not await access.can_read_design_system(user_id, design_system_id):
|
||||
return None
|
||||
return system
|
||||
|
||||
|
||||
async def list_design_systems(user_id: int) -> list[DesignSystem]:
|
||||
"""The caller's own systems, ordered by title. Empty is normal."""
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
select(DesignSystem)
|
||||
.where(
|
||||
DesignSystem.owner_user_id == user_id,
|
||||
DesignSystem.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(DesignSystem.title)
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def update_design_system(
|
||||
user_id: int, design_system_id: int, **fields: object
|
||||
) -> DesignSystem | None:
|
||||
"""Partial update. Raises DesignSystemCycle if `parent_id` would loop.
|
||||
|
||||
`parent_id` is handled apart from the other fields because None is a
|
||||
meaningful value for it — "make this a root" — where for every other field
|
||||
None means "leave alone". Callers signal it by passing the key at all.
|
||||
"""
|
||||
if not await access.can_write_design_system(user_id, design_system_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
|
||||
if "parent_id" in fields:
|
||||
parent_id = fields.pop("parent_id")
|
||||
if parent_id is not None:
|
||||
parent_id = int(parent_id)
|
||||
if not await access.can_write_design_system(user_id, parent_id):
|
||||
return None
|
||||
if would_cycle(
|
||||
design_system_id,
|
||||
parent_id,
|
||||
await _parent_map(session, system.owner_user_id),
|
||||
):
|
||||
raise DesignSystemCycle(
|
||||
f"Design system {design_system_id} cannot inherit from "
|
||||
f"{parent_id}: that system already inherits from it."
|
||||
)
|
||||
system.parent_id = parent_id
|
||||
|
||||
for key, value in fields.items():
|
||||
if key in ("title", "description", "guidance") and value is not None:
|
||||
setattr(system, key, value)
|
||||
system.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(system)
|
||||
return system
|
||||
|
||||
|
||||
async def delete_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
"""Soft-delete a system. Children survive as roots (the FK is SET NULL)."""
|
||||
if not await access.can_write_design_system(user_id, design_system_id):
|
||||
return False
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return False
|
||||
system.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# --- tokens -----------------------------------------------------------------
|
||||
|
||||
async def create_token(
|
||||
user_id: int,
|
||||
design_system_id: int,
|
||||
name: str,
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str | None = None,
|
||||
purpose: str | None = None,
|
||||
rationale: str | None = None,
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> DesignToken | None:
|
||||
if not await access.can_write_design_system(user_id, design_system_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
token = DesignToken(
|
||||
design_system_id=design_system_id,
|
||||
name=name.strip(),
|
||||
# `or {}` and not the argument as given: the column is NOT NULL so
|
||||
# that absence has exactly one spelling. Passing None here would
|
||||
# otherwise store JSON null and reintroduce the second empty state.
|
||||
value_by_mode=value_by_mode or {},
|
||||
group_name=group_name,
|
||||
purpose=purpose,
|
||||
rationale=rationale,
|
||||
# `or []` for the same reason as value_by_mode above: the column is
|
||||
# NOT NULL so absence has one spelling, and None would store JSON
|
||||
# null instead of an empty array.
|
||||
supersedes=supersedes or [],
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
await session.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def list_tokens(user_id: int, design_system_id: int) -> list[DesignToken]:
|
||||
"""One system's OWN tokens — its override set, not its effective set.
|
||||
|
||||
Resolving the chain is step 2's job; this deliberately answers the narrower
|
||||
question ("what does this system change?") that the model exists to make
|
||||
free.
|
||||
"""
|
||||
if not await access.can_read_design_system(user_id, design_system_id):
|
||||
return []
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
select(DesignToken)
|
||||
.where(
|
||||
DesignToken.design_system_id == design_system_id,
|
||||
DesignToken.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def resolve_design_system(
|
||||
user_id: int, design_system_id: int
|
||||
) -> list[ResolvedToken] | None:
|
||||
"""A system's EFFECTIVE token set — everything it inherits, with its own on top.
|
||||
|
||||
None when the caller may not read the system; an empty list when the chain
|
||||
genuinely holds no tokens, which is an ordinary state for a system that has
|
||||
just been created.
|
||||
|
||||
Two queries regardless of how deep the chain runs: one for the hierarchy's
|
||||
shape, one for every token in it. The flattening itself is
|
||||
`design_cascade.resolve_tokens` — pure, so the cascade rule is tested
|
||||
without a database and this function is only the loading.
|
||||
"""
|
||||
if not await access.can_read_design_system(user_id, design_system_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
|
||||
parents = await _parent_map(session, system.owner_user_id)
|
||||
chain = ancestry(design_system_id, parents)
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DesignToken)
|
||||
.where(
|
||||
DesignToken.design_system_id.in_(chain),
|
||||
DesignToken.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
tokens_by_system: dict[int, list[DesignToken]] = {}
|
||||
for token in rows:
|
||||
tokens_by_system.setdefault(token.design_system_id, []).append(token)
|
||||
return resolve_tokens(design_system_id, parents, tokens_by_system)
|
||||
|
||||
|
||||
async def design_context(user_id: int, design_system_id: int) -> dict | None:
|
||||
"""What a session needs to know about a design system, before it writes UI.
|
||||
|
||||
This is the DELIVERY side of a design system, and it exists because storing
|
||||
one does not make a session aware of it. Rules get pushed into every session
|
||||
by the plugin's SessionStart hook; a design system had no such channel, so
|
||||
the standards were reachable only by an agent that already knew to go
|
||||
looking — which is the same silent failure as a token nobody declares.
|
||||
|
||||
Guidance is chain-merged, ANCESTOR-FIRST, and that is the point rather than
|
||||
a convenience. A child system holds only what it CHANGES, so its own
|
||||
guidance describes a departure from a house style it never restates. Hand an
|
||||
agent the leaf alone and it builds against a fragment, with no signal that
|
||||
the rest exists.
|
||||
|
||||
Tokens are summarised, not listed: the count and the group names are enough
|
||||
to know what the system covers, and the full set is one call away. Sending
|
||||
a hundred token values into every session start would crowd out the context
|
||||
it is meant to inform.
|
||||
|
||||
None when the caller may not read the system.
|
||||
"""
|
||||
tokens = await resolve_design_system(user_id, design_system_id)
|
||||
if tokens is None:
|
||||
return None
|
||||
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
parents = await _parent_map(session, system.owner_user_id)
|
||||
chain = ancestry(design_system_id, parents)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DesignSystem).where(DesignSystem.id.in_(chain))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
by_id = {s.id: s for s in rows}
|
||||
return {
|
||||
"id": system.id,
|
||||
"title": system.title,
|
||||
"description": system.description or "",
|
||||
# Outermost ancestor first, so the reader meets the house style before
|
||||
# the app's departures from it.
|
||||
"inherits_from": [
|
||||
by_id[sid].title for sid in reversed(chain[1:]) if sid in by_id
|
||||
],
|
||||
"guidance": [
|
||||
{
|
||||
"design_system_id": sid,
|
||||
"title": by_id[sid].title,
|
||||
"guidance": (by_id[sid].guidance or "").strip(),
|
||||
}
|
||||
for sid in reversed(chain)
|
||||
if sid in by_id and (by_id[sid].guidance or "").strip()
|
||||
],
|
||||
"token_count": len(tokens),
|
||||
"token_groups": sorted({t.group_name for t in tokens if t.group_name}),
|
||||
}
|
||||
|
||||
|
||||
async def update_token(
|
||||
user_id: int, token_id: int, **fields: object
|
||||
) -> DesignToken | None:
|
||||
allowed = {
|
||||
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||
"supersedes", "order_index",
|
||||
}
|
||||
async with async_session() as session:
|
||||
token = await session.get(DesignToken, token_id)
|
||||
if token is None or token.deleted_at is not None:
|
||||
return None
|
||||
if not await access.can_write_design_system(user_id, token.design_system_id):
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def delete_token(user_id: int, token_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
token = await session.get(DesignToken, token_id)
|
||||
if token is None or token.deleted_at is not None:
|
||||
return False
|
||||
if not await access.can_write_design_system(user_id, token.design_system_id):
|
||||
return False
|
||||
token.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# --- the project pointer ----------------------------------------------------
|
||||
|
||||
async def set_project_design_system(
|
||||
user_id: int, project_id: int, design_system_id: int | None
|
||||
) -> bool:
|
||||
"""Point a project at a design system, or at nothing (None clears it).
|
||||
|
||||
Needs write on the project and READ on the system: pointing at a system is
|
||||
consuming it, not changing it, so a system shared with you through another
|
||||
project is a legitimate choice here.
|
||||
"""
|
||||
if not await access.can_write_project(user_id, project_id):
|
||||
return False
|
||||
if design_system_id is not None and not await access.can_read_design_system(
|
||||
user_id, design_system_id
|
||||
):
|
||||
return False
|
||||
async with async_session() as session:
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None or project.deleted_at is not None:
|
||||
return False
|
||||
project.design_system_id = design_system_id
|
||||
project.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# --- the master sheet -------------------------------------------------------
|
||||
|
||||
async def stylesheet_for_system(
|
||||
user_id: int, design_system_id: int, root_selector: str = ":root"
|
||||
) -> dict | None:
|
||||
"""The master CSS sheet a design system generates, plus its reuse report.
|
||||
|
||||
Purpose tokens only — the sheet styles no elements. See
|
||||
`services/design_stylesheet.py` for why that split is the design rather than
|
||||
a shortcut.
|
||||
|
||||
Returns None if the caller may not read the system. The `duplicates` half is
|
||||
advisory: two tokens sharing a value are either a deliberate alias or one
|
||||
idea recorded twice, and only the operator knows which.
|
||||
"""
|
||||
resolved = await resolve_design_system(user_id, design_system_id)
|
||||
if resolved is None:
|
||||
return None
|
||||
system = await get_design_system(user_id, design_system_id)
|
||||
|
||||
return {
|
||||
"design_system_id": design_system_id,
|
||||
"css": render_stylesheet(
|
||||
resolved,
|
||||
root_selector=root_selector,
|
||||
title=system.title if system else "",
|
||||
design_system_id=design_system_id,
|
||||
),
|
||||
"token_count": len(resolved),
|
||||
"valueless": [t.name for t in resolved if not t.value_by_mode],
|
||||
"duplicates": duplicate_values(resolved),
|
||||
# Formulas: which tokens are computed from others, and which of those
|
||||
# point at nothing. A broken formula is dropped by the browser without
|
||||
# any error, so the sheet cannot show it for itself.
|
||||
"derivation": derivation_report(resolved),
|
||||
}
|
||||
|
||||
|
||||
async def check_snippets_against_system(
|
||||
user_id: int, design_system_id: int, project_id: int = 0
|
||||
) -> dict | None:
|
||||
"""Which recorded snippets disagree with this design system's sheet.
|
||||
|
||||
The relation the operator named — "the snippets use the tags from the sheet"
|
||||
— turned into a check. For each snippet: `var(--x)` references with no such
|
||||
token, literals the sheet says to stop writing, and custom properties the
|
||||
snippet mints for itself instead of using shared ones.
|
||||
|
||||
Snippets with nothing to report are omitted entirely. A list of everything
|
||||
that is fine is a list nobody reads twice.
|
||||
"""
|
||||
resolved = await resolve_design_system(user_id, design_system_id)
|
||||
if resolved is None:
|
||||
return None
|
||||
|
||||
from scribe.services import snippets as snippets_svc
|
||||
|
||||
# `list_snippets` returns (rows, total) and caps limit at 100; `project_id`
|
||||
# must be None — not 0 — to reach across every project, since 0 would filter
|
||||
# to a project with that id.
|
||||
rows, _total = await snippets_svc.list_snippets(
|
||||
user_id=user_id, project_id=project_id or None, limit=100,
|
||||
)
|
||||
|
||||
findings: list[dict] = []
|
||||
for row in rows:
|
||||
snippet_id = row.get("id")
|
||||
if snippet_id is None:
|
||||
continue
|
||||
# The list rows carry a preview, not the code. The check has to read the
|
||||
# whole body or it would report on a truncation.
|
||||
note = await snippets_svc.get_snippet(user_id=user_id, snippet_id=int(snippet_id))
|
||||
if note is None:
|
||||
continue
|
||||
report = check_code_against_tokens(note.body or "", resolved)
|
||||
if not (
|
||||
report["unknown"] or report["superseded_literals"] or report["local_definitions"]
|
||||
):
|
||||
continue
|
||||
findings.append({
|
||||
"snippet_id": int(snippet_id),
|
||||
"title": note.title or "",
|
||||
**report,
|
||||
})
|
||||
|
||||
return {
|
||||
"design_system_id": design_system_id,
|
||||
"checked": len(rows),
|
||||
"findings": findings,
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import logging
|
||||
import math
|
||||
import os
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import delete, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.embedding import NoteEmbedding
|
||||
@@ -114,7 +116,8 @@ async def semantic_search_notes(
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
project_id: int | None = None,
|
||||
is_task: bool | None = None,
|
||||
note_type: str | None = None,
|
||||
note_type: str | Sequence[str] | None = None,
|
||||
task_kind: str | Sequence[str] | None = None,
|
||||
orphan_only: bool = False,
|
||||
scope: str = "own",
|
||||
) -> list[tuple[float, Note]]:
|
||||
@@ -123,8 +126,15 @@ async def semantic_search_notes(
|
||||
Scores are cosine similarities in [-1, 1]; only notes at or above
|
||||
*threshold* are returned, sorted highest-first.
|
||||
|
||||
`note_type` narrows to a single record kind (e.g. "snippet"), for callers
|
||||
that want prior art rather than everything embedded.
|
||||
`note_type` narrows to a record kind, or several (e.g. "snippet", or
|
||||
("snippet", "note")), for callers that want prior art rather than everything
|
||||
embedded.
|
||||
|
||||
`task_kind` restricts TASKS to the given kinds while leaving non-task notes
|
||||
untouched. That asymmetry is the point: "recorded experience" is issues plus
|
||||
dev-logs, and those differ on `is_task`, so neither `note_type` nor `is_task`
|
||||
alone can express it. With `note_type="note", task_kind="issue"` a caller
|
||||
gets fixed problems and durable notes without the open to-do list.
|
||||
|
||||
`scope` ("own" | "browse" | "read", see access.notes_visibility_clause)
|
||||
decides how far this may see. It exists because this one function serves
|
||||
@@ -179,11 +189,22 @@ async def semantic_search_notes(
|
||||
stmt = stmt.where(Note.status.isnot(None))
|
||||
elif is_task is False:
|
||||
stmt = stmt.where(Note.status.is_(None))
|
||||
# Narrow to one kind of record. Composes with is_task rather than
|
||||
# replacing it — 'snippet' is a non-task note_type, so a caller asking
|
||||
# for prior art gets snippets and not the dev-log that mentions them.
|
||||
# Narrow to one kind of record, or several. Composes with is_task
|
||||
# rather than replacing it — 'snippet' is a non-task note_type, so a
|
||||
# caller asking for prior art gets snippets and not the dev-log that
|
||||
# mentions them.
|
||||
if note_type:
|
||||
stmt = stmt.where(Note.note_type == note_type)
|
||||
kinds = [note_type] if isinstance(note_type, str) else list(note_type)
|
||||
stmt = stmt.where(Note.note_type.in_(kinds))
|
||||
# Restrict TASKS to certain kinds while leaving notes alone. A note
|
||||
# has no task_kind that means anything, so a plain `.in_()` would
|
||||
# drop every dev-log — which is exactly the record a caller asking
|
||||
# for prior experience wants most.
|
||||
if task_kind:
|
||||
tkinds = [task_kind] if isinstance(task_kind, str) else list(task_kind)
|
||||
stmt = stmt.where(
|
||||
or_(Note.status.is_(None), Note.task_kind.in_(tkinds))
|
||||
)
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
|
||||
stmt = stmt.where(distance <= max_distance).order_by(distance.asc()).limit(limit)
|
||||
|
||||
@@ -18,7 +18,7 @@ in your ambient lists.
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
@@ -108,6 +108,89 @@ def _location_clause(parts: dict[str, str]):
|
||||
return Note.data.path_exists(location_jsonpath(parts))
|
||||
|
||||
|
||||
# --- drift-check filter (#2086) ----------------------------------------------
|
||||
# `verification` selects on the drift-check verdict stored in `data.verification`
|
||||
# (see services/snippets.py). Statuses are the service's own constants; the two
|
||||
# composite values are what the operator actually asks for.
|
||||
#
|
||||
# The interesting one is `attention`. A verdict describes the code it was checked
|
||||
# against, so an OK verdict on code that has since been edited is not an OK
|
||||
# record — nobody has checked what's actually there. That is expressible in SQL
|
||||
# only because `data.code_sha` mirrors the current code's fingerprint alongside
|
||||
# the verdict's: jsonpath compares the two fields within the row, so this stays
|
||||
# one index-served predicate rather than a post-filter that would make the
|
||||
# pagination total a lie.
|
||||
#
|
||||
# Same two-dialect discipline as the location filter above, and the same hazard:
|
||||
# THE TWO MUST CHANGE TOGETHER. tests/test_snippet_drift_check.py is the guard —
|
||||
# it walks both dialects over the same cases, including the one that motivated
|
||||
# `attention` existing (an ok verdict whose code_sha has gone stale, which is
|
||||
# neither `drifted` nor `unverified` yet plainly needs looking at).
|
||||
|
||||
def verification_matches(data: dict | None, value: str) -> bool:
|
||||
"""Python dialect of the verification predicate.
|
||||
|
||||
Keep in step with _verification_clause — the semantic arm's candidates are
|
||||
already fetched, so there is no query left to narrow and the same rule has
|
||||
to be expressible twice. Same arrangement as location_matches.
|
||||
"""
|
||||
want = (value or "").strip().lower()
|
||||
if not want:
|
||||
return True
|
||||
verdict = (data or {}).get("verification") or {}
|
||||
status = verdict.get("status") or ""
|
||||
if not status:
|
||||
return want == "unverified"
|
||||
expired = verdict.get("code_sha") != (data or {}).get("code_sha")
|
||||
if want == "unverified":
|
||||
return False
|
||||
if want == "drifted":
|
||||
return status != "ok"
|
||||
if want == "attention":
|
||||
return status != "ok" or expired
|
||||
if want == "ok":
|
||||
return status == "ok" and not expired
|
||||
return status == want
|
||||
|
||||
|
||||
_VERIFY_DRIFTED_JSONPATH = '$.verification ? (@.status != "ok")'
|
||||
_VERIFY_EXPIRED_JSONPATH = "$ ? (@.verification.code_sha != @.code_sha)"
|
||||
_VERIFY_ANY_JSONPATH = "$.verification"
|
||||
|
||||
|
||||
def _verification_clause(value: str):
|
||||
"""SQL predicate for one `verification` filter value, or None for no filter."""
|
||||
want = (value or "").strip().lower()
|
||||
if not want:
|
||||
return None
|
||||
has_verdict = Note.data.path_exists(_VERIFY_ANY_JSONPATH)
|
||||
if want == "unverified":
|
||||
# Never checked at all. Rows predating migration 0070 have no `data`
|
||||
# whatsoever and land here correctly — which is right, they haven't been.
|
||||
return ~has_verdict
|
||||
if want == "drifted":
|
||||
return Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH)
|
||||
if want == "attention":
|
||||
# Everything worth looking at: a failing verdict, OR an expired one.
|
||||
return or_(
|
||||
Note.data.path_exists(_VERIFY_DRIFTED_JSONPATH),
|
||||
and_(has_verdict, Note.data.path_exists(_VERIFY_EXPIRED_JSONPATH)),
|
||||
)
|
||||
if want == "ok":
|
||||
# A clean bill of health that still describes the current code. The
|
||||
# `~expired` half matters: without it this would quietly include records
|
||||
# whose blessing has lapsed, which is the exact failure the feature is
|
||||
# meant to catch.
|
||||
return and_(
|
||||
Note.data.path_exists('$.verification ? (@.status == "ok")'),
|
||||
~Note.data.path_exists(_VERIFY_EXPIRED_JSONPATH),
|
||||
)
|
||||
# A specific status: 'missing' | 'moved' | 'changed'.
|
||||
return Note.data.path_exists(
|
||||
f"$.verification ? (@.status == {json.dumps(want)})"
|
||||
)
|
||||
|
||||
|
||||
def _note_to_item(note: Note) -> dict:
|
||||
item: dict = {
|
||||
"id": note.id,
|
||||
@@ -122,6 +205,31 @@ def _note_to_item(note: Note) -> dict:
|
||||
"created_at": note.created_at.isoformat(),
|
||||
"updated_at": note.updated_at.isoformat(),
|
||||
}
|
||||
# Drift verdict (#2086), when one has been recorded. Included here rather
|
||||
# than decorated on by the snippet layer because `current` is derivable from
|
||||
# `data` alone — the verdict's code_sha against the row's — so this needs no
|
||||
# body parsing and stays a plain projection of the column. Omitted entirely
|
||||
# when unchecked, so "no key" and "never verified" don't become two states
|
||||
# the client has to tell apart.
|
||||
# Snippet language, same reasoning as the verdict below: a plain projection of
|
||||
# the `data` mirror, no body parsing. Needed because a prior-art hit in a
|
||||
# DIFFERENT language than the file being written is useful as the shape of a
|
||||
# solution but must not be mistaken for code to paste (#2244) — and the caller
|
||||
# can only say "different" if the language is on the item.
|
||||
language = (note.data or {}).get("language") if note.data else None
|
||||
if language:
|
||||
item["language"] = language
|
||||
|
||||
verdict = (note.data or {}).get("verification") if note.data else None
|
||||
if verdict and verdict.get("status"):
|
||||
item["verification"] = {
|
||||
"status": verdict["status"],
|
||||
"current": verdict.get("code_sha") == (note.data or {}).get("code_sha"),
|
||||
"checked_at": verdict.get("checked_at"),
|
||||
"detail": verdict.get("detail"),
|
||||
"path": verdict.get("path"),
|
||||
}
|
||||
|
||||
# Task fields — override note_type and add status/priority/due_date
|
||||
if note.is_task:
|
||||
item["note_type"] = "task"
|
||||
@@ -161,6 +269,7 @@ async def query_knowledge(
|
||||
offset: int,
|
||||
project_id: int | None = None,
|
||||
locations: dict[str, str] | None = None,
|
||||
verification: str = "",
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Query knowledge objects (non-task notes) with filters.
|
||||
|
||||
@@ -171,6 +280,10 @@ async def query_knowledge(
|
||||
Today only snippets carry locations, but the column is general, so the filter
|
||||
lives here with the query rather than in one type's service.
|
||||
|
||||
`verification` narrows on the drift-check verdict: 'ok', 'drifted',
|
||||
'unverified', 'attention', or one specific failure ('missing' | 'moved' |
|
||||
'changed'). Empty means no filter.
|
||||
|
||||
Returns (items, total_count).
|
||||
"""
|
||||
# Semantic search path — scores take priority over sort
|
||||
@@ -178,6 +291,7 @@ async def query_knowledge(
|
||||
return await _semantic_knowledge_search(
|
||||
user_id, q, note_type=note_type, tags=tags, limit=limit,
|
||||
offset=offset, project_id=project_id, locations=locations,
|
||||
verification=verification,
|
||||
)
|
||||
|
||||
# No query = browsing. Narrower scope: a record shared directly with the
|
||||
@@ -196,6 +310,10 @@ async def query_knowledge(
|
||||
if locations:
|
||||
base = base.where(_location_clause(locations))
|
||||
|
||||
verify_clause = _verification_clause(verification)
|
||||
if verify_clause is not None:
|
||||
base = base.where(verify_clause)
|
||||
|
||||
# Count before pagination
|
||||
count_stmt = select(func.count()).select_from(base.subquery())
|
||||
total: int = (await session.execute(count_stmt)).scalar_one()
|
||||
@@ -224,6 +342,7 @@ async def _semantic_knowledge_search(
|
||||
offset: int,
|
||||
project_id: int | None = None,
|
||||
locations: dict[str, str] | None = None,
|
||||
verification: str = "",
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
|
||||
|
||||
@@ -259,6 +378,9 @@ async def _semantic_knowledge_search(
|
||||
base = base.where(Note.tags.contains([tag]))
|
||||
if locations:
|
||||
base = base.where(_location_clause(locations))
|
||||
verify_clause = _verification_clause(verification)
|
||||
if verify_clause is not None:
|
||||
base = base.where(verify_clause)
|
||||
# Title matches first, then body-only matches, newest first within each
|
||||
base = base.order_by(
|
||||
Note.title.ilike(pattern).desc(),
|
||||
@@ -301,6 +423,8 @@ async def _semantic_knowledge_search(
|
||||
# narrow. See the comment on location_matches.
|
||||
if locations and not location_matches(note.data, locations):
|
||||
continue
|
||||
if verification and not verification_matches(note.data, verification):
|
||||
continue
|
||||
semantic_notes.append(note)
|
||||
except Exception:
|
||||
logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
|
||||
|
||||
@@ -89,6 +89,38 @@ async def log_error(
|
||||
await session.commit()
|
||||
|
||||
|
||||
def parse_filter_datetime(value: str | None, *, end_of_day: bool = False) -> datetime | None:
|
||||
"""An ISO date/datetime from a query string as an AWARE UTC datetime.
|
||||
|
||||
The admin log filters arrive as raw `request.args` strings and used to be
|
||||
compared straight against `AppLog.created_at`. asyncpg binds a str as
|
||||
VARCHAR and Postgres has no `timestamptz >= text` operator, so supplying
|
||||
either date filter raised — the same defect as #1727 in notifications, in a
|
||||
second place (#2254).
|
||||
|
||||
Returns None for anything unparseable: a malformed filter should narrow
|
||||
nothing rather than 500 the log viewer.
|
||||
|
||||
`end_of_day` matters for the upper bound. A bare "2026-07-30" parses to
|
||||
midnight, so `created_at <= that` would exclude the whole of the day the
|
||||
user asked for — the one day they most likely wanted. With this flag a
|
||||
date-only value is pushed to the last microsecond of that day; a value that
|
||||
already carries a time is left exactly as given.
|
||||
"""
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if end_of_day and len(raw) == 10: # date-only, no time component
|
||||
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
async def get_logs(
|
||||
category: str | None = None,
|
||||
user_id: int | None = None,
|
||||
@@ -118,12 +150,14 @@ async def get_logs(
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
if date_from:
|
||||
query = query.where(AppLog.created_at >= date_from)
|
||||
count_query = count_query.where(AppLog.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.where(AppLog.created_at <= date_to)
|
||||
count_query = count_query.where(AppLog.created_at <= date_to)
|
||||
start = parse_filter_datetime(date_from)
|
||||
end = parse_filter_datetime(date_to, end_of_day=True)
|
||||
if start:
|
||||
query = query.where(AppLog.created_at >= start)
|
||||
count_query = count_query.where(AppLog.created_at >= start)
|
||||
if end:
|
||||
query = query.where(AppLog.created_at <= end)
|
||||
count_query = count_query.where(AppLog.created_at <= end)
|
||||
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
|
||||
@@ -156,26 +156,82 @@ async def get_milestone_progress(milestone_id: int) -> dict:
|
||||
for status, count in rows.fetchall():
|
||||
status_counts[status] = count
|
||||
|
||||
total = sum(status_counts.values())
|
||||
cancelled = status_counts.get("cancelled", 0)
|
||||
completed = status_counts.get("done", 0)
|
||||
# Cancelled tasks are resolved work, not pending — exclude them from the
|
||||
# percent-complete denominator so a milestone whose only open task was
|
||||
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
|
||||
active_total = total - cancelled
|
||||
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
|
||||
# Same rule as the batch path, computed in one place so the two cannot
|
||||
# drift on the cancelled-exclusion.
|
||||
return _progress_from_counts(status_counts)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"pct": pct,
|
||||
"status_counts": {
|
||||
"todo": status_counts.get("todo", 0),
|
||||
"in_progress": status_counts.get("in_progress", 0),
|
||||
"done": status_counts.get("done", 0),
|
||||
"cancelled": cancelled,
|
||||
},
|
||||
}
|
||||
|
||||
def _progress_from_counts(status_counts: dict[str, int]) -> dict:
|
||||
"""The progress shape, computed from already-fetched counts.
|
||||
|
||||
Split out of get_milestone_progress so the batch path can reuse the rule
|
||||
rather than restate it — the cancelled-exclusion below is easy to get
|
||||
subtly different in a second copy, and then two screens disagree about
|
||||
whether a milestone is finished.
|
||||
"""
|
||||
total = sum(status_counts.values())
|
||||
cancelled = status_counts.get("cancelled", 0)
|
||||
completed = status_counts.get("done", 0)
|
||||
# Cancelled tasks are resolved work, not pending — excluded from the
|
||||
# denominator so a milestone whose only open task was cancelled reaches
|
||||
# 100% instead of stalling.
|
||||
active_total = total - cancelled
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"pct": round(completed / active_total * 100, 1) if active_total > 0 else 0.0,
|
||||
"status_counts": {
|
||||
"todo": status_counts.get("todo", 0),
|
||||
"in_progress": status_counts.get("in_progress", 0),
|
||||
"done": status_counts.get("done", 0),
|
||||
"cancelled": cancelled,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_project_milestone_summaries(
|
||||
user_id: int, project_ids: list[int]
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Milestone summaries for MANY projects in two queries total.
|
||||
|
||||
The per-project version below is a nested fan-out: one query to list a
|
||||
project's milestones, then one more per milestone for its progress. Called
|
||||
for 25 projects concurrently it asked for ~250 pooled connections against a
|
||||
pool of 15, and every one of them waited out the 30-second checkout timeout
|
||||
(#2384). This does the same work in two queries and one session.
|
||||
"""
|
||||
if not project_ids:
|
||||
return {}
|
||||
|
||||
async with async_session() as session:
|
||||
milestones = list((await session.execute(
|
||||
select(Milestone).where(
|
||||
Milestone.user_id == user_id,
|
||||
Milestone.project_id.in_(project_ids),
|
||||
Milestone.deleted_at.is_(None),
|
||||
).order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
|
||||
)).scalars().all())
|
||||
|
||||
counts: dict[int, dict[str, int]] = {}
|
||||
if milestones:
|
||||
rows = await session.execute(
|
||||
select(Note.milestone_id, Note.status, func.count(Note.id))
|
||||
.where(
|
||||
Note.milestone_id.in_([m.id for m in milestones]),
|
||||
Note.status.isnot(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Note.milestone_id, Note.status)
|
||||
)
|
||||
for milestone_id, status, count in rows.fetchall():
|
||||
counts.setdefault(milestone_id, {})[status] = count
|
||||
|
||||
out: dict[int, list[dict]] = {pid: [] for pid in project_ids}
|
||||
for m in milestones:
|
||||
entry = m.to_dict()
|
||||
entry.update(_progress_from_counts(counts.get(m.id, {})))
|
||||
out.setdefault(m.project_id, []).append(entry)
|
||||
return out
|
||||
|
||||
|
||||
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Note usage telemetry — was a surfaced note ever actually pulled?
|
||||
|
||||
Two event streams, deliberately independent:
|
||||
|
||||
- SURFACED: we put this note's title in front of the agent (auto-inject, or
|
||||
either arm of the write-path prior-art trigger).
|
||||
- PULLED: someone then opened it in full (get_snippet / get_note / the REST
|
||||
detail route).
|
||||
|
||||
The ratio between them is the signal. A snippet surfaced forty times and never
|
||||
pulled is not neutral — it occupies the injection budget on every future turn
|
||||
and dilutes the menu — so this is what makes dead weight visible and prunable.
|
||||
|
||||
Design notes (mirrors retrieval_telemetry, for the same reasons):
|
||||
- Writes are fire-and-forget. `record_surfaced` / `record_pulled` extract
|
||||
plain ints synchronously and schedule the insert as a background task, so
|
||||
telemetry never adds latency to — or can break — the surface it observes.
|
||||
- Every failure path is swallowed. Losing a usage row costs a data point;
|
||||
raising would cost the operator their retrieval.
|
||||
- Reads (`usage_for_notes`) are NOT fire-and-forget — a readout the caller
|
||||
awaits, aggregated in one round-trip for a whole page of snippets rather
|
||||
than per row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _insert_events(rows: list[dict]) -> None:
|
||||
"""Persist usage rows. Best-effort: all errors are swallowed."""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
session.add_all([NoteUsageEvent(**row) for row in rows])
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.debug("note usage telemetry write skipped", exc_info=True)
|
||||
|
||||
|
||||
def _schedule(rows: list[dict]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(_insert_events(rows))
|
||||
except RuntimeError:
|
||||
# No running loop (sync context outside the app) — skip rather than
|
||||
# block. Every app path runs on the loop.
|
||||
logger.debug("note usage telemetry skipped — no running event loop")
|
||||
|
||||
|
||||
def record_surfaced(
|
||||
*, user_id: int | None, note_ids: list[int] | set[int], source: str
|
||||
) -> None:
|
||||
"""Fire-and-forget: record that these notes were shown to the agent.
|
||||
|
||||
Takes the whole menu at once — one insert per surfacing event, not per note
|
||||
— because a menu is a single decision and its rows should land together.
|
||||
"""
|
||||
try:
|
||||
rows = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"note_id": int(nid),
|
||||
"event": SURFACED,
|
||||
"source": source,
|
||||
}
|
||||
for nid in note_ids
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("note usage payload build failed", exc_info=True)
|
||||
return
|
||||
_schedule(rows)
|
||||
|
||||
|
||||
def record_pulled(*, user_id: int | None, note_id: int, source: str) -> None:
|
||||
"""Fire-and-forget: record that a note was opened in full."""
|
||||
try:
|
||||
rows = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"note_id": int(note_id),
|
||||
"event": PULLED,
|
||||
"source": source,
|
||||
}
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("note usage payload build failed", exc_info=True)
|
||||
return
|
||||
_schedule(rows)
|
||||
|
||||
|
||||
def empty_usage() -> dict:
|
||||
"""The zero readout — what a note with no recorded events looks like.
|
||||
|
||||
Callers render this shape unconditionally, so a note predating the table
|
||||
reads as "never surfaced, never pulled" rather than as a missing key.
|
||||
"""
|
||||
return {
|
||||
"surfaced_count": 0,
|
||||
"pull_count": 0,
|
||||
"last_surfaced_at": None,
|
||||
"last_pulled_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
|
||||
"""Aggregate usage for a set of notes: {note_id: {counts + timestamps}}.
|
||||
|
||||
One GROUP BY for the whole page rather than a query per row — this feeds a
|
||||
list view, so the per-row shape would be N+1 by construction. Notes with no
|
||||
events are returned with `empty_usage()` so the caller never has to
|
||||
distinguish "no events" from "not in the result".
|
||||
"""
|
||||
ids = [int(n) for n in note_ids]
|
||||
out: dict[int, dict] = {nid: empty_usage() for nid in ids}
|
||||
if not ids:
|
||||
return out
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
NoteUsageEvent.note_id,
|
||||
NoteUsageEvent.event,
|
||||
func.count().label("n"),
|
||||
func.max(NoteUsageEvent.created_at).label("last_at"),
|
||||
)
|
||||
.where(NoteUsageEvent.note_id.in_(ids))
|
||||
.group_by(NoteUsageEvent.note_id, NoteUsageEvent.event)
|
||||
)
|
||||
).all()
|
||||
except Exception:
|
||||
# A telemetry readout must not be able to break the list it decorates.
|
||||
logger.debug("note usage readout failed", exc_info=True)
|
||||
return out
|
||||
|
||||
for note_id, event, n, last_at in rows:
|
||||
slot = out.get(int(note_id))
|
||||
if slot is None:
|
||||
continue
|
||||
if event == SURFACED:
|
||||
slot["surfaced_count"] = int(n)
|
||||
slot["last_surfaced_at"] = last_at.isoformat() if last_at else None
|
||||
elif event == PULLED:
|
||||
slot["pull_count"] = int(n)
|
||||
slot["last_pulled_at"] = last_at.isoformat() if last_at else None
|
||||
return out
|
||||
@@ -10,6 +10,40 @@ from scribe.models.note import Note, TaskPriority, TaskStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def embed_note(note) -> None:
|
||||
"""Refresh a note's embedding, fire-and-forget.
|
||||
|
||||
Lives HERE — at the service, not the route — so every caller gets it by
|
||||
construction. Previously each REST route made this call itself and the MCP
|
||||
tools did not, so a record created through MCP stayed out of semantic search
|
||||
and auto-inject until the next restart's backfill ran (#2056). That is
|
||||
invisible on an instance that redeploys constantly and permanent on one that
|
||||
doesn't, which is the worst shape a bug can have: it only appears where
|
||||
nobody is looking.
|
||||
|
||||
Uses `note.user_id` — the OWNER — rather than the caller. Embeddings belong
|
||||
to the record, and a collaborator editing a shared note must refresh the
|
||||
owner's row rather than mint a second one under their own id.
|
||||
|
||||
Import is lazy so importing this module doesn't pull in the embedding model;
|
||||
exceptions are swallowed because a record that saved must not fail on its
|
||||
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
||||
not an error.
|
||||
"""
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
||||
except RuntimeError:
|
||||
pass # no running loop — a sync caller, not a failure
|
||||
except Exception: # noqa: BLE001 - never let indexing break a write
|
||||
logger.exception("embedding refresh failed for note %s", note.id)
|
||||
|
||||
|
||||
def _normalize_tags(tags: list[str]) -> list[str]:
|
||||
"""Lowercase, strip, deduplicate, and drop empty tags."""
|
||||
seen: set[str] = set()
|
||||
@@ -115,6 +149,8 @@ async def create_note(
|
||||
await session.commit()
|
||||
await session.refresh(note)
|
||||
|
||||
embed_note(note)
|
||||
|
||||
if project_id is not None:
|
||||
await _maybe_reactivate_project(project_id)
|
||||
|
||||
@@ -329,6 +365,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
||||
from scribe.services.note_versions import create_version
|
||||
await create_version(user_id, note_id, old_body, old_title, old_tags)
|
||||
|
||||
embed_note(note)
|
||||
|
||||
if note.project_id is not None:
|
||||
await _maybe_reactivate_project(note.project_id)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime, time, timezone
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
@@ -149,13 +149,25 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
|
||||
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
|
||||
|
||||
|
||||
def utc_day_start(day: date) -> datetime:
|
||||
"""Midnight UTC on `day`, as an AWARE datetime — the reminder dedup window.
|
||||
|
||||
Deliberately a `datetime` and not the bare `date` (#1727). Comparing a
|
||||
`timestamptz` column against a `date` does work, via an implicit cast — but
|
||||
Postgres resolves that cast in the SESSION's TimeZone, so the window would
|
||||
move with a server setting nobody remembers is load-bearing. An aware UTC
|
||||
datetime says what it means and compares the same way everywhere.
|
||||
"""
|
||||
return datetime.combine(day, time.min, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def check_due_tasks() -> None:
|
||||
"""Check for tasks due today and send reminder emails."""
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
today_str = today.isoformat()
|
||||
window_start = utc_day_start(today)
|
||||
|
||||
async with async_session() as session:
|
||||
# Find tasks due today or overdue, not done
|
||||
@@ -188,12 +200,18 @@ async def check_due_tasks() -> None:
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Dedup: check if we already sent a reminder today
|
||||
# Dedup: check if we already sent a reminder today.
|
||||
# `window_start` is an aware datetime, NOT `today.isoformat()`.
|
||||
# asyncpg binds a str as VARCHAR and Postgres has no
|
||||
# `timestamptz >= text` operator, so this raised on every run
|
||||
# that got this far — swallowed by the per-user `except` below,
|
||||
# which is why the only symptom was reminders silently never
|
||||
# sending plus an hourly traceback in the DB log (#1727).
|
||||
dedup_result = await session.execute(
|
||||
select(func.count(AppLog.id)).where(
|
||||
AppLog.action == "task_reminder",
|
||||
AppLog.user_id == user_id,
|
||||
AppLog.created_at >= today_str,
|
||||
AppLog.created_at >= window_start,
|
||||
)
|
||||
)
|
||||
if (dedup_result.scalar() or 0) > 0:
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rulebook import RulebookTopic
|
||||
from scribe.services import design_systems as design_systems_svc
|
||||
from scribe.services import knowledge as knowledge_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
@@ -29,6 +30,7 @@ from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.access import label_shared_items, owner_names_for
|
||||
from scribe.services.embeddings import semantic_search_notes
|
||||
from scribe.services.note_usage import record_surfaced
|
||||
from scribe.services.retrieval_telemetry import record_retrieval
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
@@ -55,16 +57,110 @@ AUTOINJECT_DEFAULT_ENABLED = True
|
||||
AUTOINJECT_DEFAULT_THRESHOLD = 0.55
|
||||
AUTOINJECT_DEFAULT_TOP_K = 3
|
||||
|
||||
# The write-path trigger (#2082) gets its own on/off switch but SHARES the
|
||||
# threshold and top-k above. One knob for "how loud may Scribe be" is easier to
|
||||
# reason about than two that drift; the switch is separate because wanting prior
|
||||
# art on writes while keeping prompts quiet (or the reverse) is a real
|
||||
# preference, and it's the only part of the gate an operator can judge without
|
||||
# data. The two surfaces log under different `source` values, so if the
|
||||
# telemetry ever shows they want different thresholds, splitting them is a
|
||||
# data-backed change rather than a guess made up front.
|
||||
# The write-path trigger (#2082) gets its own on/off switch, its own threshold,
|
||||
# and shares only top-k. It originally shared the threshold too, on the argument
|
||||
# that one "how loud may Scribe be" knob beats two that drift — and reserved the
|
||||
# split for when telemetry showed the two surfaces wanted different values.
|
||||
#
|
||||
# #2223 is that evidence. Measured against the live instance, the semantic arm's
|
||||
# scores for CODE sit far above what the same threshold means for PROSE:
|
||||
# near-duplicate of a recorded helper 0.73-0.74 (true positive)
|
||||
# unrelated colour math / Vue SFC / CSS 0.55-0.63 (false positive)
|
||||
# `x = 1` 0.58 (false positive)
|
||||
# Any two Python-shaped payloads share keywords, indentation and structure, so
|
||||
# the floor for "some code" is ~0.55-0.63 — auto-inject's 0.55 lands INSIDE that
|
||||
# noise band, and 6 of 8 probe payloads produced a nudge (4 of them noise). The
|
||||
# margin gate can't rescue it either: _AUTOINJECT_BAND is relative to the top
|
||||
# hit, so with a single hit it never engages.
|
||||
#
|
||||
# 0.68 clears every measured false positive with margin and still sits 0.05
|
||||
# below both true positives. Auto-inject keeps 0.55 — it was tuned on prose and
|
||||
# is not implicated. Tune from retrieval_logs (source='write_path') + note_usage
|
||||
# pull-through (#2085) once a real corpus accrues; a cross-encoder rerank
|
||||
# (#1038) would subsume this bump.
|
||||
WRITEPATH_ENABLED_KEY = "kb_writepath_enabled"
|
||||
WRITEPATH_THRESHOLD_KEY = "kb_writepath_threshold"
|
||||
WRITEPATH_DEFAULT_ENABLED = True
|
||||
WRITEPATH_DEFAULT_THRESHOLD = 0.68
|
||||
|
||||
# Minimum SUBSTANCE (non-whitespace chars) a payload must carry before the
|
||||
# semantic arm will run at all — the cheap half of the operator's #89 idea
|
||||
# ("a sliding scale between number of characters and semantic threshold").
|
||||
#
|
||||
# Deliberately NOT a settings knob and deliberately conservative. Its job is
|
||||
# only to drop payloads too small to carry meaning, where an embedding is noise
|
||||
# rather than signal: `x = 1`, a renamed variable, a changed string literal —
|
||||
# which is what most single-line Edits look like, and the majority of Edits are
|
||||
# single-line. 48 sits below the smallest plausible reusable helper (a one-line
|
||||
# `def` with a body runs ~60), so it errs toward keeping recall and leaves
|
||||
# precision to the threshold above, which is where the measured separation is.
|
||||
# The full length↔threshold CURVE is still open in #89 — the operator flagged it
|
||||
# as wanting a brainstorm, so this stays a flat floor rather than an invented
|
||||
# scale. It also saves a pointless embedding round-trip on trivial edits.
|
||||
WRITEPATH_MIN_CODE_CHARS = 48
|
||||
|
||||
# --- concept extraction for the semantic arm's query (#2242) ------------------
|
||||
# A snippet's embedded text is f"{title}\n{body}", and for a snippet that body is
|
||||
# composed markdown: **When to use:**, **Signature:**, **Location:**, then the
|
||||
# fenced code. So `when_to_use` — the description of what the thing is FOR —
|
||||
# appears twice in the vector, and the document is prose-forward.
|
||||
#
|
||||
# The arm used to query it with raw code and no prose at all. Measured on the
|
||||
# deployed instance against snippet #2222, same corpus:
|
||||
# query built from score best unrelated separation
|
||||
# raw code body 0.743 0.630 0.11
|
||||
# name + docstring 0.823 0.602 0.22
|
||||
# hand-written concept prose 0.835 0.583 0.25
|
||||
# A 12-word description beats a near-verbatim reimplementation of the function,
|
||||
# and code-as-query RAISES the noise floor. It is also the cleanest explanation
|
||||
# for the fragment miss recorded on #2223: a short code excerpt has almost no
|
||||
# prose to match against a document that is mostly prose.
|
||||
#
|
||||
# So we send the concept instead — and shape it like a snippet's own title,
|
||||
# "{name} — {when_to_use}", because that is the form the 0.823 measurement used.
|
||||
# Undocumented code yields little, and a Vue SFC or a config file yields nothing;
|
||||
# those fall back to the raw payload and behave exactly as before. This raises
|
||||
# the ceiling for documented helpers rather than fixing every case.
|
||||
|
||||
# Declaration forms, one pattern per shape, every pattern exposing (name, params)
|
||||
# so composition doesn't have to care which matched. Deliberately regex and not a
|
||||
# real parser: this runs on a PreToolUse hook's critical path, the payload is
|
||||
# frequently a FRAGMENT that no parser would accept (an Edit's new_string is
|
||||
# rarely a valid module), and a miss costs only a fallback to today's behaviour.
|
||||
_CONCEPT_DECL_PATTERNS = (
|
||||
# python: def / async def, and class with optional bases
|
||||
re.compile(r"^[ \t]*(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
|
||||
re.compile(r"^[ \t]*class[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))?", re.M),
|
||||
# js/ts: function decl, and the const-arrow form that dominates modern code
|
||||
re.compile(r"^[ \t]*(?:export[ \t]+)?(?:default[ \t]+)?(?:async[ \t]+)?function[ \t]+([A-Za-z_$][\w$]*)[ \t]*(\([^)]*\))", re.M),
|
||||
re.compile(r"^[ \t]*(?:export[ \t]+)?(?:const|let|var)[ \t]+([A-Za-z_$][\w$]*)[ \t]*=[ \t]*(?:async[ \t]*)?(\([^)]*\))[ \t]*=>", re.M),
|
||||
# rust / go
|
||||
re.compile(r"^[ \t]*(?:pub[ \t]+)?fn[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
|
||||
re.compile(r"^[ \t]*func[ \t]+(?:\([^)]*\)[ \t]*)?([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
|
||||
# posix shell: name() {
|
||||
re.compile(r"^[ \t]*([A-Za-z_]\w*)[ \t]*(\(\))[ \t]*\{", re.M),
|
||||
)
|
||||
|
||||
# Doc forms, tried in order. The Python pattern also matches a triple-quoted
|
||||
# string that isn't a docstring — accepted: a stray literal is still text about
|
||||
# what the code does far more often than it's misleading, and the cost is a
|
||||
# slightly worse query rather than a wrong answer.
|
||||
_CONCEPT_PY_DOC = re.compile(r'("""|\'\'\')(.*?)\1', re.S)
|
||||
_CONCEPT_JSDOC = re.compile(r"/\*\*(.*?)\*/", re.S)
|
||||
_CONCEPT_LEADING_COMMENT = re.compile(r"\A(?:[ \t]*(?://|#)[^\n]*\n?)+")
|
||||
# A shebang is a comment to the regex above but says nothing about what the code
|
||||
# DOES, and it would otherwise open the doc with "/usr/bin/env bash".
|
||||
_CONCEPT_SHEBANG = re.compile(r"\A#![^\n]*\n")
|
||||
_CONCEPT_COMMENT_MARKER = re.compile(r"^[ \t]*(?://+|#+!?)[ \t]?", re.M)
|
||||
_CONCEPT_JSDOC_STAR = re.compile(r"^[ \t]*\*+[ \t]?", re.M)
|
||||
|
||||
# Cap the doc so a long module docstring can't drown out the declaration, and cap
|
||||
# declarations so a 40-function Write doesn't turn into a wall of signatures.
|
||||
_CONCEPT_MAX_DOC_CHARS = 400
|
||||
_CONCEPT_MAX_DECLS = 4
|
||||
# Below this much substance the "concept" is too thin to be a better query than
|
||||
# the code itself (e.g. all we found was `f()`), so we keep the raw payload.
|
||||
_CONCEPT_MIN_CHARS = 16
|
||||
|
||||
# Margin gate: drop any hit more than this far below the top hit's score, so a
|
||||
# single strong match doesn't drag in a wall of barely-passing neighbours.
|
||||
@@ -194,6 +290,70 @@ def _record_kind(note) -> str:
|
||||
return note.note_type or "note"
|
||||
|
||||
|
||||
_REUSE_KINDS = ("snippet", "process")
|
||||
|
||||
|
||||
async def _reserve_slot_for_reuse(
|
||||
user_id: int,
|
||||
query: str,
|
||||
kept: list,
|
||||
cfg: dict,
|
||||
*,
|
||||
project_id: int | None,
|
||||
exclude_ids: set[int],
|
||||
) -> list:
|
||||
"""Guarantee the reuse-shaped kinds one slot, if one clears threshold (#2246).
|
||||
|
||||
Ranking by raw cosine is blind to what KIND of record answers what kind of
|
||||
ask, and the corpus makes that fatal rather than merely imperfect: Scribe's
|
||||
project records are *about software work*, so a task titled "surface snippets
|
||||
before the agent writes code" is a near-perfect lexical match for "write a
|
||||
function…" while being useless as an answer to it. Measured live, a prompt
|
||||
asking for a helper returned three records about BUILDING the retrieval
|
||||
system and zero snippets.
|
||||
|
||||
The bias is structural and gets WORSE as the project record grows — which is
|
||||
the direction Scribe is supposed to grow. Snippets are ~0.5% of the corpus
|
||||
here; no threshold tuning fixes a 200:1 ratio.
|
||||
|
||||
So the reserved hit is deliberately NOT held to the margin band. The band
|
||||
measures distance from the top overall score, and that top score is the very
|
||||
thing snippets lose to. It still has to clear the configured threshold, so a
|
||||
weak snippet cannot buy the slot — silence stays the default.
|
||||
"""
|
||||
if any(_record_kind(n) in _REUSE_KINDS for _s, n in kept):
|
||||
return kept # reuse already represented; nothing to do
|
||||
|
||||
top_k = cfg["top_k"]
|
||||
reuse = await semantic_search_notes(
|
||||
user_id, query,
|
||||
limit=1,
|
||||
threshold=cfg["threshold"],
|
||||
project_id=project_id,
|
||||
exclude_ids=exclude_ids | {int(n.id) for _s, n in kept},
|
||||
note_type=_REUSE_KINDS,
|
||||
scope="browse",
|
||||
)
|
||||
# Verify the kind rather than trusting the query that asked for it, and
|
||||
# dedup on top of exclude_ids. This slot exists FOR reuse kinds — a slot
|
||||
# silently spent on something else is worse than no slot, because the line
|
||||
# is indistinguishable from one that earned its place on score.
|
||||
kept_ids = {int(n.id) for _s, n in kept}
|
||||
fresh = [
|
||||
(s, n) for s, n in reuse
|
||||
if _record_kind(n) in _REUSE_KINDS and int(n.id) not in kept_ids
|
||||
][:1]
|
||||
if not fresh:
|
||||
return kept
|
||||
|
||||
# Take the LAST slot, never the first: the strongest overall hit is still the
|
||||
# best answer to the prompt, and displacing it would trade one blindness for
|
||||
# another.
|
||||
if len(kept) >= top_k:
|
||||
return kept[:top_k - 1] + fresh
|
||||
return (kept + fresh)[:top_k]
|
||||
|
||||
|
||||
async def build_autoinject_hint(
|
||||
user_id: int,
|
||||
query: str,
|
||||
@@ -245,6 +405,10 @@ async def build_autoinject_hint(
|
||||
# Margin gate: keep only hits close to the strongest one.
|
||||
top_score = hits[0][0]
|
||||
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
|
||||
kept = await _reserve_slot_for_reuse(
|
||||
user_id, q, kept, cfg, project_id=(project_id or None),
|
||||
exclude_ids=set(exclude_ids or []),
|
||||
)
|
||||
|
||||
# A collaborator's note can reach this menu via a shared project, and the
|
||||
# operator never asked for it — so say whose it is. Unattributed, it reads as
|
||||
@@ -271,6 +435,12 @@ async def build_autoinject_hint(
|
||||
line += f" — shared by {who}, treat as a suggestion"
|
||||
lines.append(line)
|
||||
|
||||
# Records what SURVIVED the margin gate, not what the ranker returned — the
|
||||
# menu the agent actually saw. retrieval_logs already holds the full
|
||||
# candidate set for threshold tuning; conflating the two would make
|
||||
# "surfaced" mean two different things depending on the surface (#2085).
|
||||
record_surfaced(user_id=user_id, note_ids=note_ids, source="auto_inject")
|
||||
|
||||
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
|
||||
|
||||
|
||||
@@ -290,25 +460,195 @@ async def build_autoinject_hint(
|
||||
# this exact file" is a stronger claim than "this resembles something".
|
||||
|
||||
|
||||
def _prior_art_line(item: dict, marker: str, owner: str | None) -> str:
|
||||
"""One menu line: `- #12 [here] "title"`, attributed when it isn't yours."""
|
||||
def _prior_art_line(item: dict, marker: str, owner: str | None, foreign_lang: str = "") -> str:
|
||||
"""One menu line: `- #12 [here] "title"`, attributed when it isn't yours.
|
||||
|
||||
A foreign language is folded into the marker (`[similar 0.72 · python]`)
|
||||
rather than appended after the title, so the reader sees it while still
|
||||
reading the score — the two together are the judgement being offered.
|
||||
"""
|
||||
title = (item.get("title") or "(untitled)").replace("\n", " ").strip()
|
||||
line = f"> - #{item['id']} [{marker}] \"{title}\""
|
||||
mark = f"{marker} · {foreign_lang}" if foreign_lang else marker
|
||||
line = f"> - #{item['id']} [{mark}] \"{title}\""
|
||||
if owner:
|
||||
line += f" — shared by {owner}, treat as a suggestion"
|
||||
return line
|
||||
|
||||
|
||||
# --- cross-language prior art (#2244) ----------------------------------------
|
||||
# Retrieval is concept-shaped now, and concepts are language-agnostic: a query
|
||||
# about a TypeScript union-find matches a PYTHON snippet at 0.72-0.73, comfortably
|
||||
# over the bar. That is a feature — the operator's framing is "borrow the shape of
|
||||
# the solution even when the code isn't directly reusable" — but only if the line
|
||||
# SAYS so. An unlabelled Python hit offered while writing TypeScript either gets
|
||||
# dismissed as irrelevant or, worse, pasted into a .ts file. Measured note: this
|
||||
# cross-language matching predates concept queries; it was always happening, just
|
||||
# never disclosed.
|
||||
#
|
||||
# Deliberately NOT gated behind a stricter threshold for foreign-language hits: a
|
||||
# higher bar would suppress exactly the shape-borrowing this is for. Label, don't
|
||||
# filter.
|
||||
_LANG_BY_EXT = {
|
||||
"py": "python", "pyi": "python",
|
||||
"ts": "typescript", "tsx": "typescript", "mts": "typescript", "cts": "typescript",
|
||||
"js": "javascript", "jsx": "javascript", "mjs": "javascript", "cjs": "javascript",
|
||||
"vue": "vue", "svelte": "svelte",
|
||||
"go": "go", "rs": "rust", "rb": "ruby", "php": "php",
|
||||
"java": "java", "kt": "kotlin", "kts": "kotlin", "scala": "scala",
|
||||
"c": "c", "h": "c", "cc": "cpp", "cpp": "cpp", "cxx": "cpp", "hpp": "cpp",
|
||||
"cs": "csharp", "swift": "swift", "m": "objectivec", "mm": "objectivec",
|
||||
"sh": "shell", "bash": "shell", "zsh": "shell", "fish": "shell",
|
||||
"sql": "sql", "css": "css", "scss": "scss", "less": "less",
|
||||
"html": "html", "htm": "html", "yml": "yaml", "yaml": "yaml",
|
||||
"toml": "toml", "ini": "ini", "dockerfile": "dockerfile",
|
||||
"ex": "elixir", "exs": "elixir", "erl": "erlang", "hs": "haskell",
|
||||
"lua": "lua", "pl": "perl", "r": "r", "dart": "dart", "zig": "zig",
|
||||
}
|
||||
|
||||
# `language` on a snippet is operator-typed free text, so fold the spellings that
|
||||
# mean the same thing before comparing. Anything unrecognised passes through
|
||||
# lowercased — an unknown-but-equal pair still compares equal, which is the only
|
||||
# thing this needs to get right.
|
||||
_LANG_ALIASES = {
|
||||
"py": "python", "python3": "python",
|
||||
"ts": "typescript", "tsx": "typescript",
|
||||
"js": "javascript", "jsx": "javascript", "node": "javascript",
|
||||
"sh": "shell", "bash": "shell", "zsh": "shell", "shell-script": "shell",
|
||||
"c++": "cpp", "cplusplus": "cpp", "c#": "csharp", "objective-c": "objectivec",
|
||||
"golang": "go", "rs": "rust", "rb": "ruby", "yml": "yaml",
|
||||
"postgres": "sql", "postgresql": "sql", "psql": "sql",
|
||||
"vuejs": "vue", "vue3": "vue",
|
||||
}
|
||||
|
||||
|
||||
def _canonical_language(name: str) -> str:
|
||||
"""Fold a free-text language name to a comparable token ("" if absent)."""
|
||||
token = (name or "").strip().lower()
|
||||
return _LANG_ALIASES.get(token, token)
|
||||
|
||||
|
||||
def _language_for_path(path: str) -> str:
|
||||
"""The language implied by a file path's extension ("" when unknown)."""
|
||||
tail = (path or "").rsplit("/", 1)[-1].lower()
|
||||
if tail.startswith("dockerfile"):
|
||||
return "dockerfile"
|
||||
if "." not in tail:
|
||||
return ""
|
||||
return _LANG_BY_EXT.get(tail.rsplit(".", 1)[-1], "")
|
||||
|
||||
|
||||
def _foreign_language(item: dict, target: str) -> str:
|
||||
"""The item's language when it DIFFERS from the target file's, else "".
|
||||
|
||||
Returns "" whenever either side is unknown: we can only claim a mismatch we
|
||||
can actually establish, and a wrong "· python" tag is worse than no tag.
|
||||
Same-language hits stay unlabelled so the common case keeps a clean line.
|
||||
"""
|
||||
if not target:
|
||||
return ""
|
||||
theirs = _canonical_language(item.get("language") or "")
|
||||
if not theirs or theirs == target:
|
||||
return ""
|
||||
return theirs
|
||||
|
||||
|
||||
def _concept_doc(code: str) -> str:
|
||||
"""The first doc-ish prose in `code`: docstring, else JSDoc, else leading comments."""
|
||||
m = _CONCEPT_PY_DOC.search(code)
|
||||
if m:
|
||||
return _collapse(m.group(2))
|
||||
|
||||
m = _CONCEPT_JSDOC.search(code)
|
||||
if m:
|
||||
return _collapse(_CONCEPT_JSDOC_STAR.sub("", m.group(1)))
|
||||
|
||||
# Only a comment block at the very TOP counts. A comment further down is
|
||||
# usually about one line of the implementation, not about the whole thing.
|
||||
m = _CONCEPT_LEADING_COMMENT.match(_CONCEPT_SHEBANG.sub("", code))
|
||||
if m:
|
||||
return _collapse(_CONCEPT_COMMENT_MARKER.sub("", m.group(0)))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _collapse(text: str) -> str:
|
||||
"""One line, single-spaced, length-capped — embedder input, not display text."""
|
||||
return " ".join((text or "").split())[:_CONCEPT_MAX_DOC_CHARS].strip()
|
||||
|
||||
|
||||
def concept_query(code: str) -> str:
|
||||
"""Rewrite a write payload as a CONCEPT query, or "" to keep the raw payload.
|
||||
|
||||
Returns something shaped like a snippet's own title — "name(params) — what it
|
||||
does" — because that is the form that measured best against the prose-forward
|
||||
snippet documents (#2242; see the table at _CONCEPT_DECL_PATTERNS).
|
||||
|
||||
Returns "" rather than raising or guessing whenever there's nothing worth
|
||||
sending: no declarations and no doc, or a result too thin to beat the code it
|
||||
would replace. The caller treats "" as "use the payload as-is", so every
|
||||
unhandled language degrades to exactly the previous behaviour.
|
||||
"""
|
||||
if not code or not code.strip():
|
||||
return ""
|
||||
|
||||
decls: list[str] = []
|
||||
for pattern in _CONCEPT_DECL_PATTERNS:
|
||||
for match in pattern.finditer(code):
|
||||
name, params = match.group(1), match.group(2) or ""
|
||||
label = f"{name}{params}".strip()
|
||||
if label and label not in decls:
|
||||
decls.append(label)
|
||||
if len(decls) >= _CONCEPT_MAX_DECLS:
|
||||
break
|
||||
if len(decls) >= _CONCEPT_MAX_DECLS:
|
||||
break
|
||||
|
||||
doc = _concept_doc(code)
|
||||
|
||||
# NO DOC, NO REWRITE. An identifier alone is not a concept, and it measured
|
||||
# WORSE than the code it would replace: `collapse_into_clusters(edges)` scored
|
||||
# 0.671 against #2222 where the full code body scored 0.743. Separation from
|
||||
# the noise floor is identical (0.113 either way), but the absolute value
|
||||
# drops below the 0.68 bar — so preferring a bare name would convert a
|
||||
# comfortable hit into a miss. Undocumented code keeps the raw payload.
|
||||
if not doc:
|
||||
return ""
|
||||
|
||||
head = ", ".join(decls)
|
||||
query = f"{head} — {doc}" if head else doc
|
||||
|
||||
# Guard against a doc so terse it says nothing ("# TODO", "/** x */").
|
||||
if len("".join(query.split())) < _CONCEPT_MIN_CHARS:
|
||||
return ""
|
||||
return query
|
||||
|
||||
|
||||
async def get_writepath_config(user_id: int) -> dict:
|
||||
"""Write-path trigger settings: its own `enabled`, auto-inject's gates."""
|
||||
"""Write-path trigger settings: its own `enabled` and `threshold`, auto-inject's top_k.
|
||||
|
||||
The threshold OVERRIDES the inherited auto-inject value — code embeddings
|
||||
have a much higher similarity floor than prose, so the two surfaces need
|
||||
different bars. See WRITEPATH_DEFAULT_THRESHOLD for the measurements (#2223).
|
||||
top_k is still shared: "how many titles at once" means the same thing on
|
||||
both surfaces, and nothing suggests they want different ceilings.
|
||||
"""
|
||||
cfg = await get_autoinject_config(user_id)
|
||||
enabled_raw = await get_setting(
|
||||
user_id, WRITEPATH_ENABLED_KEY,
|
||||
"true" if WRITEPATH_DEFAULT_ENABLED else "false",
|
||||
)
|
||||
|
||||
try:
|
||||
threshold = float(await get_setting(
|
||||
user_id, WRITEPATH_THRESHOLD_KEY, str(WRITEPATH_DEFAULT_THRESHOLD)))
|
||||
except (TypeError, ValueError):
|
||||
threshold = WRITEPATH_DEFAULT_THRESHOLD
|
||||
threshold = min(1.0, max(0.0, threshold))
|
||||
|
||||
return {
|
||||
**cfg,
|
||||
"enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"),
|
||||
"threshold": threshold,
|
||||
}
|
||||
|
||||
|
||||
@@ -325,11 +665,14 @@ async def build_write_path_hint(
|
||||
convention snippet locations are recorded in. `code` is what's about to be
|
||||
written, used only as the semantic query.
|
||||
|
||||
Same four anti-bloat gates as auto-inject (threshold, margin, session dedup
|
||||
via `exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH
|
||||
Carries auto-inject's anti-bloat gates (margin, session dedup via
|
||||
`exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH
|
||||
arms — so a file with a lot of recorded history can't turn one edit into a
|
||||
wall of text. Returns empty context when disabled, when there's no path, or
|
||||
when nothing is recorded — which is the common case, and the point.
|
||||
wall of text. Two gates are its OWN, because code is not prose: a stricter
|
||||
similarity threshold, and a minimum-substance floor on `code` below which the
|
||||
semantic arm doesn't run at all (#2223 — see WRITEPATH_DEFAULT_THRESHOLD and
|
||||
WRITEPATH_MIN_CODE_CHARS). Returns empty context when disabled, when there's
|
||||
no path, or when nothing is recorded — which is the common case, and the point.
|
||||
|
||||
Note the repo↔project mapping is deliberately one-way: the hook sends a git
|
||||
remote, which the ROUTE resolves to `project_id` through the repo bindings.
|
||||
@@ -341,12 +684,12 @@ async def build_write_path_hint(
|
||||
arm is logged to retrieval_logs as source='write_path' — its own source, so
|
||||
its precision is tunable separately from auto-inject's.
|
||||
|
||||
KNOWN GAP: only the semantic arm is logged, matching auto-inject's convention
|
||||
of recording the candidate set for threshold tuning. Location hits carry no
|
||||
score, so folding them in would corrupt the score distribution the log exists
|
||||
to capture — but it does mean a snippet surfaced BY PLACE leaves no trace. The
|
||||
usage signal (#2085) correlates retrieval_logs against later pulls, so it will
|
||||
need a home for un-scored surfacing before it can measure this arm.
|
||||
Location hits still carry no score and so stay out of retrieval_logs, whose
|
||||
score distribution they would corrupt. What closed the gap (#2085) is that
|
||||
un-scored surfacing now has its own home: BOTH arms emit note_usage_events,
|
||||
tagged 'write_path_place' vs 'write_path_semantic', so the place arm is
|
||||
finally measurable — and the two arms' pull-through rates are comparable,
|
||||
which is the number that says whether place really does beat meaning here.
|
||||
"""
|
||||
cfg = await get_writepath_config(user_id)
|
||||
empty = {"context": "", "note_ids": [], "config": cfg}
|
||||
@@ -387,6 +730,21 @@ async def build_write_path_hint(
|
||||
scored: list[tuple[str, dict]] = []
|
||||
remaining = top_k - len(placed)
|
||||
query = (code or "").strip()
|
||||
# Drop payloads too small to carry meaning before spending an embedding on
|
||||
# them — a one-line Edit is not a helper being rewritten, and its embedding
|
||||
# scores off the corpus floor rather than off any real resemblance (#2223).
|
||||
# Whitespace doesn't count: code is indentation-heavy, so raw length would
|
||||
# let a deeply-nested one-liner through on padding alone.
|
||||
if len("".join(query.split())) < WRITEPATH_MIN_CODE_CHARS:
|
||||
query = ""
|
||||
# ORDER MATTERS: the floor above judges the RAW payload, this rewrites it.
|
||||
# Snippet documents are prose-forward, so a concept query out-scores the code
|
||||
# itself by a wide margin (#2242). The rewritten query is allowed to be
|
||||
# short — "slugify(t) — turn text into a url slug" is a fine query at 38
|
||||
# chars, and it only exists because the raw payload already cleared the
|
||||
# floor. Applying the floor after this would throw away the best queries.
|
||||
if query:
|
||||
query = concept_query(query) or query
|
||||
if remaining > 0 and query:
|
||||
t0 = time.perf_counter()
|
||||
hits = await semantic_search_notes(
|
||||
@@ -395,7 +753,20 @@ async def build_write_path_hint(
|
||||
threshold=cfg["threshold"],
|
||||
project_id=scope_project,
|
||||
exclude_ids=seen,
|
||||
note_type="snippet",
|
||||
# Snippets AND recorded experience (#2246). This arm was
|
||||
# snippets-only, which is auto-inject's mistake inverted: an issue
|
||||
# saying "we tried this and it deadlocked", or a dev-log recording
|
||||
# how a problem was solved, is prior art for the code about to be
|
||||
# written — arguably better prior art than a resembling helper,
|
||||
# because it says what NOT to do.
|
||||
#
|
||||
# `task_kind="issue"` keeps the open to-do list out. A task titled
|
||||
# "add debouncing to the search box" resembles the code being
|
||||
# written and answers nothing; an ISSUE is corrective work with a
|
||||
# root cause in it, and a non-task note is durable knowledge. Both
|
||||
# earned their place; a todo did not.
|
||||
note_type=("snippet", "note"),
|
||||
task_kind="issue",
|
||||
# Same reasoning as auto-inject: nobody asked for this, so it takes
|
||||
# the browse scope and never surfaces a one-to-one direct share.
|
||||
scope="browse",
|
||||
@@ -403,7 +774,10 @@ async def build_write_path_hint(
|
||||
record_retrieval(
|
||||
user_id=user_id, source="write_path", query=query,
|
||||
threshold=cfg["threshold"], limit=remaining,
|
||||
project_id=scope_project, is_task=False, results=hits,
|
||||
# is_task is None, not False: this arm now returns issues too, and
|
||||
# recording it as a notes-only retrieval would misdescribe the
|
||||
# candidate set the threshold is being tuned against.
|
||||
project_id=scope_project, is_task=None, results=hits,
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
)
|
||||
if hits:
|
||||
@@ -411,9 +785,23 @@ async def build_write_path_hint(
|
||||
for score, note in hits:
|
||||
if score < top_score - _AUTOINJECT_BAND:
|
||||
continue
|
||||
# Name the kind unless it's a snippet — the menu's default and
|
||||
# the header's default reading. An issue or a dev-log offered
|
||||
# here is a different KIND of claim ("this was already tried")
|
||||
# and an unlabelled line would be read as "here is code to
|
||||
# reuse", which is the opposite of what it says.
|
||||
kind = _record_kind(note)
|
||||
scored.append((
|
||||
f"similar {score:.2f}",
|
||||
{"id": int(note.id), "title": note.title, "user_id": note.user_id},
|
||||
f"similar {score:.2f}" if kind == "snippet"
|
||||
else f"similar {score:.2f} · {kind}",
|
||||
{
|
||||
"id": int(note.id), "title": note.title, "user_id": note.user_id,
|
||||
# Carried so the line can disclose a cross-language hit
|
||||
# (#2244). The semantic arm is where these actually arise —
|
||||
# a snippet recorded at the path you're editing is almost
|
||||
# never in another language, but a concept match easily is.
|
||||
"language": (note.data or {}).get("language") if note.data else None,
|
||||
},
|
||||
))
|
||||
|
||||
menu = (placed + scored)[:top_k]
|
||||
@@ -425,19 +813,50 @@ async def build_write_path_hint(
|
||||
if it.get("user_id") is not None and int(it["user_id"]) != user_id
|
||||
})
|
||||
|
||||
lines = [
|
||||
f"> Prior art already recorded in Scribe for `{path}` — open one with "
|
||||
"`get_snippet(id)` and reuse it rather than writing a fresh one-off "
|
||||
"(titles only; shown once per session):",
|
||||
]
|
||||
note_ids: list[int] = []
|
||||
target_lang = _language_for_path(path)
|
||||
rendered: list[tuple[dict, str, str | None, str]] = []
|
||||
for marker, item in menu:
|
||||
note_ids.append(int(item["id"]))
|
||||
owner_id = item.get("user_id")
|
||||
owner = None
|
||||
if owner_id is not None and int(owner_id) != user_id:
|
||||
owner = owners.get(int(owner_id)) or "another user"
|
||||
lines.append(_prior_art_line(item, marker, owner))
|
||||
rendered.append((item, marker, owner, _foreign_language(item, target_lang)))
|
||||
|
||||
lines = [
|
||||
f"> Prior art already recorded in Scribe for `{path}` — open one with "
|
||||
"`get_snippet(id)` for a snippet, `get_task(id)` for an issue, "
|
||||
"`get_note(id)` otherwise. Reuse a snippet rather than writing a fresh "
|
||||
"one-off; read an issue before repeating what it records "
|
||||
"(titles only; shown once per session):",
|
||||
]
|
||||
# Say what a language tag MEANS, and only when one is actually on the menu.
|
||||
# Without this the reader has to infer why "· python" is attached to a hit on
|
||||
# a .ts file, and the two ways of guessing wrong are both bad: dismiss it as
|
||||
# irrelevant, or paste Python into TypeScript. Retrieval matches on concept,
|
||||
# so these are genuinely useful — as the SHAPE of a solution, not as code.
|
||||
if any(lang for _i, _m, _o, lang in rendered):
|
||||
lines.append(
|
||||
"> A tagged language means that snippet is in a DIFFERENT language "
|
||||
"than this file — it matched on what it does, so treat it as the "
|
||||
"shape of a solution to adapt, not code to copy."
|
||||
)
|
||||
|
||||
note_ids: list[int] = []
|
||||
for item, marker, owner, foreign_lang in rendered:
|
||||
note_ids.append(int(item["id"]))
|
||||
lines.append(_prior_art_line(item, marker, owner, foreign_lang))
|
||||
|
||||
# Split by arm, which is the whole reason this table exists. The place arm
|
||||
# carries no score and so has no home in retrieval_logs; before #2085 a
|
||||
# snippet surfaced BY PLACE left no trace anywhere, making the arm that
|
||||
# fires on the strongest possible claim ("there is already a canonical
|
||||
# helper in this exact file") the one arm nobody could measure.
|
||||
by_arm: dict[str, list[int]] = {}
|
||||
for marker, item in menu:
|
||||
arm = "write_path_place" if marker in ("here", "nearby") else "write_path_semantic"
|
||||
by_arm.setdefault(arm, []).append(int(item["id"]))
|
||||
for arm, ids in by_arm.items():
|
||||
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
|
||||
|
||||
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
|
||||
|
||||
@@ -512,6 +931,37 @@ async def build_session_context(
|
||||
f"Goal: {goal[:200]}" if goal else "",
|
||||
f"Open todo tasks: {open_count}",
|
||||
]
|
||||
|
||||
# A design system binds the same way a rule does, and until this
|
||||
# existed it had no push channel — the standards were reachable only
|
||||
# by an agent that already knew to look for them. Summary only: the
|
||||
# token VALUES are a tool call away, and pasting a hundred of them
|
||||
# into every session would crowd out the context they inform.
|
||||
if project.design_system_id:
|
||||
design = await design_systems_svc.design_context(
|
||||
user_id, project.design_system_id,
|
||||
)
|
||||
if design:
|
||||
inherits = (
|
||||
" (inherits " + " › ".join(design["inherits_from"]) + ")"
|
||||
if design["inherits_from"] else ""
|
||||
)
|
||||
groups = ", ".join(design["token_groups"])
|
||||
lines += [
|
||||
"",
|
||||
f"## Design system: {design['title']} "
|
||||
f"(id {design['id']}){inherits}",
|
||||
f"{design['token_count']} tokens"
|
||||
+ (f" across {groups}" if groups else "")
|
||||
+ ". This project's UI is built from these, not from "
|
||||
"literals — reach for a token before writing a colour, "
|
||||
"size, radius or duration by hand.",
|
||||
f"Values: `resolve_design_system({design['id']})` · "
|
||||
f"stylesheet: `get_design_system_stylesheet({design['id']})` "
|
||||
f"· the prose (aesthetic, voice, where the accent may "
|
||||
f"appear): `enter_project` returns it, or "
|
||||
f"`get_design_system({design['id']})`.",
|
||||
]
|
||||
elif unbound_repo:
|
||||
lines += [
|
||||
"",
|
||||
|
||||
@@ -127,6 +127,86 @@ async def delete_project(user_id: int, project_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def get_project_summaries(
|
||||
user_id: int, project_ids: list[int]
|
||||
) -> dict[int, dict]:
|
||||
"""Summaries for MANY projects — four queries and one session, total.
|
||||
|
||||
Replaces an `asyncio.gather` over the per-project version below, which was
|
||||
a nested fan-out: each project opened its own session for three queries,
|
||||
then called the milestone summary, which opened one more per milestone. For
|
||||
25 projects that asked for roughly 250 pooled connections at once against a
|
||||
pool of 15 (SQLAlchemy's default 5 + 10 overflow), so most of them sat out
|
||||
the 30-second checkout timeout and everything else on the instance queued
|
||||
behind them — including unrelated routes, which is why /api/settings
|
||||
returned 500 while /api/projects took 30.9s (#2384).
|
||||
|
||||
The comment it replaced said "one backend pass instead of N+1 frontend
|
||||
calls". It did remove the N+1 from the network — and recreated it against
|
||||
the connection pool, where it is worse: the browser had at least been
|
||||
serialising those calls.
|
||||
"""
|
||||
if not project_ids:
|
||||
return {}
|
||||
|
||||
async with async_session() as session:
|
||||
task_rows = await session.execute(
|
||||
select(Note.project_id, Note.status, func.count(Note.id))
|
||||
.where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id.in_(project_ids),
|
||||
Note.status.isnot(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Note.project_id, Note.status)
|
||||
)
|
||||
task_counts: dict[int, dict[str, int]] = {}
|
||||
for project_id, status, count in task_rows.fetchall():
|
||||
task_counts.setdefault(project_id, {})[status] = count
|
||||
|
||||
note_rows = await session.execute(
|
||||
select(Note.project_id, func.count(Note.id))
|
||||
.where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id.in_(project_ids),
|
||||
Note.status.is_(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Note.project_id)
|
||||
)
|
||||
note_counts = {pid: count for pid, count in note_rows.fetchall()}
|
||||
|
||||
# Deliberately NOT filtered by deleted_at, matching the per-project
|
||||
# version: "last activity" includes trashing something.
|
||||
activity_rows = await session.execute(
|
||||
select(Note.project_id, func.max(Note.updated_at))
|
||||
.where(Note.user_id == user_id, Note.project_id.in_(project_ids))
|
||||
.group_by(Note.project_id)
|
||||
)
|
||||
last_activity = {pid: ts for pid, ts in activity_rows.fetchall()}
|
||||
|
||||
from scribe.services.milestones import get_project_milestone_summaries
|
||||
milestones = await get_project_milestone_summaries(user_id, project_ids)
|
||||
|
||||
return {
|
||||
pid: {
|
||||
# All three lifecycle keys present so consumers can sum without
|
||||
# `?? 0` guards — the frontend declares them required, and
|
||||
# `undefined + N` renders as NaN.
|
||||
"task_counts": {
|
||||
"todo": 0, "in_progress": 0, "done": 0,
|
||||
**task_counts.get(pid, {}),
|
||||
},
|
||||
"note_count": note_counts.get(pid, 0),
|
||||
"last_activity": (
|
||||
last_activity[pid].isoformat() if last_activity.get(pid) else None
|
||||
),
|
||||
"milestone_summary": milestones.get(pid, []),
|
||||
}
|
||||
for pid in project_ids
|
||||
}
|
||||
|
||||
|
||||
async def get_project_summary(user_id: int, project_id: int) -> dict:
|
||||
"""Return task counts by status, note count, and last activity."""
|
||||
async with async_session() as session:
|
||||
|
||||
+378
-44
@@ -28,9 +28,10 @@ came from.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
@@ -50,25 +51,6 @@ SNIPPET_TAG = "snippet"
|
||||
UNSET: object = object()
|
||||
|
||||
|
||||
def _embed_snippet(note) -> None:
|
||||
"""Fire-and-forget embedding refresh for a snippet.
|
||||
|
||||
A snippet's whole value is *immediate* recall — it must join the semantic /
|
||||
auto-inject pool the moment it's recorded, not wait for the startup backfill.
|
||||
Unlike a plain note (embedded at the REST-route boundary only, so its MCP
|
||||
create path defers to restart-backfill), a snippet is recorded primarily via
|
||||
MCP, so we embed here in the service — covering BOTH the MCP tool and the
|
||||
REST route by construction. Mirrors the route pattern: fire-and-forget,
|
||||
text = title + body. Import lazily so the pure serialize/parse helpers can be
|
||||
imported without pulling in the embedding model.
|
||||
"""
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if not text:
|
||||
return
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
||||
|
||||
|
||||
# --- serialize: structured fields -> note (title/body/tags) ------------------
|
||||
|
||||
def compose_title(name: str, when_to_use: str = "") -> str:
|
||||
@@ -132,23 +114,58 @@ def _render_location_block(locations: list[dict]) -> str | None:
|
||||
return f"**Locations:**\n{lines}"
|
||||
|
||||
|
||||
def _normalize_merged_from(ids: list[int] | None) -> list[int]:
|
||||
"""Merge provenance as a clean id list: ints only, no dups, order kept.
|
||||
def _normalize_merged_from(entries: list | None) -> list[dict]:
|
||||
"""Merge provenance as `[{"id": int, "locations": [...], "tags": [...]}]`.
|
||||
|
||||
Order is history, not sorting — earlier merges stay first, so the list reads
|
||||
as the sequence of things folded in.
|
||||
|
||||
Each entry records WHAT THAT SOURCE CONTRIBUTED, which is what makes un-merge
|
||||
(#2165) exact. Two problems it solves at once:
|
||||
|
||||
- A location can arrive from a source AND genuinely be the survivor's own.
|
||||
Recording only what the source ADDED means reversing it can never strip a
|
||||
call site the survivor already had.
|
||||
- Two sources can bring the same location. Only the first records it, so
|
||||
un-merging the second leaves it in place — correctly, since the first
|
||||
still claims it.
|
||||
|
||||
A bare int is accepted and normalized to `{"id": n}` with no attribution.
|
||||
That is not legacy tolerance: `snippet_fields` falls back to PARSING THE BODY
|
||||
when a row has no `data`, and the body's `**Merged from:** #ids` line can only
|
||||
ever carry ids. Such an entry still shows provenance; un-merge refuses it
|
||||
rather than guessing, because guessing is exactly the failure above.
|
||||
"""
|
||||
out: list[int] = []
|
||||
for raw in ids or []:
|
||||
out: list[dict] = []
|
||||
seen: set[int] = set()
|
||||
for raw in entries or []:
|
||||
if isinstance(raw, dict):
|
||||
ident, locs, tags = raw.get("id"), raw.get("locations"), raw.get("tags")
|
||||
else:
|
||||
ident, locs, tags = raw, None, None
|
||||
try:
|
||||
i = int(raw)
|
||||
i = int(ident)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i > 0 and i not in out:
|
||||
out.append(i)
|
||||
if i <= 0 or i in seen:
|
||||
continue
|
||||
seen.add(i)
|
||||
entry: dict = {"id": i}
|
||||
norm_locs = _normalize_locations(locs) if locs else []
|
||||
if norm_locs:
|
||||
entry["locations"] = norm_locs
|
||||
clean_tags = [t for t in (tags or []) if isinstance(t, str) and t.strip()]
|
||||
if clean_tags:
|
||||
entry["tags"] = clean_tags
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def merged_from_ids(entries: list | None) -> list[int]:
|
||||
"""Just the absorbed ids, in history order — for display and containment."""
|
||||
return [e["id"] for e in _normalize_merged_from(entries)]
|
||||
|
||||
|
||||
def compose_body(
|
||||
*,
|
||||
code: str,
|
||||
@@ -186,8 +203,11 @@ def compose_body(
|
||||
# Human-readable mirror of data["merged_from"]. Without it, a merge folds
|
||||
# variants in and the record of what was absorbed lives only in the trash
|
||||
# — recoverable only by someone who already knows to go looking.
|
||||
# Ids only — the body is the human-readable mirror, and per-source
|
||||
# attribution belongs in `data` where it can be queried rather than
|
||||
# re-parsed out of prose.
|
||||
header.append(
|
||||
"**Merged from:** " + ", ".join(f"#{i}" for i in merged)
|
||||
"**Merged from:** " + ", ".join(f"#{e['id']}" for e in merged)
|
||||
)
|
||||
fence_lang = (language or "").strip().lower()
|
||||
code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```"
|
||||
@@ -303,8 +323,104 @@ def parse_snippet_fields(
|
||||
# and copying a blob into the column we index *around* would be pure weight.
|
||||
_DATA_FIELDS = (
|
||||
"name", "when_to_use", "signature", "language", "locations", "merged_from",
|
||||
"verification",
|
||||
)
|
||||
|
||||
# --- drift check (#2086) -----------------------------------------------------
|
||||
# A recorded snippet points at a repo · path · symbol that WILL rot: files move,
|
||||
# symbols get renamed, implementations diverge from the copy stored here. Left
|
||||
# undetected, the record degrades from "canonical reference" to "confidently
|
||||
# wrong" — which is worse than having no record, because it is surfaced with the
|
||||
# same authority either way.
|
||||
#
|
||||
# WHERE THE CHECK RUNS. Not here. Scribe has no checkout of the operator's repos
|
||||
# and must not acquire one (rule #115 — the instance stays agnostic about where
|
||||
# code lives; giving the server repo access would make every install a
|
||||
# credential problem). The agent already has the working tree, so IT does the
|
||||
# comparing and reports a verdict; the server's job is to remember the verdict,
|
||||
# make it queryable, and know when it has expired.
|
||||
#
|
||||
# WHY THE VERDICT CARRIES A CODE HASH. A stored verdict describes the code it
|
||||
# was checked against. Edit the snippet afterwards and that verdict is no longer
|
||||
# about anything — but invalidating it on write means deciding which edits count
|
||||
# (a `when_to_use` tweak shouldn't void a code check; a code rewrite must). That
|
||||
# rule is fiddly and easy to get subtly wrong. Recording the hash sidesteps it
|
||||
# entirely: a verdict whose `code_sha` no longer matches the body is self-
|
||||
# evidently expired, computed at read time, with no invalidation logic to
|
||||
# maintain and no way for an edit path to forget to call it.
|
||||
|
||||
VERIFY_OK = "ok"
|
||||
VERIFY_MISSING = "missing" # the recorded path is gone
|
||||
VERIFY_MOVED = "moved" # path is there, the symbol isn't in it
|
||||
VERIFY_CHANGED = "changed" # both present, but the source no longer matches
|
||||
VERIFY_STATUSES = (VERIFY_OK, VERIFY_MISSING, VERIFY_MOVED, VERIFY_CHANGED)
|
||||
|
||||
# Everything that isn't a clean bill of health. "Stale" in the UI and the filter
|
||||
# means this set — the operator wants one list of things to look at, not four.
|
||||
VERIFY_DRIFTED = (VERIFY_MISSING, VERIFY_MOVED, VERIFY_CHANGED)
|
||||
|
||||
|
||||
def code_sha(code: str) -> str:
|
||||
"""Stable fingerprint of a snippet's code, for expiring stale verdicts.
|
||||
|
||||
Trailing whitespace per line and leading/trailing blank lines are stripped
|
||||
before hashing: those change when a file is reformatted without the code
|
||||
meaning anything different, and a verdict shouldn't expire over an editor's
|
||||
trailing-newline habit.
|
||||
"""
|
||||
normalized = "\n".join(line.rstrip() for line in (code or "").splitlines()).strip()
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def compose_verification(
|
||||
*,
|
||||
status: str,
|
||||
checked_code_sha: str,
|
||||
detail: str = "",
|
||||
path: str = "",
|
||||
checked_at: str = "",
|
||||
) -> dict:
|
||||
"""Build the `data.verification` record. Unknown statuses are rejected here
|
||||
rather than stored, so the filter never has to cope with a typo'd status."""
|
||||
if status not in VERIFY_STATUSES:
|
||||
raise ValueError(
|
||||
f"unknown verification status {status!r} — expected one of {VERIFY_STATUSES}"
|
||||
)
|
||||
out = {
|
||||
"status": status,
|
||||
"code_sha": checked_code_sha,
|
||||
"checked_at": checked_at or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if (detail or "").strip():
|
||||
out["detail"] = detail.strip()
|
||||
if (path or "").strip():
|
||||
out["path"] = path.strip()
|
||||
return out
|
||||
|
||||
|
||||
def verification_view(note, fields: dict) -> dict:
|
||||
"""The verification readout for one snippet, including whether it's expired.
|
||||
|
||||
`status` is what was last reported; `current` says whether that verdict still
|
||||
describes the code in the record. A verdict that no longer matches reads as
|
||||
unverified, because that is what it is — nobody has checked THIS code.
|
||||
"""
|
||||
stored = (fields.get("verification") or {}) if isinstance(fields, dict) else {}
|
||||
if not stored or not stored.get("status"):
|
||||
return {"status": "unverified", "current": False, "checked_at": None}
|
||||
current = stored.get("code_sha") == code_sha(fields.get("code") or "")
|
||||
return {
|
||||
"status": stored["status"],
|
||||
"current": current,
|
||||
"checked_at": stored.get("checked_at"),
|
||||
"detail": stored.get("detail"),
|
||||
"path": stored.get("path"),
|
||||
# What the operator actually wants to know: is there something to fix?
|
||||
# An expired verdict counts as "needs looking at" even if it said ok,
|
||||
# since the code it blessed is not the code that's there now.
|
||||
"needs_attention": (not current) or stored["status"] in VERIFY_DRIFTED,
|
||||
}
|
||||
|
||||
|
||||
def compose_data(
|
||||
*,
|
||||
@@ -312,8 +428,10 @@ def compose_data(
|
||||
when_to_use: str = "",
|
||||
signature: str = "",
|
||||
language: str = "",
|
||||
code: str = "",
|
||||
locations: list[dict] | None = None,
|
||||
merged_from: list[int] | None = None,
|
||||
verification: dict | None = None,
|
||||
) -> dict:
|
||||
"""Build the `notes.data` mirror of a snippet's structured fields.
|
||||
|
||||
@@ -337,6 +455,19 @@ def compose_data(
|
||||
merged = _normalize_merged_from(merged_from)
|
||||
if merged:
|
||||
out["merged_from"] = merged
|
||||
# Carried, never composed here — like merged_from. An ordinary edit must not
|
||||
# silently drop the last drift check, and it doesn't need to invalidate it
|
||||
# either: the verdict's code_sha expires it on read if the code moved on.
|
||||
if verification:
|
||||
out["verification"] = verification
|
||||
# The current code's fingerprint — NOT the code, which stays in the body
|
||||
# (see _DATA_FIELDS). Its only job is to make "this verdict has expired"
|
||||
# expressible in SQL: a jsonpath can compare `@.verification.code_sha` to
|
||||
# `@.code_sha` within the same row, so "show me everything that needs
|
||||
# looking at" stays one index-served query instead of a post-filter that
|
||||
# would break pagination counts.
|
||||
if (code or "").strip():
|
||||
out["code_sha"] = code_sha(code)
|
||||
return out
|
||||
|
||||
|
||||
@@ -419,6 +550,7 @@ async def backfill_snippet_data(*, batch: int = 500) -> int:
|
||||
when_to_use=fields["when_to_use"],
|
||||
signature=fields["signature"],
|
||||
language=fields["language"],
|
||||
code=fields["code"],
|
||||
locations=fields["locations"],
|
||||
merged_from=fields["merged_from"],
|
||||
)
|
||||
@@ -439,7 +571,13 @@ def snippet_to_dict(note) -> dict:
|
||||
``snippet`` already reports, and shipping both would give API consumers two
|
||||
sources of truth for the same facts."""
|
||||
data = note.to_dict()
|
||||
data["snippet"] = snippet_fields(note)
|
||||
fields = snippet_fields(note)
|
||||
data["snippet"] = fields
|
||||
# Promoted out of `snippet` because it is a computed READOUT, not a recorded
|
||||
# field: `current` and `needs_attention` are derived at read time by hashing
|
||||
# the code, and burying them among the stored fields would invite a caller
|
||||
# to try writing them back.
|
||||
data["verification"] = verification_view(note, fields)
|
||||
return data
|
||||
|
||||
|
||||
@@ -479,10 +617,9 @@ async def create_snippet(
|
||||
# body so the two can never describe different things.
|
||||
data=compose_data(
|
||||
name=name, when_to_use=when_to_use, signature=signature,
|
||||
language=language, locations=locations,
|
||||
language=language, code=code, locations=locations,
|
||||
),
|
||||
)
|
||||
_embed_snippet(note)
|
||||
return note
|
||||
|
||||
|
||||
@@ -513,6 +650,7 @@ async def list_snippets(
|
||||
repo: str = "",
|
||||
path: str = "",
|
||||
symbol: str = "",
|
||||
verification: str = "",
|
||||
) -> tuple[list[dict], int]:
|
||||
"""List snippets (id/title/tags/preview dicts), most-recently-updated first.
|
||||
|
||||
@@ -523,7 +661,11 @@ async def list_snippets(
|
||||
``repo`` / ``path`` / ``symbol`` are the reverse lookup: "what canonical
|
||||
helpers already live here?" They narrow to snippets recorded at a matching
|
||||
location, ANDed within one location entry, with ``path`` also matching as a
|
||||
directory prefix. Combinable with ``q`` — search *and* place."""
|
||||
directory prefix. Combinable with ``q`` — search *and* place.
|
||||
|
||||
``verification`` narrows on the drift check: ``attention`` is the useful one
|
||||
— everything whose recorded location or code no longer checks out, plus
|
||||
everything whose verdict expired because the snippet was edited since."""
|
||||
return await knowledge_svc.query_knowledge(
|
||||
user_id=user_id,
|
||||
note_type=SNIPPET_NOTE_TYPE,
|
||||
@@ -535,6 +677,7 @@ async def list_snippets(
|
||||
project_id=project_id,
|
||||
locations=knowledge_svc.location_parts(repo=repo, path=path, symbol=symbol)
|
||||
or None,
|
||||
verification=verification,
|
||||
)
|
||||
|
||||
|
||||
@@ -615,8 +758,12 @@ async def update_snippet(
|
||||
"data": compose_data(
|
||||
name=merged["name"], when_to_use=merged["when_to_use"],
|
||||
signature=merged["signature"], language=merged["language"],
|
||||
locations=merged_locations,
|
||||
code=merged["code"], locations=merged_locations,
|
||||
merged_from=merged.get("merged_from"),
|
||||
# Carried through the edit rather than cleared. If this edit changed
|
||||
# the code, the verdict's code_sha stops matching and it reads as
|
||||
# unverified from here on — no invalidation branch to get wrong.
|
||||
verification=merged.get("verification"),
|
||||
),
|
||||
}
|
||||
# Recompute tags: keep any non-language, non-marker tags the note already had
|
||||
@@ -633,12 +780,62 @@ async def update_snippet(
|
||||
# As the OWNER: update_note is owner-scoped, so a shared editor's own id
|
||||
# would find nothing. The write was authorised by can_write_note above.
|
||||
updated = await notes_svc.update_note(note.user_id, snippet_id, **fields)
|
||||
if updated is not None:
|
||||
# Title/body changed → refresh the embedding so recall reflects the edit.
|
||||
_embed_snippet(updated)
|
||||
return updated
|
||||
|
||||
|
||||
async def record_verification(
|
||||
user_id: int,
|
||||
snippet_id: int,
|
||||
*,
|
||||
status: str,
|
||||
detail: str = "",
|
||||
path: str = "",
|
||||
):
|
||||
"""Record the result of a drift check against the snippet's source.
|
||||
|
||||
The CHECK happens agent-side — Scribe has no checkout and shouldn't want one
|
||||
(see the drift-check note above). This just remembers the verdict, stamped
|
||||
with a hash of the code it was checked against so it expires by itself when
|
||||
the snippet is edited.
|
||||
|
||||
Requires WRITE access: a verdict changes how the record is presented and
|
||||
whether it shows up in the operator's "needs attention" list, so being able
|
||||
to read a shared snippet must not let you mark it broken.
|
||||
|
||||
Returns the updated note, or None if the id isn't a snippet this user may
|
||||
write. Raises ValueError on an unknown status.
|
||||
"""
|
||||
note = await get_snippet(user_id, snippet_id)
|
||||
if note is None:
|
||||
return None
|
||||
from scribe.services.access import can_write_note
|
||||
if not await can_write_note(user_id, snippet_id):
|
||||
return None
|
||||
|
||||
fields = snippet_fields(note)
|
||||
verification = compose_verification(
|
||||
status=status,
|
||||
checked_code_sha=code_sha(fields.get("code") or ""),
|
||||
detail=detail,
|
||||
path=path or fields.get("path") or "",
|
||||
)
|
||||
# Rebuilt from the CURRENT stored fields plus the new verdict, so recording a
|
||||
# check can't quietly rewrite anything else about the record. Note the body
|
||||
# is untouched — a verdict is metadata about the snippet, not part of it, and
|
||||
# writing it into the body would put it into the embedding.
|
||||
data = compose_data(
|
||||
name=fields.get("name", ""),
|
||||
when_to_use=fields.get("when_to_use", ""),
|
||||
signature=fields.get("signature", ""),
|
||||
language=fields.get("language", ""),
|
||||
code=fields.get("code", ""),
|
||||
locations=fields.get("locations") or [],
|
||||
merged_from=fields.get("merged_from") or [],
|
||||
verification=verification,
|
||||
)
|
||||
return await notes_svc.update_note(note.user_id, snippet_id, data=data)
|
||||
|
||||
|
||||
async def delete_snippet(user_id: int, snippet_id: int) -> bool:
|
||||
"""Retire a snippet to the trash (recoverable). Returns False if the id isn't
|
||||
a snippet this user may WRITE.
|
||||
@@ -676,15 +873,32 @@ def merge_snippet_fields(
|
||||
"""Pure merge: union locations (target's first, then each source in order)
|
||||
and union extra tags. The target's scalar fields (name/when_to_use/signature/
|
||||
language/code) win — only locations and tags accumulate. ``sources`` is a
|
||||
list of (parsed_fields, tags). Returns (locations, extra_tags)."""
|
||||
locations = list(target_fields.get("locations") or [])
|
||||
list of (parsed_fields, tags).
|
||||
|
||||
Returns (locations, extra_tags, contributions) where `contributions` is one
|
||||
`{"locations": [...], "tags": [...]}` per source, positionally aligned with
|
||||
`sources`, holding ONLY what that source actually added — anything the
|
||||
survivor (or an earlier source) already had is not attributed to it. That is
|
||||
what lets un-merge subtract exactly, without stripping a call site the
|
||||
survivor legitimately owns."""
|
||||
locations = _normalize_locations(target_fields.get("locations") or [])
|
||||
extra = _extra_tags(target_tags, target_fields.get("language", ""))
|
||||
contributions: list[dict] = []
|
||||
for sfields, stags in sources:
|
||||
locations.extend(sfields.get("locations") or [])
|
||||
before = {_location_str(loc) for loc in locations}
|
||||
added_locs = []
|
||||
for loc in _normalize_locations(sfields.get("locations") or []):
|
||||
if _location_str(loc) not in before:
|
||||
before.add(_location_str(loc))
|
||||
locations.append(loc)
|
||||
added_locs.append(loc)
|
||||
added_tags = []
|
||||
for t in _extra_tags(stags, sfields.get("language", "")):
|
||||
if t not in extra:
|
||||
extra.append(t)
|
||||
return _normalize_locations(locations), extra
|
||||
added_tags.append(t)
|
||||
contributions.append({"locations": added_locs, "tags": added_tags})
|
||||
return _normalize_locations(locations), extra, contributions
|
||||
|
||||
|
||||
async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
||||
@@ -734,15 +948,25 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
||||
|
||||
tgt_fields = snippet_fields(target)
|
||||
parsed_sources = [(snippet_fields(s), s.tags) for s in sources]
|
||||
locations, extra_tags = merge_snippet_fields(tgt_fields, target.tags, parsed_sources)
|
||||
locations, extra_tags, contributions = merge_snippet_fields(
|
||||
tgt_fields, target.tags, parsed_sources
|
||||
)
|
||||
|
||||
# Provenance: what this record absorbed, and when it absorbed it, in order.
|
||||
# Merge keeps the target's scalar fields and trashes the sources, so without
|
||||
# this the fact that a variant ever existed survives only in the trash — and
|
||||
# only for someone who already knew to go looking. Accumulated, not replaced:
|
||||
# a target merged twice keeps both histories.
|
||||
#
|
||||
# Each entry also carries what THAT source contributed, which is what makes
|
||||
# un-merge exact (#2165) rather than a blind subtraction that would strip
|
||||
# call sites the survivor legitimately owns.
|
||||
merged_from = _normalize_merged_from(
|
||||
list(tgt_fields.get("merged_from") or []) + [s.id for s in sources]
|
||||
list(tgt_fields.get("merged_from") or [])
|
||||
+ [
|
||||
{"id": s.id, **contrib}
|
||||
for s, contrib in zip(sources, contributions)
|
||||
]
|
||||
)
|
||||
|
||||
# Owner-scoped write, authorised above — same reason as update_snippet.
|
||||
@@ -760,7 +984,10 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
||||
data=compose_data(
|
||||
name=tgt_fields["name"], when_to_use=tgt_fields["when_to_use"],
|
||||
signature=tgt_fields["signature"], language=tgt_fields["language"],
|
||||
locations=locations, merged_from=merged_from,
|
||||
code=tgt_fields["code"], locations=locations, merged_from=merged_from,
|
||||
# No verification carried: the survivor's code is a union of several
|
||||
# sources, so no prior verdict describes it. It reads as unverified,
|
||||
# which is the honest answer — nobody has checked THIS code.
|
||||
),
|
||||
)
|
||||
if updated is None:
|
||||
@@ -774,5 +1001,112 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
||||
if batch is not None:
|
||||
merged_ids.append(s.id)
|
||||
|
||||
_embed_snippet(updated)
|
||||
return updated, merged_ids
|
||||
|
||||
|
||||
class UnmergeError(Exception):
|
||||
"""Un-merge refused — the reason is the message, meant for the operator."""
|
||||
|
||||
|
||||
async def unmerge_snippet(user_id: int, survivor_id: int, source_id: int):
|
||||
"""Reverse ONE source out of a merged survivor: restore it, and strip exactly
|
||||
what it contributed.
|
||||
|
||||
WHY THIS OWNS THE RESTORE. The obvious alternative was to have trash-restore
|
||||
notice that the record it's reviving was merged into something and offer to
|
||||
reverse. That would make the generic trash path learn snippet semantics for
|
||||
one record type. Instead un-merge performs the restore itself, so the inverse
|
||||
is one operation with one authorization check and the trash path stays
|
||||
ignorant. Restoring from the trash directly is still allowed and still leaves
|
||||
both records claiming the same call sites — which is why this exists — but it
|
||||
is no longer the only way back.
|
||||
|
||||
EXACTNESS. Subtraction uses the contribution recorded at merge time, not the
|
||||
source's current locations. A source that was itself edited after being
|
||||
merged would otherwise strip locations it never contributed, and a location
|
||||
the survivor independently owned would be lost. When an entry carries no
|
||||
attribution (provenance parsed back out of the body, which can only hold
|
||||
ids), this REFUSES rather than guessing.
|
||||
|
||||
Returns (survivor_note, restored_source_note). Raises UnmergeError with a
|
||||
reason the operator can act on; returns None if the survivor isn't a snippet
|
||||
this caller can see.
|
||||
"""
|
||||
from scribe.services.access import can_write_note
|
||||
|
||||
survivor = await get_snippet(user_id, survivor_id)
|
||||
if survivor is None:
|
||||
return None
|
||||
if not await can_write_note(user_id, survivor_id):
|
||||
raise PermissionError(
|
||||
f"snippet {survivor_id} is shared with you read-only — you can't "
|
||||
f"un-merge a record you can't edit"
|
||||
)
|
||||
|
||||
fields = snippet_fields(survivor)
|
||||
entries = _normalize_merged_from(fields.get("merged_from"))
|
||||
entry = next((e for e in entries if e["id"] == int(source_id)), None)
|
||||
if entry is None:
|
||||
raise UnmergeError(
|
||||
f"snippet {survivor_id} has no record of absorbing #{source_id}"
|
||||
)
|
||||
if "locations" not in entry and "tags" not in entry:
|
||||
raise UnmergeError(
|
||||
f"#{source_id} was folded into {survivor_id} before per-source "
|
||||
f"provenance was recorded, so what it contributed isn't known. "
|
||||
f"Restore it from the trash and adjust both records by hand — "
|
||||
f"subtracting a guess could strip call sites {survivor_id} owns."
|
||||
)
|
||||
|
||||
# Bring the source back FIRST: if it can't be revived there is nothing to
|
||||
# un-merge into, and the survivor is better left whole than stripped of
|
||||
# locations whose other claimant never returned.
|
||||
#
|
||||
# An ALREADY-ALIVE source is the common case, not an error — the operator
|
||||
# restored it from the trash themselves, which is precisely the state that
|
||||
# motivated this feature: both records then claim the same call sites, and
|
||||
# nothing had ever stripped the survivor's copy. Skip the revive and go
|
||||
# straight to the subtraction that fixes it.
|
||||
from scribe.services.trash import restore_entity
|
||||
|
||||
restored = await get_snippet(user_id, int(source_id))
|
||||
if restored is None:
|
||||
if await restore_entity(survivor.user_id, "note", int(source_id)) is None:
|
||||
raise UnmergeError(
|
||||
f"#{source_id} could not be restored — it was most likely purged "
|
||||
f"from the trash, and a purged source cannot be brought back"
|
||||
)
|
||||
restored = await get_snippet(user_id, int(source_id))
|
||||
|
||||
drop_locs = {_location_str(loc) for loc in (entry.get("locations") or [])}
|
||||
drop_tags = set(entry.get("tags") or [])
|
||||
kept_locations = [
|
||||
loc for loc in (fields.get("locations") or [])
|
||||
if _location_str(loc) not in drop_locs
|
||||
]
|
||||
kept_extra = [
|
||||
t for t in _extra_tags(survivor.tags, fields.get("language", ""))
|
||||
if t not in drop_tags
|
||||
]
|
||||
remaining = [e for e in entries if e["id"] != int(source_id)]
|
||||
|
||||
updated = await notes_svc.update_note(
|
||||
survivor.user_id, survivor_id,
|
||||
body=compose_body(
|
||||
code=fields["code"], language=fields["language"],
|
||||
signature=fields["signature"], when_to_use=fields["when_to_use"],
|
||||
locations=kept_locations, merged_from=remaining,
|
||||
),
|
||||
tags=compose_tags(fields["language"], kept_extra),
|
||||
data=compose_data(
|
||||
name=fields["name"], when_to_use=fields["when_to_use"],
|
||||
signature=fields["signature"], language=fields["language"],
|
||||
code=fields["code"], locations=kept_locations, merged_from=remaining,
|
||||
# Same reasoning as merge: the survivor's location set just changed,
|
||||
# so any prior drift verdict no longer describes it. Dropped rather
|
||||
# than carried.
|
||||
),
|
||||
)
|
||||
if updated is None:
|
||||
return None
|
||||
return updated, restored
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user