Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf55fc488 |
@@ -46,6 +46,8 @@ on:
|
||||
- "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
|
||||
@@ -176,15 +178,6 @@ jobs:
|
||||
- name: Design token check
|
||||
run: python3 scripts/check_design_tokens.py --report-literals
|
||||
|
||||
# Dangling styles: an element whose classes have only modifier rules and
|
||||
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
|
||||
# this way (a link rendering as raw browser blue, a flex row whose parent
|
||||
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
|
||||
# style typechecks perfectly. Reported, not gated — a bare wrapper is
|
||||
# legitimate, so the signal is the count growing.
|
||||
- name: Dangling style check
|
||||
run: python3 scripts/check_dangling_styles.py
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -223,16 +216,6 @@ jobs:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
|
||||
# The hook-EXECUTION tests (test_write_path_trigger's nudge pair) run the
|
||||
# real bash hook, which exits silently without jq — and those tests skip
|
||||
# rather than fail when it's absent, so without this step they would
|
||||
# quietly never be verified anywhere (ci-python ships without jq; same
|
||||
# install the Plugin hooks job does).
|
||||
- name: Install jq for hook execution tests
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends jq
|
||||
|
||||
- name: Run tests
|
||||
# Integration tests (real Postgres) run in the `integration` job below.
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||
@@ -277,21 +260,6 @@ jobs:
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
# Standing answers to the checks carried by rules 81 and 79 — two facts
|
||||
# about THIS runner that conditional rules assert as fact, and that
|
||||
# otherwise need a throwaway job to confirm (#3237). Printing them on
|
||||
# every integration run makes the next rulebook sweep a log read.
|
||||
# Rule 80's evidence is the container listing the next step already
|
||||
# prints. Every command is guarded: a diagnostic that can break the lane
|
||||
# it observes is worse than no diagnostic.
|
||||
- name: Runner facts (rules 79 and 81)
|
||||
run: |
|
||||
echo "--- rule 81: which shell runs a run: step ---"
|
||||
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
|
||||
ps -p $$ -o comm= || true
|
||||
echo "--- rule 79: is a service reachable by its hostname yet? ---"
|
||||
getent hosts postgres \
|
||||
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
|
||||
- name: Integration suite (resolve service IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -302,9 +270,8 @@ jobs:
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
||||
# Wait for Postgres to accept connections. The run: shell is dash
|
||||
# (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by
|
||||
# the step above) — no bash /dev/tcp, so use Python.
|
||||
# Wait for Postgres to accept connections (busybox sh — the runner
|
||||
# default — has no bash /dev/tcp, so use Python).
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
for _ in range(30):
|
||||
@@ -341,14 +308,6 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Rule 149 asks for this on any job deriving the version NAME. The
|
||||
# name here comes from HEAD's commit TIME, which a depth-1 clone
|
||||
# already has — but the rule states it unconditionally because the
|
||||
# failure it guards is silent (a too-low value, every lane green),
|
||||
# and a later change to how the name is derived would inherit the
|
||||
# landmine rather than the guard.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
@@ -361,27 +320,7 @@ jobs:
|
||||
# the runner log on commit 2a374d9.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
|
||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31
|
||||
# BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the
|
||||
# image self-reported {"version":"main"}, a channel name where a
|
||||
# build identifier belongs. That cost a debugging session: with the
|
||||
# deploy misbehaving, nothing on the running instance could say
|
||||
# which commit was serving it.
|
||||
|
||||
# 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes
|
||||
# since 2020-01-01. Never a commit count (not monotonic across
|
||||
# branches) and never commit time (goes DOWN when an older
|
||||
# commit is rebuilt).
|
||||
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
||||
|
||||
# 2. NAME — COMMIT time, so the same source reports the same string
|
||||
# on every lane and the channel is the only thing that differs.
|
||||
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
||||
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
|
||||
|
||||
# 3. CHANNEL — its own value. Never a suffix, never a segment.
|
||||
CHANNEL="dev"
|
||||
BUILD_VERSION="dev"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
@@ -390,17 +329,15 @@ jobs:
|
||||
# main IS the production line: publish :latest (plus the :<sha>
|
||||
# set above). No separate :main tag.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
CHANNEL="stable"
|
||||
BUILD_VERSION="main"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
CHANNEL="stable"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
|
||||
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
|
||||
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space
|
||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||
@@ -430,15 +367,7 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
# All three, plus the commit — rule 145: the registry's identity for
|
||||
# a build (:<sha>) and the artifact's identity for itself must
|
||||
# agree, and they can only be checked against each other if the
|
||||
# artifact says which commit it is.
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
|
||||
BUILD_KEY=${{ steps.tags.outputs.build_key }}
|
||||
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
|
||||
BUILD_COMMIT=${{ github.sha }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
# Registry-backed layer cache. Pull from :cache to prime
|
||||
# BuildKit, push updated layers back to :cache so the next
|
||||
# build starts warm even if the runner's local cache was
|
||||
|
||||
+2
-21
@@ -41,29 +41,10 @@ COPY alembic/ alembic/
|
||||
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
|
||||
#
|
||||
# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same
|
||||
# string on every lane for the same source, so it answers "is this the same
|
||||
# code?" rather than "which lane built it?".
|
||||
# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) —
|
||||
# the only value anything may compare to decide what is newer.
|
||||
# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name.
|
||||
# BUILD_COMMIT lets the artifact's self-report be checked against the :<sha>
|
||||
# it was published under (rule 145).
|
||||
#
|
||||
# Each defaults to empty rather than to a placeholder, EXCEPT the name: a
|
||||
# local build genuinely has no ordering key or channel, and the endpoint says
|
||||
# so by omitting them. Inventing values would make a local image claim a
|
||||
# position in an update order it is not part of.
|
||||
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
|
||||
# Falls back to "dev" for local / untagged builds
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_KEY=
|
||||
ARG BUILD_CHANNEL=
|
||||
ARG BUILD_COMMIT=
|
||||
ENV APP_VERSION=$BUILD_VERSION
|
||||
ENV APP_BUILD_KEY=$BUILD_KEY
|
||||
ENV APP_CHANNEL=$BUILD_CHANNEL
|
||||
ENV APP_COMMIT=$BUILD_COMMIT
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt
|
||||
|
||||
# --- Docker ---
|
||||
|
||||
@@ -36,12 +36,3 @@ test:
|
||||
|
||||
# Run all checks in one shot (mirrors what CI does)
|
||||
check: lint typecheck test
|
||||
|
||||
# --- Plugin ---
|
||||
|
||||
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
|
||||
# committing. The plugin ships straight from git with no build step, so its
|
||||
# version is minted here rather than stamped by CI; the lane fails if you
|
||||
# forget, but this is what makes remembering cheap.
|
||||
mint-plugin:
|
||||
python3 scripts/mint_plugin_version.py
|
||||
|
||||
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
|
||||
|
||||
## Features
|
||||
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (with an inception step that decides what each project inherits), and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""retire the two settings that designated a design source for the app itself
|
||||
|
||||
Revision ID: 0075
|
||||
Revises: 0074
|
||||
Create Date: 2026-08-03
|
||||
|
||||
Two keys, retired for the same reason a week apart, so they go in one change
|
||||
rather than one migration each:
|
||||
|
||||
design_rulebook_id which rulebook described how this app should look
|
||||
ui_design_system_id which design system this app's own UI was built from
|
||||
|
||||
Both named a design source for THE RUNNING INSTALL. The design surface is for
|
||||
the projects an install tracks, and a project already carries its own pointer
|
||||
(`projects.design_system_id`) — so an install-wide designation had nothing left
|
||||
to mean. `ui_design_system_id` was introduced by this same migration's first
|
||||
draft and never reached a deployed database; it is listed here rather than
|
||||
undone by an 0076 that would reverse a change nobody ran.
|
||||
|
||||
Deleting settings rows by key is safe in a way dropping a column is not — the
|
||||
table is free-form key/value, so an install that never designated one simply has
|
||||
no row to delete.
|
||||
|
||||
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
|
||||
lie: the pointer lives on the project now, and always did for anyone who set it
|
||||
there.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0075"
|
||||
down_revision = "0074"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM settings "
|
||||
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,115 +0,0 @@
|
||||
"""note_supersessions; drop the never-written notes.consolidated_at
|
||||
|
||||
Revision ID: 0076
|
||||
Revises: 0075
|
||||
Create Date: 2026-08-07
|
||||
|
||||
Step 1 of milestone #278. Structure only — nothing reads or writes the new
|
||||
table yet, and nothing behaves differently after this runs.
|
||||
|
||||
## What the table is for
|
||||
|
||||
Old records outrank newer ones on the same subject, because a similarity score
|
||||
cannot tell time. A note that accurately described how something worked in June
|
||||
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
|
||||
that, so nothing could act on it.
|
||||
|
||||
The claim points FORWARD — the newer record names what it overtakes — because
|
||||
the older one cannot know it has been overtaken. Many-to-many and partial: a
|
||||
note may supersede parts of several others and be overtaken piecemeal by
|
||||
several later ones, which is why this is a table rather than a column. Both
|
||||
directions are queried: `superseded_id` answers "has this been overtaken?" at
|
||||
ranking time, `superseder_id` answers "what does this replace?" in a record
|
||||
view. An array column could serve one and not the other.
|
||||
|
||||
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
|
||||
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
|
||||
them back. The cascade fires only on `purge_trash`, where the row genuinely
|
||||
goes — and a claim about a row that no longer exists is not actionable.
|
||||
|
||||
## What is being dropped, and why now
|
||||
|
||||
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
|
||||
— while being serialised into every note and task payload as `null`. It cost a
|
||||
column, a line in every response, and worse: it IMPLIED a capability. A reader
|
||||
reasonably concludes notes can be consolidated and this records when.
|
||||
|
||||
That reading was reasonable precisely because merge/unmerge exists for snippets
|
||||
and not for notes, so the column looked like the notes-side half of that
|
||||
feature, modelled and abandoned.
|
||||
|
||||
It is dropped rather than repurposed for supersession, and the distinction is
|
||||
the point (#2483): consolidation folds several records into one survivor and
|
||||
destroys the originals. Merging two snippets is lossless — one helper, several
|
||||
call sites. Folding two dev-logs means writing a summary and losing what each
|
||||
actually said. Supersession is the opposite act: both records survive, and the
|
||||
older one is merely ranked behind. Smuggling one in under a column named for
|
||||
the other would have buried that difference in schema.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
|
||||
restores the shape, not the (nonexistent) data. Drops the table; any recorded
|
||||
supersession claims are lost, which costs ranking its input and nothing else,
|
||||
since no note's own content depends on them.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0076"
|
||||
down_revision = "0075"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"note_supersessions",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"superseder_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"superseded_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
|
||||
),
|
||||
# Declaring that a note supersedes ITSELF is meaningless, and under flat
|
||||
# demotion it would demote a record on its own authority. Refused in the
|
||||
# service too, with a message — this is the backstop that holds when
|
||||
# something writes rows directly.
|
||||
sa.CheckConstraint(
|
||||
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
|
||||
)
|
||||
|
||||
op.drop_column("notes", "consolidated_at")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
|
||||
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
|
||||
op.drop_table("note_supersessions")
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Chunked embeddings: one note_embeddings row per chunk (#280)
|
||||
|
||||
Revision ID: 0077
|
||||
Revises: 0076
|
||||
Create Date: 2026-08-09
|
||||
|
||||
The embedding model reads at most 512 tokens and fastembed truncates the rest
|
||||
silently, so the old one-row-per-note shape permanently lost everything past
|
||||
~400 words of a record. A note now stores one row per chunk of
|
||||
`embeddings.chunk_document`: PK (note_id, chunk_index), plus the chunk's text
|
||||
(inspectability + future "matched section" surfacing) and the chunker version
|
||||
that produced it (so later shape changes re-embed by version comparison
|
||||
instead of repeating this wipe).
|
||||
|
||||
Embeddings are DERIVED data (0067 precedent): rows are cleared here and the
|
||||
startup backfill regenerates the whole corpus at the new shape on next boot.
|
||||
The HNSW index is untouched — it indexes chunk rows exactly as it indexed
|
||||
note rows.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0077"
|
||||
down_revision = "0076"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Derived data — the version-aware startup backfill re-embeds everything
|
||||
# at the chunked shape. Old whole-document rows would be indistinguishable
|
||||
# from properly-chunked single-chunk notes, so they cannot be carried over.
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
|
||||
# Empty table, so NOT NULL columns need no defaults and the PK swap is
|
||||
# instant.
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_index integer NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_text text NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunker_version integer NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
|
||||
op.execute(
|
||||
"ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id, chunk_index)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Same reasoning in reverse: chunk rows make no sense to a whole-document
|
||||
# reader, so clear and let the old backfill regenerate.
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_index")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_text")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunker_version")
|
||||
op.execute("ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id)")
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Forge connections move to the user level (#2778)
|
||||
|
||||
Revision ID: 0078
|
||||
Revises: 0077
|
||||
Create Date: 2026-08-19
|
||||
|
||||
A forge token is a user's credential, not an instance's: the single
|
||||
admin-settings config meant every user's snippet-freshness and coverage reads
|
||||
ran under the operator's token. Each user now owns a keyring of connections —
|
||||
one per forge host — and projects resolve forge reads on their OWNER's
|
||||
keyring, with an optional per-project pin (projects.forge_connection_id).
|
||||
|
||||
The data move carries the existing admin config into a connection row for the
|
||||
first admin user (host parsed from the base URL), then deletes the old
|
||||
setting keys outright — no legacy dual-read (rule #22). The env-var channel
|
||||
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
|
||||
survives as an implicit keyring entry for admin users only.
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0078"
|
||||
down_revision = "0077"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"forge_connections",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("base_url", sa.Text(), nullable=False),
|
||||
sa.Column("host", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
|
||||
)
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column(
|
||||
"forge_connection_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey(
|
||||
"forge_connections.id",
|
||||
ondelete="SET NULL",
|
||||
name="fk_projects_forge_connection_id",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Data move: the admin-settings config becomes the first admin's keyring
|
||||
# row. All three values must be present — a partial config never produced
|
||||
# an adapter, so carrying it over would invent a connection that never
|
||||
# worked.
|
||||
conn = op.get_bind()
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT s.key, s.value FROM settings s"
|
||||
" JOIN users u ON u.id = s.user_id"
|
||||
" WHERE u.role = 'admin' AND s.key IN :keys"
|
||||
" AND s.user_id = ("
|
||||
" SELECT MIN(id) FROM users WHERE role = 'admin'"
|
||||
" )"
|
||||
).bindparams(sa.bindparam("keys", expanding=True)),
|
||||
{"keys": list(_SETTING_KEYS)},
|
||||
).fetchall()
|
||||
values = {key: (value or "").strip() for key, value in row}
|
||||
kind = values.get("forge_kind", "").lower()
|
||||
base_url = values.get("forge_base_url", "").rstrip("/")
|
||||
token = values.get("forge_token", "")
|
||||
host = (urlsplit(base_url).hostname or "").lower()
|
||||
if kind and base_url and token and host:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO forge_connections"
|
||||
" (user_id, kind, base_url, host, token, created_at, updated_at)"
|
||||
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
|
||||
" FROM users WHERE role = 'admin'"
|
||||
),
|
||||
{"kind": kind, "base_url": base_url, "host": host, "token": token},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DELETE FROM settings WHERE key IN :keys"
|
||||
).bindparams(sa.bindparam("keys", expanding=True)),
|
||||
{"keys": list(_SETTING_KEYS)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse data move: the first admin's row (if any) becomes the admin
|
||||
# settings again. Other users' rows have no pre-0078 representation and
|
||||
# are dropped with the table.
|
||||
conn = op.get_bind()
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT user_id, kind, base_url, token FROM forge_connections"
|
||||
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
|
||||
" ORDER BY id LIMIT 1"
|
||||
)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
for key, value in (
|
||||
("forge_kind", row.kind),
|
||||
("forge_base_url", row.base_url),
|
||||
("forge_token", row.token),
|
||||
):
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO settings (user_id, key, value)"
|
||||
" VALUES (:uid, :key, :value)"
|
||||
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
|
||||
),
|
||||
{"uid": row.user_id, "key": key, "value": value},
|
||||
)
|
||||
op.drop_column("projects", "forge_connection_id")
|
||||
op.drop_table("forge_connections")
|
||||
@@ -1,66 +0,0 @@
|
||||
"""The shape ledger: code_shapes (#2787, milestone 294)
|
||||
|
||||
Revision ID: 0079
|
||||
Revises: 0078
|
||||
Create Date: 2026-08-19
|
||||
|
||||
The accounting half of the pattern system (governing note 2786): the snippet
|
||||
library records canon (small); this table accounts for EVERY shape the
|
||||
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
|
||||
from the coverage sync (step 2) and gain judgments — canonical / instance /
|
||||
variant / exempt — from audits, hooks, and the mechanical proposer.
|
||||
Unclassified IS the todo list.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0079"
|
||||
down_revision = "0078"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_shapes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("repo_key", sa.Text(), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="unclassified"),
|
||||
sa.Column(
|
||||
"snippet_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("classified_by", sa.Text(), nullable=True),
|
||||
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"project_id", "repo_key", "path", "symbol", "kind",
|
||||
name="uq_code_shapes_identity",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
|
||||
)
|
||||
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
|
||||
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
|
||||
op.drop_table("code_shapes")
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
|
||||
|
||||
Revision ID: 0080
|
||||
Revises: 0079
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
|
||||
(definition line + a whitespace/comment-insensitive hash of its block) so the
|
||||
proposer can match on content and a later drift recheck can notice change,
|
||||
without the ledger ever storing code. The proposal columns carry the
|
||||
proposer's standing suggestion for an unclassified row — instance-of-#N with
|
||||
a basis and score, or a derive-first group key — and `proposed_sha`
|
||||
remembers the content it was judged at so a refresh re-examines only what
|
||||
changed. Mechanical and recomputable: a restore that lacks them loses
|
||||
nothing the next refresh does not rebuild.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0080"
|
||||
down_revision = "0079"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column(
|
||||
"code_shapes",
|
||||
sa.Column(
|
||||
"proposed_snippet_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
|
||||
op.create_index(
|
||||
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
|
||||
for col in (
|
||||
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
|
||||
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
|
||||
):
|
||||
op.drop_column("code_shapes", col)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
|
||||
|
||||
Revision ID: 0081
|
||||
Revises: 0080
|
||||
Create Date: 2026-08-21
|
||||
|
||||
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
|
||||
a judgment was made at so a later body change under an instance/variant can
|
||||
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
|
||||
the previous refresh, where one canon dominates its directory+kind, and not
|
||||
proposed as that canon). `code_shape_events` is the what-was-used-when
|
||||
record: every classification, vanish, reappearance, and drift as it
|
||||
happened — history the row alone cannot keep once it moves on.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0081"
|
||||
down_revision = "0080"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column(
|
||||
"code_shapes",
|
||||
sa.Column(
|
||||
"diverges_from",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
|
||||
op.create_table(
|
||||
"code_shape_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"shape_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("project_id", sa.Integer(), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=True),
|
||||
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("classified_by", sa.Text(), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
|
||||
op.create_index(
|
||||
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
|
||||
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
|
||||
op.drop_table("code_shape_events")
|
||||
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
|
||||
for col in ("diverges_from", "recheck_at", "classified_sha"):
|
||||
op.drop_column("code_shapes", col)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
|
||||
|
||||
Revision ID: 0082
|
||||
Revises: 0081
|
||||
Create Date: 2026-08-21
|
||||
|
||||
A repo binding used to imply the repo's default branch; the shape ledger
|
||||
therefore only saw work after a merge to main, while the operator's work
|
||||
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
|
||||
NULL keeps today's behaviour (the forge's default branch).
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0082"
|
||||
down_revision = "0081"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("repo_bindings", "ref")
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
|
||||
|
||||
Revision ID: 0083
|
||||
Revises: 0082
|
||||
Create Date: 2026-08-21
|
||||
|
||||
The 2026-08 audit wrote the same free-text reason thousands of times
|
||||
("scoped rule — styles one element of this view"); a judgment's WHY stays
|
||||
prose, but an optional code from a fixed catalogue makes the ledger
|
||||
filterable and aggregable ("how many pure helpers, how many test helpers").
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0083"
|
||||
down_revision = "0082"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("code_shapes", "reason_code")
|
||||
@@ -1,40 +0,0 @@
|
||||
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
|
||||
|
||||
Revision ID: 0084
|
||||
Revises: 0083
|
||||
Create Date: 2026-08-21
|
||||
|
||||
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
|
||||
a canon). But a shape can also CALL several canonical helpers — e.g. a
|
||||
service function both conforming to the service-function convention and
|
||||
consuming hash_token. The 2026-08 audit had to pick one; hook evidence
|
||||
("pulled #N then wrote code referencing it") was stamped as instance when it
|
||||
is a uses fact. This table holds the many-valued relation: shape → snippet,
|
||||
with the basis and the evidence. Cascades with the shape and the snippet.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0084"
|
||||
down_revision = "0083"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_shape_uses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("basis", sa.Text(), nullable=False),
|
||||
sa.Column("evidence", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
|
||||
)
|
||||
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
|
||||
op.drop_table("code_shape_uses")
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
|
||||
|
||||
Revision ID: 0085
|
||||
Revises: 0084
|
||||
Create Date: 2026-08-22
|
||||
|
||||
`projects.inception` is the WHY a project inherits what it does — NULL until
|
||||
someone decides, at which point enter_project stops asking. The new
|
||||
association `project_rulebook_exclusions` is the opt-out of a whole always-on
|
||||
rulebook for one project (the sibling of the rule/topic suppressions).
|
||||
|
||||
Backfill: every project that exists when this runs is stamped
|
||||
via="legacy" with its CURRENT standing (no exclusions, its subscriptions,
|
||||
its design_system_id, no seed) — so the ask fires only for projects created
|
||||
after the step shipped, and nothing a running install relies on changes.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0085"
|
||||
down_revision = "0084"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column("inception", postgresql.JSONB(), nullable=True),
|
||||
)
|
||||
op.create_table(
|
||||
"project_rulebook_exclusions",
|
||||
sa.Column(
|
||||
"project_id", sa.BigInteger(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
primary_key=True, nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"rulebook_id", sa.BigInteger(),
|
||||
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||
primary_key=True, nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
)
|
||||
# Legacy stamp: what each existing project inherits today, recorded as a
|
||||
# decision so the inception ask does not fire on a project that has been
|
||||
# running for months.
|
||||
op.execute(sa.text("""
|
||||
UPDATE projects p SET inception = jsonb_build_object(
|
||||
'via', 'legacy',
|
||||
'decided_at', to_jsonb(now()),
|
||||
'decided_by', NULL,
|
||||
'choices', jsonb_build_object(
|
||||
'exclude_always_on_rulebooks', '[]'::jsonb,
|
||||
'subscribe_rulebooks', COALESCE(
|
||||
(SELECT jsonb_agg(s.rulebook_id ORDER BY s.rulebook_id)
|
||||
FROM project_rulebook_subscriptions s
|
||||
WHERE s.project_id = p.id),
|
||||
'[]'::jsonb),
|
||||
'design_system_id', to_jsonb(p.design_system_id),
|
||||
'seed_systems', false
|
||||
)
|
||||
)
|
||||
WHERE p.inception IS NULL
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("project_rulebook_exclusions")
|
||||
op.drop_column("projects", "inception")
|
||||
@@ -1,35 +0,0 @@
|
||||
"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917)
|
||||
|
||||
Revision ID: 0086
|
||||
Revises: 0085
|
||||
Create Date: 2026-08-23
|
||||
|
||||
CSS is watched by name, by recipe, by token and by WHAT USES IT. This table
|
||||
holds the fourth: CSS shape → the file whose markup names its class, with how
|
||||
many times. Mechanical and recomputed by every coverage sync from the repo
|
||||
archive; the analogue of code_shape_uses for styling. Cascades with the shape.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0086"
|
||||
down_revision = "0085"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_shape_consumers",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("basis", sa.Text(), nullable=False, server_default="template"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("code_shape_consumers")
|
||||
@@ -1,109 +0,0 @@
|
||||
"""canonical_systems — the global area vocabulary, promoted from a constant
|
||||
to a table (milestone 307 step 1, decision note 3026)
|
||||
|
||||
Revision ID: 0087
|
||||
Revises: 0086
|
||||
Create Date: 2026-08-26
|
||||
|
||||
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
|
||||
services/systems.py that milestone 297 seeds into a project at inception. A
|
||||
constant cannot be referenced: a rule that applies across projects has nothing
|
||||
to point at, because `systems.project_id` is NOT NULL and a family rule cannot
|
||||
be chained to one project's row. This makes the vocabulary a table so it can be
|
||||
a foreign key, and adds the nullable `systems.canonical_id` that maps a
|
||||
project's local System onto it.
|
||||
|
||||
Deliberately no `user_id`: the catalog is GLOBAL so a shared project inherits
|
||||
the vocabulary rather than re-earning it. `record_systems` is untouched — it
|
||||
joins note_id/system_id and never sees this table, so no association data
|
||||
moves, and no System's own `name` is rewritten.
|
||||
|
||||
The seed rows are written here verbatim rather than imported from the service:
|
||||
a migration is a historical record and must keep running unchanged after the
|
||||
service's list moves on.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0087"
|
||||
down_revision = "0086"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# (name, slug, description) — the milestone-297 vocabulary, with the slug the
|
||||
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
|
||||
# collapsed to "-"). Charters stay generic on purpose: a project refines its
|
||||
# own System's description, never this one. Nothing here names an app, a repo,
|
||||
# a vendor or a house convention — the catalog ships to every install (rule 115).
|
||||
_SEED = (
|
||||
("CI & Release", "ci-and-release",
|
||||
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
|
||||
("Auth & Access", "auth-and-access",
|
||||
"Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
|
||||
("Data Model & Storage", "data-model-and-storage",
|
||||
"What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
|
||||
("API Surface", "api-surface",
|
||||
"The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
|
||||
("UI & Design", "ui-and-design",
|
||||
"What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
|
||||
("Import & Export", "import-and-export",
|
||||
"Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
|
||||
("Background Jobs", "background-jobs",
|
||||
"Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
|
||||
("Observability", "observability",
|
||||
"How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
canonical_systems = op.create_table(
|
||||
"canonical_systems",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("slug", sa.Text(), nullable=False),
|
||||
sa.Column("description", 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),
|
||||
)
|
||||
# Unique among LIVE rows only, so a soft-deleted entry doesn't block
|
||||
# recreating or restoring the same area (the rules/topics convention).
|
||||
op.create_index(
|
||||
"uq_canonical_systems_slug", "canonical_systems", ["slug"],
|
||||
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
op.bulk_insert(
|
||||
canonical_systems,
|
||||
[
|
||||
{"name": name, "slug": slug, "description": description, "order_index": index}
|
||||
for index, (name, slug, description) in enumerate(_SEED)
|
||||
],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"systems",
|
||||
sa.Column("canonical_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
# SET NULL, not CASCADE: retiring a catalog entry must never delete a
|
||||
# project's System along with it.
|
||||
op.create_foreign_key(
|
||||
"fk_systems_canonical_id", "systems", "canonical_systems",
|
||||
["canonical_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_systems_canonical_id", "systems", ["canonical_id"])
|
||||
|
||||
# Existing Systems are left UNMAPPED on purpose. An exact-slug match would
|
||||
# be safe, but a near miss ("CI & runners" vs "CI & Release") is a judgment
|
||||
# call — those go through the propose/confirm path so a human approves each
|
||||
# one, rather than being decided by a migration nobody reviews.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_systems_canonical_id", table_name="systems")
|
||||
op.drop_constraint("fk_systems_canonical_id", "systems", type_="foreignkey")
|
||||
op.drop_column("systems", "canonical_id")
|
||||
op.drop_index("uq_canonical_systems_slug", table_name="canonical_systems")
|
||||
op.drop_table("canonical_systems")
|
||||
@@ -1,105 +0,0 @@
|
||||
"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307
|
||||
step 3, decision note 3026)
|
||||
|
||||
Revision ID: 0088
|
||||
Revises: 0087
|
||||
Create Date: 2026-08-26
|
||||
|
||||
A rule could not say WHEN it applies, WHICH area it is about, or WHAT other
|
||||
rule it belongs with. All three were being written as prose instead — a
|
||||
project's System description restating rule text, a rule's `why` naming the
|
||||
note that caused it, and two halves of one shape merged into a single row
|
||||
because either could surface without the other.
|
||||
|
||||
Four additions, each replacing something that was already being said in words:
|
||||
|
||||
- `when_to_apply` — the trigger. Nullable HERE and required at the service
|
||||
layer, because existing rules have none and a migration cannot invent one.
|
||||
- `tier` — `always_on` (preloaded, as everything is today) or `conditional`
|
||||
(reachable, surfaced when its trigger fires). Defaults to `always_on`, so
|
||||
this migration changes NOTHING about which rules bind: an install upgrades
|
||||
and every rule keeps arriving exactly as it did.
|
||||
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
|
||||
already have.
|
||||
- `rule_systems` / `rule_relations` — the canon tag and the typed edges.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0088"
|
||||
down_revision = "0087"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Kept in one place so upgrade and the CHECK agree by construction (rule 36:
|
||||
# a whitelisted value means DROP + ADD CONSTRAINT in the same migration —
|
||||
# there is no prior constraint here, so the pair is created together).
|
||||
_TIERS = ("always_on", "conditional")
|
||||
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
|
||||
|
||||
def _in_list(column: str, values: tuple[str, ...]) -> str:
|
||||
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rules", sa.Column("when_to_apply", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
|
||||
)
|
||||
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
|
||||
|
||||
# SET NULL, not CASCADE: the record that prompted a rule can be trashed
|
||||
# without taking the rule with it — provenance is a claim about history,
|
||||
# and losing the source does not repeal the rule.
|
||||
op.add_column("rules", sa.Column("arose_from_id", sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_rules_arose_from_id", "rules", "notes",
|
||||
["arose_from_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# Which global AREA a rule is about. Points at the canonical catalog, never
|
||||
# at a project's `systems` row — a rule that spans projects cannot be
|
||||
# chained to one project's vocabulary (0087).
|
||||
op.create_table(
|
||||
"rule_systems",
|
||||
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("canonical_id", sa.Integer(), sa.ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_rule_systems_canonical_id", "rule_systems", ["canonical_id"])
|
||||
|
||||
# Typed edges between rules. Each kind exists because its absence forced a
|
||||
# workaround: co_surfaces (merging two rules into one row), overrides (a
|
||||
# stricter project rule written as a duplicate), elaborates (a local
|
||||
# addendum sitting beside its parent with nothing to say it is one).
|
||||
op.create_table(
|
||||
"rule_relations",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("from_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("to_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.CheckConstraint(_in_list("kind", _RELATION_KINDS), name="ck_rule_relations_kind"),
|
||||
# A rule cannot relate to itself, and one pair carries a given kind
|
||||
# once — a second row would surface the same rule twice.
|
||||
sa.CheckConstraint("from_rule_id <> to_rule_id", name="ck_rule_relations_not_self"),
|
||||
sa.UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
|
||||
)
|
||||
op.create_index("ix_rule_relations_from", "rule_relations", ["from_rule_id"])
|
||||
op.create_index("ix_rule_relations_to", "rule_relations", ["to_rule_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_relations_to", table_name="rule_relations")
|
||||
op.drop_index("ix_rule_relations_from", table_name="rule_relations")
|
||||
op.drop_table("rule_relations")
|
||||
op.drop_index("ix_rule_systems_canonical_id", table_name="rule_systems")
|
||||
op.drop_table("rule_systems")
|
||||
op.drop_constraint("fk_rules_arose_from_id", "rules", type_="foreignkey")
|
||||
op.drop_column("rules", "arose_from_id")
|
||||
op.drop_constraint("ck_rules_tier", "rules", type_="check")
|
||||
op.drop_column("rules", "tier")
|
||||
op.drop_column("rules", "when_to_apply")
|
||||
@@ -1,58 +0,0 @@
|
||||
"""rule_embeddings — rules become findable by meaning (milestone 307 step 4,
|
||||
decision note 3026)
|
||||
|
||||
Revision ID: 0089
|
||||
Revises: 0088
|
||||
Create Date: 2026-08-26
|
||||
|
||||
Rules were the only major record type with no vector, so `search` could never
|
||||
return one and a rule could only ever arrive by being preloaded. That single
|
||||
fact is what made every rule compete for the same always-on budget.
|
||||
|
||||
A sibling table rather than a generalisation of note_embeddings: the row could
|
||||
have been made polymorphic, but the SEARCH could not — semantic_search_notes is
|
||||
Note-specific scoping end to end, and a rule shares none of it. See the model
|
||||
docstring for the full reasoning.
|
||||
|
||||
The vectors are DERIVED data. Nothing is backfilled here: the startup backfill
|
||||
regenerates them, which is also how a chunker-version bump is handled.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0089"
|
||||
down_revision = "0088"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Matches note_embeddings — bge-small-en-v1.5, 384-dim unit-normalized.
|
||||
_EMBEDDING_DIM = 384
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_embeddings",
|
||||
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("chunk_index", sa.Integer(), primary_key=True),
|
||||
sa.Column("chunk_text", sa.Text(), nullable=False),
|
||||
sa.Column("chunker_version", sa.Integer(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
# The vector column is added by raw DDL for the same reason 0067 did it:
|
||||
# the type comes from the pgvector extension, not from SQLAlchemy's
|
||||
# type system.
|
||||
op.execute(f"ALTER TABLE rule_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL")
|
||||
# HNSW for cosine distance — matches Vector.cosine_distance (`<=>`), so the
|
||||
# search is an indexed ORDER BY ... LIMIT k rather than a full scan.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX ix_rule_embeddings_embedding_hnsw
|
||||
ON rule_embeddings
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_rule_embeddings_embedding_hnsw")
|
||||
op.drop_table("rule_embeddings")
|
||||
@@ -1,64 +0,0 @@
|
||||
"""a rule can carry its own check — verify_with, expires_when, verified_at
|
||||
(milestone 312 step 1)
|
||||
|
||||
Revision ID: 0090
|
||||
Revises: 0089
|
||||
Create Date: 2026-08-27
|
||||
|
||||
A rulebook holds two kinds of row in one table. A NORM is a decision: it has
|
||||
no truth value, and it changes only when its author changes it — which they
|
||||
know they did. A CONSTRAINT is a fact about someone else's software: a
|
||||
runner's shell, a bot's config, a tool that exists. Nobody is present when
|
||||
that goes false.
|
||||
|
||||
Milestone 307's rulebook audit found nine stale sites. Every one was a
|
||||
constraint; not one norm had rotted. One of them had been telling every
|
||||
session to skip database-backed tests for weeks while the integration lane
|
||||
sat green in the workflow.
|
||||
|
||||
Three nullable columns, so a rule can say how to check itself:
|
||||
|
||||
- `verify_with` — how to tell whether this is still true. A command, a path,
|
||||
a URL, a query. Prose is allowed; something runnable is better.
|
||||
- `expires_when` — the STATE under which it stops being true. Deliberately
|
||||
not a date: constraints do not expire on a schedule, they expire when the
|
||||
world underneath them moves.
|
||||
- `verified_at` — when the check last passed. NULL means never checked, and
|
||||
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
|
||||
|
||||
All three nullable and all three optional, because most rules should set
|
||||
none of them. A null `verify_with` is not an omission — it is the honest
|
||||
marker of "this one is a decision, and there is nothing to go and check."
|
||||
That signal only works if the field stays empty wherever it belongs empty.
|
||||
|
||||
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
|
||||
backfilled: a migration cannot invent a check any more than 0088 could
|
||||
invent a trigger.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0090"
|
||||
down_revision = "0089"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True))
|
||||
op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# No index on (verify_with, verified_at). The sweep this exists for reads
|
||||
# an operator's whole rulebook — hundreds of rows, not millions — and runs
|
||||
# when a human asks for it, never on a request path. An index here would
|
||||
# be maintained on every rule write to serve a query that a sequential
|
||||
# scan answers instantly.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rules", "verified_at")
|
||||
op.drop_column("rules", "expires_when")
|
||||
op.drop_column("rules", "verify_with")
|
||||
@@ -1,66 +0,0 @@
|
||||
"""task_kind gains 'spike' — the investigation, not the change
|
||||
(milestone 312 step 5)
|
||||
|
||||
Revision ID: 0091
|
||||
Revises: 0090
|
||||
Create Date: 2026-08-27
|
||||
|
||||
A spike is a task shape the others cannot hold. `work` ships a change;
|
||||
`issue` fixes something broken. A spike is time-boxed and its output is
|
||||
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the
|
||||
end of it. "Find out whether the runner can be given a bash shell" is not
|
||||
work, and filing it as work makes a finished investigation look like an
|
||||
abandoned change.
|
||||
|
||||
It is the record a failed check asks for. Milestone 312 gave rules a
|
||||
`verify_with`; when one of those fails, the rule is wrong and the next move
|
||||
is often to go and find out what replaced it. `notes.arose_from_id` already
|
||||
exists (0065), so that constraint -> spike link needs no further schema.
|
||||
|
||||
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
|
||||
widened constraint land in the SAME migration — DROP then ADD, exactly as
|
||||
0065 did when it introduced 'issue'. Adding the value and constraining it
|
||||
later leaves a window where the database accepts anything.
|
||||
|
||||
'plan' stays in the list though it is retired (plans are milestones since
|
||||
0066): historical plan-tasks still carry it, and dropping it from the
|
||||
whitelist would make old rows unwritable.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0091"
|
||||
down_revision = "0090"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# One tuple so the upgrade and the downgrade cannot disagree about what the
|
||||
# list was on either side of this migration.
|
||||
_KINDS_AFTER = ("work", "plan", "issue", "spike")
|
||||
_KINDS_BEFORE = ("work", "plan", "issue")
|
||||
|
||||
|
||||
# Restated rather than imported from 0088, which has the same helper. A
|
||||
# migration is a snapshot: it must keep working when the code around it has
|
||||
# moved on, so it never imports from live modules or from its siblings. Six
|
||||
# duplicated lines are the price of that, and the cheap half of the bargain.
|
||||
def _in_list(values: tuple[str, ...]) -> str:
|
||||
return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_AFTER),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Any row already filed as a spike would violate the narrowed constraint,
|
||||
# so they are demoted to 'work' first. Lossy and deliberately so: the
|
||||
# alternative is a downgrade that fails on real data, which is worse than
|
||||
# a downgrade that says what it did.
|
||||
op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'")
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE),
|
||||
)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""a note can carry its own check — verify_with, expires_when, verified_at
|
||||
(milestone 317 step 1)
|
||||
|
||||
Revision ID: 0092
|
||||
Revises: 0091
|
||||
Create Date: 2026-08-28
|
||||
|
||||
The sibling of 0090, which gave rules the same three columns. Same
|
||||
distinction, one table over:
|
||||
|
||||
A NORM is a decision — no truth value, and it changes only when its author
|
||||
changes it, which they know they did. A CONSTRAINT is a fact about someone
|
||||
else's software, and nobody is present when it goes false.
|
||||
|
||||
Notes hold far more constraints than rules do, and hold them for longer. A
|
||||
cross-project reference note asserting what a signing service does on a
|
||||
duplicate upload, or how a forge numbers its CI runs, is believed by every
|
||||
project that reads it, and there is nothing in the record that says when
|
||||
anyone last looked. `note_supersessions` only fires once a human has read
|
||||
the note, disagreed, and written the correction — which is the case where
|
||||
the note was already believed.
|
||||
|
||||
Three nullable columns:
|
||||
|
||||
- `verify_with` — how to tell whether this is still true. A command, a path,
|
||||
a URL, a query. Prose is allowed; something runnable is better.
|
||||
- `expires_when` — the STATE under which it stops being true. Deliberately
|
||||
not a date: constraints do not expire on a schedule, they expire when the
|
||||
world underneath them moves.
|
||||
- `verified_at` — when the check last passed. NULL means never checked, and
|
||||
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
|
||||
|
||||
WHICH ROWS THESE ARE FOR. `notes` is one table holding notes, tasks,
|
||||
snippets and processes, so these columns land on all of them. Only non-task,
|
||||
non-snippet records are OFFERED them (milestone 317 decisions 1 and 2, gated
|
||||
at the service in step 2): a task's decay is its status, and a snippet
|
||||
already carries a richer, location-aware verdict in `data.verification`. The
|
||||
columns exist on the other rows and stay null there; a gate that lives in
|
||||
the schema would have meant a partial index or a CHECK across three columns
|
||||
to express something the write path can say in two lines.
|
||||
|
||||
All three optional, because most notes should set none of them — the whole
|
||||
value of the sweep is that its output is short. A null `verify_with` is not
|
||||
an omission; it is the honest marker of "this one is a decision, and there
|
||||
is nothing to go and check."
|
||||
|
||||
No CHECK constraint is involved, so rule 36 does not apply. Nothing is
|
||||
backfilled: a migration cannot invent a check.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0092"
|
||||
down_revision = "0091"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("verify_with", sa.Text(), nullable=True))
|
||||
op.add_column("notes", sa.Column("expires_when", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# No index, for 0090's reason — the sweep runs when a human asks, never on
|
||||
# a request path — but the margin is thinner here and worth naming. `rules`
|
||||
# is hundreds of rows; `notes` is thousands and grows with every session.
|
||||
#
|
||||
# Still a sequential scan's job at this size, and an index on
|
||||
# (verified_at) filtered to `verify_with IS NOT NULL` would be maintained
|
||||
# on every note write to serve one operator-initiated query. If step 3's
|
||||
# live acceptance measures otherwise, add it there against a real plan
|
||||
# rather than guessing here.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "verified_at")
|
||||
op.drop_column("notes", "expires_when")
|
||||
op.drop_column("notes", "verify_with")
|
||||
@@ -1,83 +0,0 @@
|
||||
"""rules gain an edit history — rule_versions (milestone 323 step 1)
|
||||
|
||||
Revision ID: 0093
|
||||
Revises: 0092
|
||||
Create Date: 2026-08-29
|
||||
|
||||
The sibling `note_versions` has had for a long time. A note's every meaningful
|
||||
edit is snapshotted, and the design-system note calls that history "the
|
||||
changelog". A RULE — which binds behaviour on every session that loads it —
|
||||
had nothing: an edit destroyed what it used to say, with no record anywhere.
|
||||
|
||||
Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement
|
||||
had to be hand-copied into a task log to survive the edit (#3237), which is
|
||||
not a process, it is a person remembering. The more consequential record had
|
||||
the weaker protection.
|
||||
|
||||
Three things are deliberately NOT copied from note_versions, and each is a
|
||||
guard that exists there for a reason that does not hold here:
|
||||
|
||||
- **No pruning, and no MAX_VERSIONS.** That cap defends against note autosave
|
||||
filling every slot. Rules have no autosave; every edit is a deliberate
|
||||
update_rule. A rule is edited a handful of times in its life, and capping
|
||||
invites losing the one edit somebody needed.
|
||||
- **No pin columns.** `pin_kind`/`pin_label` exist so a note's version can
|
||||
survive that pruning. With nothing pruning, a pin protects a row that was
|
||||
never at risk.
|
||||
- **No minimum interval.** 300 seconds between snapshots is also an autosave
|
||||
defence; here it would only ever discard a second deliberate edit.
|
||||
|
||||
`user_id` is the ACTOR rather than the owner, and is SET NULL rather than
|
||||
CASCADE: deleting a user must not erase the history of the rules they edited.
|
||||
The edit still happened and the rule still binds because of it.
|
||||
|
||||
No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a
|
||||
migration cannot invent the text a rule used to have, and inventing "the
|
||||
current text, as of now" would be worse than an empty history, because it
|
||||
would look like a record of an edit that never occurred.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0093"
|
||||
down_revision = "0092"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_versions",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("rules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("title", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("statement", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("why", sa.Text(), nullable=True),
|
||||
sa.Column("how_to_apply", sa.Text(), nullable=True),
|
||||
sa.Column("when_to_apply", sa.Text(), nullable=True),
|
||||
sa.Column("tier", sa.Text(), nullable=True),
|
||||
sa.Column("verify_with", sa.Text(), nullable=True),
|
||||
sa.Column("expires_when", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
# The only query this table serves is "the history of THIS rule, newest
|
||||
# first" — unlike 0092's columns, which are read by an operator-initiated
|
||||
# sweep over the whole set. Every read here is keyed on rule_id, so the
|
||||
# index earns its write cost immediately rather than on a hunch.
|
||||
op.create_index("ix_rule_versions_rule_id", "rule_versions", ["rule_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_versions_rule_id", table_name="rule_versions")
|
||||
op.drop_table("rule_versions")
|
||||
@@ -1,86 +0,0 @@
|
||||
"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1)
|
||||
|
||||
Revision ID: 0094
|
||||
Revises: 0093
|
||||
Create Date: 2026-09-02
|
||||
|
||||
The sibling `note_usage_events` has had since 0071, and the third rule-side
|
||||
table to arrive after `rule_embeddings` and `rule_versions` — each one added
|
||||
because the rule side kept inheriting machinery built for notes and getting
|
||||
the weaker version of it.
|
||||
|
||||
WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval
|
||||
surface in Scribe whose usefulness cannot be observed, and — not coincidentally
|
||||
— the only one that has never declined to fire. Over 30 days it took 296 calls,
|
||||
returned something on every one, and cleared its threshold 100% of the time,
|
||||
while every other surface declines most of the time (#3311). That is either a
|
||||
perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs`
|
||||
cannot tell them apart: it records what the ranker scored, never whether the
|
||||
hint was any use.
|
||||
|
||||
WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific
|
||||
fields and the aggregate readout is the same shape, which is the strongest case
|
||||
for sharing that note #3163 admits. What decides against it is identity at
|
||||
RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map`
|
||||
and drops what does not resolve. A rule id parked in that column would come
|
||||
back from a backup silently reattached to whatever note took that number —
|
||||
telemetry not merely lost but wrong, and wrong in a way nothing downstream
|
||||
could detect. `rule_versions` made the same call for the same reason.
|
||||
|
||||
FK-free on `rule_id` and `user_id`, matching note_usage_events, retrieval_logs
|
||||
and app_logs — and deliberately unlike `rule_versions`, which does carry FKs.
|
||||
The difference is what the row is for: a version belongs to a rule's history
|
||||
and dies with it; telemetry outlives the row it describes. Deleting a rule must
|
||||
not erase the evidence that it was surfaced forty times and opened never, since
|
||||
that evidence is exactly the case for having deleted it.
|
||||
|
||||
No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to
|
||||
a column that is already gated; it does not require gating one that never was,
|
||||
and a two-member enum whose members are written by two functions in one module
|
||||
is not where that discipline earns its cost.
|
||||
|
||||
Downgrade drops the table outright. The data is purely observational — nothing
|
||||
reads it for correctness, so losing it costs history and no behaviour.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0094"
|
||||
down_revision = "0093"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_usage_events",
|
||||
# BigInteger throughout where the note twin uses Integer: rules.id is
|
||||
# BigInteger, so rule_id must be, and a high-churn append-only table is
|
||||
# a poor place to discover an id ceiling.
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("rule_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.Text(), nullable=False),
|
||||
)
|
||||
# Every readout is "these rule 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_rule_usage_rule_event", "rule_usage_events", ["rule_id", "event"]
|
||||
)
|
||||
op.create_index("ix_rule_usage_created_at", "rule_usage_events", ["created_at"])
|
||||
op.create_index("ix_rule_usage_user_id", "rule_usage_events", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_usage_user_id", table_name="rule_usage_events")
|
||||
op.drop_index("ix_rule_usage_created_at", table_name="rule_usage_events")
|
||||
op.drop_index("ix_rule_usage_rule_event", table_name="rule_usage_events")
|
||||
op.drop_table("rule_usage_events")
|
||||
@@ -43,10 +43,8 @@ client straight to the URL with a Bearer token.
|
||||
|
||||
Authenticate with an API key generated from **Settings → API Keys** (see above),
|
||||
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
|
||||
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
|
||||
any write/delete tool is rejected with `403`. The allow-list is explicit rather
|
||||
than derived from the name — see `_READ_ONLY_TOOLS`, which is why the two reads
|
||||
without a read-shaped name are spelled out here. A `write`-scoped key may call everything.
|
||||
read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
|
||||
is rejected with `403`. A `write`-scoped key may call everything.
|
||||
|
||||
### Claude Code (Project-scoped)
|
||||
|
||||
@@ -87,7 +85,7 @@ table here. The tools are grouped by family:
|
||||
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
|
||||
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
|
||||
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
||||
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
||||
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||
|
||||
@@ -76,9 +76,7 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
|
||||
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
|
||||
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
|
||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
||||
@@ -120,7 +118,6 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
||||
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
|
||||
|
||||
## Sharing
|
||||
|
||||
@@ -172,8 +169,6 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
||||
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
||||
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
|
||||
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
|
||||
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
|
||||
|
||||
## Dashboard, Export, Trash, Users
|
||||
|
||||
+23
-36
@@ -7,20 +7,12 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiPut } from "@/api/client";
|
||||
import { fetchVersion } from "@/api/version";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
|
||||
useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
|
||||
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
|
||||
// This used to default to the literal "dev" and swallow the error, which meant
|
||||
// an instance that could not answer was indistinguishable from a local build
|
||||
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
|
||||
// readout whose whole job is to say what is running.
|
||||
const appVersion = ref<string | null>(null);
|
||||
const appVersionFailed = ref(false);
|
||||
const appVersion = ref("dev");
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
@@ -127,12 +119,10 @@ onMounted(async () => {
|
||||
startAppServices();
|
||||
}
|
||||
try {
|
||||
appVersion.value = (await fetchVersion()).version;
|
||||
const data = await apiGet<{ version: string }>("/api/version");
|
||||
appVersion.value = data.version;
|
||||
} catch {
|
||||
// Not silent any more: the footer says it could not find out, rather than
|
||||
// showing a version it never received. The full readout (version, channel,
|
||||
// commit, build) lives in Settings → Config.
|
||||
appVersionFailed.value = true;
|
||||
// silent — version display is non-critical
|
||||
}
|
||||
});
|
||||
|
||||
@@ -161,10 +151,7 @@ onUnmounted(() => {
|
||||
<div id="main-content" class="app-content">
|
||||
<router-view />
|
||||
</div>
|
||||
<footer class="app-footer">
|
||||
<span v-if="appVersion">v{{ appVersion }}</span>
|
||||
<span v-else-if="appVersionFailed">version unknown</span>
|
||||
</footer>
|
||||
<footer class="app-footer">v{{ appVersion }}</footer>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts overlay -->
|
||||
@@ -267,7 +254,7 @@ onUnmounted(() => {
|
||||
left: 0.5rem;
|
||||
z-index: 9999;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--fs-accent);
|
||||
background: var(--color-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 0 0 4px 4px;
|
||||
font-size: 0.875rem;
|
||||
@@ -303,7 +290,7 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.45;
|
||||
user-select: none;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -313,17 +300,17 @@ onUnmounted(() => {
|
||||
.shortcuts-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--fs-overlay);
|
||||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.shortcuts-panel {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
|
||||
width: min(420px, 92vw);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -332,25 +319,25 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.shortcuts-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.shortcuts-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.shortcuts-close:hover {
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.shortcuts-body {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
@@ -363,7 +350,7 @@ onUnmounted(() => {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.shortcut-row {
|
||||
@@ -378,23 +365,23 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
min-width: 1.8rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.shortcut-key-sep {
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.shortcut-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
|
||||
* map onto (milestone 307).
|
||||
*
|
||||
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
|
||||
* name the project gave it, and `canonical_id` only records which shared area
|
||||
* it is an instance of. An unmapped System is fully usable — the catalog is a
|
||||
* convergence aid, not a gate.
|
||||
*/
|
||||
import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client";
|
||||
|
||||
export interface CanonicalSystem {
|
||||
id: number;
|
||||
name: string;
|
||||
/** The match key: lowercase, "&" folded to "and", punctuation collapsed. */
|
||||
slug: string;
|
||||
description: string | null;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested mapping. `basis` is the whole point of showing it:
|
||||
* - `exact` — the names differ only in spelling. Mechanical.
|
||||
* - `overlap` — they share a meaningful word. A judgment call the reviewer is
|
||||
* making, and it must never be presented as if it were the first.
|
||||
*/
|
||||
export interface CanonicalMatch {
|
||||
id: number;
|
||||
name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface MappingProposal {
|
||||
system_id: number;
|
||||
system_name: string;
|
||||
canonical_id: number;
|
||||
canonical_name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score: number;
|
||||
}
|
||||
|
||||
export async function listCanonicalSystems(): Promise<CanonicalSystem[]> {
|
||||
const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>(
|
||||
"/api/canonical-systems",
|
||||
);
|
||||
return data.canonical_systems;
|
||||
}
|
||||
|
||||
/** Admin only — a global list anyone can extend stops being shared. */
|
||||
export async function createCanonicalSystem(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<CanonicalSystem> {
|
||||
return apiPost("/api/canonical-systems", data);
|
||||
}
|
||||
|
||||
export async function updateCanonicalSystem(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
): Promise<CanonicalSystem> {
|
||||
return apiPatch(`/api/canonical-systems/${id}`, data);
|
||||
}
|
||||
|
||||
/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */
|
||||
export async function proposeMappings(projectId: number): Promise<MappingProposal[]> {
|
||||
const data = await apiGet<{ proposals: MappingProposal[] }>(
|
||||
`/api/projects/${projectId}/canonical-proposals`,
|
||||
);
|
||||
return data.proposals;
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping. `null` unmaps. */
|
||||
export async function mapSystem(
|
||||
systemId: number,
|
||||
canonicalId: number | null,
|
||||
): Promise<{ id: number; canonical_id: number | null }> {
|
||||
return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId });
|
||||
}
|
||||
+32
-141
@@ -38,135 +38,41 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's `{"error": "..."}` message from a failed call, or `fallback`
|
||||
* when the failure carried none (network error, non-JSON body). The one place
|
||||
* the error envelope is unpacked on the client — views used to restate this
|
||||
* as a six-line `"body" in e` branch at every catch site.
|
||||
*/
|
||||
export function apiErrorMessage(e: unknown, fallback: string): string {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: unknown } }).body;
|
||||
if (body && typeof body.error === "string" && body.error) return body.error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long an ordinary JSON call may wait before it is declared failed.
|
||||
*
|
||||
* Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait
|
||||
* as long as the browser will, which is not a deadline — it is the absence of
|
||||
* one, and it renders as a spinner that never resolves. There is no state a
|
||||
* surface can show for "pending forever" that is not a lie.
|
||||
*
|
||||
* 30s is chosen to be longer than anything healthy: it has to clear a cold
|
||||
* embedding call and a list view under connection-pool contention (#2384 had
|
||||
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
|
||||
* tripping it means something is genuinely wrong rather than merely busy. Slow
|
||||
* BY DESIGN is a different case and passes its own value — see the callers in
|
||||
* SettingsView that do.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means
|
||||
* "the client gave up" rather than anything the server said. */
|
||||
const CLIENT_TIMEOUT_STATUS = 408;
|
||||
|
||||
/**
|
||||
* How long a STREAM may take to answer with its headers.
|
||||
*
|
||||
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
|
||||
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
|
||||
* it mid-flight along with the body. But that does not exempt them from rule
|
||||
* 156 — it relocates the deadline. Two different waits are involved:
|
||||
*
|
||||
* connect — the server answering with headers. CAN fail to answer, so it
|
||||
* carries this deadline, cleared the moment headers arrive.
|
||||
* stream — the body, open indefinitely on purpose. Its failure mode is
|
||||
* going quiet, which a timeout cannot tell from being idle; that
|
||||
* is what reconnection and Last-Event-ID are for, not this.
|
||||
*
|
||||
* Reading the connect as exempt because "the stream is long-lived" is the easy
|
||||
* mistake here, and it leaves an unreachable server looking like a quiet one.
|
||||
*/
|
||||
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* A signal that aborts if headers do not arrive in time, plus the `settle` to
|
||||
* call once they do. After `settle()` the returned signal never fires, so the
|
||||
* stream body runs unbounded — which is the intent.
|
||||
*/
|
||||
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
|
||||
const gate = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
|
||||
STREAM_CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
return {
|
||||
signal: AbortSignal.any([base, gate.signal]),
|
||||
settle: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RequestOpts {
|
||||
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a request is actually made — every verb below goes through
|
||||
* here, so the deadline cannot be forgotten by adding a sixth.
|
||||
*
|
||||
* EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the
|
||||
* failure has to arrive in the shape the caller already handles. A bare
|
||||
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
|
||||
* an object with no `body`, so every catch site in the app would report its
|
||||
* generic fallback and the timeout would be invisible in the very situation it
|
||||
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
|
||||
* report it correctly without being touched.
|
||||
*
|
||||
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
|
||||
* `AbortError` and is left alone — a caller that cancelled its own request
|
||||
* does not want it reported as a server failure.
|
||||
*/
|
||||
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "TimeoutError") {
|
||||
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
|
||||
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
/** JSON body headers — the three write verbs sent an identical literal each. */
|
||||
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||
|
||||
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, {}, opts);
|
||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
}
|
||||
|
||||
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
|
||||
return request<void>(path, { method: "DELETE" }, opts);
|
||||
export async function apiDelete(path: string): Promise<void> {
|
||||
const res = await fetch(path, { method: "DELETE" });
|
||||
return handleResponse<void>(res, path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -301,14 +207,7 @@ export function apiSSEStream(
|
||||
}
|
||||
|
||||
const done = (async () => {
|
||||
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||
const connect = connectDeadline(combinedSignal);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { headers, signal: connect.signal });
|
||||
} finally {
|
||||
connect.settle();
|
||||
}
|
||||
const res = await fetch(path, { headers, signal: combinedSignal });
|
||||
if (!res.ok) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
@@ -405,19 +304,11 @@ export async function apiStreamPost(
|
||||
body: unknown,
|
||||
onChunk: (data: Record<string, unknown>) => void
|
||||
): Promise<void> {
|
||||
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||
const connect = connectDeadline(new AbortController().signal);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
signal: connect.signal,
|
||||
});
|
||||
} finally {
|
||||
connect.settle();
|
||||
}
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errBody: Record<string, unknown> = {};
|
||||
try {
|
||||
|
||||
@@ -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");
|
||||
@@ -74,28 +74,11 @@ export const fetchDesignSystems = () =>
|
||||
export const fetchDesignSystem = (id: number) =>
|
||||
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
||||
|
||||
export interface StarterRoleGroup {
|
||||
group: string;
|
||||
description: string;
|
||||
token_count: number;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
/** The starter token ROLES offered at creation — names and purposes, never
|
||||
* values. A default palette would be one install's taste shipped as product
|
||||
* (rule #115), so the values are always the operator's to fill. */
|
||||
export const listStarterRoleGroups = () =>
|
||||
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
|
||||
"/api/design-systems/starter-roles",
|
||||
);
|
||||
|
||||
export const createDesignSystem = (body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
starter_role_groups?: string[];
|
||||
token_prefix?: string;
|
||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||
|
||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||
@@ -200,14 +183,6 @@ export interface SnippetCheck {
|
||||
findings: SnippetFinding[];
|
||||
}
|
||||
|
||||
/** Which recorded snippets disagree with this design system's sheet.
|
||||
*
|
||||
* `projectId` narrows to the snippets one project owns — which is how a
|
||||
* project asks about its OWN code. Omit it to check every project, which is
|
||||
* the right default from the system's side: a component recorded elsewhere
|
||||
* still has to use the same tags. */
|
||||
export const checkSnippets = (id: number, projectId?: number) =>
|
||||
apiGet<SnippetCheck>(
|
||||
`/api/design-systems/${id}/snippet-check`
|
||||
+ (projectId ? `?project_id=${projectId}` : ""),
|
||||
);
|
||||
/** Which recorded snippets disagree with this design system's sheet. */
|
||||
export const checkSnippets = (id: number) =>
|
||||
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/** Project inception (milestone 297): what a project was decided to inherit. */
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
|
||||
export interface InceptionChoices {
|
||||
exclude_always_on_rulebooks: number[];
|
||||
subscribe_rulebooks: number[];
|
||||
design_system_id: number | null;
|
||||
seed_systems: boolean;
|
||||
}
|
||||
|
||||
export interface InceptionRecord {
|
||||
decided_at: string;
|
||||
decided_by: number | null;
|
||||
via: "mcp" | "ui" | "legacy";
|
||||
choices: InceptionChoices;
|
||||
}
|
||||
|
||||
export interface InceptionDefaults {
|
||||
always_on_rulebooks: { id: number; title: string }[];
|
||||
other_rulebooks: { id: number; title: string }[];
|
||||
excluded_always_on: { id: number; title: string }[];
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
design_system_id: number | null;
|
||||
design_systems: { id: number; title: string }[];
|
||||
systems: number;
|
||||
}
|
||||
|
||||
export interface InceptionDecision {
|
||||
project_id: number;
|
||||
inception: InceptionRecord;
|
||||
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
|
||||
}
|
||||
|
||||
export const emptyChoices = (): InceptionChoices => ({
|
||||
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
|
||||
});
|
||||
|
||||
export const fetchInceptionDefaults = (projectId: number) =>
|
||||
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
|
||||
|
||||
export const decideInception = (projectId: number, choices: InceptionChoices) =>
|
||||
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
|
||||
+14
-222
@@ -1,26 +1,5 @@
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** How a rule reaches a session (milestone 307). */
|
||||
export type RuleTier = "always_on" | "conditional";
|
||||
|
||||
/**
|
||||
* A typed edge between two rules. Each kind exists because its absence forced
|
||||
* a workaround: merging two rules into one row, writing an override as a
|
||||
* near-copy, or leaving a local addendum with nothing to say it is one.
|
||||
*/
|
||||
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
|
||||
|
||||
export interface RuleRelation {
|
||||
id: number;
|
||||
kind: RuleRelationKind;
|
||||
/** The rule at the OTHER end. */
|
||||
rule_id: number;
|
||||
direction: "outgoing" | "incoming";
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Rulebook {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
@@ -47,76 +26,35 @@ export interface Rule {
|
||||
project_id: number | null;
|
||||
title: string;
|
||||
statement: string;
|
||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||
when_to_apply: string;
|
||||
/**
|
||||
* always_on preloads into every session; conditional is reachable and
|
||||
* surfaced when its trigger fires. A rule with no tier set behaves as
|
||||
* always_on, which is how every rule behaved before this existed.
|
||||
*/
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
/**
|
||||
* How to check the rule is still true, and the state that ends it. Set
|
||||
* only on a rule that asserts a fact about something outside the
|
||||
* operator's control; empty on a rule that is a decision, which is most
|
||||
* of them. Empty is meaningful, not missing.
|
||||
*/
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
/** When the check last passed. Null means never checked. */
|
||||
verified_at: string | null;
|
||||
/** The note or task that caused this rule, if one was recorded. */
|
||||
arose_from_id: number | null;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
/** Present only when the rule has them (the server omits empty keys). */
|
||||
systems?: { id: number; name: string }[];
|
||||
relations?: RuleRelation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
|
||||
* age deliberately: a rule written before the capability it duplicates is
|
||||
* otherwise indistinguishable, at a glance, from one still doing work.
|
||||
*/
|
||||
export interface RuleHeader {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number | null;
|
||||
tier: RuleTier;
|
||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||
updated_at: string | null;
|
||||
when_to_apply?: string;
|
||||
arose_from_id?: number;
|
||||
/**
|
||||
* Present ONLY on a rule that carries a check — the presence of the key
|
||||
* is itself the signal that this rule asserts a fact that can go false.
|
||||
* A date (YYYY-MM-DD), or the literal "never".
|
||||
*/
|
||||
last_verified?: string;
|
||||
/**
|
||||
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
||||
* Zero-filled by the list route, so a rule predating the table reads as
|
||||
* "never surfaced" rather than as a missing field — which for a while is
|
||||
* every rule on every install.
|
||||
*/
|
||||
usage?: RecordUsage;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
// Both lists are rule_brief's output — the SAME builder, so they are
|
||||
// described the same way here rather than as two hand-written shapes that
|
||||
// drift from it and from each other (which is what the server side had).
|
||||
rules: (RuleHeader & {
|
||||
rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number;
|
||||
topic_title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
})[];
|
||||
project_rules: RuleHeader[];
|
||||
}[];
|
||||
project_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
}[];
|
||||
suppressed_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -133,8 +71,6 @@ export interface ApplicableRules {
|
||||
}[];
|
||||
truncated: boolean;
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||
excluded_always_on: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||
@@ -195,91 +131,14 @@ export async function getRule(id: number): Promise<Rule> {
|
||||
return apiGet(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
|
||||
*
|
||||
* Sending "" for a nullable text field CLEARS it here — the server maps an
|
||||
* empty string to NULL, so an emptied form input does what it looks like it
|
||||
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
|
||||
* clear_fields list instead; the two idioms reach the same state.)
|
||||
*/
|
||||
export interface RuleWrite {
|
||||
title: string;
|
||||
statement: string;
|
||||
when_to_apply: string;
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
order_index: number;
|
||||
system_ids: number[];
|
||||
arose_from_id: number | null;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
||||
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
|
||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
||||
}
|
||||
|
||||
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
|
||||
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
|
||||
return apiPatch(`/api/rules/${id}`, data);
|
||||
}
|
||||
|
||||
/** Draw a typed edge from one rule to another. Idempotent. */
|
||||
export async function relateRules(
|
||||
fromRuleId: number,
|
||||
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
|
||||
): Promise<{ id: number }> {
|
||||
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
|
||||
}
|
||||
|
||||
export async function unrelateRules(relationId: number): Promise<void> {
|
||||
return apiDelete(`/api/rule-relations/${relationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in a rule's edit history.
|
||||
*
|
||||
* Each entry holds the text the edit REPLACED, not the text it introduced —
|
||||
* so the newest entry is what the rule said before its most recent change,
|
||||
* and what that change produced is the rule as it stands now. Read the other
|
||||
* way round, every diff comes out backwards.
|
||||
*
|
||||
* The listing form omits the long fields; open one to get them.
|
||||
*/
|
||||
export interface RuleVersion {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
/** Who made the edit. Null when that account has since been deleted. */
|
||||
user_id: number | null;
|
||||
title: string;
|
||||
created_at: string;
|
||||
statement?: string;
|
||||
why?: string;
|
||||
how_to_apply?: string;
|
||||
when_to_apply?: string;
|
||||
tier?: string;
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
}
|
||||
|
||||
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
|
||||
const data = await apiGet<{ versions: RuleVersion[] }>(
|
||||
`/api/rules/${ruleId}/versions`,
|
||||
);
|
||||
return data.versions;
|
||||
}
|
||||
|
||||
export async function getRuleVersion(
|
||||
ruleId: number, versionId: number,
|
||||
): Promise<RuleVersion> {
|
||||
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording
|
||||
// back goes through updateRule, which snapshots what it replaces — so the
|
||||
// undo stays visible in the history like any other edit.
|
||||
|
||||
export async function deleteRule(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rules/${id}`);
|
||||
}
|
||||
@@ -300,7 +159,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
|
||||
|
||||
export async function createProjectRule(
|
||||
projectId: number,
|
||||
data: Partial<RuleWrite> & { statement: string },
|
||||
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||
): Promise<Rule> {
|
||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||
}
|
||||
@@ -322,70 +181,3 @@ export async function suppressTopicForProject(projectId: number, topicId: number
|
||||
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||
}
|
||||
|
||||
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
|
||||
|
||||
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
|
||||
}
|
||||
|
||||
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
||||
* in full — the reader is about to go and run it, so the text is the point
|
||||
* of the payload rather than the bloat a listing avoids.
|
||||
*/
|
||||
export interface RuleVerificationRow {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
tier: RuleTier;
|
||||
topic_id: number | null;
|
||||
project_id: number | null;
|
||||
when_to_apply: string;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
/** A date (YYYY-MM-DD), or the literal "never". */
|
||||
last_verified: string | null;
|
||||
/** Null when never verified — "never" is not zero days ago. */
|
||||
days_since_verified: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules asserting a fact that may have gone false, oldest verification
|
||||
* first, never-checked at the top. Rules without a check never appear:
|
||||
* they are decisions, and there is nothing to go and check.
|
||||
*
|
||||
* Not filterable by project — a project reaches rules through project
|
||||
* scope, subscriptions, always-on rulebooks and exclusions, and a filter
|
||||
* missing one of those paths would under-report.
|
||||
*/
|
||||
export async function listRulesDueForVerification(opts: {
|
||||
olderThanDays?: number;
|
||||
tier?: RuleTier;
|
||||
neverOnly?: boolean;
|
||||
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
||||
if (opts.tier) q.set("tier", opts.tier);
|
||||
if (opts.neverOnly) q.set("never_only", "true");
|
||||
const qs = q.toString();
|
||||
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a rule's check was RUN, and what it said.
|
||||
*
|
||||
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
|
||||
* is not in a recordable state, it is wrong — so it stays at the top of the
|
||||
* sweep until someone corrects or retires it.
|
||||
*/
|
||||
export async function markRuleVerified(
|
||||
id: number, stillTrue = true,
|
||||
): Promise<Rule & { verified: boolean }> {
|
||||
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** One canonical location of a reusable thing. A snippet that unifies several
|
||||
@@ -52,11 +50,15 @@ export interface Snippet {
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** Kept as a name because every consumer here says "snippet usage" — but it IS
|
||||
* the shared shape, since rules answer the same question off their own table
|
||||
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
|
||||
* fields here is how the two drift. */
|
||||
export type SnippetUsage = RecordUsage;
|
||||
/** 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
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
|
||||
export interface System {
|
||||
id: number;
|
||||
project_id: number;
|
||||
name: string;
|
||||
/**
|
||||
* The global area this System is an instance of, or null. Null is a valid
|
||||
* resting state — a project-specific area should stay unmapped.
|
||||
*/
|
||||
canonical_id: number | null;
|
||||
description: string;
|
||||
color: string | null;
|
||||
status: "active" | "archived";
|
||||
@@ -24,23 +18,10 @@ export async function listSystems(projectId: number): Promise<System[]> {
|
||||
return data.systems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A created System, plus the catalog's answer about its name. An `exact`
|
||||
* catalog hit is applied by the server and arrives as a populated
|
||||
* `canonical_id`; an `overlap` is only OFFERED, and comes back here for the
|
||||
* caller to accept or ignore.
|
||||
*
|
||||
* A same-named System in this project is a 409 ApiError carrying
|
||||
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
|
||||
*/
|
||||
export interface CreatedSystem extends System {
|
||||
canonical_suggestion?: CanonicalMatch;
|
||||
}
|
||||
|
||||
export async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
): Promise<CreatedSystem> {
|
||||
data: { name: string; description?: string; color?: string },
|
||||
): Promise<System> {
|
||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { apiGet } from "./client";
|
||||
|
||||
/**
|
||||
* What `/api/version` answers — the client's half of `build_version_payload`
|
||||
* (`src/scribe/routes/api.py`), which is where the reasoning for the shape is
|
||||
* written down.
|
||||
*
|
||||
* EVERY FIELD BUT `version` IS OPTIONAL, and an absent one means "this build
|
||||
* does not know", not "empty". A local build has no ordering key and no
|
||||
* channel, and the server says so by omitting the keys rather than sending
|
||||
* `""` — emitting a placeholder would let it claim a position in an update
|
||||
* order it is not part of.
|
||||
*
|
||||
* So a renderer must read ABSENCE, never falsiness. `build` is a number and
|
||||
* `0` is a legitimate ordering key, so `v.build || "unknown"` would report a
|
||||
* real value as unknown; `v.build ?? "unknown"` is the correct form.
|
||||
*/
|
||||
export interface VersionPayload {
|
||||
/** The NAME — `YYYY.MM.DD.HHMM` from commit time. Answers "is this the same code?" */
|
||||
version: string;
|
||||
/** The ORDERING KEY — minutes since 2020-01-01, from build time. Absent on a local build. */
|
||||
build?: number;
|
||||
/** `dev` / `main` / a tag. Its own field, never folded into the name. */
|
||||
channel?: string;
|
||||
/** The commit the artifact was published under, so its claim can be checked against the registry. */
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHORTER than the client's 30s default, deliberately.
|
||||
*
|
||||
* This readout answers "what is running?" during an incident, which is exactly
|
||||
* when the server may be the thing that is unwell — and it is one static field
|
||||
* off a route that does no work, so a healthy instance answers it immediately.
|
||||
* Waiting the full default before saying so would leave a person staring at
|
||||
* "still loading" for half a minute in the moment they are trying to find out
|
||||
* whether the instance is alive at all. Eight seconds clears a slow-but-alive
|
||||
* instance and tells them something quickly when it is not.
|
||||
*/
|
||||
const VERSION_TIMEOUT_MS = 8000;
|
||||
|
||||
export function fetchVersion(): Promise<VersionPayload> {
|
||||
return apiGet<VersionPayload>("/api/version", { timeoutMs: VERSION_TIMEOUT_MS });
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
|
||||
The five auth views used to carry byte-identical copies of these rules in
|
||||
their scoped blocks (2026-08 shape audit). Loaded per view with
|
||||
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
|
||||
rules are scoped under .auth-card so nothing leaks into the app's other
|
||||
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
|
||||
stay in the view. */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-hint {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-hint a {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
/* A centred status paragraph block: registration closed, invalid/expired
|
||||
token, "check your inbox". One rule — the views used to name it
|
||||
.closed-msg / .error-block / .success-msg with identical bodies. */
|
||||
.auth-note {
|
||||
text-align: center;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.auth-note p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.auth-loading {
|
||||
text-align: center;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.95rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.auth-card .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-card .field label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.auth-card .input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.auth-card .input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.auth-card .input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.auth-card .input-error,
|
||||
.auth-card .input-error:focus {
|
||||
border-color: var(--fs-error);
|
||||
}
|
||||
.auth-card .field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.auth-card .error-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.auth-card .error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
@@ -36,8 +36,7 @@
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger,
|
||||
.btn-danger-outline,
|
||||
.btn-cta {
|
||||
.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 */
|
||||
@@ -47,14 +46,6 @@
|
||||
line-height: var(--fs-leading-body);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
/* So a button carrying an icon centres it against the label without each
|
||||
caller re-inventing the flex row — the shape they all reached for
|
||||
separately, and the reason icon buttons sat a pixel or two off. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--fs-space-2);
|
||||
text-decoration: none;
|
||||
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||
color var(--fs-dur-fast) var(--fs-ease);
|
||||
@@ -67,8 +58,7 @@
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-danger-outline:disabled,
|
||||
.btn-cta:disabled {
|
||||
.btn-danger-outline:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -77,8 +67,7 @@
|
||||
.btn-secondary:focus-visible,
|
||||
.btn-ghost:focus-visible,
|
||||
.btn-danger:focus-visible,
|
||||
.btn-danger-outline:focus-visible,
|
||||
.btn-cta:focus-visible {
|
||||
.btn-danger-outline:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
@@ -89,19 +78,19 @@
|
||||
* are universal across the family so a Save button looks identical in every
|
||||
* app — the accent is identity, not action. */
|
||||
.btn-primary {
|
||||
background: var(--fs-action-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--fs-action-primary-hover);
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--fs-action-secondary);
|
||||
background: var(--color-action-secondary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-secondary:not(:disabled):hover {
|
||||
background: var(--fs-action-secondary-hover);
|
||||
background: var(--color-action-secondary-hover);
|
||||
}
|
||||
|
||||
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
|
||||
@@ -112,21 +101,21 @@
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-ghost:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--fs-surface-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(--fs-action-destructive);
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-danger:not(:disabled):hover {
|
||||
background: var(--fs-action-destructive-hover);
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* A bare text button: no fill, no border. The most common shape in the dense
|
||||
@@ -136,7 +125,7 @@
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fs-text-tertiary);
|
||||
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);
|
||||
@@ -144,7 +133,7 @@
|
||||
cursor: pointer;
|
||||
transition: color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.btn-text:not(:disabled):hover { color: var(--fs-text-primary); }
|
||||
.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); }
|
||||
|
||||
@@ -153,33 +142,14 @@
|
||||
* a one-off: it is what a delete looks like when it must not shout. */
|
||||
.btn-danger-outline {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-action-destructive);
|
||||
color: var(--fs-action-destructive);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
}
|
||||
.btn-danger-outline:not(:disabled):hover {
|
||||
background: var(--fs-action-destructive);
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
/* The one place the accent is allowed on a button: a deliberate brand moment,
|
||||
* never an ordinary action. The system carries `--fs-gradient-cta` and
|
||||
* `--fs-glow-cta` for exactly this and nothing else was using them.
|
||||
*
|
||||
* It exists because ProjectView's Workspace link WAS this button, defined in a
|
||||
* scoped block that the migration deleted — leaving a `:hover` rule with no
|
||||
* base and a link that rendered as raw browser blue. A variant living in one
|
||||
* view is a variant waiting to be deleted by someone tidying another; this is
|
||||
* the shared home so the next sweep can't strand it. */
|
||||
.btn-cta {
|
||||
background: var(--fs-gradient-cta);
|
||||
color: var(--fs-text-on-action);
|
||||
box-shadow: var(--fs-glow-cta);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-cta:not(:disabled):hover {
|
||||
box-shadow: var(--fs-glow-cta-hover);
|
||||
}
|
||||
|
||||
/* --- size modifiers ------------------------------------------------------
|
||||
*
|
||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
||||
@@ -214,166 +184,10 @@
|
||||
/* 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: flex; /* not `block` — the shared shape centres with flex */
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
/* ── Modal ─────────────────────────────────────────────────────────────────
|
||||
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
|
||||
the create-project / merge-snippet / systems dialogs, the editors' confirm
|
||||
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
|
||||
styles of its own, so these must be loaded with the app, not with whichever
|
||||
view happens to be open. Views add only their own overrides (a wider card,
|
||||
a form layout). Destructive = action-destructive per the Hybrid rule. */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--fs-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
}
|
||||
.modal-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.modal-message {
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover {
|
||||
background: var(--fs-surface-page);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--fs-action-primary);
|
||||
border-color: var(--fs-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
background: var(--fs-action-primary-hover);
|
||||
}
|
||||
.modal-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.modal-btn-danger {
|
||||
background: var(--fs-action-destructive);
|
||||
border-color: var(--fs-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-danger:hover {
|
||||
background: var(--fs-action-destructive-hover);
|
||||
border-color: var(--fs-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* ── Page container ─────────────────────────────────────────────────────────
|
||||
The one wrapper a top-level view sits in: page width from the layout
|
||||
tokens, centred, clipped horizontally so a wide child (a kanban, a table)
|
||||
scrolls inside itself instead of the page. ProjectListView, ProjectView and
|
||||
SnippetListView each carried this rule under their own name until #2903
|
||||
(milestone 299). */
|
||||
.page-container {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Form input (fs-surfaces, snippet #2336) ────────────────────────────────
|
||||
Inputs sit DARKER than the page they're on — an inset well rather than a
|
||||
raised panel; that inversion is what makes a field read as writable. The
|
||||
design system's recipe, verbatim; width/box-sizing stay the caller's
|
||||
(an inline select and a full-width textarea differ there). Three scoped
|
||||
copies of an older input recipe were folded into this in #2903. */
|
||||
.fs-input {
|
||||
background: var(--fs-surface-page);
|
||||
border: var(--fs-border);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-2) var(--fs-space-3); /* 8px 12px */
|
||||
color: var(--fs-text-primary);
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-body);
|
||||
transition: box-shadow var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.fs-input::placeholder { color: var(--fs-text-tertiary); }
|
||||
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
/* Page scaffold + feedback text recipes (milestone 302, note 2917): name
|
||||
families the consumer map showed to be one recipe living in many views.
|
||||
A view keeps only its deviation as a scoped remainder/override. */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-header h1 { margin: 0; }
|
||||
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
.state-msg { color: var(--fs-text-tertiary); font-size: 0.9rem; }
|
||||
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; }
|
||||
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; color: var(--fs-text-tertiary); margin: 0 0 1rem; }
|
||||
|
||||
.required { color: var(--fs-error); }
|
||||
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* --- usage badge ----------------------------------------------------------
|
||||
"surfaced N×, opened M×" on a list row, for any record kind the retrieval
|
||||
surfaces can choose: snippets and notes from note_usage_events, rules from
|
||||
rule_usage_events. Promoted here from SnippetListView's scoped block when
|
||||
the rule list needed the same chip (milestone 333 step 5) — a second scoped
|
||||
copy is how the ninth duplicated CSS family starts (#3207).
|
||||
|
||||
Geometry and colour only. A view keeps its own spacing as a scoped
|
||||
remainder, the way it does for every other recipe in this file. */
|
||||
.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(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
}
|
||||
|
||||
/* 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(--fs-warning) 18%, transparent);
|
||||
color: var(--fs-warning-fg);
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/* The near-duplicate report, shared by KnowledgeView (notes/tasks) and
|
||||
SnippetListView (snippets) so the two reports read as one feature. Load
|
||||
with <style src="@/assets/dup-report.css" /> beside the view's scoped
|
||||
block; the view keeps only its own extras (.dup-claimed, .dup-action).
|
||||
Promoted from two identical scoped copies in #2903 (milestone 299). */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.dup-empty { margin-bottom: 0; }
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dup-member:hover { background: var(--fs-surface-hover); }
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -13,7 +13,19 @@
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Toolbar & inputs ── */
|
||||
@@ -29,36 +41,36 @@
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--fs-accent);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tab {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--fs-accent);
|
||||
border-bottom-color: var(--fs-accent);
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.preview-pane {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: 200px;
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
/* ── Tag suggestions ── */
|
||||
@@ -66,44 +78,133 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border: 1px solid var(--fs-accent);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--fs-accent);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.tag-pill:hover:not(:disabled) {
|
||||
background: var(--fs-accent);
|
||||
background: var(--color-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.tag-pill.applied {
|
||||
background: var(--fs-success);
|
||||
border-color: var(--fs-success);
|
||||
background: var(--color-success, #2ecc71);
|
||||
border-color: var(--color-success, #2ecc71);
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: default;
|
||||
}
|
||||
.tag-check {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Assist panel ── */
|
||||
.assist-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.assist-panel-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.assist-panel-title {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.assist-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Section list */
|
||||
.assist-sections-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.assist-sections {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.assist-section-item {
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.assist-section-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.assist-section-item.selected {
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.assist-empty,
|
||||
.assist-hint {
|
||||
padding: 0.6rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.assist-target-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assist-target-preview em {
|
||||
font-style: normal;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.assist-instruction {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
@@ -112,27 +213,64 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Streaming */
|
||||
.assist-streaming-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assist-preview-box {
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
font-size: 0.9rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.typing-indicator {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.15em;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Active hint shown in the panel while output is inline */
|
||||
.assist-active-hint {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.assist-error {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||
border: 1px solid var(--fs-error);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-error-fg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Review / diff */
|
||||
.assist-review-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.diff-view {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
font-size: 0.82rem;
|
||||
font-family: monospace;
|
||||
max-height: 340px;
|
||||
@@ -146,15 +284,15 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
.diff-delete {
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||
color: var(--fs-error-fg);
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success-fg);
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
.diff-equal {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.diff-marker {
|
||||
flex-shrink: 0;
|
||||
@@ -170,7 +308,7 @@
|
||||
}
|
||||
.diff-empty {
|
||||
padding: 0.5rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.assist-actions {
|
||||
@@ -178,16 +316,63 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
}
|
||||
.modal-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.modal-message {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.modal-btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Floating inline assist button (teleported to body) ── */
|
||||
.inline-assist-btn {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--fs-action-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
@@ -199,12 +384,12 @@
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-secondary);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
@@ -223,7 +408,7 @@
|
||||
.sb-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -231,10 +416,10 @@
|
||||
.sb-input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
@@ -242,11 +427,11 @@
|
||||
.sb-select:focus,
|
||||
.sb-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.sb-divider {
|
||||
height: 1px;
|
||||
background: var(--fs-border-color);
|
||||
background: var(--color-border);
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
@@ -260,9 +445,22 @@
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.editor-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.assist-panel {
|
||||
width: auto;
|
||||
flex: 0 0 45%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
.editor-header {
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
}
|
||||
.editor-main {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -282,14 +480,14 @@
|
||||
.btn-accept,
|
||||
.btn-generate,
|
||||
.btn-save {
|
||||
background: var(--fs-action-primary);
|
||||
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(--fs-action-primary-hover);
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
@@ -299,7 +497,7 @@
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-back:not(:disabled):hover,
|
||||
.btn-clear:not(:disabled):hover,
|
||||
@@ -307,16 +505,16 @@
|
||||
.btn-proofread:not(:disabled):hover,
|
||||
.btn-suggest-tags:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--fs-surface-hover);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: var(--fs-action-destructive);
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-delete:not(:disabled):hover {
|
||||
background: var(--fs-action-destructive-hover);
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* Shared geometry for every alias above. */
|
||||
@@ -343,12 +541,12 @@
|
||||
.btn-dismiss-tags {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fs-text-tertiary);
|
||||
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(--fs-text-primary); }
|
||||
.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,
|
||||
@@ -357,36 +555,3 @@
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Shared by NoteEditorView and TaskEditorView — both carried identical scoped
|
||||
copies of these until #2903 (milestone 299); one source here. */
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: var(--fs-surface-code);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--color-code-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background: var(--fs-surface-code-inline);
|
||||
background: var(--color-code-inline-bg);
|
||||
border-radius: 3px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
@@ -73,25 +73,25 @@
|
||||
|
||||
.prose th,
|
||||
.prose td {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose thead th {
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose tbody tr:nth-child(even) {
|
||||
background: var(--fs-table-stripe);
|
||||
background: var(--color-table-stripe);
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--fs-border-color);
|
||||
border-left: 3px solid var(--color-border);
|
||||
margin: 0 0 0.6rem;
|
||||
padding: 0.25rem 0 0.25rem 0.75rem;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.prose blockquote p:last-child {
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--fs-accent);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -119,8 +119,8 @@
|
||||
}
|
||||
|
||||
.prose .inline-tag {
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--color-tag-text);
|
||||
background: var(--color-tag-bg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
@@ -133,8 +133,8 @@
|
||||
}
|
||||
|
||||
.prose .wikilink {
|
||||
color: var(--fs-wikilink);
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--color-wikilink);
|
||||
background: var(--color-wikilink-bg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
@@ -168,7 +168,7 @@
|
||||
|
||||
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
accent-color: var(--fs-accent);
|
||||
accent-color: var(--color-primary);
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
margin: 0;
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Interactive checkboxes — marked output in the list-note viewer */
|
||||
@@ -196,7 +196,7 @@
|
||||
}
|
||||
.prose--checklist li input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--fs-accent);
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
@@ -205,7 +205,7 @@
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
||||
text-decoration: line-through;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
|
||||
text-decoration: none; /* don't strike through the checkbox itself */
|
||||
@@ -219,7 +219,7 @@
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted, var(--color-text-secondary));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
@@ -227,12 +227,12 @@
|
||||
}
|
||||
|
||||
.tiptap-wrapper {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tiptap-wrapper:focus-within {
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/* Shared by the rules panes (RulebookListPane, RuleListPane,
|
||||
RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the
|
||||
title chip. Counting them in this comment went stale the first time a
|
||||
fourth was added, so it no longer does. Load with
|
||||
<style src="@/assets/rules-shared.css" /> beside the component's own
|
||||
scoped block; never restate these there (#2903, milestone 299). */
|
||||
.pane {
|
||||
background: var(--fs-surface-hover);
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pane header h2 {
|
||||
font-family: Fraunces, serif;
|
||||
font-style: italic;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
|
||||
/* A small marker beside a rule's title. Two of these appeared within one
|
||||
milestone (tier, then verification) and were byte-identical; a third would
|
||||
have drifted. The pane's italic serif title is inherited by anything inside
|
||||
it, so the chip resets family and style explicitly. */
|
||||
.rule-chip {
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--fs-font-body);
|
||||
font-style: normal;
|
||||
font-size: 0.62rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
+124
-32
@@ -12,13 +12,6 @@
|
||||
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.
|
||||
|
||||
The -fg tokens are a badge's TEXT colour, added because the ladder used its
|
||||
raw hue as text on a 12% tint of the same hue — measured 1.60-2.97:1 on the
|
||||
dark palette against the kit's AA floor of 4.5. Each is the hue mixed toward
|
||||
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
|
||||
surface-hover in BOTH modes. Mixing toward that token is what makes one
|
||||
declaration cover both: it inverts, so the text follows the mode.
|
||||
|
||||
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.
|
||||
@@ -31,7 +24,6 @@
|
||||
--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-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary)); /* Accent TEXT on an 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);
|
||||
@@ -86,13 +78,10 @@
|
||||
/* priority */
|
||||
--fs-priority-low: var(--fs-info);
|
||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
||||
--fs-priority-low-fg: color-mix(in srgb, var(--fs-priority-low) 45%, var(--fs-text-primary)); /* Badge TEXT for low priority — the readable partner of the -bg tint */
|
||||
--fs-priority-medium: var(--fs-warning);
|
||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
||||
--fs-priority-medium-fg: color-mix(in srgb, var(--fs-priority-medium) 55%, var(--fs-text-primary)); /* Badge TEXT for medium priority */
|
||||
--fs-priority-high: var(--fs-error);
|
||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
||||
--fs-priority-high-fg: color-mix(in srgb, var(--fs-priority-high) 55%, var(--fs-text-primary)); /* Badge TEXT for high priority */
|
||||
|
||||
/* radius */
|
||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||
@@ -103,11 +92,8 @@
|
||||
|
||||
/* semantic */
|
||||
--fs-success: var(--fs-action-primary);
|
||||
--fs-success-fg: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary)); /* Success TEXT on a success tint */
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-text-primary)); /* Warning TEXT on a warning tint */
|
||||
--fs-error: #C04A1F;
|
||||
--fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-text-primary)); /* Error TEXT on an error tint */
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
||||
|
||||
@@ -130,16 +116,12 @@
|
||||
/* status */
|
||||
--fs-status-todo: var(--fs-border-color);
|
||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
||||
--fs-status-todo-fg: color-mix(in srgb, var(--fs-status-todo) 40%, var(--fs-text-primary)); /* Badge TEXT for a not-started task */
|
||||
--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-in-progress-fg: color-mix(in srgb, var(--fs-status-in-progress) 45%, var(--fs-text-primary)); /* Badge TEXT for a task underway */
|
||||
--fs-status-done: var(--fs-success);
|
||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
||||
--fs-status-done-fg: color-mix(in srgb, var(--fs-status-done) 50%, var(--fs-text-primary)); /* Badge TEXT for a completed task */
|
||||
--fs-overdue: var(--fs-error);
|
||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
||||
--fs-status-cancelled-fg: color-mix(in srgb, var(--fs-status-cancelled) 60%, var(--fs-text-primary)); /* Badge TEXT for a cancelled task */
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||
@@ -152,9 +134,7 @@
|
||||
/* text */
|
||||
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
||||
--fs-text-secondary: #C2BFB4;
|
||||
--fs-text-secondary-fg: color-mix(in srgb, var(--fs-text-secondary) 90%, var(--fs-text-primary)); /* Secondary TEXT on a secondary tint (barely moves; no exceptions) */
|
||||
--fs-text-tertiary: #9C9A92;
|
||||
--fs-text-tertiary-fg: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary)); /* Tertiary TEXT on a tertiary tint */
|
||||
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
|
||||
|
||||
/* type */
|
||||
@@ -208,21 +188,126 @@
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
The compatibility-alias block that lived here is GONE (#2533). It let ~55
|
||||
components keep their historical --color-* names while theme.css was
|
||||
repointed at the design system; the rename sweep it promised ran on
|
||||
2026-08-08 and every component now references --fs-* directly. Do not
|
||||
reintroduce app-local alias names — the design system's tokens are the
|
||||
vocabulary, and check_snippets_against_design_system can only see through
|
||||
names the system actually declares.
|
||||
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 {
|
||||
/* A VALUE, not an alias — the one survivor of the alias block. The design
|
||||
system has no shadow-colour token yet, so this is a recorded gap: when a
|
||||
second app needs it, promote it to an --fs-* token in the system and
|
||||
regenerate, rather than copying this line. */
|
||||
/* 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);
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -292,10 +377,17 @@ button:not(:disabled):active,
|
||||
display: none !important;
|
||||
}
|
||||
button,
|
||||
[role="button"] {
|
||||
[role="button"],
|
||||
.btn-new-conv,
|
||||
.btn-send {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.hide-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -15,27 +15,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ctx-crumb-parent {
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
text-decoration: none;
|
||||
}
|
||||
.ctx-crumb-parent:hover {
|
||||
color: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ctx-crumb-project {
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ctx-crumb-project:hover {
|
||||
background: color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
.ctx-crumb-milestone {
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -50,12 +50,6 @@ router.afterEach(() => {
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<!-- A design system is a RECORD you author, not a setting. It sat in
|
||||
the utility cluster with Trash and Settings while /design was a
|
||||
read-only gallery, and stayed there after it became a record type
|
||||
with its own table, sharing and MCP tools. Content, by the same
|
||||
rule that puts Snippets and Rulebooks here. -->
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,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" />
|
||||
@@ -102,9 +106,9 @@ router.afterEach(() => {
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
@@ -124,35 +128,20 @@ router.afterEach(() => {
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--fs-surface-hover), var(--fs-surface-page));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
position: relative;
|
||||
}
|
||||
/* Three tracks, not a flex row with an absolutely-centred overlay.
|
||||
*
|
||||
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
|
||||
* not participate in layout: when the header ran out of room it OVERLAPPED the
|
||||
* brand and the utility cluster rather than pushing them, and nothing wrapped
|
||||
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
|
||||
* is an ordinary window on any monitor.
|
||||
*
|
||||
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
|
||||
* neither side can be squeezed below its content, and the two side tracks stay
|
||||
* equal to each other — which is what keeps the bar centred in the viewport
|
||||
* rather than merely centred in the leftover space. Overflow becomes the
|
||||
* header growing, not two things sharing pixels. */
|
||||
.nav {
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left — brand */
|
||||
.nav-brand {
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
@@ -170,7 +159,9 @@ router.afterEach(() => {
|
||||
|
||||
/* Center — pill bar */
|
||||
.nav-center {
|
||||
justify-self: center;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -178,23 +169,21 @@ router.afterEach(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--fs-accent-faint);
|
||||
background: var(--color-primary-faint);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Right */
|
||||
.nav-right {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
@@ -202,34 +191,72 @@ router.afterEach(() => {
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--color-text);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--fs-accent-fg);
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: default;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||
they want to read as vital (Moss/Warning/Error are too muted for
|
||||
a "ready" indicator). Hardcoded bright values; the rest of the
|
||||
system still uses the semantic tokens. */
|
||||
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: #ef4444; }
|
||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
|
||||
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
|
||||
}
|
||||
|
||||
/* Icon buttons (?, theme, gear) */
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border-color: var(--fs-accent);
|
||||
.btn-icon:hover,
|
||||
.btn-icon.active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* User info */
|
||||
@@ -239,42 +266,36 @@ router.afterEach(() => {
|
||||
gap: 0.4rem;
|
||||
margin-left: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.username {
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
/* The widest thing on the right and the only one that can give: a long
|
||||
username shouldn't be what decides where the nav bar sits. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 12ch;
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.btn-logout {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
color: var(--fs-error);
|
||||
border-color: var(--fs-error);
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Hamburger — mobile only */
|
||||
@@ -292,7 +313,7 @@ router.afterEach(() => {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--fs-text-primary);
|
||||
background: var(--color-text);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
@@ -301,13 +322,13 @@ router.afterEach(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.mobile-divider {
|
||||
height: 1px;
|
||||
background: var(--fs-border-color);
|
||||
background: var(--color-border);
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.mobile-actions {
|
||||
@@ -321,29 +342,15 @@ router.afterEach(() => {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* The grid above means running out of room can no longer cause a collision —
|
||||
but it can still make the header wider than the window, and a horizontally
|
||||
scrolling header is its own defect. So shed width before that happens. The
|
||||
wordmark goes first: the logo beside it says the same thing and is still the
|
||||
link home. */
|
||||
@media (max-width: 1280px) {
|
||||
.brand-text {
|
||||
display: none;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-center {
|
||||
display: none;
|
||||
}
|
||||
.status-indicator,
|
||||
.btn-icon,
|
||||
.user-info {
|
||||
display: none;
|
||||
@@ -359,7 +366,7 @@ router.afterEach(() => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mobile-menu .nav-link.router-link-active {
|
||||
background: var(--fs-accent-wash);
|
||||
background: var(--color-primary-wash);
|
||||
box-shadow: none;
|
||||
}
|
||||
.mobile-user .btn-logout {
|
||||
|
||||
@@ -13,8 +13,8 @@ defineProps<{ size?: number }>();
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="var(--fs-accent)" />
|
||||
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
|
||||
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
||||
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Book body -->
|
||||
@@ -44,12 +44,12 @@ defineProps<{ size?: number }>();
|
||||
<style scoped>
|
||||
.logo-book {
|
||||
fill: url(#logo-gradient);
|
||||
stroke: color-mix(in srgb, var(--fs-accent) 70%, transparent);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
.logo-spine {
|
||||
stroke: var(--fs-text-secondary);
|
||||
stroke: var(--color-text-secondary);
|
||||
}
|
||||
.logo-lines {
|
||||
stroke: var(--fs-text-tertiary);
|
||||
stroke: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -90,9 +90,9 @@ function markerFor(type: DiffLine['type']): string {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -103,15 +103,15 @@ function markerFor(type: DiffLine['type']): string {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.78rem;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-summary-ins { color: var(--fs-success); }
|
||||
.diff-summary-del { color: var(--fs-error); }
|
||||
.diff-summary-ins { color: var(--color-success, #2ecc71); }
|
||||
.diff-summary-del { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
.diff-scroll {
|
||||
flex: 1;
|
||||
@@ -123,7 +123,7 @@ function markerFor(type: DiffLine['type']): string {
|
||||
.diff-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -136,24 +136,24 @@ function markerFor(type: DiffLine['type']): string {
|
||||
}
|
||||
|
||||
.diff-delete {
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||
color: var(--fs-error-fg);
|
||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success-fg);
|
||||
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
|
||||
color: var(--color-success, #2ecc71);
|
||||
}
|
||||
|
||||
.diff-equal {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.diff-collapse {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
border-top: 1px dashed var(--fs-border-color);
|
||||
border-bottom: 1px dashed var(--fs-border-color);
|
||||
border-top: 1px dashed var(--color-border);
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
padding-top: 0.2rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
|
||||
interface NoteVersion {
|
||||
id: number;
|
||||
@@ -33,10 +32,38 @@ const loadingDetail = ref(false);
|
||||
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (!selectedVersion.value?.body) return [];
|
||||
const a = props.currentBody;
|
||||
const b = selectedVersion.value.body;
|
||||
|
||||
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||
const aLines = a.split('\n');
|
||||
const bLines = b.split('\n');
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i+1][j+1] + 1
|
||||
: Math.max(dp[i+1][j], dp[i][j+1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
|
||||
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
|
||||
else result.push({ type: 'insert', text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
|
||||
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -185,7 +212,7 @@ onMounted(loadVersions);
|
||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||
class="history-item-label"
|
||||
>{{ v.pin_label }}</div>
|
||||
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
|
||||
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,7 +309,7 @@ onMounted(loadVersions);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
@@ -295,11 +322,11 @@ onMounted(loadVersions);
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.history-close:hover { color: var(--fs-text-primary); }
|
||||
.history-close:hover { color: var(--color-text); }
|
||||
|
||||
.history-body {
|
||||
flex: 1;
|
||||
@@ -311,7 +338,7 @@ onMounted(loadVersions);
|
||||
.history-list {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--fs-border-color);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -319,18 +346,18 @@ onMounted(loadVersions);
|
||||
padding: 0.6rem 0.9rem;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.history-item:hover { background: var(--fs-surface-raised); }
|
||||
.history-item:hover { background: var(--color-bg-secondary); }
|
||||
.history-item.selected {
|
||||
border-left-color: var(--fs-accent);
|
||||
background: var(--fs-surface-raised);
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.history-item-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -338,7 +365,7 @@ onMounted(loadVersions);
|
||||
|
||||
.history-item-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
@@ -354,7 +381,7 @@ onMounted(loadVersions);
|
||||
.history-empty {
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.history-footer {
|
||||
@@ -363,7 +390,7 @@ onMounted(loadVersions);
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -376,12 +403,12 @@ onMounted(loadVersions);
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
.pin-badge-manual { color: var(--fs-accent); }
|
||||
.pin-badge-auto { color: var(--fs-text-tertiary); }
|
||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
||||
|
||||
.history-item-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-accent);
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: italic;
|
||||
margin-top: 0.15rem;
|
||||
overflow: hidden;
|
||||
@@ -392,7 +419,7 @@ onMounted(loadVersions);
|
||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
||||
.version-pin-controls {
|
||||
padding: 0.4rem 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pin-actions {
|
||||
@@ -403,7 +430,7 @@ onMounted(loadVersions);
|
||||
}
|
||||
.pin-state {
|
||||
font-style: italic;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -415,13 +442,13 @@ onMounted(loadVersions);
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-unpin:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
@@ -436,27 +463,27 @@ onMounted(loadVersions);
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: inherit;
|
||||
}
|
||||
.pin-label-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save, .btn-pin-cancel {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin-save:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The inception form (milestone 297): "what does this project inherit?"
|
||||
*
|
||||
* Two homes, one component. mode="create" rides the New-project modal's
|
||||
* second step and only emits the choices (the project does not exist yet);
|
||||
* mode="decide" sits on ProjectView for an undecided project, loads that
|
||||
* project's current defaults, and records the decision itself.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { fetchDesignSystems } from "@/api/designSystems";
|
||||
import {
|
||||
decideInception, emptyChoices, fetchInceptionDefaults,
|
||||
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
|
||||
} from "@/api/inception";
|
||||
import { listRulebooks } from "@/api/rulebooks";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
mode: "create" | "decide";
|
||||
projectId?: number;
|
||||
choices?: InceptionChoices;
|
||||
}>(), { projectId: 0, choices: undefined });
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:choices": [value: InceptionChoices];
|
||||
decided: [decision: InceptionDecision];
|
||||
}>();
|
||||
|
||||
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||
const alwaysOn = ref<{ id: number; title: string }[]>([]);
|
||||
const others = ref<{ id: number; title: string }[]>([]);
|
||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||
const systemsCount = ref(0);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function emitChoices() {
|
||||
emit("update:choices", { ...local.value });
|
||||
}
|
||||
watch(local, emitChoices, { deep: true });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (props.mode === "decide" && props.projectId) {
|
||||
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||
alwaysOn.value = d.always_on_rulebooks;
|
||||
others.value = d.other_rulebooks;
|
||||
designSystems.value = d.design_systems;
|
||||
systemsCount.value = d.systems;
|
||||
// Start from what stands today so "record" without changes is a true inherit-all.
|
||||
local.value = {
|
||||
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
|
||||
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
||||
design_system_id: d.design_system_id,
|
||||
seed_systems: false,
|
||||
};
|
||||
} else {
|
||||
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
|
||||
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function inherits(id: number): boolean {
|
||||
return !local.value.exclude_always_on_rulebooks.includes(id);
|
||||
}
|
||||
function toggleInherit(id: number) {
|
||||
const list = local.value.exclude_always_on_rulebooks;
|
||||
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||
}
|
||||
function subscribed(id: number): boolean {
|
||||
return local.value.subscribe_rulebooks.includes(id);
|
||||
}
|
||||
function toggleSubscribe(id: number) {
|
||||
const list = local.value.subscribe_rulebooks;
|
||||
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||
}
|
||||
|
||||
const nothingToDecide = computed(
|
||||
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
|
||||
);
|
||||
|
||||
async function record() {
|
||||
if (!props.projectId) return;
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const decision = await decideInception(props.projectId, local.value);
|
||||
emit("decided", decision);
|
||||
} catch (e: unknown) {
|
||||
error.value = apiErrorMessage(e, "Could not record the decision");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inception" aria-labelledby="inception-title">
|
||||
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
|
||||
<p class="inception-lede">
|
||||
A project's inheritance is a decision, not a default. Until it is recorded,
|
||||
every always-on rulebook binds, nothing is subscribed, and there is no design
|
||||
system or Systems.
|
||||
</p>
|
||||
<p v-if="loading" class="inception-muted">Loading…</p>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
<template v-else>
|
||||
<div v-if="alwaysOn.length" class="inception-group">
|
||||
<h4>Always-on rulebooks</h4>
|
||||
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
|
||||
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
|
||||
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
|
||||
<span>{{ rb.title }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="others.length" class="inception-group">
|
||||
<h4>Subscribe to rulebooks</h4>
|
||||
<label v-for="rb in others" :key="rb.id" class="inception-choice">
|
||||
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
||||
<span>{{ rb.title }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inception-group">
|
||||
<h4>Design system</h4>
|
||||
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inception-group">
|
||||
<label class="inception-choice">
|
||||
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
|
||||
<span>
|
||||
Seed the standard starter Systems (CI & Release, Auth & Access, Data Model & Storage, …)
|
||||
<em v-if="systemsCount > 0" class="inception-muted"> — this project already has {{ systemsCount }}</em>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="nothingToDecide" class="inception-muted">
|
||||
Nothing to inherit yet on this install — recording still settles the question.
|
||||
</p>
|
||||
<div v-if="mode === 'decide'" class="inception-actions">
|
||||
<button class="btn-primary" :disabled="saving" @click="record">
|
||||
{{ saving ? "Recording…" : "Record decision" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inception {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.inception-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
|
||||
.inception-lede { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
|
||||
.inception-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0 0 0.35rem; }
|
||||
.inception-group { margin-bottom: 1rem; }
|
||||
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
|
||||
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
|
||||
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||
.inception-select {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
|
||||
</style>
|
||||
@@ -74,11 +74,11 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
<style scoped>
|
||||
.iap {
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
@@ -88,16 +88,16 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* ── Streaming ── */
|
||||
.iap-streaming {
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.iap-streaming .iap-header {
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.iap-pulse {
|
||||
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--fs-accent);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@@ -117,7 +117,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
.iap-label {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -125,9 +125,9 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
.iap-btn-cancel {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.iap-btn-cancel:hover {
|
||||
border-color: var(--fs-error);
|
||||
color: var(--fs-error);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
|
||||
.iap-stream-preview {
|
||||
@@ -150,29 +150,29 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
.iap-waiting {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Review ── */
|
||||
.iap-review-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.iap-btn-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.iap-btn-toggle:hover {
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.iap-actions {
|
||||
@@ -183,7 +183,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
.iap-btn-accept,
|
||||
.iap-btn-reject {
|
||||
border: none;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
font-weight: var(--fs-weight-medium);
|
||||
}
|
||||
.iap-btn-accept {
|
||||
background: var(--fs-success);
|
||||
background: var(--color-success, #22c55e);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.iap-btn-accept:hover { opacity: 0.85; }
|
||||
|
||||
.iap-btn-reject {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-secondary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--color-bg-card, var(--color-bg));
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.iap-btn-reject:hover {
|
||||
border-color: var(--fs-error);
|
||||
color: var(--fs-error);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
|
||||
/* ── Diff ── */
|
||||
@@ -224,14 +224,14 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
||||
.iap-diff-equal { color: var(--color-text-muted); }
|
||||
.iap-diff-delete {
|
||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||
color: var(--fs-error-fg);
|
||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
.iap-diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
||||
color: var(--fs-success-fg);
|
||||
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
|
||||
color: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.iap-diff-marker {
|
||||
@@ -243,7 +243,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
.iap-diff-empty {
|
||||
padding: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A task's KIND, shown on a list row — issue, spike, or a legacy plan.
|
||||
*
|
||||
* Sibling of PriorityBadge, and shaped like it on purpose: same geometry, and
|
||||
* the same rule that the DEFAULT value renders nothing. `work` is most tasks,
|
||||
* so badging it would put a chip on nearly every row and say nothing — the
|
||||
* same reason RuleListPane marks only `conditional`.
|
||||
*
|
||||
* Kind is not status. A task can be an in-progress issue or a done spike;
|
||||
* this answers "what kind of work is this", never "how is it going".
|
||||
*/
|
||||
import type { TaskKind } from "@/types/note";
|
||||
|
||||
const props = defineProps<{ kind?: TaskKind | null }>();
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
issue: "Issue",
|
||||
spike: "Spike",
|
||||
plan: "Plan",
|
||||
};
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
issue: "Corrective work — something was broken",
|
||||
spike: "Time-boxed investigation — the output is an answer, not a change",
|
||||
plan: "Legacy plan-task; plans are milestones now",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="props.kind && LABELS[props.kind]"
|
||||
:class="['kind-badge', `kind-${props.kind}`]"
|
||||
:title="TITLES[props.kind]"
|
||||
>{{ LABELS[props.kind] }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kind-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
/* 500, not the 600 StatusBadge and PriorityBadge use. The house style
|
||||
allows two weights, 400 and 500 — those two predate the constraint and
|
||||
copying them would spread it. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Issue and spike are opposite in character — corrective vs exploratory — so
|
||||
they are split by TEMPERATURE, warm against cool, which survives being
|
||||
small and stays distinguishable without relying on reading the word.
|
||||
Neither uses the accent: one accent per app, and kind is not one of the
|
||||
places it is allowed.
|
||||
The text is the hue mixed toward --fs-text-primary rather than the raw
|
||||
semantic colour. Raw fails the contrast floor on the dark palette —
|
||||
measured: warning on its own 12% tint is 2.97:1, well under AA's 4.5.
|
||||
Mixing toward the text token also makes these follow the mode for free,
|
||||
since that token inverts. Measured both ways: issue 5.23:1 dark / 6.68:1
|
||||
light, spike 5.33:1 / 9.26:1. */
|
||||
.kind-issue {
|
||||
background: color-mix(in srgb, var(--fs-warning) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-warning) 60%, var(--fs-text-primary));
|
||||
}
|
||||
.kind-spike {
|
||||
background: color-mix(in srgb, var(--fs-info) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-info) 50%, var(--fs-text-primary));
|
||||
}
|
||||
/* Retired since 0066 — deliberately hue-free so a legacy row reads as
|
||||
archival rather than as a fourth active kind competing for attention. */
|
||||
.kind-plan {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -111,9 +111,9 @@ const groups = [
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ const groups = [
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--fs-border-color);
|
||||
background: var(--color-border);
|
||||
flex-shrink: 0;
|
||||
margin: 0 3px;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ const groups = [
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||
@@ -149,19 +149,19 @@ const groups = [
|
||||
}
|
||||
|
||||
.md-btn:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.md-btn.active {
|
||||
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
|
||||
color: var(--fs-accent-fg);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.md-btn.active:hover {
|
||||
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
|
||||
@@ -51,7 +51,7 @@ function onChange(e: Event) {
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="fs-input milestone-select"
|
||||
class="milestone-select"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="!projectId || loading"
|
||||
@change="onChange"
|
||||
@@ -64,10 +64,23 @@ function onChange(e: Event) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.milestone-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.milestone-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.milestone-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,15 +60,15 @@ function goEdit() {
|
||||
.note-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--fs-surface-raised);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -78,18 +78,18 @@ function goEdit() {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transform: none !important;
|
||||
}
|
||||
.note-card.compact:first-child {
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
|
||||
background: rgba(91, 74, 138, 0.04);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
@@ -108,7 +108,7 @@ function goEdit() {
|
||||
}
|
||||
.timestamp-compact {
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -133,20 +133,20 @@ function goEdit() {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-secondary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
color: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.note-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
max-height: 7.5em;
|
||||
overflow: hidden;
|
||||
@@ -163,6 +163,6 @@ function goEdit() {
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The staleness sweep for NOTES: notes that assert a fact, oldest first.
|
||||
*
|
||||
* Sibling of RuleSweepPane, not a shared component — the two read differently
|
||||
* enough that merging them would mean a prop for every difference (a rule has
|
||||
* a tier and a statement; a note has a project and opens at a route). What
|
||||
* they share is the SHAPE of the judgement, and that is worth copying
|
||||
* deliberately rather than abstracting: the ordering carries urgency, "never"
|
||||
* is categorically different from a date, and a failed check writes nothing.
|
||||
*
|
||||
* Lives in the Knowledge view rather than beside the rules sweep (operator's
|
||||
* call, milestone 317 step 4): notes stay where notes live. The cost, accepted
|
||||
* knowingly, is that there is no single screen showing every record anyone has
|
||||
* left unconfirmed — /rules keeps its own.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
interface DueNote {
|
||||
id: number;
|
||||
title: string;
|
||||
project_id: number | null;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
last_verified: string | null;
|
||||
days_since_verified: number | null;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ "open-note": [id: number] }>();
|
||||
|
||||
const toast = useToastStore();
|
||||
const rows = ref<DueNote[]>([]);
|
||||
const loading = ref(false);
|
||||
const neverOnly = ref(false);
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const p = new URLSearchParams();
|
||||
if (neverOnly.value) p.set("never_only", "1");
|
||||
const data = await apiGet<{ notes: DueNote[] }>(
|
||||
`/api/notes/due-for-verification?${p}`,
|
||||
);
|
||||
rows.value = data.notes;
|
||||
} catch {
|
||||
toast.show("Could not load the sweep", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(id: number, stillTrue: boolean) {
|
||||
busyId.value = id;
|
||||
try {
|
||||
await apiPost(`/api/notes/${id}/verify`, { still_true: stillTrue });
|
||||
if (stillTrue) {
|
||||
// It has been confirmed, so it leaves the list — the sweep shows what
|
||||
// still needs looking at, and leaving it in place would invite a second
|
||||
// stamp nobody earned.
|
||||
rows.value = rows.value.filter((r) => r.id !== id);
|
||||
toast.show("Recorded — checked today");
|
||||
} else {
|
||||
// It stays. A failed check writes nothing on purpose: the note is wrong
|
||||
// rather than in a state worth recording, so it keeps its place until
|
||||
// someone corrects, supersedes, or unhooks it.
|
||||
toast.show("Recorded as no longer true — the note keeps its place here");
|
||||
}
|
||||
} catch {
|
||||
toast.show("Could not record that", "error");
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sweep">
|
||||
<header>
|
||||
<h2>Due for verification</h2>
|
||||
<p class="lede">
|
||||
Notes that assert a fact about something outside your control — what a
|
||||
service does, how a tool behaves. Most notes are decisions and never
|
||||
appear here; they have no truth value to go stale.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<label class="filter">
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="state">Loading…</p>
|
||||
|
||||
<!-- An empty sweep is GOOD NEWS and must not read like a broken page. -->
|
||||
<p v-else-if="!rows.length" class="state empty">
|
||||
Nothing to check.
|
||||
{{ neverOnly
|
||||
? "Every note that carries a check has been confirmed at least once."
|
||||
: "No note carries a check yet — add one to a note that asserts a fact." }}
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="n in rows" :key="n.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-note', n.id)">{{ n.title }}</button>
|
||||
<span class="age" :class="{ unchecked: n.days_since_verified === null }">
|
||||
{{ n.days_since_verified === null
|
||||
? "never checked"
|
||||
: `${n.days_since_verified}d ago` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="check">
|
||||
<dt>Check</dt>
|
||||
<dd>{{ n.verify_with }}</dd>
|
||||
<template v-if="n.expires_when">
|
||||
<dt>Ends when</dt>
|
||||
<dd>{{ n.expires_when }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="busyId === n.id" @click="verify(n.id, true)">Still true</button>
|
||||
<button :disabled="busyId === n.id" @click="verify(n.id, false)">No longer true</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="rows.length" class="footnote">
|
||||
Record a result only after actually running the check. “No longer true” stores nothing
|
||||
on purpose — the note is wrong rather than in a state worth recording, so it keeps its
|
||||
place here until you correct it, supersede it, or remove its check.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
h2 { margin: 0; font-size: 1.05rem; }
|
||||
.lede {
|
||||
margin: 0.35rem 0 0;
|
||||
max-width: 62ch;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
|
||||
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
|
||||
|
||||
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.row {
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
}
|
||||
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.row-title {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
|
||||
color: var(--fs-text-primary); text-align: left;
|
||||
}
|
||||
.row-title:hover { text-decoration: underline; }
|
||||
/* The ORDER carries urgency — the top of this list is the least-confirmed
|
||||
thing in the corpus. No red/amber ramp: it would restate the ordering and
|
||||
force an invented "stale after N days" threshold. "Never" is marked because
|
||||
it is categorically DIFFERENT from a date, not a worse one. */
|
||||
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
|
||||
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
|
||||
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
|
||||
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
|
||||
.actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
</style>
|
||||
@@ -60,11 +60,11 @@ onUnmounted(() => {
|
||||
|
||||
.btn-bell {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: pointer;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -72,16 +72,16 @@ onUnmounted(() => {
|
||||
}
|
||||
.btn-bell:hover,
|
||||
.btn-bell.active {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border-color: var(--fs-accent);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: var(--fs-error);
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -85,9 +85,9 @@ onMounted(() => store.fetchAll())
|
||||
width: 340px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--fs-surface-hover);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-xl);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
z-index: 500;
|
||||
}
|
||||
@@ -97,10 +97,10 @@ onMounted(() => store.fetchAll())
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--fs-surface-hover);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.notif-panel-title {
|
||||
@@ -114,12 +114,12 @@ onMounted(() => store.fetchAll())
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item:hover { background: var(--fs-surface-hover); }
|
||||
.notif-item:hover { background: var(--color-hover); }
|
||||
|
||||
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
|
||||
|
||||
@@ -127,10 +127,10 @@ onMounted(() => store.fetchAll())
|
||||
.notif-msg {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.notif-time { font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -74,27 +74,27 @@ function goToPage(page: number) {
|
||||
}
|
||||
.page-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--fs-accent);
|
||||
background: var(--color-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ellipsis {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { TaskPriority } from "@/types/task";
|
||||
|
||||
const props = defineProps<{
|
||||
priority: TaskPriority;
|
||||
/** Dense surfaces — see StatusBadge. */
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const labels: Record<TaskPriority, string> = {
|
||||
@@ -18,7 +16,7 @@ const labels: Record<TaskPriority, string> = {
|
||||
<template>
|
||||
<span
|
||||
v-if="props.priority !== 'none'"
|
||||
:class="['priority-badge', `priority-${props.priority}`, { compact }]"
|
||||
:class="['priority-badge', `priority-${props.priority}`]"
|
||||
>
|
||||
{{ labels[props.priority] }}
|
||||
</span>
|
||||
@@ -30,28 +28,20 @@ const labels: Record<TaskPriority, string> = {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.compact {
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.priority-low {
|
||||
background: var(--fs-priority-low-bg);
|
||||
color: var(--fs-priority-low-fg);
|
||||
background: var(--color-priority-low-bg);
|
||||
color: var(--color-priority-low);
|
||||
}
|
||||
.priority-medium {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
.priority-high {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high-fg);
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A project's own code, checked against the design system it is bound to (#2432).
|
||||
*
|
||||
* This is what the design surface is FOR: a project's recorded components
|
||||
* measured against the sheet they are supposed to use. The check itself is not
|
||||
* new — `check_snippets_against_system` has taken a project id since it was
|
||||
* written, and the route has always read `?project_id=`. Nothing on this side
|
||||
* ever passed one, so the capability shipped and stayed unreachable.
|
||||
*
|
||||
* The finding that matters most is the quiet one. `local_definitions` is a
|
||||
* snippet minting its own custom property instead of reaching for the shared
|
||||
* one — the codebase re-solving a solved problem, one component at a time.
|
||||
* Nothing breaks, no test fails, and the duplication only becomes visible when
|
||||
* someone changes the shared value and half the components don't move.
|
||||
*
|
||||
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
|
||||
* snippets — because that is the code Scribe holds. A repository's own sources
|
||||
* are checked where they live, by that project's CI.
|
||||
*/
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
|
||||
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
|
||||
|
||||
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
|
||||
|
||||
const check = ref<SnippetCheck | null>(null);
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
async function run() {
|
||||
check.value = null;
|
||||
failed.value = false;
|
||||
if (props.designSystemId === null) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
check.value = await checkSnippets(props.designSystemId, props.projectId);
|
||||
} catch {
|
||||
// Said out loud rather than rendered as an empty result. "Couldn't check"
|
||||
// and "nothing to report" look identical if you let them, and that is how
|
||||
// a check comes to sit dead without anyone noticing (#2419).
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
watch(() => [props.projectId, props.designSystemId], run);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pdt">
|
||||
<div v-if="designSystemId === null" class="pdt-note">
|
||||
<strong>No design system for this project.</strong>
|
||||
<p>
|
||||
Bind one in the sidebar and this tab reports where the project's recorded
|
||||
components disagree with it — references to tokens the system doesn't
|
||||
have, literals it says to stop writing, and properties a component mints
|
||||
for itself instead of reusing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets…</p>
|
||||
|
||||
<div v-else-if="failed" class="pdt-note">
|
||||
<strong>The check couldn't run.</strong>
|
||||
<p>Nothing was compared — this is a failure, not a clean result.</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="check">
|
||||
<p v-if="!check.checked" class="pdt-muted">
|
||||
This project has no recorded snippets, so nothing was checked. Record the
|
||||
components you reuse and they get measured against the sheet.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!check.findings.length" class="pdt-clean">
|
||||
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
|
||||
every reference resolves, and none mints a property of its own.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="pdt-summary">
|
||||
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
|
||||
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
|
||||
</p>
|
||||
|
||||
<ul class="pdt-list">
|
||||
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
|
||||
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
|
||||
{{ f.title || "Untitled snippet" }}
|
||||
</router-link>
|
||||
|
||||
<!-- Renders as nothing at all: no error, no failing test, just an
|
||||
element that quietly isn't styled. Leads for that reason. -->
|
||||
<div v-if="f.unknown.length" class="pdt-row">
|
||||
<span class="pdt-tag unknown">no such token</span>
|
||||
<span class="pdt-detail">
|
||||
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="f.local_definitions.length" class="pdt-row">
|
||||
<span class="pdt-tag local">defines its own</span>
|
||||
<span class="pdt-detail">
|
||||
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="f.superseded_literals.length" class="pdt-row">
|
||||
<span class="pdt-tag superseded">write the token</span>
|
||||
<span class="pdt-detail">
|
||||
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
|
||||
<code>{{ s.literal }}</code> → <code>{{ s.use_instead }}</code>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pdt {
|
||||
padding: var(--fs-space-2) 0;
|
||||
}
|
||||
|
||||
.pdt-note {
|
||||
background: var(--fs-surface-hover);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-left: 3px solid var(--fs-warning);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: var(--fs-space-3) var(--fs-space-4);
|
||||
}
|
||||
|
||||
.pdt-note p {
|
||||
margin: var(--fs-space-2) 0 0;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
line-height: var(--fs-leading-body);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.pdt-muted,
|
||||
.pdt-clean,
|
||||
.pdt-summary {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.pdt-clean {
|
||||
color: var(--fs-status-done-fg);
|
||||
}
|
||||
|
||||
.pdt-summary {
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.pdt-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
|
||||
.pdt-finding {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdt-title {
|
||||
display: block;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--fs-space-2);
|
||||
}
|
||||
.pdt-title:hover { color: var(--fs-accent); }
|
||||
|
||||
.pdt-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--fs-space-2);
|
||||
flex-wrap: wrap;
|
||||
padding: 0.15rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdt-tag {
|
||||
font-size: var(--fs-size-tiny);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--fs-tracking-tiny);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.pdt-tag.unknown {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
|
||||
.pdt-tag.local {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
|
||||
.pdt-tag.superseded {
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.pdt-detail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-2);
|
||||
font-size: var(--fs-size-code);
|
||||
color: var(--fs-text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdt-swap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -51,10 +51,10 @@ function onChange(e: Event) {
|
||||
<style scoped>
|
||||
.project-selector {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
@@ -62,6 +62,6 @@ function onChange(e: Event) {
|
||||
}
|
||||
.project-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A PROJECT's lifecycle state as a pill — active, paused, completed, archived.
|
||||
*
|
||||
* Deliberately not StatusBadge. That component is typed to TaskStatus and
|
||||
* speaks a different vocabulary; these two only ever shared a CSS class name,
|
||||
* which is what made them look like one shape that had drifted (#3132).
|
||||
*
|
||||
* Extracted because ProjectView and ProjectListView really were spelling the
|
||||
* same pill twice, with the differences you get from two hands rather than
|
||||
* two intentions: 0.68rem against 0.7rem, a 14% tint against 15%, one with a
|
||||
* border and one without.
|
||||
*/
|
||||
const props = defineProps<{ status: string }>();
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
completed: "Completed",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const label = (s: string) => LABELS[s] ?? s;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['project-status', `project-status--${props.status}`]">
|
||||
{{ label(props.status) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-status {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text is the hue mixed toward --fs-text-primary, not the raw hue. Both old
|
||||
spellings painted the hue on a 15% tint of itself, which measured 1.61-2.39:1
|
||||
against AA's 4.5 — the same defect the status and priority ladders had, and
|
||||
invisible to the token checker because the background was an inline
|
||||
color-mix rather than a `-bg` token. The checker was widened alongside this.
|
||||
Measured worst-case over raised and hover in both modes: active 4.82:1,
|
||||
paused 4.63:1, completed 4.78:1, archived 4.84:1.
|
||||
No new design tokens: four values used by one component are the kind of
|
||||
growth Scribe's own design-system note warns about ("if this system grows
|
||||
past a handful of tokens, that is worth noticing rather than
|
||||
accommodating"). The derivation is stated once, here. */
|
||||
.project-status--active {
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--paused {
|
||||
background: color-mix(in srgb, var(--fs-warning) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-warning) 55%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--completed {
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--archived {
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary));
|
||||
}
|
||||
</style>
|
||||
@@ -153,19 +153,19 @@ const calendarDayMax = computed(() =>
|
||||
}
|
||||
.rec-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
.rec-num-input {
|
||||
width: 4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--color-input-border, var(--color-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rec-num-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.rec-unit { min-width: 6rem; }
|
||||
</style>
|
||||
|
||||
@@ -28,14 +28,14 @@ defineExpose({ focus: () => inputRef.value?.focus() });
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -206,9 +206,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.share-dialog {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-xl);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
@@ -220,7 +220,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.share-title {
|
||||
@@ -228,9 +228,10 @@ onMounted(async () => {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
|
||||
.share-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
@@ -239,17 +240,17 @@ onMounted(async () => {
|
||||
|
||||
.share-tab {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.share-tab.active {
|
||||
background: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
@@ -268,23 +269,23 @@ onMounted(async () => {
|
||||
.share-input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.share-input:focus { border-color: var(--fs-accent); }
|
||||
.share-input:focus { border-color: var(--color-primary); }
|
||||
|
||||
.user-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
margin-top: 2px;
|
||||
list-style: none;
|
||||
@@ -303,23 +304,24 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.user-result-item:hover { background: var(--fs-surface-raised); }
|
||||
.user-result-item:hover { background: var(--color-bg-secondary); }
|
||||
|
||||
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
||||
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
|
||||
|
||||
.perm-select {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--fs-gradient-cta);
|
||||
background: var(--gradient-cta);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -340,7 +342,7 @@ onMounted(async () => {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
@@ -359,7 +361,7 @@ onMounted(async () => {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
@@ -367,10 +369,10 @@ onMounted(async () => {
|
||||
|
||||
.perm-select-inline {
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The starter token ROLES offered when a design system is created (#2349).
|
||||
*
|
||||
* WHY THIS IS A COMPONENT
|
||||
* DesignSystemsView has two creation forms — the empty state and the one inside
|
||||
* the body — because the empty state is a sibling branch, not a parent. Putting
|
||||
* the checklist inline would make it the third thing in this codebase defined
|
||||
* twice and free to drift, which is what the whole button migration was about.
|
||||
*
|
||||
* WHAT IT OFFERS
|
||||
* Names and purposes, never values. A role is a question the operator answers
|
||||
* with their own palette; a default palette would be one install's taste
|
||||
* shipped as product (rule #115). Every group is individually skippable —
|
||||
* an operator who wants three tokens should get three.
|
||||
*
|
||||
* All groups are checked by default. That default lives HERE rather than in the
|
||||
* service, because the service must never seed rows into a system whose caller
|
||||
* did not ask; a UI default is visible and reversible before the click.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
|
||||
|
||||
// props + emit rather than defineModel, matching TagInput and the rest of
|
||||
// components/ — being the only file using a different binding idiom costs more
|
||||
// than the few lines it saves.
|
||||
const props = defineProps<{ selected: string[]; prefix: string }>();
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [value: string[]];
|
||||
"update:prefix": [value: string];
|
||||
}>();
|
||||
|
||||
const groups = ref<StarterRoleGroup[]>([]);
|
||||
const defaultPrefix = ref("--ds-");
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listStarterRoleGroups();
|
||||
groups.value = data.groups;
|
||||
defaultPrefix.value = data.default_prefix;
|
||||
if (!props.prefix) emit("update:prefix", data.default_prefix);
|
||||
// Everything on by default — see the note above.
|
||||
if (!props.selected.length) {
|
||||
emit("update:selected", data.groups.map((g) => g.group));
|
||||
}
|
||||
} catch {
|
||||
// A creation form must still work when this fails. Roles are an
|
||||
// accelerator, not a prerequisite: the operator can add tokens by hand.
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggle(group: string) {
|
||||
emit(
|
||||
"update:selected",
|
||||
props.selected.includes(group)
|
||||
? props.selected.filter((g) => g !== group)
|
||||
: [...props.selected, group],
|
||||
);
|
||||
}
|
||||
|
||||
const totalTokens = () =>
|
||||
groups.value
|
||||
.filter((g) => props.selected.includes(g.group))
|
||||
.reduce((n, g) => n + g.token_count, 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="srp-note">Loading starter roles…</div>
|
||||
|
||||
<!-- Failure is not fatal and should not read as one. -->
|
||||
<div v-else-if="failed" class="srp-note">
|
||||
Starter roles unavailable — you can add tokens by hand after creating.
|
||||
</div>
|
||||
|
||||
<fieldset v-else-if="groups.length" class="srp">
|
||||
<legend class="srp-legend">Start with these token roles</legend>
|
||||
<p class="srp-intro">
|
||||
Named now, valued later. A role you haven't filled in shows as
|
||||
<em>to be decided</em>; a role that doesn't exist is what gets written as a
|
||||
literal instead. Uncheck anything this system won't have.
|
||||
</p>
|
||||
|
||||
<div class="srp-grid">
|
||||
<label v-for="g in groups" :key="g.group" class="srp-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="props.selected.includes(g.group)"
|
||||
@change="toggle(g.group)"
|
||||
/>
|
||||
<span class="srp-name">{{ g.group }}</span>
|
||||
<span class="srp-count">{{ g.token_count }}</span>
|
||||
<span class="srp-desc">{{ g.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="srp-footer">
|
||||
<label class="srp-prefix">
|
||||
<span>Prefix</span>
|
||||
<input
|
||||
:value="props.prefix" class="input srp-prefix-input" type="text"
|
||||
:placeholder="defaultPrefix"
|
||||
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span class="srp-total">
|
||||
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.srp {
|
||||
border: var(--fs-border);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-4);
|
||||
margin: 0 0 var(--fs-space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.srp-legend {
|
||||
font-size: var(--fs-size-label);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
color: var(--fs-text-primary);
|
||||
padding: 0 var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-intro,
|
||||
.srp-note {
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: var(--fs-leading-body);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.srp-note {
|
||||
margin-bottom: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: baseline;
|
||||
gap: var(--fs-space-2);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
.srp-item:hover { background: var(--fs-surface-hover); }
|
||||
|
||||
.srp-name {
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.srp-count {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The description is the useful part on a wide card and the first thing worth
|
||||
dropping on a narrow one — the group name alone still identifies the row. */
|
||||
.srp-desc {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.srp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-3);
|
||||
margin-top: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.srp-prefix-input {
|
||||
width: 8rem;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-code);
|
||||
}
|
||||
|
||||
.srp-total {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -4,16 +4,13 @@ import type { TaskStatus } from "@/types/task";
|
||||
const props = defineProps<{
|
||||
status: TaskStatus;
|
||||
clickable?: boolean;
|
||||
/** Dense surfaces — smaller, unshouted. The canon (#2960) names compact a
|
||||
VARIANT of this component rather than a reason to re-spell it. */
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ click: [] }>();
|
||||
|
||||
const labels: Record<TaskStatus, string> = {
|
||||
todo: "Todo",
|
||||
in_progress: "In progress",
|
||||
in_progress: "In Progress",
|
||||
done: "Done",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
@@ -21,7 +18,7 @@ const labels: Record<TaskStatus, string> = {
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="['status-badge', `status-${props.status}`, { clickable, compact }]"
|
||||
:class="['status-badge', `status-${props.status}`, { clickable }]"
|
||||
@click="clickable ? $emit('click') : undefined"
|
||||
:role="clickable ? 'button' : undefined"
|
||||
:tabindex="clickable ? 0 : undefined"
|
||||
@@ -36,37 +33,25 @@ const labels: Record<TaskStatus, string> = {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
/* Text comes from the -fg tokens, which are the hue mixed toward
|
||||
--fs-text-primary until they clear AA. The old spelling darkened the hue
|
||||
with `#000 15%` — a light-mode instinct that made these WORSE on the dark
|
||||
palette, where the surface is already near-black, and a literal besides. */
|
||||
.status-todo {
|
||||
background: var(--fs-status-todo-bg);
|
||||
color: var(--fs-status-todo-fg);
|
||||
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
|
||||
}
|
||||
.status-in_progress {
|
||||
background: var(--fs-status-in-progress-bg);
|
||||
color: var(--fs-status-in-progress-fg);
|
||||
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
|
||||
}
|
||||
.status-done {
|
||||
background: var(--fs-status-done-bg);
|
||||
color: var(--fs-status-done-fg);
|
||||
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
|
||||
}
|
||||
.status-cancelled {
|
||||
background: var(--fs-status-todo-bg);
|
||||
color: var(--fs-status-cancelled-fg);
|
||||
}
|
||||
.compact {
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -70,9 +70,9 @@ defineExpose({ onKeyDown });
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
@@ -85,6 +85,6 @@ defineExpose({ onKeyDown });
|
||||
}
|
||||
.ac-item:hover,
|
||||
.ac-item.active {
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { getProjectIssues } from "@/api/systems";
|
||||
import type { System, TaskLike } from "@/api/systems";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
const store = useSystemsStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
@@ -23,26 +19,14 @@ const issues = ref<TaskLike[]>([]);
|
||||
const showCreate = ref(false);
|
||||
const newName = ref("");
|
||||
const newDescription = ref("");
|
||||
// The global area, chosen explicitly. A PICKER rather than a live matcher on
|
||||
// purpose: reproducing the server's slug rule in TypeScript would give this
|
||||
// feature two matchers to keep in step, which is the exact drift the catalog
|
||||
// exists to end. The server still applies an exact hit on submit.
|
||||
const newCanonicalId = ref<number | null>(null);
|
||||
const creating = ref(false);
|
||||
// An `overlap` the server offered after a create — an offer, never applied.
|
||||
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
|
||||
|
||||
// Edit state
|
||||
const editingId = ref<number | null>(null);
|
||||
const editName = ref("");
|
||||
const editDescription = ref("");
|
||||
const editCanonicalId = ref<number | null>(null);
|
||||
const savingEdit = ref(false);
|
||||
|
||||
// Mapping review
|
||||
const showReview = ref(false);
|
||||
const reviewBusy = ref<number | null>(null);
|
||||
|
||||
// Delete confirmation
|
||||
const deletingSystem = ref<System | null>(null);
|
||||
|
||||
@@ -53,12 +37,6 @@ const visibleSystems = computed(() =>
|
||||
showArchived.value ? systems.value : activeSystems.value,
|
||||
);
|
||||
|
||||
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
|
||||
|
||||
function areaName(system: System): string | null {
|
||||
return canon.byId(system.canonical_id)?.name ?? null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
error.value = null;
|
||||
try {
|
||||
@@ -71,14 +49,6 @@ async function load() {
|
||||
} catch {
|
||||
issues.value = [];
|
||||
}
|
||||
// Both fail soft: the catalog is a naming aid, and a review prompt that
|
||||
// cannot load must not take the Systems list down with it.
|
||||
await canon.fetchCatalog();
|
||||
try {
|
||||
await canon.fetchProposals(props.projectId);
|
||||
} catch {
|
||||
/* no proposals shown */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -88,14 +58,12 @@ function openCreate() {
|
||||
showCreate.value = true;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
showCreate.value = false;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
@@ -103,60 +71,23 @@ async function submitCreate() {
|
||||
if (!name || creating.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
const created = await store.createSystem(props.projectId, {
|
||||
await store.createSystem(props.projectId, {
|
||||
name,
|
||||
description: newDescription.value.trim() || undefined,
|
||||
canonical_id: newCanonicalId.value ?? undefined,
|
||||
});
|
||||
cancelCreate();
|
||||
if (created.canonical_suggestion) {
|
||||
// An overlap: shown as an offer beside the new System, never applied.
|
||||
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
|
||||
}
|
||||
toast.show(
|
||||
created.canonical_id
|
||||
? `System created and filed under ${canon.byId(created.canonical_id)?.name}`
|
||||
: "System created",
|
||||
);
|
||||
} catch (e) {
|
||||
// 409 = this project already has that System. Say WHICH one, so the
|
||||
// answer is actionable rather than "it didn't work".
|
||||
toast.show(apiErrorMessage(e, "Failed to create system"), "error");
|
||||
toast.show("System created");
|
||||
} catch {
|
||||
toast.show("Failed to create system", "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSuggestion() {
|
||||
const pending = suggestion.value;
|
||||
if (!pending) return;
|
||||
suggestion.value = null;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, pending.systemId, pending.match.id);
|
||||
await store.fetchSystems(props.projectId);
|
||||
toast.show(`Filed under ${pending.match.name}`);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProposal(systemId: number, canonicalId: number) {
|
||||
reviewBusy.value = systemId;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, systemId, canonicalId);
|
||||
await store.fetchSystems(props.projectId);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
} finally {
|
||||
reviewBusy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(system: System) {
|
||||
editingId.value = system.id;
|
||||
editName.value = system.name;
|
||||
editDescription.value = system.description;
|
||||
editCanonicalId.value = system.canonical_id;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
@@ -172,12 +103,6 @@ async function submitEdit(system: System) {
|
||||
name,
|
||||
description: editDescription.value.trim(),
|
||||
});
|
||||
// The mapping is a separate write with its own validation — one column,
|
||||
// one writer (services/canonical_systems.set_system_canonical).
|
||||
if (editCanonicalId.value !== system.canonical_id) {
|
||||
await canon.mapSystem(props.projectId, system.id, editCanonicalId.value);
|
||||
await store.fetchSystems(props.projectId);
|
||||
}
|
||||
editingId.value = null;
|
||||
toast.show("System updated");
|
||||
} catch {
|
||||
@@ -236,65 +161,6 @@ async function confirmDelete() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Mapping review. Only appears when there is something to decide, and
|
||||
it says HOW MANY rather than nagging with a permanent banner. -->
|
||||
<div v-if="proposals.length" class="area-review">
|
||||
<button class="area-review-head" @click="showReview = !showReview">
|
||||
<span class="area-review-count">{{ proposals.length }}</span>
|
||||
{{ proposals.length === 1 ? "system" : "systems" }} may belong to a shared area
|
||||
<span class="area-review-chev">{{ showReview ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<ul v-if="showReview" class="area-proposals">
|
||||
<li v-for="p in proposals" :key="p.system_id" class="area-proposal">
|
||||
<div class="area-proposal-text">
|
||||
<span class="area-proposal-name">{{ p.system_name }}</span>
|
||||
<span class="area-proposal-arrow" aria-hidden="true">→</span>
|
||||
<span class="area-proposal-target">{{ p.canonical_name }}</span>
|
||||
<!-- The basis is the decision the reviewer is making: `exact`
|
||||
differs only in spelling, `overlap` is a judgment call.
|
||||
Showing them identically is how a wrong mapping is waved
|
||||
through, so they never share a style. -->
|
||||
<span
|
||||
class="area-basis"
|
||||
:class="p.basis === 'exact' ? 'area-basis--exact' : 'area-basis--overlap'"
|
||||
:title="
|
||||
p.basis === 'exact'
|
||||
? 'Same name up to spelling — safe to accept.'
|
||||
: 'Shares a word. Accept only if it is really the same area.'
|
||||
"
|
||||
>{{ p.basis === "exact" ? "same name" : "similar" }}</span>
|
||||
</div>
|
||||
<div class="area-proposal-actions">
|
||||
<button
|
||||
class="btn-primary btn-compact"
|
||||
:disabled="reviewBusy === p.system_id"
|
||||
@click="applyProposal(p.system_id, p.canonical_id)"
|
||||
>
|
||||
{{ reviewBusy === p.system_id ? "Filing…" : "File here" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="canon.dismissProposal(props.projectId, p.system_id)"
|
||||
>
|
||||
Not this
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- An overlap offered by the server after a create. Never applied. -->
|
||||
<div v-if="suggestion" class="area-offer">
|
||||
<span>
|
||||
Is this the same area as
|
||||
<strong>{{ suggestion.match.name }}</strong>?
|
||||
</span>
|
||||
<div class="area-proposal-actions">
|
||||
<button class="btn-primary btn-compact" @click="acceptSuggestion">File it there</button>
|
||||
<button class="btn-ghost btn-compact" @click="suggestion = null">No, it's ours</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="systems-toolbar">
|
||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||
@@ -310,7 +176,7 @@ async function confirmDelete() {
|
||||
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
|
||||
<input
|
||||
v-model="newName"
|
||||
class="fs-input system-input"
|
||||
class="system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@@ -318,25 +184,11 @@ async function confirmDelete() {
|
||||
/>
|
||||
<textarea
|
||||
v-model="newDescription"
|
||||
class="fs-input system-textarea"
|
||||
class="system-textarea"
|
||||
rows="2"
|
||||
placeholder="What is this subsystem responsible for? (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="newCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<!-- .field-hint is the shared hint class beside .fs-input
|
||||
(components.css) — not restated scoped. -->
|
||||
<span class="field-hint">
|
||||
Files this system under an area shared by every project. Your name stays as you typed it.
|
||||
</span>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
@@ -375,7 +227,7 @@ async function confirmDelete() {
|
||||
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
||||
<input
|
||||
v-model="editName"
|
||||
class="fs-input system-input"
|
||||
class="system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@@ -383,20 +235,11 @@ async function confirmDelete() {
|
||||
/>
|
||||
<textarea
|
||||
v-model="editDescription"
|
||||
class="fs-input system-textarea"
|
||||
class="system-textarea"
|
||||
rows="2"
|
||||
placeholder="Description (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="editCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||
{{ savingEdit ? "Saving…" : "Save" }}
|
||||
@@ -410,7 +253,7 @@ async function confirmDelete() {
|
||||
<template v-else>
|
||||
<span
|
||||
class="system-swatch"
|
||||
:style="{ background: system.color || 'var(--fs-text-tertiary)' }"
|
||||
:style="{ background: system.color || 'var(--color-text-muted)' }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="system-body">
|
||||
@@ -421,13 +264,6 @@ async function confirmDelete() {
|
||||
:title="`${system.open_issue_count} open issue(s)`"
|
||||
>{{ system.open_issue_count }} open</span>
|
||||
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
|
||||
<!-- Not a TagPill: that recipe prefixes "#" and means a tag.
|
||||
This is the shared AREA this system is an instance of. -->
|
||||
<span
|
||||
v-if="areaName(system)"
|
||||
class="area-chip"
|
||||
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
|
||||
>{{ areaName(system) }}</span>
|
||||
</div>
|
||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||
</div>
|
||||
@@ -489,126 +325,41 @@ async function confirmDelete() {
|
||||
|
||||
/* ── Open issues ──────────────────────────────────────────────── */
|
||||
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fs-text-tertiary); }
|
||||
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--fs-radius-sm); text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
|
||||
.issue-link:hover { background: var(--fs-surface-raised); }
|
||||
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--fs-accent); }
|
||||
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||
.issue-link:hover { background: var(--color-bg-secondary); }
|
||||
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
|
||||
/* ── Shared-area mapping (milestone 307) ──────────────────────────
|
||||
The review is a disclosure, not a banner: it exists only while there is
|
||||
something to decide, and collapses to one line until opened. */
|
||||
.area-review {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.area-review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fs-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.area-review-head:hover { color: var(--fs-text-primary); }
|
||||
.area-review-head:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.area-review-count {
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.area-review-chev { margin-left: auto; color: var(--fs-text-tertiary); }
|
||||
|
||||
.area-proposals { list-style: none; margin: 0; padding: 0 var(--fs-space-3) var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.area-proposal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.area-proposal-text { display: flex; align-items: center; gap: var(--fs-space-2); flex-wrap: wrap; font-size: 0.85rem; min-width: 0; }
|
||||
.area-proposal-name { color: var(--fs-text-primary); }
|
||||
.area-proposal-arrow { color: var(--fs-text-tertiary); }
|
||||
.area-proposal-target { color: var(--fs-accent); }
|
||||
.area-proposal-actions { display: flex; gap: var(--fs-space-2); flex-shrink: 0; }
|
||||
|
||||
/* The two bases must never look alike — one is mechanical, the other is the
|
||||
reviewer's judgment, and that difference is the whole decision. */
|
||||
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
|
||||
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
|
||||
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
|
||||
|
||||
.area-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-3);
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-accent-faint);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
.area-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.area-label { font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
.area-select { box-sizing: border-box; width: 100%; }
|
||||
|
||||
.area-chip {
|
||||
font-size: 0.66rem;
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.btn-add-system {
|
||||
background: none;
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-add-system:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.btn-add-system:focus-visible { outline: none; border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.archived-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.archived-checkbox { accent-color: var(--fs-accent); cursor: pointer; }
|
||||
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
|
||||
|
||||
/* ── Create / edit form ───────────────────────────────────────── */
|
||||
.system-form {
|
||||
@@ -616,52 +367,26 @@ async function confirmDelete() {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.system-input, .system-textarea { box-sizing: border-box; width: 100%; }
|
||||
.system-input, .system-textarea {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
|
||||
.system-textarea { resize: vertical; }
|
||||
|
||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||
|
||||
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
|
||||
`--archived` modifier and the `:hover .system-actions` reveal survived.
|
||||
The card WAS a flex row and every child still says so — `.system-swatch`
|
||||
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
|
||||
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
|
||||
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
|
||||
of text rather than centred against the whole card.
|
||||
|
||||
The list had no rule at all, so it rendered with browser bullets and
|
||||
indent — invisible to the dangling-style check, which can only see a class
|
||||
that is PARTLY styled. A class with no rules anywhere looks exactly like a
|
||||
semantic-only hook.
|
||||
|
||||
Surface values match `.system-form` above, which is the same card shape in
|
||||
this file and the reason they can be recovered rather than guessed. */
|
||||
.systems-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.system-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
.system-card--archived { opacity: 0.6; }
|
||||
|
||||
.system-swatch {
|
||||
@@ -673,13 +398,13 @@ async function confirmDelete() {
|
||||
}
|
||||
.system-body { flex: 1; min-width: 0; }
|
||||
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.system-name { font-weight: 500; color: var(--fs-text-primary); word-break: break-word; }
|
||||
.system-name { font-weight: 500; color: var(--color-text); word-break: break-word; }
|
||||
.issue-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
@@ -689,15 +414,15 @@ async function confirmDelete() {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
}
|
||||
.system-description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -712,15 +437,15 @@ async function confirmDelete() {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
|
||||
.action-btn:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 1px; opacity: 1; }
|
||||
.action-delete:hover { color: var(--fs-error); }
|
||||
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
||||
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
||||
.action-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
/* ── Empty ────────────────────────────────────────────────────── */
|
||||
.systems-empty {
|
||||
@@ -730,29 +455,61 @@ async function confirmDelete() {
|
||||
gap: 0.4rem;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
/* remainders over the shared recipes (components.css, m302) */
|
||||
.empty-title { margin: 0; color: var(--fs-text-primary); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; max-width: 32ch; }
|
||||
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.skel-row {
|
||||
height: 3rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--fs-surface-raised) 25%,
|
||||
color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
|
||||
var(--fs-surface-raised) 75%
|
||||
var(--color-bg-secondary) 25%,
|
||||
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
||||
var(--color-bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
}
|
||||
.skel-row--short { width: 65%; }
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
}
|
||||
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
|
||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@ function scrollTo(id: string) {
|
||||
.toc-title {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -73,11 +73,11 @@ function scrollTo(id: string) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.toc-link {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toc-link:hover {
|
||||
color: var(--fs-accent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -154,9 +154,9 @@ function focusInput() {
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
cursor: text;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
@@ -166,9 +166,9 @@ function focusInput() {
|
||||
gap: 0.2rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
border: 1px solid var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
border: 1px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -195,7 +195,7 @@ function focusInput() {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -205,9 +205,9 @@ function focusInput() {
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -218,11 +218,11 @@ function focusInput() {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.tag-autocomplete-item:hover,
|
||||
.tag-autocomplete-item.selected {
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-accent);
|
||||
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,8 +29,8 @@ defineEmits<{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
background: var(--color-tag-bg);
|
||||
color: var(--color-tag-text);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
@@ -39,13 +39,13 @@ defineEmits<{
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tag-pill:hover {
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fs-accent);
|
||||
color: var(--color-tag-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskStatus } from "@/types/task";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { renderPreview } from "@/utils/markdown";
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task;
|
||||
compact?: boolean;
|
||||
projectTitle?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
"tag-click": [tag: string];
|
||||
"status-toggle": [id: number, status: TaskStatus];
|
||||
}>();
|
||||
|
||||
const statusCycle: Record<TaskStatus, TaskStatus> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: "todo",
|
||||
cancelled: "todo",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<TaskStatus, string> = {
|
||||
todo: "dot-todo",
|
||||
in_progress: "dot-in-progress",
|
||||
done: "dot-done",
|
||||
cancelled: "dot-cancelled",
|
||||
};
|
||||
|
||||
const statusTitle: Record<TaskStatus, string> = {
|
||||
todo: "Todo — click to mark In Progress",
|
||||
in_progress: "In Progress — click to mark Done",
|
||||
done: "Done — click to mark Todo",
|
||||
cancelled: "Cancelled — click to mark Todo",
|
||||
};
|
||||
|
||||
function cycleStatus() {
|
||||
emit("status-toggle", props.task.id, statusCycle[props.task.status!]);
|
||||
}
|
||||
|
||||
|
||||
function isOverdue(): boolean {
|
||||
if (!props.task.due_date || props.task.status === "done") return false;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return props.task.due_date < today;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="`/tasks/${task.id}`" :class="['task-card', { compact }]">
|
||||
|
||||
<!-- Compact: single row -->
|
||||
<template v-if="compact">
|
||||
<button
|
||||
:class="['status-dot', statusDotClass[task.status!]]"
|
||||
:title="statusTitle[task.status!]"
|
||||
@click.prevent.stop="cycleStatus"
|
||||
></button>
|
||||
<PriorityBadge :priority="task.priority!" />
|
||||
<span class="task-title-compact">{{ task.title || "Untitled" }}</span>
|
||||
<span v-if="projectTitle" class="project-crumb">{{ projectTitle }}</span>
|
||||
<div class="task-tags-compact">
|
||||
<TagPill
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click.stop="emit('tag-click', tag)"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
|
||||
{{ task.due_date }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Full: original layout -->
|
||||
<template v-else>
|
||||
<div class="task-top">
|
||||
<StatusBadge
|
||||
:status="task.status!"
|
||||
clickable
|
||||
@click.prevent.stop="cycleStatus"
|
||||
/>
|
||||
<PriorityBadge :priority="task.priority!" />
|
||||
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
|
||||
</div>
|
||||
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
|
||||
<div class="task-meta">
|
||||
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
|
||||
Due: {{ task.due_date }}
|
||||
</span>
|
||||
<TagPill
|
||||
v-for="tag in task.tags"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click.stop="emit('tag-click', tag)"
|
||||
/>
|
||||
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Compact single-row layout */
|
||||
.task-card.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.status-dot:hover {
|
||||
transform: scale(1.25);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot-todo {
|
||||
background: var(--color-status-todo, #94a3b8);
|
||||
border: 2px solid var(--color-status-todo, #94a3b8);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-text-muted);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--color-status-in-progress, #3b82f6);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-status-cancelled, #6b7280);
|
||||
}
|
||||
|
||||
.task-title-compact {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-crumb {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.1rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-tags-compact {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Full layout */
|
||||
.task-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.task-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
max-height: 7.5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--color-overdue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,6 @@ import { ref, onMounted } from "vue";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import type { TaskLog } from "@/types/task";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
|
||||
const props = defineProps<{ taskId: number }>();
|
||||
|
||||
@@ -16,6 +15,13 @@ const editingId = ref<number | null>(null);
|
||||
const editContent = ref("");
|
||||
const editDuration = ref("");
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${datePart}, ${timePart}`;
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
@@ -122,7 +128,7 @@ onMounted(loadLogs);
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="log-entry-meta">
|
||||
<span class="log-date">{{ fmtStamp(log.created_at) }}</span>
|
||||
<span class="log-date">{{ formatDate(log.created_at) }}</span>
|
||||
<span v-if="log.duration_minutes" class="log-duration-badge">
|
||||
{{ formatDuration(log.duration_minutes) }}
|
||||
</span>
|
||||
@@ -169,9 +175,9 @@ onMounted(loadLogs);
|
||||
|
||||
<style scoped>
|
||||
.log-section {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -181,7 +187,7 @@ onMounted(loadLogs);
|
||||
.log-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.15rem;
|
||||
@@ -189,11 +195,11 @@ onMounted(loadLogs);
|
||||
|
||||
.log-empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -206,11 +212,11 @@ onMounted(loadLogs);
|
||||
}
|
||||
|
||||
.log-date {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.log-duration-badge {
|
||||
background: var(--fs-accent);
|
||||
background: var(--color-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 99px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
@@ -229,10 +235,10 @@ onMounted(loadLogs);
|
||||
.log-textarea {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
@@ -241,7 +247,7 @@ onMounted(loadLogs);
|
||||
|
||||
.log-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.log-add-controls,
|
||||
@@ -253,7 +259,7 @@ onMounted(loadLogs);
|
||||
|
||||
.log-duration-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
@@ -262,16 +268,16 @@ onMounted(loadLogs);
|
||||
.log-duration-input {
|
||||
width: 5rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.log-duration-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ defineExpose({ editor });
|
||||
<style scoped>
|
||||
.editor-error {
|
||||
padding: 1rem;
|
||||
color: var(--fs-error);
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,13 +57,13 @@ const toastStore = useToastStore();
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.toast--success {
|
||||
background: var(--fs-success);
|
||||
background: var(--color-toast-success);
|
||||
}
|
||||
.toast--error {
|
||||
background: var(--fs-error);
|
||||
background: var(--color-toast-error);
|
||||
}
|
||||
.toast--warning {
|
||||
background: var(--fs-warning);
|
||||
background: var(--color-warning);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.toast--warning .toast-close {
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A design system's tokens, drawn rather than listed (#2431).
|
||||
*
|
||||
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
|
||||
* Every value is resolved on an offscreen probe carrying only this system's
|
||||
* declarations (`resolveDeclared`), never read from the page. So a token like
|
||||
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
|
||||
* accent, not the accent of the app you happen to be looking at. Previewing
|
||||
* another project's palette from here is the point; a preview that quietly
|
||||
* borrows the host app's values would be worse than no preview, because it
|
||||
* would look right.
|
||||
*
|
||||
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
|
||||
* A colour is drawn as a swatch, a length as a rule of that length, a font
|
||||
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
|
||||
* naming convention, because the convention is the install's (rule #115) — a
|
||||
* system that calls its spacing `--gap-N` gets the same treatment.
|
||||
*
|
||||
* A token with no value for the chosen mode is shown as undecided rather than
|
||||
* skipped. A named role awaiting a decision is information; a gap in a grid
|
||||
* is not.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { ResolvedToken } from "@/api/designSystems";
|
||||
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
|
||||
|
||||
const props = defineProps<{ tokens: ResolvedToken[] }>();
|
||||
|
||||
const modes = computed(() => modesPresent(props.tokens));
|
||||
const mode = ref(BASE_MODE);
|
||||
|
||||
/** Values as the browser would compute them, for the chosen mode. */
|
||||
const rendered = ref<Map<string, string>>(new Map());
|
||||
|
||||
function recompute() {
|
||||
const declared = new Map<string, string>();
|
||||
for (const token of props.tokens) {
|
||||
const value = valueForMode(token.value_by_mode, mode.value);
|
||||
if (value) declared.set(token.name, value);
|
||||
}
|
||||
rendered.value = resolveDeclared(declared);
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.tokens, mode],
|
||||
() => {
|
||||
// Keep the selection only while it still exists — switching systems can
|
||||
// drop a mode, and a stale one would silently render as base.
|
||||
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
|
||||
recompute();
|
||||
},
|
||||
{ immediate: true, deep: false },
|
||||
);
|
||||
|
||||
type Shape = "colour" | "surface" | "length" | "font" | "plain";
|
||||
|
||||
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
|
||||
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
|
||||
const GRADIENT = /gradient\(/;
|
||||
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
|
||||
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
|
||||
/** A stack of family names: commas, no functions, no digits. */
|
||||
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
|
||||
|
||||
function shapeOf(value: string): Shape {
|
||||
const v = value.trim();
|
||||
if (!v) return "plain";
|
||||
if (COLOUR.test(v)) return "colour";
|
||||
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
|
||||
if (LENGTH.test(v)) return "length";
|
||||
if (FONT_STACK.test(v)) return "font";
|
||||
return "plain";
|
||||
}
|
||||
|
||||
interface Specimen {
|
||||
name: string;
|
||||
declared: string;
|
||||
rendered: string;
|
||||
shape: Shape;
|
||||
purpose: string | null;
|
||||
/** True when `var()` substitution changed the value — worth showing on hover. */
|
||||
substituted: boolean;
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
const out = new Map<string, Specimen[]>();
|
||||
for (const token of props.tokens) {
|
||||
const declared = valueForMode(token.value_by_mode, mode.value);
|
||||
const value = rendered.value.get(token.name) ?? "";
|
||||
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
|
||||
bucket.push({
|
||||
name: token.name,
|
||||
declared,
|
||||
rendered: value,
|
||||
shape: shapeOf(value),
|
||||
purpose: token.purpose,
|
||||
substituted: Boolean(declared) && value !== declared,
|
||||
});
|
||||
out.set(token.group_name ?? "ungrouped", bucket);
|
||||
}
|
||||
return [...out.entries()];
|
||||
});
|
||||
|
||||
/**
|
||||
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
|
||||
* are visibly different — but a stray `100vw` can't stretch the row.
|
||||
*/
|
||||
function ruleWidth(value: string): string {
|
||||
return `min(${value}, 12rem)`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tp">
|
||||
<div v-if="modes.length > 1" class="tp-modes">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m"
|
||||
class="tp-mode"
|
||||
:class="{ active: m === mode }"
|
||||
@click="mode = m"
|
||||
>{{ m }}</button>
|
||||
<span class="tp-modes-note">
|
||||
The system's own modes — independent of the theme this app is in.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
|
||||
<h3 class="tp-group-heading">{{ group }}</h3>
|
||||
<ul class="tp-grid">
|
||||
<li v-for="s in specimens" :key="s.name" class="tp-item">
|
||||
<div
|
||||
class="tp-specimen"
|
||||
:class="`is-${s.shape}`"
|
||||
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
|
||||
>
|
||||
<span
|
||||
v-if="s.shape === 'colour'"
|
||||
class="tp-swatch"
|
||||
:style="{ '--tp-fill': s.rendered }"
|
||||
/>
|
||||
<span
|
||||
v-else-if="s.shape === 'surface'"
|
||||
class="tp-surface"
|
||||
:style="s.rendered.includes('gradient(')
|
||||
? { background: s.rendered }
|
||||
: { boxShadow: s.rendered }"
|
||||
/>
|
||||
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
|
||||
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
|
||||
<span class="tp-rule-label">{{ s.rendered }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="s.shape === 'font'"
|
||||
class="tp-font"
|
||||
:style="{ fontFamily: s.rendered }"
|
||||
>Ag</span>
|
||||
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
|
||||
<span v-else class="tp-plain">{{ s.rendered }}</span>
|
||||
</div>
|
||||
|
||||
<code class="tp-name">{{ s.name }}</code>
|
||||
<span class="tp-value" :title="s.declared">{{ s.declared || "—" }}</span>
|
||||
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tp-modes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.tp-mode {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font: inherit;
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tp-mode:hover { color: var(--fs-text-primary); }
|
||||
.tp-mode.active {
|
||||
color: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
background: var(--fs-accent-faint);
|
||||
}
|
||||
|
||||
.tp-modes-note {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.tp-group { margin-bottom: var(--fs-space-6); }
|
||||
|
||||
.tp-group-heading {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--fs-tracking-tiny);
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
padding-bottom: var(--fs-space-2);
|
||||
border-bottom: var(--fs-border);
|
||||
}
|
||||
|
||||
.tp-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: var(--fs-space-4) var(--fs-space-3);
|
||||
}
|
||||
|
||||
.tp-item {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
|
||||
*
|
||||
* The stage itself is plain. An earlier version put the checkerboard here, so
|
||||
* every specimen — including opaque colours and plain text — sat inside a
|
||||
* frame of checks, and the pattern read as the loudest thing on the page. The
|
||||
* checks belong to the ONE case that needs them: a colour that might be
|
||||
* translucent. */
|
||||
.tp-specimen {
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: var(--fs-space-1);
|
||||
overflow: hidden;
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
/* Text-bearing specimens get no box at all — a border around a value is a
|
||||
frame around nothing, which is most of what made the grid feel busy. */
|
||||
.tp-specimen.is-plain,
|
||||
.tp-specimen.is-length,
|
||||
.tp-specimen.is-font {
|
||||
background: none;
|
||||
padding: 0 var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* Checks UNDER the colour, not around it: an opaque value hides them
|
||||
completely, and a 15% tint shows exactly as much of them as it should.
|
||||
Layering the fill as a gradient is what lets one element do both. */
|
||||
.tp-swatch {
|
||||
/* Declared here, overridden inline per swatch. Two reasons it is a real
|
||||
default rather than a formality: a token that resolves to nothing renders
|
||||
as bare checks instead of an invalid gradient, and a custom property that
|
||||
exists ONLY as an inline style is invisible to the CI token check — which
|
||||
reads it as an unresolvable reference, correctly, since nothing in any
|
||||
stylesheet declares it. */
|
||||
--tp-fill: transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||
background-image:
|
||||
linear-gradient(var(--tp-fill), var(--tp-fill)),
|
||||
repeating-conic-gradient(
|
||||
var(--fs-border-color) 0% 25%,
|
||||
var(--fs-surface-raised) 0% 50%
|
||||
);
|
||||
background-size: auto, 10px 10px;
|
||||
}
|
||||
|
||||
.tp-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
.tp-rule-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tp-rule {
|
||||
height: 0.4rem;
|
||||
min-width: 1px;
|
||||
flex: none;
|
||||
background: var(--fs-accent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tp-rule-label {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tp-font {
|
||||
font-size: 1.4rem;
|
||||
color: var(--fs-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tp-plain {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-code);
|
||||
color: var(--fs-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tp-undecided {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* One line each, with the full text on hover.
|
||||
*
|
||||
* These wrapped freely at first, so a card was two lines tall or five depending
|
||||
* on how long its `color-mix()` happened to be, and the grid lost any rhythm —
|
||||
* which is most of what "messy" was. A derived value is not something anyone
|
||||
* reads character by character in a gallery; it is something you check the
|
||||
* shape of and open if it matters. */
|
||||
.tp-name,
|
||||
.tp-value,
|
||||
.tp-purpose {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tp-name {
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.tp-value {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.tp-purpose {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "N/M used" on a list row — surfaced vs opened, for any record kind.
|
||||
*
|
||||
* Extracted from SnippetListView when the rule list needed the same chip
|
||||
* (milestone 333 step 5). The counts read identically for both; what differs
|
||||
* is the ADVICE, which is why that is a prop. A snippet surfaced repeatedly
|
||||
* and never opened should probably go; a rule in the same position may simply
|
||||
* have a `when_to_apply` that fires on the wrong thing, and telling an
|
||||
* operator to delete it would be the wrong nudge half the time.
|
||||
*/
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
const props = defineProps<{
|
||||
usage?: RecordUsage | null;
|
||||
/** What to suggest when this record looks like dead weight. Appended to the
|
||||
* tooltip; kind-specific, because the remedies are. */
|
||||
deadWeightAdvice: string;
|
||||
/** What the record is called in the tooltip's own sentence. */
|
||||
noun?: string;
|
||||
}>();
|
||||
|
||||
/** Offered repeatedly and never opened. Three rather than one because one or
|
||||
* two surfacings is noise — the record may simply not have come up in a
|
||||
* relevant context yet. */
|
||||
const isDeadWeight = () =>
|
||||
!!props.usage && props.usage.pull_count === 0 && props.usage.surfaced_count >= 3;
|
||||
|
||||
/** "" renders nothing. A record nobody has surfaced yet gets no badge at all:
|
||||
* "0/0" would read as a verdict when it is an absence of evidence — and on a
|
||||
* freshly-migrated install that is every row. */
|
||||
const label = () => {
|
||||
const u = props.usage;
|
||||
if (!u || u.surfaced_count === 0) return "";
|
||||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||
};
|
||||
|
||||
const title = () => {
|
||||
const u = props.usage;
|
||||
if (!u) return "";
|
||||
const last = u.last_pulled_at
|
||||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||||
: "Never opened.";
|
||||
const verdict = isDeadWeight() ? ` ${props.deadWeightAdvice}` : "";
|
||||
return (
|
||||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||||
`${u.pull_count}×. ${last}${verdict}`
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="label()"
|
||||
class="usage-tag"
|
||||
:class="{ 'usage-dead': isDeadWeight() }"
|
||||
:title="title()"
|
||||
>{{ label() }}</span>
|
||||
</template>
|
||||
|
||||
<!-- The look lives in components.css (canon). Nothing scoped here on purpose:
|
||||
a view that needs different spacing keeps that as its own remainder. -->
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
|
||||
interface NoteVersion {
|
||||
id: number;
|
||||
@@ -31,7 +31,25 @@ const loadingDetail = ref(false);
|
||||
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (!selectedVersion.value?.body) return [];
|
||||
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||
const aLines = props.currentBody.split("\n");
|
||||
const bLines = selectedVersion.value.body.split("\n");
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
|
||||
else result.push({ type: "insert", text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: "delete", text: aLines[i++] });
|
||||
while (j < n) result.push({ type: "insert", text: bLines[j++] });
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -146,7 +164,7 @@ function restore() {
|
||||
|
||||
<style scoped>
|
||||
.vh-section {
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.vh-header {
|
||||
@@ -161,19 +179,19 @@ function restore() {
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.vh-header:hover { background: var(--fs-surface-raised); }
|
||||
.vh-header:hover { background: var(--color-bg-secondary); }
|
||||
|
||||
.vh-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.vh-chevron {
|
||||
font-size: 0.7rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vh-body {
|
||||
@@ -183,20 +201,20 @@ function restore() {
|
||||
.vh-empty {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vh-item {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.vh-item:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
border-left-color: var(--fs-accent);
|
||||
background: var(--color-bg-secondary);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.vh-diff-actions {
|
||||
@@ -207,20 +225,20 @@ function restore() {
|
||||
|
||||
.vh-btn-back {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.vh-btn-back:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.vh-btn-restore {
|
||||
background: var(--fs-action-primary);
|
||||
background: var(--color-action-primary);
|
||||
border: none;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-on-action);
|
||||
|
||||
@@ -50,11 +50,11 @@ const label = computed(() => {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.word-count:hover { color: var(--fs-text-primary); }
|
||||
.word-count:hover { color: var(--color-text); }
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,6 @@ import TagInput from "@/components/TagInput.vue";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import WordCount from "@/components/WordCount.vue";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
@@ -253,6 +252,20 @@ async function confirmDelete(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHrs < 24) return `${diffHrs}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
watch(noteTitle, () => { dirty.value = true; });
|
||||
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
||||
watch(noteTags, () => { dirty.value = true; });
|
||||
@@ -333,7 +346,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
>
|
||||
<div class="note-row-main">
|
||||
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
|
||||
<span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
|
||||
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
|
||||
</div>
|
||||
<div v-if="note.tags?.length" class="note-row-tags">
|
||||
<span
|
||||
@@ -439,8 +452,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--fs-surface-hover);
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
background: var(--color-surface);
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Left rail ── */
|
||||
@@ -450,7 +463,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
@@ -458,12 +471,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
@@ -471,12 +484,13 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.rail-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -489,7 +503,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.rail-state {
|
||||
padding: 1rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
@@ -505,16 +519,16 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 60%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
gap: 0.15rem;
|
||||
border-right: 2px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row.active {
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
|
||||
border-right-color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-right-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-row-main {
|
||||
@@ -530,12 +544,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.note-row-age {
|
||||
font-size: 0.62rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -548,8 +562,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.note-tag-pill {
|
||||
font-size: 0.58rem;
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.3rem;
|
||||
white-space: nowrap;
|
||||
@@ -558,13 +572,13 @@ defineExpose({ reload: loadProjectNotes });
|
||||
max-width: 5rem;
|
||||
}
|
||||
.note-tag-pill.tag-match {
|
||||
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
||||
outline: 1px solid var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
outline: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.note-tag-more {
|
||||
font-size: 0.58rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -574,6 +588,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
|
||||
/* Editor UI */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
@@ -590,8 +606,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unsaved { font-size: 0.72rem; color: var(--fs-text-tertiary); }
|
||||
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
|
||||
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
|
||||
@@ -602,14 +618,14 @@ defineExpose({ reload: loadProjectNotes });
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
font-family: 'Fraunces', serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
@@ -621,45 +637,48 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
|
||||
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
||||
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-suggestions-label { font-size: 0.72rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
|
||||
.btn-tag-suggestion {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-tag-suggestion:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.btn-tag-suggestion:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-tag-suggestion.applied {
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
|
||||
.link-suggest-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.link-suggest-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
@@ -669,15 +688,15 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.btn-chip-link {
|
||||
background: none;
|
||||
border: 1px solid var(--fs-accent);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--fs-accent);
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,8 @@ import { RouterLink } from "vue-router";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
@@ -30,7 +27,6 @@ interface Task {
|
||||
due_date: string | null;
|
||||
updated_at: string;
|
||||
body?: string;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([]);
|
||||
@@ -202,6 +198,20 @@ function cancelDeleteTask() {
|
||||
deleteConfirmPending.value = false;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHrs < 24) return `${diffHrs}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
onMounted(loadAll);
|
||||
defineExpose({ reload: loadAll });
|
||||
</script>
|
||||
@@ -245,9 +255,8 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
@@ -271,9 +280,8 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
@@ -286,7 +294,7 @@ defineExpose({ reload: loadAll });
|
||||
<div v-if="activeTask" class="task-detail">
|
||||
<div class="detail-header">
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<span :class="['status-cycler', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
<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">
|
||||
@@ -336,8 +344,8 @@ defineExpose({ reload: loadAll });
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--fs-surface-hover);
|
||||
border-right: 1px solid var(--fs-border-color);
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ── List view ── */
|
||||
@@ -352,17 +360,17 @@ defineExpose({ reload: loadAll });
|
||||
flex: 0 0 44%;
|
||||
}
|
||||
.task-active {
|
||||
background: color-mix(in srgb, var(--fs-accent) 6%, var(--fs-surface-hover)) !important;
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
@@ -373,20 +381,20 @@ defineExpose({ reload: loadAll });
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-add-input {
|
||||
flex: 1;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.28rem 0.5rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.task-add-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
||||
|
||||
@@ -396,7 +404,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
|
||||
.ms-group {
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ms-group-header {
|
||||
@@ -405,18 +413,18 @@ defineExpose({ reload: loadAll });
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.65rem;
|
||||
background: var(--fs-surface-raised);
|
||||
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ms-group-header:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover)); }
|
||||
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
|
||||
|
||||
.ms-chevron { font-size: 0.6rem; color: var(--fs-text-tertiary); width: 0.8rem; }
|
||||
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
||||
.ms-count { font-size: 0.72rem; color: var(--fs-text-tertiary); background: var(--fs-surface-page); border-radius: 10px; padding: 0 0.4rem; }
|
||||
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
|
||||
|
||||
.ms-status {
|
||||
font-size: 0.68rem;
|
||||
@@ -424,8 +432,8 @@ defineExpose({ reload: loadAll });
|
||||
border-radius: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success-fg); }
|
||||
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
|
||||
|
||||
.task-items {
|
||||
list-style: none;
|
||||
@@ -439,9 +447,9 @@ defineExpose({ reload: loadAll });
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 50%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
}
|
||||
.task-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
||||
.task-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.task-row:last-child { border-bottom: none; }
|
||||
|
||||
.status-dot {
|
||||
@@ -449,7 +457,7 @@ defineExpose({ reload: loadAll });
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--fs-border-color);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.62rem;
|
||||
@@ -457,8 +465,8 @@ defineExpose({ reload: loadAll });
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
|
||||
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
|
||||
|
||||
.task-title {
|
||||
flex: 1;
|
||||
@@ -466,20 +474,20 @@ defineExpose({ reload: loadAll });
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.task-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
|
||||
.task-title.done { text-decoration: line-through; color: var(--color-text-muted); }
|
||||
|
||||
.task-age {
|
||||
font-size: 0.68rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--fs-text-tertiary); }
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
|
||||
/* ── Detail pane (bottom split) ── */
|
||||
.task-detail {
|
||||
@@ -488,8 +496,8 @@ defineExpose({ reload: loadAll });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--fs-surface-hover);
|
||||
border-top: 2px solid var(--fs-border-color);
|
||||
background: var(--color-surface);
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
@@ -497,34 +505,31 @@ defineExpose({ reload: loadAll });
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* An interactive CYCLER, not a chip: it is clickable, outlined and
|
||||
transparent. It shared a name with the task chip and was never the same
|
||||
shape (#3132). */
|
||||
.status-cycler {
|
||||
.status-badge {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--fs-border-color);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: none;
|
||||
text-transform: capitalize;
|
||||
user-select: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
||||
.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 { margin-left: 0.25rem; }
|
||||
.btn-edit-task:hover { text-decoration: underline; }
|
||||
|
||||
.detail-body {
|
||||
padding: 0.5rem 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
max-height: 40%;
|
||||
overflow-y: auto;
|
||||
@@ -532,17 +537,17 @@ defineExpose({ reload: loadAll });
|
||||
|
||||
.body-loading {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detail-body .prose {
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.5;
|
||||
color: var(--fs-text-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-delete-task { margin-left: 0.25rem; }
|
||||
.btn-delete-task:hover { color: var(--fs-action-destructive); }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-delete-confirm { margin-left: 0.25rem; }
|
||||
|
||||
@@ -558,9 +563,9 @@ defineExpose({ reload: loadAll });
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
@@ -568,20 +573,20 @@ defineExpose({ reload: loadAll });
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
}
|
||||
.milestone-select:disabled { opacity: 0.5; cursor: default; }
|
||||
.milestone-select:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.milestone-select:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.detail-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.6rem 0.6rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Detail fade transition */
|
||||
@@ -604,12 +609,12 @@ defineExpose({ reload: loadAll });
|
||||
/* Due date on task rows */
|
||||
.task-due {
|
||||
font-size: 0.65rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-due.overdue {
|
||||
color: var(--fs-error);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,19 +46,13 @@ watch(() => props.projectId, load);
|
||||
<style scoped>
|
||||
.plan-rules {
|
||||
margin-top: 1.5rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.plan-rules h3 {
|
||||
font-size: 0.9em; opacity: 0.7;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
|
||||
below, and its children carry their own spacing (the h4 keeps the UA
|
||||
margin-top that separates one rulebook group from the next). Nothing here
|
||||
assumes a flex or grid parent, which is the tell that distinguishes this
|
||||
from a base rule someone deleted (#2444). Stated so the next reader doesn't
|
||||
re-open the question. */
|
||||
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
||||
.rb h5 {
|
||||
font-size: 0.8em; opacity: 0.7;
|
||||
@@ -66,7 +60,7 @@ watch(() => props.projectId, load);
|
||||
}
|
||||
.plan-rules ul {
|
||||
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
}
|
||||
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
||||
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
||||
|
||||
@@ -2,18 +2,10 @@
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
getProjectApplicableRules,
|
||||
subscribeProject,
|
||||
unsubscribeProject,
|
||||
listRulebooks,
|
||||
getRule,
|
||||
createProjectRule,
|
||||
deleteRule,
|
||||
suppressRuleForProject,
|
||||
unsuppressRuleForProject,
|
||||
suppressTopicForProject,
|
||||
unsuppressTopicForProject,
|
||||
includeAlwaysOnRulebook,
|
||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||
listRulebooks, getRule, createProjectRule, deleteRule,
|
||||
suppressRuleForProject, unsuppressRuleForProject,
|
||||
suppressTopicForProject, unsuppressTopicForProject,
|
||||
} from "@/api/rulebooks";
|
||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||
|
||||
@@ -24,16 +16,10 @@ const allRulebooks = ref<Rulebook[]>([]);
|
||||
const showPicker = ref(false);
|
||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
||||
|
||||
const ruleDetails = ref<Record<number, {
|
||||
why: string; how_to_apply: string;
|
||||
verify_with: string; expires_when: string; verified_at: string | null;
|
||||
}>>({});
|
||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||
|
||||
const showProjectRuleForm = ref(false);
|
||||
const newProjectRule = ref({
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
||||
});
|
||||
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
||||
|
||||
async function load() {
|
||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||
@@ -49,11 +35,6 @@ async function subscribe(rulebookId: number) {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function includeBack(rulebookId: number) {
|
||||
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function unsubscribe(rulebookId: number) {
|
||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||
await unsubscribeProject(props.projectId, rulebookId);
|
||||
@@ -70,9 +51,6 @@ async function toggleRuleExpand(ruleId: number) {
|
||||
ruleDetails.value[ruleId] = {
|
||||
why: rule.why || "",
|
||||
how_to_apply: rule.how_to_apply || "",
|
||||
verify_with: rule.verify_with || "",
|
||||
expires_when: rule.expires_when || "",
|
||||
verified_at: rule.verified_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -80,11 +58,6 @@ async function toggleRuleExpand(ruleId: number) {
|
||||
expandedRuleIds.value = new Set(expandedRuleIds.value);
|
||||
}
|
||||
|
||||
/** "never run" reads as a stronger claim than an absent date — and it is. */
|
||||
function checkAge(verifiedAt: string | null): string {
|
||||
return verifiedAt ? `last passed ${verifiedAt.slice(0, 10)}` : "never run";
|
||||
}
|
||||
|
||||
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||
const query: Record<string, string> = { rb: String(rulebookId) };
|
||||
if (ruleId) query.rule = String(ruleId);
|
||||
@@ -104,20 +77,14 @@ interface RulebookGroup {
|
||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||
const byRulebook = new Map<number, RulebookGroup>();
|
||||
for (const r of rules) {
|
||||
// A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach
|
||||
// this list, so a null topic would be a server-side contradiction — skip
|
||||
// it rather than widen the group's type to accommodate a case that means
|
||||
// something is wrong upstream.
|
||||
if (r.topic_id === null) continue;
|
||||
const topicId = r.topic_id;
|
||||
let rb = byRulebook.get(r.rulebook_id);
|
||||
if (!rb) {
|
||||
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||
byRulebook.set(r.rulebook_id, rb);
|
||||
}
|
||||
let topic = rb.topics.find((t) => t.topic_id === topicId);
|
||||
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
|
||||
if (!topic) {
|
||||
topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] };
|
||||
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
|
||||
rb.topics.push(topic);
|
||||
}
|
||||
topic.rules.push(r);
|
||||
@@ -133,13 +100,8 @@ async function submitProjectRule() {
|
||||
title: newProjectRule.value.title.trim() || undefined,
|
||||
why: newProjectRule.value.why.trim() || undefined,
|
||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||
tier: newProjectRule.value.tier,
|
||||
});
|
||||
newProjectRule.value = {
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on",
|
||||
};
|
||||
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
||||
showProjectRuleForm.value = false;
|
||||
await load();
|
||||
}
|
||||
@@ -210,17 +172,6 @@ watch(() => props.projectId, load);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="applicable.excluded_always_on?.length" class="excluded">
|
||||
<h3>Excluded always-on rulebooks</h3>
|
||||
<p class="excluded-note">Opted out at inception — these do not bind this project.</p>
|
||||
<div class="chips">
|
||||
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
|
||||
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
||||
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again">↩</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="project-rules">
|
||||
<div class="section-head">
|
||||
<h3>Project rules</h3>
|
||||
@@ -244,24 +195,6 @@ watch(() => props.projectId, load);
|
||||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<textarea
|
||||
v-model="newProjectRule.when_to_apply"
|
||||
placeholder="When to apply — the trigger, not the instruction"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="tier-row">
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
||||
Always on
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
|
||||
Conditional
|
||||
</label>
|
||||
<span class="tier-hint">
|
||||
Conditional if you had to name a system, an artifact or a moment to state the trigger.
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="newProjectRule.why"
|
||||
placeholder="Why (optional) — the rationale"
|
||||
@@ -290,16 +223,6 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<!-- Shown only when the rule carries a check. Read-only here: this
|
||||
tab is the project's view of what binds it, and editing a rule
|
||||
belongs on the rulebook surface that owns it. -->
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -353,13 +276,6 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button
|
||||
class="edit-link"
|
||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||
@@ -405,14 +321,6 @@ watch(() => props.projectId, load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.tier-row input { accent-color: var(--fs-accent); }
|
||||
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||
.chip-excluded .chip-remove { text-decoration: none; }
|
||||
.rules-tab { padding: 1rem; }
|
||||
h3 {
|
||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
@@ -421,7 +329,7 @@ h3 {
|
||||
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
background: var(--fs-accent-soft);
|
||||
background: var(--color-primary-bg, rgba(99,102,241,0.15));
|
||||
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||||
}
|
||||
.chip a { cursor: pointer; }
|
||||
@@ -429,47 +337,38 @@ h3 {
|
||||
.chip-remove:hover { opacity: 1; }
|
||||
.add {
|
||||
background: none;
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
border: 1px dashed var(--color-border, #2a2a2e);
|
||||
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
select {
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.applicable { margin-top: 2rem; }
|
||||
.rb-group { margin-bottom: 1.5rem; }
|
||||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
||||
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
|
||||
one and the flex row further down), with the h5's own margin-top doing the
|
||||
separating. Its children assume nothing about it, which is what tells it
|
||||
apart from a base rule someone deleted (#2444). */
|
||||
.topic-group h5 {
|
||||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule {
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
padding-left: 0.75rem; margin: 0.5rem 0;
|
||||
}
|
||||
.rule-head { cursor: pointer; }
|
||||
.rule-title { font-weight: 500; }
|
||||
.rule-check-age {
|
||||
margin-left: var(--fs-space-2);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||
.rule-detail {
|
||||
margin-top: 0.5rem; padding: 0.5rem;
|
||||
background: var(--fs-surface-page); border-radius: 6px;
|
||||
background: var(--color-bg, #111113); border-radius: 6px;
|
||||
}
|
||||
.rule-detail > div { margin-bottom: 0.5rem; }
|
||||
.edit-link {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--fs-accent); padding: 0.5rem 0 0 0;
|
||||
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||
.empty a { cursor: pointer; text-decoration: underline; }
|
||||
@@ -478,18 +377,18 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.new-rule-form {
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
padding: 0.75rem; margin: 0.5rem 0;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
background: var(--color-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
}
|
||||
.new-rule-form input, .new-rule-form textarea {
|
||||
background: var(--fs-surface-hover); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
background: var(--color-surface, #18181b); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.5rem; font: inherit; resize: vertical;
|
||||
}
|
||||
.rule-list { margin-top: 0.5rem; }
|
||||
.delete-link {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--fs-destructive); padding: 0.5rem 0 0 0;
|
||||
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||
.topic-group h5 {
|
||||
@@ -501,14 +400,14 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule-head-text { flex: 1; cursor: pointer; }
|
||||
.skip-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--fs-text-tertiary); font-size: 0.75rem;
|
||||
color: var(--color-muted, #888); font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topic-group h5:hover .skip-btn,
|
||||
.rule:hover .skip-btn,
|
||||
.skip-btn:focus { opacity: 1; }
|
||||
.skip-btn:hover { color: var(--fs-destructive); }
|
||||
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
||||
/* Suppressed section */
|
||||
.suppressed { margin-top: 1.5rem; }
|
||||
.suppressed-toggle {
|
||||
@@ -527,13 +426,13 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.suppressed-kind {
|
||||
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--color-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
}
|
||||
.suppressed-path { flex: 1; }
|
||||
.reenable-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--fs-accent); font-size: 0.85em;
|
||||
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
||||
}
|
||||
.reenable-btn:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -1,67 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const title = ref("");
|
||||
const statement = ref("");
|
||||
const whenToApply = ref("");
|
||||
const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
const verifyWith = ref("");
|
||||
const expiresWhen = ref("");
|
||||
|
||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||
|
||||
// The label a reader needs to judge an edge, not the stored token.
|
||||
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||||
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
||||
overrides: { outgoing: "overrides", incoming: "is overridden by" },
|
||||
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
|
||||
};
|
||||
|
||||
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
||||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||||
}
|
||||
|
||||
function toggleSystem(id: number) {
|
||||
const at = systemIds.value.indexOf(id);
|
||||
if (at >= 0) systemIds.value.splice(at, 1);
|
||||
else systemIds.value.push(id);
|
||||
}
|
||||
|
||||
const isCreating = ref(props.ruleId === null);
|
||||
|
||||
// The stored stamp, not the draft: it describes the check that was RUN, and
|
||||
// an unsaved edit to the textarea has not been run against anything.
|
||||
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
|
||||
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
|
||||
// Built here rather than in the template: same shape as the server's
|
||||
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
|
||||
const stampLabel = computed(() =>
|
||||
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
|
||||
);
|
||||
const verifying = ref(false);
|
||||
|
||||
async function verify(stillTrue: boolean) {
|
||||
if (props.ruleId === null) return;
|
||||
verifying.value = true;
|
||||
try {
|
||||
await store.verifyRule(props.ruleId, stillTrue);
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (props.ruleId !== null) {
|
||||
await store.fetchRule(props.ruleId);
|
||||
@@ -69,26 +20,15 @@ async function load() {
|
||||
if (r) {
|
||||
title.value = r.title;
|
||||
statement.value = r.statement;
|
||||
whenToApply.value = r.when_to_apply || "";
|
||||
tier.value = r.tier || "always_on";
|
||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||
why.value = r.why || "";
|
||||
howToApply.value = r.how_to_apply || "";
|
||||
verifyWith.value = r.verify_with || "";
|
||||
expiresWhen.value = r.expires_when || "";
|
||||
}
|
||||
} else {
|
||||
title.value = "";
|
||||
statement.value = "";
|
||||
whenToApply.value = "";
|
||||
tier.value = "always_on";
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
verifyWith.value = "";
|
||||
expiresWhen.value = "";
|
||||
}
|
||||
await canon.fetchCatalog();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -96,26 +36,16 @@ async function save() {
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
const fields = {
|
||||
title: title.value,
|
||||
statement: statement.value,
|
||||
when_to_apply: whenToApply.value,
|
||||
tier: tier.value,
|
||||
// Always sent, so clearing the last area actually clears it — the server
|
||||
// reads a list as "these ARE the areas now".
|
||||
system_ids: systemIds.value,
|
||||
why: why.value,
|
||||
how_to_apply: howToApply.value,
|
||||
// Always sent, including empty. The REST door maps "" to NULL, so
|
||||
// clearing a field here actually clears it — the MCP door's "" means
|
||||
// "leave unchanged" and needs an explicit clear_fields list instead.
|
||||
verify_with: verifyWith.value,
|
||||
expires_when: expiresWhen.value,
|
||||
};
|
||||
if (isCreating.value && props.topicId !== null) {
|
||||
await store.createRule(props.topicId, fields);
|
||||
await store.createRule(props.topicId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
} else if (props.ruleId !== null) {
|
||||
await store.updateRule(props.ruleId, fields);
|
||||
await store.updateRule(props.ruleId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
}
|
||||
emit("close");
|
||||
}
|
||||
@@ -147,108 +77,6 @@ watch(() => props.ruleId, load);
|
||||
Statement <span class="required">*</span>
|
||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||
</label>
|
||||
<label>
|
||||
When to apply
|
||||
<textarea
|
||||
v-model="whenToApply"
|
||||
rows="2"
|
||||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset class="tier">
|
||||
<legend>How it reaches a session</legend>
|
||||
<label class="tier-opt">
|
||||
<input v-model="tier" type="radio" value="always_on" />
|
||||
<span>
|
||||
<strong>Always on</strong>
|
||||
— loaded into every session.
|
||||
</span>
|
||||
</label>
|
||||
<label class="tier-opt">
|
||||
<input v-model="tier" type="radio" value="conditional" />
|
||||
<span>
|
||||
<strong>Conditional</strong>
|
||||
— arrives when its trigger fires.
|
||||
</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
The test: can you name the trigger <em>without</em> naming a system, an artifact type
|
||||
or a moment? If the honest answer is “whenever you are working”, it is always on.
|
||||
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
|
||||
it needs to be.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="canon.catalog.length" class="areas">
|
||||
<legend>Areas this rule is about</legend>
|
||||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="systemIds.includes(entry.id)"
|
||||
@change="toggleSystem(entry.id)"
|
||||
/>
|
||||
<span>{{ entry.name }}</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
What lets this rule reach a project working in that area.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="check">
|
||||
<legend>Can this rule go stale?</legend>
|
||||
<p class="tier-test intro">
|
||||
Most rules are <em>decisions</em> — they have no truth value and change only when you
|
||||
change them. Leave this empty for those. Fill it in when the rule asserts a
|
||||
<em>fact</em> about something outside your control, because those go false quietly.
|
||||
</p>
|
||||
<label>
|
||||
How to check it is still true
|
||||
<textarea
|
||||
v-model="verifyWith"
|
||||
rows="2"
|
||||
placeholder="A command, a path, a query — something runnable beats prose."
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
What would end it
|
||||
<textarea
|
||||
v-model="expiresWhen"
|
||||
rows="2"
|
||||
placeholder="A state, not a date — “when the runner can be given a bash shell”."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="savedCheck" class="stamp">
|
||||
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
|
||||
<span class="stamp-actions">
|
||||
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
|
||||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="savedCheck" class="tier-test">
|
||||
Record this after actually running the check, never on the strength of the rule
|
||||
sounding plausible. “No longer true” deliberately stores nothing — the rule is wrong,
|
||||
not in a state worth recording, so it stays at the top of the sweep until you fix or
|
||||
retire it.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<section v-if="relations.length" class="relations">
|
||||
<h3>Related rules</h3>
|
||||
<ul>
|
||||
<li v-for="rel in relations" :key="rel.id" class="relation">
|
||||
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
|
||||
<span class="relation-target">rule #{{ rel.rule_id }}</span>
|
||||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="tier-test">
|
||||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||
cited, surfaced or suppressed a clause at a time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<label>
|
||||
Why
|
||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||
@@ -257,17 +85,6 @@ watch(() => props.ruleId, load);
|
||||
How to apply
|
||||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||||
</label>
|
||||
|
||||
<!-- Only on an existing rule: a rule being created has no past, and an
|
||||
"Edit history — none" line on a blank form reads as a broken panel.
|
||||
Keyed on ruleId so switching rules reloads rather than showing the
|
||||
previous rule's history under the new one's text. -->
|
||||
<RuleHistoryPanel
|
||||
v-if="!isCreating && ruleId !== null"
|
||||
:key="ruleId"
|
||||
:rule-id="ruleId"
|
||||
:current="store.currentRule"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -281,8 +98,8 @@ watch(() => props.ruleId, load);
|
||||
.slide-over {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: min(520px, 90vw);
|
||||
background: var(--fs-surface-hover);
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
background: var(--color-surface, #18181b);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||
@@ -293,56 +110,14 @@ header h2 {
|
||||
font-family: Fraunces, serif; font-style: italic;
|
||||
}
|
||||
label { display: block; margin-bottom: 1rem; }
|
||||
.required { color: var(--fs-accent); }
|
||||
.required { color: var(--color-primary, #6366f1); }
|
||||
input, textarea {
|
||||
width: 100%; margin-top: 0.25rem;
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.5rem; font: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
|
||||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.relation-kind { color: var(--fs-accent); }
|
||||
.relation-target { color: var(--fs-text-primary); }
|
||||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* A real base rule, not just descendants: the dangling-style check reads a
|
||||
class that only ever appears as an ancestor as a half-deleted rule, and it
|
||||
is right to — an element whose appearance comes only from its tag is one
|
||||
`fieldset {}` edit away from being unstyled. */
|
||||
.check { margin-bottom: 1rem; }
|
||||
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
|
||||
.check label { margin-bottom: 0.75rem; }
|
||||
.stamp {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
|
||||
state of every constraint anyone has just written. --fs-overdue (error red)
|
||||
is reserved for a broken promise like a missed due date; a verification age
|
||||
is not one, and colouring it that way would make a brand-new rule look
|
||||
broken. Secondary text, weighted normally. */
|
||||
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
|
||||
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
|
||||
.stamp-actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-raised); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||
.trash:hover, .close:hover { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* What a rule USED TO SAY — inside the slide-over, where a rule is read in
|
||||
* full. Not on the list row: a history entry point there would compete with
|
||||
* the row's actual job.
|
||||
*
|
||||
* A SIBLING OF HistoryPanel.vue, NOT A REUSE OF IT, and the reason is in its
|
||||
* props: `noteId` + `currentBody`, a `NoteVersion` carrying tags and pin
|
||||
* columns, a fetch of /api/notes/…, a `restore` emit, and pin/unpin buttons.
|
||||
* Every one of those is note-shaped. Rules have no tags, no pins, and
|
||||
* deliberately no restore, and a rule's text is EIGHT fields rather than one
|
||||
* body — which changes the central question from "what changed" to "which
|
||||
* fields moved".
|
||||
*
|
||||
* What was genuinely shared is shared: DiffView.vue takes DiffLine[] and
|
||||
* nothing note-shaped, and the LCS walk now lives in utils/diff.ts, which
|
||||
* this file uses rather than copying a fourth time (#3207).
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import { computeDiff } from "@/utils/diff";
|
||||
import {
|
||||
listRuleVersions, getRuleVersion, type Rule, type RuleVersion,
|
||||
} from "@/api/rulebooks";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const props = defineProps<{ ruleId: number; current: Rule | null }>();
|
||||
|
||||
const toast = useToastStore();
|
||||
const versions = ref<RuleVersion[]>([]);
|
||||
const selected = ref<RuleVersion | null>(null);
|
||||
const expanded = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadingDetail = ref(false);
|
||||
|
||||
// The eight TEXT fields a version carries, in the order the editor shows
|
||||
// them. Narrowed to its own type rather than `keyof RuleVersion`, which would
|
||||
// also admit id/rule_id/user_id/created_at — none of which is text a reader
|
||||
// compares, and all of which would widen every lookup below to `number`.
|
||||
// Labels rather than column names: a reader is deciding whether to open a
|
||||
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
||||
type TextField =
|
||||
| "title" | "statement" | "when_to_apply" | "tier"
|
||||
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
||||
|
||||
const FIELDS: Array<[TextField, string]> = [
|
||||
["title", "Title"],
|
||||
["statement", "Statement"],
|
||||
["when_to_apply", "When to apply"],
|
||||
["tier", "Tier"],
|
||||
["why", "Why"],
|
||||
["how_to_apply", "How to apply"],
|
||||
["verify_with", "Check"],
|
||||
["expires_when", "Ends when"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Which fields this edit moved.
|
||||
*
|
||||
* A version holds the text the edit REPLACED, so the edit is the step from
|
||||
* this row to the NEXT NEWER state — the version above it in the list, or,
|
||||
* for the newest row, the rule as it stands now. Comparing against the row
|
||||
* below instead would attribute every change to the wrong edit.
|
||||
*/
|
||||
function changedFields(index: number): string[] {
|
||||
const before = versions.value[index];
|
||||
// `Rule` carries all eight as required strings; a RuleVersion carries them
|
||||
// only once opened, which is what the undefined check below is about.
|
||||
const after: Pick<Rule, TextField> | RuleVersion | null =
|
||||
index === 0 ? props.current : versions.value[index - 1] ?? null;
|
||||
if (!before || !after) return [];
|
||||
return FIELDS
|
||||
.filter(([key]) => {
|
||||
// A listing row carries only the title; the rest arrive when opened.
|
||||
// Undefined means NOT LOADED, which is not the same as unchanged — so a
|
||||
// field nobody has fetched is claimed as neither.
|
||||
const a = before[key];
|
||||
const b = after[key];
|
||||
if (a === undefined || b === undefined) return false;
|
||||
return (a ?? "") !== (b ?? "");
|
||||
})
|
||||
.map(([, label]) => label);
|
||||
}
|
||||
|
||||
/** True when this edit rewrote or removed the rule's check.
|
||||
*
|
||||
* Worth its own marker because editing `verify_with` silently drops
|
||||
* `verified_at` (milestone 312) — the moment a rule re-entered the staleness
|
||||
* sweep. That happens nowhere a reader can see it, and this row is the only
|
||||
* surface that can say when it happened. */
|
||||
function checkChanged(index: number): boolean {
|
||||
return changedFields(index).includes("Check");
|
||||
}
|
||||
|
||||
const diff = computed(() => {
|
||||
if (!selected.value || selected.value.statement === undefined) return [];
|
||||
const now = props.current?.statement ?? "";
|
||||
return computeDiff(now, selected.value.statement);
|
||||
});
|
||||
|
||||
function stamp(iso: string): string {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
versions.value = await listRuleVersions(props.ruleId);
|
||||
} catch {
|
||||
toast.show("Could not load this rule's history", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function open(v: RuleVersion) {
|
||||
if (selected.value?.id === v.id) {
|
||||
selected.value = null;
|
||||
return;
|
||||
}
|
||||
loadingDetail.value = true;
|
||||
try {
|
||||
const full = await getRuleVersion(props.ruleId, v.id);
|
||||
// Merged back into the list so `changedFields` can compare against real
|
||||
// text once a neighbour has been opened, instead of staying blind.
|
||||
const at = versions.value.findIndex((x) => x.id === v.id);
|
||||
if (at >= 0) versions.value[at] = { ...versions.value[at], ...full };
|
||||
selected.value = versions.value[at] ?? full;
|
||||
} catch {
|
||||
toast.show("Could not open that version", "error");
|
||||
} finally {
|
||||
loadingDetail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.ruleId, () => { selected.value = null; load(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="history">
|
||||
<button class="toggle" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
<span>Edit history</span>
|
||||
<span class="count">{{ versions.length || "none" }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded" class="body">
|
||||
<p v-if="loading" class="state">Loading…</p>
|
||||
|
||||
<!-- Never reworded is the ordinary case, and must not read as a fault. -->
|
||||
<p v-else-if="!versions.length" class="state empty">
|
||||
This rule has never been reworded. Nothing was recorded before the history
|
||||
existed, so an older rule starts empty too.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="lede">
|
||||
Each entry is what the rule said <em>before</em> that edit. The wording it
|
||||
was changed to is the rule as it stands above.
|
||||
</p>
|
||||
<ol class="rows">
|
||||
<li v-for="(v, i) in versions" :key="v.id" class="row">
|
||||
<button
|
||||
class="row-head"
|
||||
:class="{ open: selected?.id === v.id }"
|
||||
@click="open(v)"
|
||||
>
|
||||
<span class="when">{{ stamp(v.created_at) }}</span>
|
||||
<span class="fields">
|
||||
{{ changedFields(i).join(", ") || "opened to compare" }}
|
||||
</span>
|
||||
<span v-if="checkChanged(i)" class="check-moved">check reset</span>
|
||||
</button>
|
||||
|
||||
<div v-if="selected?.id === v.id" class="detail">
|
||||
<p v-if="loadingDetail" class="state">Loading…</p>
|
||||
<template v-else>
|
||||
<p v-if="checkChanged(i)" class="warn">
|
||||
This edit changed the rule's check, which cleared its verification
|
||||
stamp — the rule went back to the top of the staleness sweep here.
|
||||
</p>
|
||||
<dl class="fields-list">
|
||||
<template v-for="[key, label] in FIELDS" :key="key">
|
||||
<template v-if="key !== 'statement' && v[key]">
|
||||
<dt>{{ label }}</dt>
|
||||
<dd>{{ v[key] }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
</dl>
|
||||
<h4>Statement</h4>
|
||||
<DiffView v-if="diff.length" :diff="diff" />
|
||||
<p v-else class="state">The statement did not change in this edit.</p>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history { border-top: 1px solid var(--fs-border-color); padding-top: var(--fs-space-3); }
|
||||
|
||||
.toggle {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2); width: 100%;
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary);
|
||||
}
|
||||
.toggle:hover { color: var(--fs-text-primary); }
|
||||
.count {
|
||||
margin-left: auto; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.body { margin-top: var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
.lede {
|
||||
margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.row { background: var(--fs-surface-raised); border-radius: var(--fs-radius-md); }
|
||||
|
||||
.row-head {
|
||||
display: flex; align-items: baseline; gap: var(--fs-space-3); width: 100%;
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-primary);
|
||||
}
|
||||
.row-head:hover { background: var(--fs-surface-hover); border-radius: var(--fs-radius-md); }
|
||||
.when {
|
||||
font-variant-numeric: tabular-nums; color: var(--fs-text-secondary);
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
.fields { color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* A TINT, not the solid token. `--fs-warning-fg` is defined as "warning text
|
||||
ON A WARNING TINT" — painting it over solid `--fs-warning` is the same-hue
|
||||
contrast failure #3141 records. The 12% mix is how theme.css builds its own
|
||||
`-bg` pairs, and it keeps the value a resolvable var() rather than a raw hex
|
||||
that check_design_tokens.py cannot see at all. */
|
||||
.check-moved {
|
||||
margin-left: auto; flex: none;
|
||||
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
||||
color: var(--fs-warning-fg);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: var(--fs-size-tiny); letter-spacing: var(--fs-tracking-tiny);
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 0 var(--fs-space-3) var(--fs-space-3);
|
||||
display: flex; flex-direction: column; gap: var(--fs-space-2);
|
||||
}
|
||||
.warn {
|
||||
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
||||
color: var(--fs-warning-fg);
|
||||
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
||||
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
|
||||
}
|
||||
.fields-list { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: 0; }
|
||||
.fields-list dt {
|
||||
font-size: var(--fs-size-tiny); text-transform: uppercase;
|
||||
letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary);
|
||||
}
|
||||
.fields-list dd {
|
||||
margin: 0; font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere;
|
||||
}
|
||||
h4 { margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
|
||||
</style>
|
||||
@@ -1,16 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { RuleHeader } from "@/api/rulebooks";
|
||||
import UsageBadge from "@/components/UsageBadge.vue";
|
||||
|
||||
/** The dead-weight nudge for a RULE — two remedies, not one, which is the
|
||||
* whole reason this advice is per-kind. A snippet nobody opens should
|
||||
* probably go. A rule nobody opens may be perfectly good and simply firing on
|
||||
* the wrong thing, so "delete it" would be the wrong nudge half the time and
|
||||
* the operator has to be the one who picks. */
|
||||
const RULE_DEAD_WEIGHT =
|
||||
"Kept arriving without being read. Either its trigger fires on the wrong " +
|
||||
"work — reword “when to apply” so it says when — or it is not wanted here. " +
|
||||
"Until one or the other, it takes a slot in every write it matches.";
|
||||
|
||||
defineProps<{ topicId: number; rules: RuleHeader[] }>();
|
||||
const emit = defineEmits<{
|
||||
@@ -24,58 +13,28 @@ const emit = defineEmits<{
|
||||
<header><h2>Rules</h2></header>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||
<div class="title">
|
||||
{{ r.title }}
|
||||
<!-- Only conditional is marked: always-on is the default and
|
||||
badging every row would say nothing. -->
|
||||
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
<!-- Present only on a rule carrying a check, so the chip's very
|
||||
presence says "this one asserts a fact that can go false". -->
|
||||
<span
|
||||
v-if="r.last_verified"
|
||||
class="rule-chip check-chip"
|
||||
:class="{ unchecked: r.last_verified === 'never' }"
|
||||
:title="r.last_verified === 'never'
|
||||
? 'Asserts a fact nobody has confirmed yet'
|
||||
: `Check last passed ${r.last_verified}`"
|
||||
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
||||
<UsageBadge :usage="r.usage" :dead-weight-advice="RULE_DEAD_WEIGHT" />
|
||||
</div>
|
||||
<div class="title">{{ r.title }}</div>
|
||||
<div class="statement">{{ r.statement }}</div>
|
||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||
<span v-if="r.when_to_apply" class="trigger">{{ r.when_to_apply }}</span>
|
||||
<span v-if="r.updated_at" class="age" :title="`Last changed ${r.updated_at}`">{{ r.updated_at }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
margin-bottom: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||
/* Only the departures from .rule-chip (rules-shared.css) live here. */
|
||||
.check-chip { font-variant-numeric: tabular-nums; }
|
||||
/* No age-graded colour on purpose. The sweep is already ordered by urgency, so
|
||||
a red/amber ramp would restate the ordering AND require an invented "stale
|
||||
after N days" threshold — a magic number nobody could defend and the first
|
||||
thing to go out of date. Only "never" is marked, because it is categorically
|
||||
different from a date rather than a worse one. */
|
||||
.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
.new-rule { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The staleness sweep: rules that assert a FACT, oldest verification first.
|
||||
*
|
||||
* Cross-cutting by nature — a rule that has gone false does not care which
|
||||
* rulebook it sits in — so this is its own pane rather than a filter on the
|
||||
* per-topic rule list. That list can only ever show one topic of one
|
||||
* rulebook, so filtering it would quietly under-report, which is the exact
|
||||
* failure this surface exists to catch.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
|
||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const neverOnly = ref(false);
|
||||
const tier = ref<RuleTier | "">("");
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
return store.fetchRulesDue({
|
||||
neverOnly: neverOnly.value || undefined,
|
||||
tier: tier.value || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function verify(id: number, stillTrue: boolean) {
|
||||
busyId.value = id;
|
||||
try {
|
||||
await store.verifyRule(id, stillTrue);
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pane sweep">
|
||||
<header>
|
||||
<h2>Due for verification</h2>
|
||||
<p class="lede">
|
||||
Rules that assert a fact about something outside your control. Most rules are
|
||||
decisions and never appear here — they have no truth value to go stale.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<label class="filter">
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</label>
|
||||
<label class="filter">
|
||||
<span>Tier</span>
|
||||
<select v-model="tier" @change="reload">
|
||||
<option value="">any</option>
|
||||
<option value="always_on">always on</option>
|
||||
<option value="conditional">conditional</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="store.loading" class="state">Loading…</p>
|
||||
|
||||
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
||||
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||
Nothing to check.
|
||||
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
||||
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
|
||||
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
||||
{{ r.days_since_verified === null
|
||||
? "never checked"
|
||||
: `${r.days_since_verified}d ago` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="statement">{{ r.statement }}</p>
|
||||
|
||||
<dl class="check">
|
||||
<dt>Check</dt>
|
||||
<dd><code>{{ r.verify_with }}</code></dd>
|
||||
<template v-if="r.expires_when">
|
||||
<dt>Ends when</dt>
|
||||
<dd>{{ r.expires_when }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="store.rulesDue.length" class="footnote">
|
||||
Record a result only after actually running the check. “No longer true” stores nothing
|
||||
on purpose — the rule is wrong rather than in a state worth recording, so it keeps its
|
||||
place here until you correct or retire it.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.lede {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
|
||||
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
|
||||
.filter select {
|
||||
font: inherit; font-size: 0.82rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.row {
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
}
|
||||
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.row-title {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
|
||||
color: var(--fs-text-primary); text-align: left;
|
||||
}
|
||||
.row-title:hover { text-decoration: underline; }
|
||||
/* The ORDER carries urgency — the top of this list is the most overdue thing
|
||||
in the rulebook. No red/amber ramp: it would restate the ordering and force
|
||||
an invented "stale after N days" threshold. "Never" is marked because it is
|
||||
categorically different from a date, not a worse one. */
|
||||
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
|
||||
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
|
||||
|
||||
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
|
||||
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
|
||||
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; }
|
||||
.check code {
|
||||
font-family: var(--fs-font-mono);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.05rem 0.3rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
|
||||
.actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
</style>
|
||||
@@ -121,9 +121,10 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
.always-on-toggle {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
||||
@@ -132,22 +133,18 @@ header { display: flex; align-items: center; justify-content: space-between; gap
|
||||
.always-on-toggle input { cursor: pointer; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
|
||||
button-or-form whose children style themselves; the second is a `<ul>`, and
|
||||
the bare `ul` rule above already gives it list-style, padding and margin —
|
||||
a base a class-name check cannot see, since it comes from an element
|
||||
selector. Both namespace descendant rules and assume nothing about layout. */
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
.new-topic input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
.subscriptions {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { Rulebook } from "@/api/rulebooks";
|
||||
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
||||
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
||||
const emit = defineEmits<{ select: [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
@@ -34,18 +34,6 @@ async function submitNew() {
|
||||
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
|
||||
every rule the operator owns. It lives here because this is where you
|
||||
come to look at rules, and a rule that has gone false belongs to no
|
||||
one rulebook. -->
|
||||
<button
|
||||
class="sweep-entry"
|
||||
:class="{ active: sweepActive }"
|
||||
@click="emit('select-sweep')"
|
||||
>
|
||||
Due for verification
|
||||
</button>
|
||||
|
||||
<div class="new-rulebook">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||
<form v-else @submit.prevent="submitNew">
|
||||
@@ -59,37 +47,30 @@ async function submitNew() {
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
.always-on-badge {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-on-action);
|
||||
background: var(--color-accent, rgba(91,74,138,0.25));
|
||||
color: var(--color-accent-fg, inherit);
|
||||
margin-left: auto;
|
||||
}
|
||||
.sweep-entry {
|
||||
display: block; width: 100%; text-align: left;
|
||||
margin-top: var(--fs-space-3);
|
||||
padding: 0.5rem; border-radius: 6px;
|
||||
background: none; border: 1px dashed var(--fs-border-color);
|
||||
color: var(--fs-text-secondary); font: inherit; cursor: pointer;
|
||||
}
|
||||
.sweep-entry:hover { background: var(--fs-surface-hover); }
|
||||
.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); }
|
||||
.new-rulebook { margin-top: 1rem; }
|
||||
.new-rulebook input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
button { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ref, computed, watch, type Ref } from "vue";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
import { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import {
|
||||
@@ -10,16 +9,17 @@ import {
|
||||
export type AssistState = "idle" | "streaming" | "review";
|
||||
export type ScopeMode = "document" | "section";
|
||||
|
||||
// Re-exported: this composable was where DiffLine lived before the diff
|
||||
// moved to a shared util, and every consumer still imports the type from here.
|
||||
export type { DiffLine };
|
||||
|
||||
export interface AssistTarget {
|
||||
text: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'equal' | 'delete' | 'insert';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface NoteDraft {
|
||||
id: number;
|
||||
note_id: number;
|
||||
@@ -31,6 +31,27 @@ export interface NoteDraft {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function computeDiff(a: string, b: string): DiffLine[] {
|
||||
const aLines = a.split('\n');
|
||||
const bLines = b.split('\n');
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i+1][j+1] + 1
|
||||
: Math.max(dp[i+1][j], dp[i][j+1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
|
||||
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
|
||||
else result.push({ type: 'insert', text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
|
||||
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
|
||||
const toast = useToastStore();
|
||||
|
||||
@@ -9,15 +9,3 @@ export function relativeTime(iso: string): string {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* relativeTime() for the recent past, a short date once it's a week old —
|
||||
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
|
||||
* panels used to carry identical copies of this.
|
||||
*/
|
||||
export function relativeTimeOrDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
||||
if (days < 7) return relativeTime(iso);
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
@@ -110,11 +110,15 @@ const router = createRouter({
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// The design systems this install RECORDS — for the projects it tracks,
|
||||
// not for the install itself. There was a sibling `/design` that read the
|
||||
// running app's own stylesheet out of the browser; it could only ever
|
||||
// inspect the instance it was served from, which made it a mirror rather
|
||||
// than a tool (#274).
|
||||
// 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"),
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import * as api from "@/api/canonicalSystems";
|
||||
import type { CanonicalSystem, MappingProposal } from "@/api/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
/**
|
||||
* The global area catalog (milestone 307). Shared by every project, so it is
|
||||
* fetched ONCE per session rather than per project — the whole point of the
|
||||
* table is that it is the same list everywhere.
|
||||
*/
|
||||
export const useCanonicalSystemsStore = defineStore("canonicalSystems", () => {
|
||||
const catalog = ref<CanonicalSystem[]>([]);
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
const proposalsByProject = ref<Record<number, MappingProposal[]>>({});
|
||||
|
||||
async function fetchCatalog(force = false) {
|
||||
if (loaded.value && !force) return catalog.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
catalog.value = await api.listCanonicalSystems();
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
// A naming aid must never break the screen it rides on — an empty
|
||||
// catalog degrades the suggestion, it does not fail the form.
|
||||
catalog.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return catalog.value;
|
||||
}
|
||||
|
||||
function byId(id: number | null): CanonicalSystem | undefined {
|
||||
if (id == null) return undefined;
|
||||
return catalog.value.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
async function fetchProposals(projectId: number) {
|
||||
proposalsByProject.value[projectId] = await api.proposeMappings(projectId);
|
||||
return proposalsByProject.value[projectId];
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping, then drop it from the pending proposals. */
|
||||
async function mapSystem(projectId: number, systemId: number, canonicalId: number | null) {
|
||||
try {
|
||||
await api.mapSystem(systemId, canonicalId);
|
||||
} catch (e) {
|
||||
useToastStore().show(apiErrorMessage(e, "Failed to map system"), "error");
|
||||
throw e;
|
||||
}
|
||||
dismissProposal(projectId, systemId);
|
||||
}
|
||||
|
||||
/** Remove a proposal from the pending list without writing anything. */
|
||||
function dismissProposal(projectId: number, systemId: number) {
|
||||
const list = proposalsByProject.value[projectId];
|
||||
if (list) {
|
||||
proposalsByProject.value[projectId] = list.filter((p) => p.system_id !== systemId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createEntry(data: { name: string; description?: string }) {
|
||||
const entry = await api.createCanonicalSystem(data);
|
||||
catalog.value.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function updateEntry(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
) {
|
||||
const entry = await api.updateCanonicalSystem(id, data);
|
||||
const idx = catalog.value.findIndex((c) => c.id === id);
|
||||
if (idx >= 0) catalog.value[idx] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
catalog,
|
||||
loaded,
|
||||
loading,
|
||||
proposalsByProject,
|
||||
fetchCatalog,
|
||||
byId,
|
||||
fetchProposals,
|
||||
mapSystem,
|
||||
dismissProposal,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
};
|
||||
});
|
||||
@@ -31,8 +31,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
note_type?: string;
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -44,11 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<
|
||||
Note,
|
||||
"title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type"
|
||||
| "verify_with" | "expires_when"
|
||||
>>
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -9,11 +9,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||
const currentRule = ref<Rule | null>(null);
|
||||
const rulesDue = ref<api.RuleVerificationRow[]>([]);
|
||||
// Kept so a verify re-reads the sweep with the SAME filters the operator is
|
||||
// looking at — re-fetching unfiltered would silently widen the list under
|
||||
// them at the moment they acted on it.
|
||||
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchRulebooks() {
|
||||
@@ -40,7 +35,9 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
async function fetchRules(topicId: number) {
|
||||
try {
|
||||
const rules = await api.listRules({ topic_id: topicId });
|
||||
rulesByTopic.value[topicId] = rules.map(toHeader);
|
||||
rulesByTopic.value[topicId] = rules.map((r) => ({
|
||||
id: r.id, title: r.title, statement: r.statement, topic_id: r.topic_id,
|
||||
}));
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load rules", "error");
|
||||
throw e;
|
||||
@@ -101,100 +98,24 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
delete rulesByTopic.value[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row built from a full rule. The row shape is the server's
|
||||
* rule_brief, so every field it carries has to be mirrored here or the two
|
||||
* disagree the moment a list is patched locally instead of re-fetched.
|
||||
*/
|
||||
function toHeader(rule: Rule): api.RuleHeader {
|
||||
return {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
statement: rule.statement,
|
||||
topic_id: rule.topic_id,
|
||||
tier: rule.tier,
|
||||
updated_at: rule.updated_at,
|
||||
when_to_apply: rule.when_to_apply || undefined,
|
||||
arose_from_id: rule.arose_from_id ?? undefined,
|
||||
// Mirrors services.rulebooks.last_verified_label: present ONLY when the
|
||||
// rule carries a check, and "never" rather than absent when it has one
|
||||
// nobody has run. Computed here so a row just written looks identical to
|
||||
// the same row re-fetched, instead of losing its chip until a reload.
|
||||
last_verified: rule.verify_with
|
||||
? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never")
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
|
||||
async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) {
|
||||
const rule = await api.createRule(topicId, data);
|
||||
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
||||
rulesByTopic.value[topicId].push(toHeader(rule));
|
||||
rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id });
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function updateRule(id: number, data: Partial<api.RuleWrite>) {
|
||||
async function updateRule(id: number, data: Partial<Pick<Rule, "title" | "statement" | "why" | "how_to_apply" | "order_index">>) {
|
||||
const rule = await api.updateRule(id, data);
|
||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const list = rulesByTopic.value[Number(tid)];
|
||||
const idx = list.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) list[idx] = toHeader(rule);
|
||||
if (idx >= 0) list[idx] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id };
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function relateRules(
|
||||
fromRuleId: number,
|
||||
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
|
||||
) {
|
||||
await api.relateRules(fromRuleId, data);
|
||||
// Re-read rather than patching locally: the edge reads from BOTH ends, so
|
||||
// the far rule's relations changed too and a local splice would show only
|
||||
// half of what just happened.
|
||||
await fetchRule(fromRuleId);
|
||||
}
|
||||
|
||||
async function unrelateRules(relationId: number, refreshRuleId: number) {
|
||||
await api.unrelateRules(relationId);
|
||||
await fetchRule(refreshRuleId);
|
||||
}
|
||||
|
||||
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
||||
async function fetchRulesDue(opts: {
|
||||
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
||||
} = {}) {
|
||||
loading.value = true;
|
||||
lastSweepOpts.value = opts;
|
||||
try {
|
||||
const data = await api.listRulesDueForVerification(opts);
|
||||
rulesDue.value = data.rules;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a rule's check was RUN, and what it said.
|
||||
*
|
||||
* A pass re-sorts the row to the back of the sweep, so the list is re-read
|
||||
* rather than patched: the whole point of this surface is an ORDER, and a
|
||||
* locally-mutated row would sit in its old position claiming a new date.
|
||||
* A failure writes nothing server-side and the row keeps its place — also
|
||||
* correct, and also what a re-read shows.
|
||||
*/
|
||||
async function verifyRule(id: number, stillTrue: boolean) {
|
||||
const rule = await api.markRuleVerified(id, stillTrue);
|
||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const list = rulesByTopic.value[Number(tid)];
|
||||
const idx = list.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) list[idx] = toHeader(rule);
|
||||
}
|
||||
if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value);
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
await api.deleteRule(id);
|
||||
if (currentRule.value?.id === id) currentRule.value = null;
|
||||
@@ -204,11 +125,10 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||
fetchRulesDue, verifyRule,
|
||||
createRule, updateRule, deleteRule,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
||||
|
||||
async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
data: { name: string; description?: string; color?: string },
|
||||
) {
|
||||
const system = await api.createSystem(projectId, data);
|
||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||
|
||||
@@ -55,9 +55,6 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
|
||||
async function updateTask(
|
||||
id: number,
|
||||
// IssueFields carries `kind`, which the PATCH route now reads. It has
|
||||
// always been SENT by the task editor; until #3129 the route dropped it
|
||||
// and the save reported success while changing nothing.
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||
> & IssueFields
|
||||
|
||||
@@ -2,16 +2,7 @@ import type { System } from "@/api/systems";
|
||||
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
/**
|
||||
* What KIND of work a task is, not how it is going.
|
||||
* work — ships a change (default)
|
||||
* issue — corrective; something was broken
|
||||
* spike — time-boxed, output is knowledge; it succeeds by producing an
|
||||
* answer and nothing ships at the end of it
|
||||
* plan — retired (plans are milestones); kept so historical plan-tasks
|
||||
* still render their kind
|
||||
*/
|
||||
export type TaskKind = "work" | "plan" | "issue" | "spike";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
export type NoteType = "note" | "process" | "snippet";
|
||||
|
||||
export interface Note {
|
||||
@@ -19,6 +10,7 @@ export interface Note {
|
||||
title: string;
|
||||
body: string;
|
||||
description: string | null;
|
||||
consolidated_at: string | null;
|
||||
tags: string[];
|
||||
parent_id: number | null;
|
||||
parent_title?: string | null;
|
||||
@@ -34,15 +26,6 @@ export interface Note {
|
||||
is_task: boolean;
|
||||
note_type: NoteType;
|
||||
task_kind?: TaskKind;
|
||||
// The note's own check (milestone 317). Empty on almost every note — that
|
||||
// is the normal case: a note with no `verify_with` is a DECISION, and there
|
||||
// is nothing to go and check. Only a note asserting a fact about something
|
||||
// outside the operator's control carries one. `verified_at` null while
|
||||
// `verify_with` is set means NOBODY HAS EVER CONFIRMED IT, which is the
|
||||
// state the sweep ranks first.
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
verified_at?: string | null;
|
||||
systems?: System[];
|
||||
arose_from_id?: number | null;
|
||||
created_at: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user