Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf55fc488 |
@@ -178,15 +178,6 @@ jobs:
|
|||||||
- name: Design token check
|
- name: Design token check
|
||||||
run: python3 scripts/check_design_tokens.py --report-literals
|
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:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
@@ -225,16 +216,6 @@ jobs:
|
|||||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||||
run: uv sync --locked --extra dev
|
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
|
- name: Run tests
|
||||||
# Integration tests (real Postgres) run in the `integration` job below.
|
# Integration tests (real Postgres) run in the `integration` job below.
|
||||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
|
|||||||
|
|
||||||
## Features
|
## 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
|
## 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")
|
|
||||||
@@ -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),
|
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
|
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
|
||||||
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
|
read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
|
||||||
any write/delete tool is rejected with `403`. The allow-list is explicit rather
|
is rejected with `403`. A `write`-scoped key may call everything.
|
||||||
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.
|
|
||||||
|
|
||||||
### Claude Code (Project-scoped)
|
### 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 |
|
| 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 |
|
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||||
|
|||||||
@@ -76,9 +76,7 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
|
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / 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 | `/api/projects/:id/notes` | Notes + tasks in this project |
|
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||||
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||||
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
| 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 | `/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/rules/:rid` | Suppress / unsuppress a rule |
|
||||||
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
| 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
|
## 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/context` | SessionStart context payload (rules + active-project) |
|
||||||
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
||||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
| 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 |
|
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
|
||||||
|
|
||||||
## Dashboard, Export, Trash, Users
|
## Dashboard, Export, Trash, Users
|
||||||
|
|||||||
+17
-17
@@ -254,7 +254,7 @@ onUnmounted(() => {
|
|||||||
left: 0.5rem;
|
left: 0.5rem;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
background: var(--fs-accent);
|
background: var(--color-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -290,7 +290,7 @@ onUnmounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -300,17 +300,17 @@ onUnmounted(() => {
|
|||||||
.shortcuts-overlay {
|
.shortcuts-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--fs-overlay);
|
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.shortcuts-panel {
|
.shortcuts-panel {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--radius-md, 8px);
|
||||||
box-shadow: 0 8px 32px var(--color-shadow);
|
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
|
||||||
width: min(420px, 92vw);
|
width: min(420px, 92vw);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -319,25 +319,25 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.85rem 1rem 0.75rem;
|
padding: 0.85rem 1rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.shortcuts-header h3 {
|
.shortcuts-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.shortcuts-close {
|
.shortcuts-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
.shortcuts-close:hover {
|
.shortcuts-close:hover {
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.shortcuts-body {
|
.shortcuts-body {
|
||||||
padding: 0.75rem 1rem 1rem;
|
padding: 0.75rem 1rem 1rem;
|
||||||
@@ -350,7 +350,7 @@ onUnmounted(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.shortcut-row {
|
.shortcut-row {
|
||||||
@@ -365,23 +365,23 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 1.8rem;
|
min-width: 1.8rem;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-bottom-width: 2px;
|
border-bottom-width: 2px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.shortcut-key-sep {
|
.shortcut-key-sep {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.shortcut-desc {
|
.shortcut-desc {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
margin-left: 0.25rem;
|
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 });
|
|
||||||
}
|
|
||||||
@@ -38,20 +38,6 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The server's `{"error": "..."}` message from a failed call, or `fallback`
|
|
||||||
* when the failure carried none (network error, non-JSON body). The one place
|
|
||||||
* the error envelope is unpacked on the client — views used to restate this
|
|
||||||
* as a six-line `"body" in e` branch at every catch site.
|
|
||||||
*/
|
|
||||||
export function apiErrorMessage(e: unknown, fallback: string): string {
|
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
|
||||||
const body = (e as { body?: { error?: unknown } }).body;
|
|
||||||
if (body && typeof body.error === "string" && body.error) return body.error;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path);
|
const res = await fetch(path);
|
||||||
return handleResponse<T>(res, path);
|
return handleResponse<T>(res, path);
|
||||||
|
|||||||
@@ -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) =>
|
export const fetchDesignSystem = (id: number) =>
|
||||||
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
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: {
|
export const createDesignSystem = (body: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
guidance?: string;
|
guidance?: string;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
starter_role_groups?: string[];
|
|
||||||
token_prefix?: string;
|
|
||||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||||
|
|
||||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||||
@@ -200,14 +183,6 @@ export interface SnippetCheck {
|
|||||||
findings: SnippetFinding[];
|
findings: SnippetFinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which recorded snippets disagree with this design system's sheet.
|
/** Which recorded snippets disagree with this design system's sheet. */
|
||||||
*
|
export const checkSnippets = (id: number) =>
|
||||||
* `projectId` narrows to the snippets one project owns — which is how a
|
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
||||||
* 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}` : ""),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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
-213
@@ -1,24 +1,5 @@
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
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 {
|
export interface Rulebook {
|
||||||
id: number;
|
id: number;
|
||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
@@ -45,69 +26,35 @@ export interface Rule {
|
|||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
statement: 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;
|
why: string;
|
||||||
how_to_apply: 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;
|
order_index: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_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 {
|
export interface RuleHeader {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
topic_id: number | null;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicableRules {
|
export interface ApplicableRules {
|
||||||
// Both lists are rule_brief's output — the SAME builder, so they are
|
rules: {
|
||||||
// described the same way here rather than as two hand-written shapes that
|
id: number;
|
||||||
// drift from it and from each other (which is what the server side had).
|
title: string;
|
||||||
rules: (RuleHeader & {
|
statement: string;
|
||||||
|
topic_id: number;
|
||||||
topic_title: string;
|
topic_title: string;
|
||||||
rulebook_id: number;
|
rulebook_id: number;
|
||||||
rulebook_title: string;
|
rulebook_title: string;
|
||||||
})[];
|
}[];
|
||||||
project_rules: RuleHeader[];
|
project_rules: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
statement: string;
|
||||||
|
}[];
|
||||||
suppressed_rules: {
|
suppressed_rules: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -124,8 +71,6 @@ export interface ApplicableRules {
|
|||||||
}[];
|
}[];
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
subscribed_rulebooks: { id: number; title: string }[];
|
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 ───────────────────────────────────────────────────────
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||||
@@ -186,91 +131,14 @@ export async function getRule(id: number): Promise<Rule> {
|
|||||||
return apiGet(`/api/rules/${id}`);
|
return apiGet(`/api/rules/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
|
||||||
* 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> {
|
|
||||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
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);
|
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> {
|
export async function deleteRule(id: number): Promise<void> {
|
||||||
return apiDelete(`/api/rules/${id}`);
|
return apiDelete(`/api/rules/${id}`);
|
||||||
}
|
}
|
||||||
@@ -291,7 +159,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
|
|||||||
|
|
||||||
export async function createProjectRule(
|
export async function createProjectRule(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
data: Partial<RuleWrite> & { statement: string },
|
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||||
): Promise<Rule> {
|
): Promise<Rule> {
|
||||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||||
}
|
}
|
||||||
@@ -313,70 +181,3 @@ export async function suppressTopicForProject(projectId: number, topicId: number
|
|||||||
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
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,15 +1,9 @@
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
|
||||||
|
|
||||||
export interface System {
|
export interface System {
|
||||||
id: number;
|
id: number;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
name: string;
|
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;
|
description: string;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
status: "active" | "archived";
|
status: "active" | "archived";
|
||||||
@@ -24,23 +18,10 @@ export async function listSystems(projectId: number): Promise<System[]> {
|
|||||||
return data.systems;
|
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(
|
export async function createSystem(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
data: { name: string; description?: string; color?: string },
|
||||||
): Promise<CreatedSystem> {
|
): Promise<System> {
|
||||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-secondary,
|
||||||
.btn-ghost,
|
.btn-ghost,
|
||||||
.btn-danger,
|
.btn-danger,
|
||||||
.btn-danger-outline,
|
.btn-danger-outline {
|
||||||
.btn-cta {
|
|
||||||
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
||||||
@@ -47,14 +46,6 @@
|
|||||||
line-height: var(--fs-leading-body);
|
line-height: var(--fs-leading-body);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
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),
|
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||||
color var(--fs-dur-fast) var(--fs-ease);
|
color var(--fs-dur-fast) var(--fs-ease);
|
||||||
@@ -67,8 +58,7 @@
|
|||||||
.btn-secondary:disabled,
|
.btn-secondary:disabled,
|
||||||
.btn-ghost:disabled,
|
.btn-ghost:disabled,
|
||||||
.btn-danger:disabled,
|
.btn-danger:disabled,
|
||||||
.btn-danger-outline:disabled,
|
.btn-danger-outline:disabled {
|
||||||
.btn-cta:disabled {
|
|
||||||
opacity: var(--fs-disabled-opacity);
|
opacity: var(--fs-disabled-opacity);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -77,8 +67,7 @@
|
|||||||
.btn-secondary:focus-visible,
|
.btn-secondary:focus-visible,
|
||||||
.btn-ghost:focus-visible,
|
.btn-ghost:focus-visible,
|
||||||
.btn-danger:focus-visible,
|
.btn-danger:focus-visible,
|
||||||
.btn-danger-outline:focus-visible,
|
.btn-danger-outline:focus-visible {
|
||||||
.btn-cta:focus-visible {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: var(--fs-focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
@@ -89,19 +78,19 @@
|
|||||||
* are universal across the family so a Save button looks identical in every
|
* are universal across the family so a Save button looks identical in every
|
||||||
* app — the accent is identity, not action. */
|
* app — the accent is identity, not action. */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--fs-action-primary);
|
background: var(--color-action-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.btn-primary:not(:disabled):hover {
|
.btn-primary:not(:disabled):hover {
|
||||||
background: var(--fs-action-primary-hover);
|
background: var(--color-action-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--fs-action-secondary);
|
background: var(--color-action-secondary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.btn-secondary:not(:disabled):hover {
|
.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
|
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
|
||||||
@@ -112,21 +101,21 @@
|
|||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: none;
|
background: none;
|
||||||
border: var(--fs-border);
|
border: var(--fs-border);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.btn-ghost:not(:disabled):hover {
|
.btn-ghost:not(:disabled):hover {
|
||||||
border: var(--fs-border-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 is NOT the error colour: an error is a failure that happened, a
|
||||||
* destructive action is one about to happen. Pair with an icon. */
|
* destructive action is one about to happen. Pair with an icon. */
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--fs-action-destructive);
|
background: var(--color-action-destructive);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.btn-danger:not(:disabled):hover {
|
.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
|
/* A bare text button: no fill, no border. The most common shape in the dense
|
||||||
@@ -136,7 +125,7 @@
|
|||||||
.btn-text {
|
.btn-text {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
padding: var(--fs-space-1) var(--fs-space-2);
|
padding: var(--fs-space-1) var(--fs-space-2);
|
||||||
font-family: var(--fs-font-body);
|
font-family: var(--fs-font-body);
|
||||||
font-size: var(--fs-size-tiny);
|
font-size: var(--fs-size-tiny);
|
||||||
@@ -144,7 +133,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color var(--fs-dur-fast) var(--fs-ease);
|
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:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||||
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
.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. */
|
* a one-off: it is what a delete looks like when it must not shout. */
|
||||||
.btn-danger-outline {
|
.btn-danger-outline {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-action-destructive);
|
border: 1px solid var(--color-action-destructive);
|
||||||
color: var(--fs-action-destructive);
|
color: var(--color-action-destructive);
|
||||||
}
|
}
|
||||||
.btn-danger-outline:not(:disabled):hover {
|
.btn-danger-outline:not(:disabled):hover {
|
||||||
background: var(--fs-action-destructive);
|
background: var(--color-action-destructive);
|
||||||
color: var(--fs-text-on-action);
|
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 ------------------------------------------------------
|
/* --- size modifiers ------------------------------------------------------
|
||||||
*
|
*
|
||||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
||||||
@@ -214,140 +184,10 @@
|
|||||||
/* Full width, for a form's single submitting action — the auth screens. Width
|
/* Full width, for a form's single submitting action — the auth screens. Width
|
||||||
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
||||||
.btn-block {
|
.btn-block {
|
||||||
display: flex; /* not `block` — the shared shape centres with flex */
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
||||||
because a full-width button
|
because a full-width button
|
||||||
is the page's main action */
|
is the page's main action */
|
||||||
font-size: var(--fs-size-body-sm);
|
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); }
|
|
||||||
|
|||||||
@@ -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;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 1.5rem 0.5rem;
|
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 ── */
|
/* ── Toolbar & inputs ── */
|
||||||
@@ -29,36 +41,36 @@
|
|||||||
with a Trash icon at the call site to reinforce intent. */
|
with a Trash icon at the call site to reinforce intent. */
|
||||||
.title-input:focus {
|
.title-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-bottom-color: var(--fs-accent);
|
border-bottom-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.title-input::placeholder {
|
.title-input::placeholder {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.editor-tabs {
|
.editor-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
padding: 0.45rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
}
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
border-bottom-color: var(--fs-accent);
|
border-bottom-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tag suggestions ── */
|
/* ── Tag suggestions ── */
|
||||||
@@ -66,44 +78,133 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.tag-pill {
|
.tag-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.2rem 0.55rem;
|
||||||
border: 1px solid var(--fs-accent);
|
border: 1px solid var(--color-primary);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.tag-pill:hover:not(:disabled) {
|
.tag-pill:hover:not(:disabled) {
|
||||||
background: var(--fs-accent);
|
background: var(--color-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.tag-pill.applied {
|
.tag-pill.applied {
|
||||||
background: var(--fs-success);
|
background: var(--color-success, #2ecc71);
|
||||||
border-color: var(--fs-success);
|
border-color: var(--color-success, #2ecc71);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.tag-check {
|
.tag-check {
|
||||||
font-size: 0.7rem;
|
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 {
|
.assist-instruction {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 3.5rem;
|
min-height: 3.5rem;
|
||||||
}
|
}
|
||||||
@@ -112,27 +213,64 @@
|
|||||||
gap: 0.5rem;
|
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 */
|
/* Active hint shown in the panel while output is inline */
|
||||||
.assist-active-hint {
|
.assist-active-hint {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error */
|
/* Error */
|
||||||
.assist-error {
|
.assist-error {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
border: 1px solid var(--fs-error);
|
border: 1px solid var(--color-danger);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.85rem;
|
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 {
|
.diff-view {
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
max-height: 340px;
|
max-height: 340px;
|
||||||
@@ -146,15 +284,15 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.diff-delete {
|
.diff-delete {
|
||||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
color: var(--fs-error-fg);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
.diff-insert {
|
.diff-insert {
|
||||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||||
color: var(--fs-success-fg);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
.diff-equal {
|
.diff-equal {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.diff-marker {
|
.diff-marker {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -170,7 +308,7 @@
|
|||||||
}
|
}
|
||||||
.diff-empty {
|
.diff-empty {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.assist-actions {
|
.assist-actions {
|
||||||
@@ -178,16 +316,63 @@
|
|||||||
gap: 0.5rem;
|
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) ── */
|
/* ── Floating inline assist button (teleported to body) ── */
|
||||||
.inline-assist-btn {
|
.inline-assist-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.3rem 0.75rem;
|
||||||
background: var(--fs-action-primary);
|
background: var(--color-action-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
box-shadow: 0 2px 8px var(--color-shadow);
|
box-shadow: 0 2px 8px var(--color-shadow);
|
||||||
@@ -199,12 +384,12 @@
|
|||||||
display: none;
|
display: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -223,7 +408,7 @@
|
|||||||
.sb-label {
|
.sb-label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
@@ -231,10 +416,10 @@
|
|||||||
.sb-input {
|
.sb-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -242,11 +427,11 @@
|
|||||||
.sb-select:focus,
|
.sb-select:focus,
|
||||||
.sb-input:focus {
|
.sb-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.sb-divider {
|
.sb-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--fs-border-color);
|
background: var(--color-border);
|
||||||
margin: 0.15rem 0;
|
margin: 0.15rem 0;
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -260,9 +445,22 @@
|
|||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.editor-header {
|
||||||
padding: 0.75rem 1rem 0.5rem;
|
padding: 0.75rem 1rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
.editor-main {
|
||||||
|
padding: 0.5rem 1rem 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -282,14 +480,14 @@
|
|||||||
.btn-accept,
|
.btn-accept,
|
||||||
.btn-generate,
|
.btn-generate,
|
||||||
.btn-save {
|
.btn-save {
|
||||||
background: var(--fs-action-primary);
|
background: var(--color-action-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.btn-accept:not(:disabled):hover,
|
.btn-accept:not(:disabled):hover,
|
||||||
.btn-generate:not(:disabled):hover,
|
.btn-generate:not(:disabled):hover,
|
||||||
.btn-save:not(:disabled):hover {
|
.btn-save:not(:disabled):hover {
|
||||||
background: var(--fs-action-primary-hover);
|
background: var(--color-action-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-back,
|
.btn-back,
|
||||||
@@ -299,7 +497,7 @@
|
|||||||
.btn-suggest-tags {
|
.btn-suggest-tags {
|
||||||
background: none;
|
background: none;
|
||||||
border: var(--fs-border);
|
border: var(--fs-border);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.btn-back:not(:disabled):hover,
|
.btn-back:not(:disabled):hover,
|
||||||
.btn-clear:not(:disabled):hover,
|
.btn-clear:not(:disabled):hover,
|
||||||
@@ -307,16 +505,16 @@
|
|||||||
.btn-proofread:not(:disabled):hover,
|
.btn-proofread:not(:disabled):hover,
|
||||||
.btn-suggest-tags:not(:disabled):hover {
|
.btn-suggest-tags:not(:disabled):hover {
|
||||||
border: var(--fs-border-hover);
|
border: var(--fs-border-hover);
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
background: var(--fs-action-destructive);
|
background: var(--color-action-destructive);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.btn-delete:not(:disabled):hover {
|
.btn-delete:not(:disabled):hover {
|
||||||
background: var(--fs-action-destructive-hover);
|
background: var(--color-action-destructive-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared geometry for every alias above. */
|
/* Shared geometry for every alias above. */
|
||||||
@@ -343,12 +541,12 @@
|
|||||||
.btn-dismiss-tags {
|
.btn-dismiss-tags {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
padding: 2px var(--fs-space-1);
|
padding: 2px var(--fs-space-1);
|
||||||
font-size: var(--fs-size-tiny);
|
font-size: var(--fs-size-tiny);
|
||||||
line-height: 1;
|
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-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
|
||||||
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
|
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
|
||||||
@@ -357,36 +555,3 @@
|
|||||||
opacity: var(--fs-disabled-opacity);
|
opacity: var(--fs-disabled-opacity);
|
||||||
cursor: not-allowed;
|
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 {
|
.prose pre {
|
||||||
background: var(--fs-surface-code);
|
background: var(--color-code-bg);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose code {
|
.prose code {
|
||||||
background: var(--fs-surface-code-inline);
|
background: var(--color-code-inline-bg);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.15rem 0.35rem;
|
padding: 0.15rem 0.35rem;
|
||||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
@@ -73,25 +73,25 @@
|
|||||||
|
|
||||||
.prose th,
|
.prose th,
|
||||||
.prose td {
|
.prose td {
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose thead th {
|
.prose thead th {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose tbody tr:nth-child(even) {
|
.prose tbody tr:nth-child(even) {
|
||||||
background: var(--fs-table-stripe);
|
background: var(--color-table-stripe);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose blockquote {
|
.prose blockquote {
|
||||||
border-left: 3px solid var(--fs-border-color);
|
border-left: 3px solid var(--color-border);
|
||||||
margin: 0 0 0.6rem;
|
margin: 0 0 0.6rem;
|
||||||
padding: 0.25rem 0 0.25rem 0.75rem;
|
padding: 0.25rem 0 0.25rem 0.75rem;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose blockquote p:last-child {
|
.prose blockquote p:last-child {
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
.prose hr {
|
.prose hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border);
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.prose a {
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose .inline-tag {
|
.prose .inline-tag {
|
||||||
color: var(--fs-accent);
|
color: var(--color-tag-text);
|
||||||
background: var(--fs-accent-soft);
|
background: var(--color-tag-bg);
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -133,8 +133,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose .wikilink {
|
.prose .wikilink {
|
||||||
color: var(--fs-wikilink);
|
color: var(--color-wikilink);
|
||||||
background: var(--fs-accent-soft);
|
background: var(--color-wikilink-bg);
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
|
|
||||||
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
|
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
accent-color: var(--fs-accent);
|
accent-color: var(--color-primary);
|
||||||
width: 0.95em;
|
width: 0.95em;
|
||||||
height: 0.95em;
|
height: 0.95em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
|
|
||||||
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
|
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Interactive checkboxes — marked output in the list-note viewer */
|
/* Interactive checkboxes — marked output in the list-note viewer */
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
}
|
}
|
||||||
.prose--checklist li input[type="checkbox"] {
|
.prose--checklist li input[type="checkbox"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
accent-color: var(--fs-accent);
|
accent-color: var(--color-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 0.95em;
|
width: 0.95em;
|
||||||
height: 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) > p,
|
||||||
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
||||||
text-decoration: line-through;
|
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"] {
|
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
|
||||||
text-decoration: none; /* don't strike through the checkbox itself */
|
text-decoration: none; /* don't strike through the checkbox itself */
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
.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);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -227,12 +227,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-wrapper {
|
.tiptap-wrapper {
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-wrapper:focus-within {
|
.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
|
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.
|
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,
|
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
|
||||||
radius, and every derived colour — is stated once, because a value built with
|
radius, and every derived colour — is stated once, because a value built with
|
||||||
var() resolves where it is USED, not where it is written.
|
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-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-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-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-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: 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);
|
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
|
||||||
@@ -86,13 +78,10 @@
|
|||||||
/* priority */
|
/* priority */
|
||||||
--fs-priority-low: var(--fs-info);
|
--fs-priority-low: var(--fs-info);
|
||||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
--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: var(--fs-warning);
|
||||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
--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: var(--fs-error);
|
||||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
--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 */
|
/* radius */
|
||||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||||
@@ -103,11 +92,8 @@
|
|||||||
|
|
||||||
/* semantic */
|
/* semantic */
|
||||||
--fs-success: var(--fs-action-primary);
|
--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: #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: #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-info: #3D5A6E;
|
||||||
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
||||||
|
|
||||||
@@ -130,16 +116,12 @@
|
|||||||
/* status */
|
/* status */
|
||||||
--fs-status-todo: var(--fs-border-color);
|
--fs-status-todo: var(--fs-border-color);
|
||||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
--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: var(--fs-accent);
|
||||||
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
|
--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: var(--fs-success);
|
||||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
--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-overdue: var(--fs-error);
|
||||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
--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 */
|
/* surface */
|
||||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||||
@@ -152,9 +134,7 @@
|
|||||||
/* text */
|
/* text */
|
||||||
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
||||||
--fs-text-secondary: #C2BFB4;
|
--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: #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 */
|
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
|
||||||
|
|
||||||
/* type */
|
/* type */
|
||||||
@@ -208,21 +188,126 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
The compatibility-alias block that lived here is GONE (#2533). It let ~55
|
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
|
||||||
components keep their historical --color-* names while theme.css was
|
|
||||||
repointed at the design system; the rename sweep it promised ran on
|
These exist so ~55 components keep working while they migrate to --fs-*
|
||||||
2026-08-08 and every component now references --fs-* directly. Do not
|
one at a time. Every one is a plain var() reference, which is what lets this
|
||||||
reintroduce app-local alias names — the design system's tokens are the
|
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
|
||||||
vocabulary, and check_snippets_against_design_system can only see through
|
--color-bg follows, because the alias resolves at use time.
|
||||||
names the system actually declares.
|
|
||||||
|
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 {
|
:root {
|
||||||
/* A VALUE, not an alias — the one survivor of the alias block. The design
|
/* surfaces */
|
||||||
system has no shadow-colour token yet, so this is a recorded gap: when a
|
--color-bg: var(--fs-surface-page);
|
||||||
second app needs it, promote it to an --fs-* token in the system and
|
--color-bg-secondary: var(--fs-surface-raised);
|
||||||
regenerate, rather than copying this line. */
|
--color-bg-card: var(--fs-surface-raised);
|
||||||
|
--color-surface: var(--fs-surface-hover);
|
||||||
|
--color-code-bg: var(--fs-surface-code);
|
||||||
|
--color-code-inline-bg: var(--fs-surface-code-inline);
|
||||||
|
--color-table-stripe: var(--fs-table-stripe);
|
||||||
|
--color-overlay: var(--fs-overlay);
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--color-text: var(--fs-text-primary);
|
||||||
|
--color-text-secondary: var(--fs-text-secondary);
|
||||||
|
--color-text-muted: var(--fs-text-tertiary);
|
||||||
|
|
||||||
|
/* lines */
|
||||||
|
--color-border: var(--fs-border-color);
|
||||||
|
--color-input-border: var(--fs-border-color);
|
||||||
|
--focus-ring: var(--fs-focus-ring);
|
||||||
|
|
||||||
|
/* brand */
|
||||||
|
--color-primary: var(--fs-accent);
|
||||||
|
--color-primary-solid: var(--fs-accent);
|
||||||
|
--color-primary-deep: var(--fs-accent-deep);
|
||||||
|
--color-primary-faint: var(--fs-accent-faint);
|
||||||
|
--color-primary-tint: var(--fs-accent-soft);
|
||||||
|
--color-primary-wash: var(--fs-accent-wash);
|
||||||
|
--color-tag-bg: var(--fs-accent-soft);
|
||||||
|
--color-tag-text: var(--fs-accent);
|
||||||
|
--color-wikilink: var(--fs-wikilink);
|
||||||
|
--color-wikilink-bg: var(--fs-accent-soft);
|
||||||
|
--gradient-cta: var(--fs-gradient-cta);
|
||||||
|
--glow-cta: var(--fs-glow-cta);
|
||||||
|
--glow-cta-hover: var(--fs-glow-cta-hover);
|
||||||
|
|
||||||
|
/* actions */
|
||||||
|
--color-action-primary: var(--fs-action-primary);
|
||||||
|
--color-action-primary-hover: var(--fs-action-primary-hover);
|
||||||
|
--color-action-secondary: var(--fs-action-secondary);
|
||||||
|
--color-action-secondary-hover: var(--fs-action-secondary-hover);
|
||||||
|
--color-action-destructive: var(--fs-action-destructive);
|
||||||
|
--color-action-destructive-hover: var(--fs-action-destructive-hover);
|
||||||
|
|
||||||
|
/* semantic */
|
||||||
|
--color-success: var(--fs-success);
|
||||||
|
--color-warning: var(--fs-warning);
|
||||||
|
--color-danger: var(--fs-error);
|
||||||
|
--color-overdue: var(--fs-overdue);
|
||||||
|
--color-toast-success: var(--fs-success);
|
||||||
|
--color-toast-error: var(--fs-error);
|
||||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
--color-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;
|
display: none !important;
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
[role="button"] {
|
[role="button"],
|
||||||
|
.btn-new-conv,
|
||||||
|
.btn-send {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.hide-desktop {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -15,27 +15,27 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ctx-crumb-parent {
|
.ctx-crumb-parent {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.ctx-crumb-parent:hover {
|
.ctx-crumb-parent:hover {
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.ctx-crumb-project {
|
.ctx-crumb-project {
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.ctx-crumb-project:hover {
|
.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 {
|
.ctx-crumb-milestone {
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
import NotificationBell from "@/components/NotificationBell.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 { theme, toggleTheme } = useTheme();
|
||||||
const { toggleShortcuts } = useShortcuts();
|
const { toggleShortcuts } = useShortcuts();
|
||||||
@@ -50,12 +50,6 @@ router.afterEach(() => {
|
|||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,6 +64,16 @@ router.afterEach(() => {
|
|||||||
<Moon v-else :size="16" />
|
<Moon v-else :size="16" />
|
||||||
</button>
|
</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 -->
|
<!-- Trash link -->
|
||||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||||
<Trash2 :size="16" />
|
<Trash2 :size="16" />
|
||||||
@@ -102,9 +106,9 @@ router.afterEach(() => {
|
|||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</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>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<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="/trash" class="nav-link">Trash</router-link>
|
||||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
@@ -124,35 +128,20 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-header {
|
.app-header {
|
||||||
background: linear-gradient(180deg, var(--fs-surface-hover), var(--fs-surface-page));
|
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||||
position: relative;
|
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 {
|
.nav {
|
||||||
padding: 0.6rem 1.5rem;
|
padding: 0.6rem 1.5rem;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: space-between;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left — brand */
|
/* Left — brand */
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
justify-self: start;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -170,7 +159,9 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
/* Center — pill bar */
|
/* Center — pill bar */
|
||||||
.nav-center {
|
.nav-center {
|
||||||
justify-self: center;
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -178,23 +169,21 @@ router.afterEach(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--fs-accent-faint);
|
background: var(--color-primary-faint);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right */
|
/* Right */
|
||||||
.nav-right {
|
.nav-right {
|
||||||
justify-self: end;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.3rem 0.75rem;
|
||||||
@@ -202,34 +191,72 @@ router.afterEach(() => {
|
|||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
background: var(--fs-accent-soft);
|
background: var(--color-primary-tint);
|
||||||
}
|
}
|
||||||
.nav-link.router-link-active {
|
.nav-link.router-link-active {
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary-solid);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
background: rgba(91, 74, 138, 0.25);
|
||||||
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
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) */
|
/* Icon buttons (?, theme, gear) */
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.25rem 0.45rem;
|
padding: 0.25rem 0.45rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.btn-icon:hover {
|
.btn-icon:hover,
|
||||||
background: var(--fs-surface-raised);
|
.btn-icon.active {
|
||||||
color: var(--fs-text-primary);
|
background: var(--color-bg-card);
|
||||||
border-color: var(--fs-accent);
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User info */
|
/* User info */
|
||||||
@@ -239,42 +266,36 @@ router.afterEach(() => {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
border-left: 1px solid var(--fs-border-color);
|
border-left: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.username {
|
.username {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: 500;
|
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 {
|
.admin-badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.btn-logout {
|
.btn-logout {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.btn-logout:hover {
|
.btn-logout:hover {
|
||||||
color: var(--fs-error);
|
color: var(--color-danger);
|
||||||
border-color: var(--fs-error);
|
border-color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hamburger — mobile only */
|
/* Hamburger — mobile only */
|
||||||
@@ -292,7 +313,7 @@ router.afterEach(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--fs-text-primary);
|
background: var(--color-text);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,13 +322,13 @@ router.afterEach(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5rem 1rem 0.75rem;
|
padding: 0.5rem 1rem 0.75rem;
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
.mobile-divider {
|
.mobile-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--fs-border-color);
|
background: var(--color-border);
|
||||||
margin: 0.4rem 0;
|
margin: 0.4rem 0;
|
||||||
}
|
}
|
||||||
.mobile-actions {
|
.mobile-actions {
|
||||||
@@ -321,29 +342,15 @@ router.afterEach(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding-top: 0.4rem;
|
padding-top: 0.4rem;
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border);
|
||||||
margin-top: 0.25rem;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.nav-center {
|
.nav-center {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.status-indicator,
|
||||||
.btn-icon,
|
.btn-icon,
|
||||||
.user-info {
|
.user-info {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -359,7 +366,7 @@ router.afterEach(() => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.mobile-menu .nav-link.router-link-active {
|
.mobile-menu .nav-link.router-link-active {
|
||||||
background: var(--fs-accent-wash);
|
background: var(--color-primary-wash);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.mobile-user .btn-logout {
|
.mobile-user .btn-logout {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ defineProps<{ size?: number }>();
|
|||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0%" stop-color="var(--fs-accent)" />
|
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
||||||
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
|
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<!-- Book body -->
|
<!-- Book body -->
|
||||||
@@ -44,12 +44,12 @@ defineProps<{ size?: number }>();
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.logo-book {
|
.logo-book {
|
||||||
fill: url(#logo-gradient);
|
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 {
|
.logo-spine {
|
||||||
stroke: var(--fs-text-secondary);
|
stroke: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
.logo-lines {
|
.logo-lines {
|
||||||
stroke: var(--fs-text-tertiary);
|
stroke: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</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-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +103,15 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-summary-ins { color: var(--fs-success); }
|
.diff-summary-ins { color: var(--color-success, #2ecc71); }
|
||||||
.diff-summary-del { color: var(--fs-error); }
|
.diff-summary-del { color: var(--color-danger, #e74c3c); }
|
||||||
|
|
||||||
.diff-scroll {
|
.diff-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -123,7 +123,7 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
.diff-empty {
|
.diff-empty {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-line {
|
.diff-line {
|
||||||
@@ -136,24 +136,24 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.diff-delete {
|
.diff-delete {
|
||||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
|
||||||
color: var(--fs-error-fg);
|
color: var(--color-danger, #e74c3c);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-insert {
|
.diff-insert {
|
||||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
|
||||||
color: var(--fs-success-fg);
|
color: var(--color-success, #2ecc71);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-equal {
|
.diff-equal {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-collapse {
|
.diff-collapse {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
border-top: 1px dashed var(--fs-border-color);
|
border-top: 1px dashed var(--color-border);
|
||||||
border-bottom: 1px dashed var(--fs-border-color);
|
border-bottom: 1px dashed var(--color-border);
|
||||||
padding-top: 0.2rem;
|
padding-top: 0.2rem;
|
||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
import type { DiffLine } from "@/composables/useAssist";
|
||||||
import { fmtStamp } from "@/utils/dateFormat";
|
|
||||||
|
|
||||||
interface NoteVersion {
|
interface NoteVersion {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,10 +32,38 @@ const loadingDetail = ref(false);
|
|||||||
|
|
||||||
const diff = computed<DiffLine[]>(() => {
|
const diff = computed<DiffLine[]>(() => {
|
||||||
if (!selectedVersion.value?.body) return [];
|
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() {
|
async function loadVersions() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -185,7 +212,7 @@ onMounted(loadVersions);
|
|||||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||||
class="history-item-label"
|
class="history-item-label"
|
||||||
>{{ v.pin_label }}</div>
|
>{{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,7 +309,7 @@ onMounted(loadVersions);
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.9rem 1.25rem;
|
padding: 0.9rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-title {
|
.history-title {
|
||||||
@@ -295,11 +322,11 @@ onMounted(loadVersions);
|
|||||||
border: none;
|
border: none;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.1rem 0.3rem;
|
||||||
}
|
}
|
||||||
.history-close:hover { color: var(--fs-text-primary); }
|
.history-close:hover { color: var(--color-text); }
|
||||||
|
|
||||||
.history-body {
|
.history-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -311,7 +338,7 @@ onMounted(loadVersions);
|
|||||||
.history-list {
|
.history-list {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-right: 1px solid var(--fs-border-color);
|
border-right: 1px solid var(--color-border);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,18 +346,18 @@ onMounted(loadVersions);
|
|||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-left: 3px solid transparent;
|
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 {
|
.history-item.selected {
|
||||||
border-left-color: var(--fs-accent);
|
border-left-color: var(--color-primary);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item-title {
|
.history-item-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -338,7 +365,7 @@ onMounted(loadVersions);
|
|||||||
|
|
||||||
.history-item-date {
|
.history-item-date {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +381,7 @@ onMounted(loadVersions);
|
|||||||
.history-empty {
|
.history-empty {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-footer {
|
.history-footer {
|
||||||
@@ -363,7 +390,7 @@ onMounted(loadVersions);
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 0.75rem 1.25rem;
|
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;
|
font-size: 0.85em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.pin-badge-manual { color: var(--fs-accent); }
|
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
||||||
.pin-badge-auto { color: var(--fs-text-tertiary); }
|
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
||||||
|
|
||||||
.history-item-label {
|
.history-item-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary, #6366f1);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -392,7 +419,7 @@ onMounted(loadVersions);
|
|||||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
||||||
.version-pin-controls {
|
.version-pin-controls {
|
||||||
padding: 0.4rem 0.5rem 0.5rem;
|
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;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.pin-actions {
|
.pin-actions {
|
||||||
@@ -403,7 +430,7 @@ onMounted(loadVersions);
|
|||||||
}
|
}
|
||||||
.pin-state {
|
.pin-state {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -415,13 +442,13 @@ onMounted(loadVersions);
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||||
background: rgba(99, 102, 241, 0.12);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary, #6366f1);
|
||||||
}
|
}
|
||||||
.btn-unpin:hover:not(:disabled) {
|
.btn-unpin:hover:not(:disabled) {
|
||||||
background: rgba(239, 68, 68, 0.10);
|
background: rgba(239, 68, 68, 0.10);
|
||||||
@@ -436,27 +463,27 @@ onMounted(loadVersions);
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm, 4px);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.pin-label-input:focus {
|
.pin-label-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary, #6366f1);
|
||||||
}
|
}
|
||||||
.btn-pin-save, .btn-pin-cancel {
|
.btn-pin-save, .btn-pin-cancel {
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm, 4px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-pin-save:hover:not(:disabled) {
|
.btn-pin-save:hover:not(:disabled) {
|
||||||
background: rgba(99, 102, 241, 0.12);
|
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-save:disabled, .btn-pin-cancel:disabled,
|
||||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin: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>
|
<style scoped>
|
||||||
.iap {
|
.iap {
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
@@ -88,16 +88,16 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Streaming ── */
|
/* ── Streaming ── */
|
||||||
.iap-streaming {
|
.iap-streaming {
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.iap-streaming .iap-header {
|
.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 {
|
.iap-pulse {
|
||||||
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--fs-accent);
|
background: var(--color-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
animation: iap-pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
.iap-label {
|
.iap-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -125,9 +125,9 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
|
|
||||||
.iap-btn-cancel {
|
.iap-btn-cancel {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.iap-btn-cancel:hover {
|
.iap-btn-cancel:hover {
|
||||||
border-color: var(--fs-error);
|
border-color: var(--color-danger, #e74c3c);
|
||||||
color: var(--fs-error);
|
color: var(--color-danger, #e74c3c);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-stream-preview {
|
.iap-stream-preview {
|
||||||
@@ -150,29 +150,29 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
.iap-waiting {
|
.iap-waiting {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Review ── */
|
/* ── Review ── */
|
||||||
.iap-review-title {
|
.iap-review-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-btn-toggle {
|
.iap-btn-toggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.iap-btn-toggle:hover {
|
.iap-btn-toggle:hover {
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-actions {
|
.iap-actions {
|
||||||
@@ -183,7 +183,7 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
.iap-btn-accept,
|
.iap-btn-accept,
|
||||||
.iap-btn-reject {
|
.iap-btn-reject {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.2rem 0.65rem;
|
padding: 0.2rem 0.65rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
font-weight: var(--fs-weight-medium);
|
font-weight: var(--fs-weight-medium);
|
||||||
}
|
}
|
||||||
.iap-btn-accept {
|
.iap-btn-accept {
|
||||||
background: var(--fs-success);
|
background: var(--color-success, #22c55e);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.iap-btn-accept:hover { opacity: 0.85; }
|
.iap-btn-accept:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.iap-btn-reject {
|
.iap-btn-reject {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card, var(--color-bg));
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.iap-btn-reject:hover {
|
.iap-btn-reject:hover {
|
||||||
border-color: var(--fs-error);
|
border-color: var(--color-danger, #e74c3c);
|
||||||
color: var(--fs-error);
|
color: var(--color-danger, #e74c3c);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Diff ── */
|
/* ── Diff ── */
|
||||||
@@ -224,14 +224,14 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
.iap-diff-equal { color: var(--color-text-muted); }
|
||||||
.iap-diff-delete {
|
.iap-diff-delete {
|
||||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
|
||||||
color: var(--fs-error-fg);
|
color: var(--color-danger, #e74c3c);
|
||||||
}
|
}
|
||||||
.iap-diff-insert {
|
.iap-diff-insert {
|
||||||
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
|
||||||
color: var(--fs-success-fg);
|
color: var(--color-success, #22c55e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-diff-marker {
|
.iap-diff-marker {
|
||||||
@@ -243,7 +243,7 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
|
|
||||||
.iap-diff-empty {
|
.iap-diff-empty {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: inherit;
|
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;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--radius-md);
|
||||||
padding: 3px 4px;
|
padding: 3px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ const groups = [
|
|||||||
display: block;
|
display: block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--fs-border-color);
|
background: var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0 3px;
|
margin: 0 3px;
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ const groups = [
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||||
@@ -149,19 +149,19 @@ const groups = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-btn:hover {
|
.md-btn:hover {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-btn.active {
|
.md-btn.active {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-btn.active:hover {
|
.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 {
|
.btn-icon {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function onChange(e: Event) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<select
|
<select
|
||||||
class="fs-input milestone-select"
|
class="milestone-select"
|
||||||
:value="modelValue ?? ''"
|
:value="modelValue ?? ''"
|
||||||
:disabled="!projectId || loading"
|
:disabled="!projectId || loading"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@@ -64,10 +64,23 @@ function onChange(e: Event) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* The input itself is the .fs-input canon (components.css); only the
|
|
||||||
layout remainder lives here. */
|
|
||||||
.milestone-select {
|
.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;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.milestone-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.milestone-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ function goEdit() {
|
|||||||
.note-card {
|
.note-card {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
|
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;
|
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
.note-card:hover {
|
.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);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,18 +78,18 @@ function goEdit() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
.note-card.compact:first-child {
|
.note-card.compact:first-child {
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.note-card.compact:hover {
|
.note-card.compact:hover {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
|
background: rgba(91, 74, 138, 0.04);
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
.note-title-compact {
|
.note-title-compact {
|
||||||
@@ -108,7 +108,7 @@ function goEdit() {
|
|||||||
}
|
}
|
||||||
.timestamp-compact {
|
.timestamp-compact {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -133,20 +133,20 @@ function goEdit() {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
.btn-edit:hover {
|
.btn-edit:hover {
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.note-preview {
|
.note-preview {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
max-height: 7.5em;
|
max-height: 7.5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -163,6 +163,6 @@ function goEdit() {
|
|||||||
.timestamp {
|
.timestamp {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</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 {
|
.btn-bell {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.25rem 0.45rem;
|
padding: 0.25rem 0.45rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -72,16 +72,16 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.btn-bell:hover,
|
.btn-bell:hover,
|
||||||
.btn-bell.active {
|
.btn-bell.active {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bell-badge {
|
.bell-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px;
|
top: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
background: var(--fs-error);
|
background: var(--color-danger, #ef4444);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ onMounted(() => store.fetchAll())
|
|||||||
width: 340px;
|
width: 340px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
}
|
}
|
||||||
@@ -97,10 +97,10 @@ onMounted(() => store.fetchAll())
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-panel-title {
|
.notif-panel-title {
|
||||||
@@ -114,12 +114,12 @@ onMounted(() => store.fetchAll())
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.notif-item:last-child { border-bottom: none; }
|
.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; }
|
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
|
||||||
|
|
||||||
@@ -127,10 +127,10 @@ onMounted(() => store.fetchAll())
|
|||||||
.notif-msg {
|
.notif-msg {
|
||||||
margin: 0 0 0.2rem;
|
margin: 0 0 0.2rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -74,27 +74,27 @@ function goToPage(page: number) {
|
|||||||
}
|
}
|
||||||
.page-btn {
|
.page-btn {
|
||||||
padding: 0.35rem 0.7rem;
|
padding: 0.35rem 0.7rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.page-btn:hover:not(:disabled) {
|
.page-btn:hover:not(:disabled) {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
.page-btn:disabled {
|
.page-btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.page-btn.active {
|
.page-btn.active {
|
||||||
background: var(--fs-accent);
|
background: var(--color-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import type { TaskPriority } from "@/types/task";
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
priority: TaskPriority;
|
priority: TaskPriority;
|
||||||
/** Dense surfaces — see StatusBadge. */
|
|
||||||
compact?: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const labels: Record<TaskPriority, string> = {
|
const labels: Record<TaskPriority, string> = {
|
||||||
@@ -18,7 +16,7 @@ const labels: Record<TaskPriority, string> = {
|
|||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
v-if="props.priority !== 'none'"
|
v-if="props.priority !== 'none'"
|
||||||
:class="['priority-badge', `priority-${props.priority}`, { compact }]"
|
:class="['priority-badge', `priority-${props.priority}`]"
|
||||||
>
|
>
|
||||||
{{ labels[props.priority] }}
|
{{ labels[props.priority] }}
|
||||||
</span>
|
</span>
|
||||||
@@ -30,28 +28,20 @@ const labels: Record<TaskPriority, string> = {
|
|||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
font-weight: 600;
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
}
|
}
|
||||||
.compact {
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
}
|
|
||||||
.priority-low {
|
.priority-low {
|
||||||
background: var(--fs-priority-low-bg);
|
background: var(--color-priority-low-bg);
|
||||||
color: var(--fs-priority-low-fg);
|
color: var(--color-priority-low);
|
||||||
}
|
}
|
||||||
.priority-medium {
|
.priority-medium {
|
||||||
background: var(--fs-priority-medium-bg);
|
background: var(--color-priority-medium-bg);
|
||||||
color: var(--fs-priority-medium-fg);
|
color: var(--color-priority-medium);
|
||||||
}
|
}
|
||||||
.priority-high {
|
.priority-high {
|
||||||
background: var(--fs-priority-high-bg);
|
background: var(--color-priority-high-bg);
|
||||||
color: var(--fs-priority-high-fg);
|
color: var(--color-priority-high);
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<style scoped>
|
||||||
.project-selector {
|
.project-selector {
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -62,6 +62,6 @@ function onChange(e: Event) {
|
|||||||
}
|
}
|
||||||
.project-selector:focus {
|
.project-selector:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</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 {
|
.rec-label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
min-width: 2.5rem;
|
min-width: 2.5rem;
|
||||||
}
|
}
|
||||||
.rec-num-input {
|
.rec-num-input {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem 0.4rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border, var(--color-border));
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: inherit;
|
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; }
|
.rec-unit { min-width: 6rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ defineExpose({ focus: () => inputRef.value?.focus() });
|
|||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.search-input::placeholder {
|
.search-input::placeholder {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -206,9 +206,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.share-dialog {
|
.share-dialog {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
@@ -220,7 +220,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-title {
|
.share-title {
|
||||||
@@ -228,9 +228,10 @@ onMounted(async () => {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.share-tabs {
|
.share-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -239,17 +240,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.share-tab {
|
.share-tab {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.3rem 0.8rem;
|
padding: 0.3rem 0.8rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.share-tab.active {
|
.share-tab.active {
|
||||||
background: var(--fs-accent);
|
background: var(--color-primary);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,23 +269,23 @@ onMounted(async () => {
|
|||||||
.share-input {
|
.share-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.45rem 0.7rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.share-input:focus { border-color: var(--fs-accent); }
|
.share-input:focus { border-color: var(--color-primary); }
|
||||||
|
|
||||||
.user-results {
|
.user-results {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -303,23 +304,24 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
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-name { font-weight: 600; font-size: 0.88rem; }
|
||||||
|
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
|
||||||
|
|
||||||
.perm-select {
|
.perm-select {
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-share {
|
.btn-add-share {
|
||||||
padding: 0.45rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--fs-gradient-cta);
|
background: var(--gradient-cta);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -340,7 +342,7 @@ onMounted(async () => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +361,7 @@ onMounted(async () => {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
|
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
@@ -367,10 +369,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.perm-select-inline {
|
.perm-select-inline {
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem 0.4rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
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<{
|
const props = defineProps<{
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
clickable?: boolean;
|
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: [] }>();
|
defineEmits<{ click: [] }>();
|
||||||
|
|
||||||
const labels: Record<TaskStatus, string> = {
|
const labels: Record<TaskStatus, string> = {
|
||||||
todo: "Todo",
|
todo: "Todo",
|
||||||
in_progress: "In progress",
|
in_progress: "In Progress",
|
||||||
done: "Done",
|
done: "Done",
|
||||||
cancelled: "Cancelled",
|
cancelled: "Cancelled",
|
||||||
};
|
};
|
||||||
@@ -21,7 +18,7 @@ const labels: Record<TaskStatus, string> = {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="['status-badge', `status-${props.status}`, { clickable, compact }]"
|
:class="['status-badge', `status-${props.status}`, { clickable }]"
|
||||||
@click="clickable ? $emit('click') : undefined"
|
@click="clickable ? $emit('click') : undefined"
|
||||||
:role="clickable ? 'button' : undefined"
|
:role="clickable ? 'button' : undefined"
|
||||||
:tabindex="clickable ? 0 : undefined"
|
:tabindex="clickable ? 0 : undefined"
|
||||||
@@ -36,37 +33,25 @@ const labels: Record<TaskStatus, string> = {
|
|||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
font-weight: 600;
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.025em;
|
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 {
|
.status-todo {
|
||||||
background: var(--fs-status-todo-bg);
|
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
|
||||||
color: var(--fs-status-todo-fg);
|
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
|
||||||
}
|
}
|
||||||
.status-in_progress {
|
.status-in_progress {
|
||||||
background: var(--fs-status-in-progress-bg);
|
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
|
||||||
color: var(--fs-status-in-progress-fg);
|
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
|
||||||
}
|
}
|
||||||
.status-done {
|
.status-done {
|
||||||
background: var(--fs-status-done-bg);
|
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
||||||
color: var(--fs-status-done-fg);
|
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
|
||||||
}
|
}
|
||||||
.status-cancelled {
|
.status-cancelled {
|
||||||
background: var(--fs-status-todo-bg);
|
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
|
||||||
color: var(--fs-status-cancelled-fg);
|
color: var(--color-text-muted);
|
||||||
}
|
|
||||||
.compact {
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
}
|
}
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ defineExpose({ onKeyDown });
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
box-shadow: 0 4px 12px var(--color-shadow);
|
box-shadow: 0 4px 12px var(--color-shadow);
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -85,6 +85,6 @@ defineExpose({ onKeyDown });
|
|||||||
}
|
}
|
||||||
.ac-item:hover,
|
.ac-item:hover,
|
||||||
.ac-item.active {
|
.ac-item.active {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useSystemsStore } from "@/stores/systems";
|
import { useSystemsStore } from "@/stores/systems";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { getProjectIssues } from "@/api/systems";
|
import { getProjectIssues } from "@/api/systems";
|
||||||
import type { System, TaskLike } 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";
|
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>();
|
const props = defineProps<{ projectId: number }>();
|
||||||
|
|
||||||
const store = useSystemsStore();
|
const store = useSystemsStore();
|
||||||
const canon = useCanonicalSystemsStore();
|
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -23,26 +19,14 @@ const issues = ref<TaskLike[]>([]);
|
|||||||
const showCreate = ref(false);
|
const showCreate = ref(false);
|
||||||
const newName = ref("");
|
const newName = ref("");
|
||||||
const newDescription = 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);
|
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
|
// Edit state
|
||||||
const editingId = ref<number | null>(null);
|
const editingId = ref<number | null>(null);
|
||||||
const editName = ref("");
|
const editName = ref("");
|
||||||
const editDescription = ref("");
|
const editDescription = ref("");
|
||||||
const editCanonicalId = ref<number | null>(null);
|
|
||||||
const savingEdit = ref(false);
|
const savingEdit = ref(false);
|
||||||
|
|
||||||
// Mapping review
|
|
||||||
const showReview = ref(false);
|
|
||||||
const reviewBusy = ref<number | null>(null);
|
|
||||||
|
|
||||||
// Delete confirmation
|
// Delete confirmation
|
||||||
const deletingSystem = ref<System | null>(null);
|
const deletingSystem = ref<System | null>(null);
|
||||||
|
|
||||||
@@ -53,12 +37,6 @@ const visibleSystems = computed(() =>
|
|||||||
showArchived.value ? systems.value : activeSystems.value,
|
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() {
|
async function load() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
@@ -71,14 +49,6 @@ async function load() {
|
|||||||
} catch {
|
} catch {
|
||||||
issues.value = [];
|
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);
|
onMounted(load);
|
||||||
@@ -88,14 +58,12 @@ function openCreate() {
|
|||||||
showCreate.value = true;
|
showCreate.value = true;
|
||||||
newName.value = "";
|
newName.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
newCanonicalId.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCreate() {
|
function cancelCreate() {
|
||||||
showCreate.value = false;
|
showCreate.value = false;
|
||||||
newName.value = "";
|
newName.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
newCanonicalId.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
@@ -103,60 +71,23 @@ async function submitCreate() {
|
|||||||
if (!name || creating.value) return;
|
if (!name || creating.value) return;
|
||||||
creating.value = true;
|
creating.value = true;
|
||||||
try {
|
try {
|
||||||
const created = await store.createSystem(props.projectId, {
|
await store.createSystem(props.projectId, {
|
||||||
name,
|
name,
|
||||||
description: newDescription.value.trim() || undefined,
|
description: newDescription.value.trim() || undefined,
|
||||||
canonical_id: newCanonicalId.value ?? undefined,
|
|
||||||
});
|
});
|
||||||
cancelCreate();
|
cancelCreate();
|
||||||
if (created.canonical_suggestion) {
|
toast.show("System created");
|
||||||
// An overlap: shown as an offer beside the new System, never applied.
|
} catch {
|
||||||
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
|
toast.show("Failed to create system", "error");
|
||||||
}
|
|
||||||
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");
|
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
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) {
|
function startEdit(system: System) {
|
||||||
editingId.value = system.id;
|
editingId.value = system.id;
|
||||||
editName.value = system.name;
|
editName.value = system.name;
|
||||||
editDescription.value = system.description;
|
editDescription.value = system.description;
|
||||||
editCanonicalId.value = system.canonical_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -172,12 +103,6 @@ async function submitEdit(system: System) {
|
|||||||
name,
|
name,
|
||||||
description: editDescription.value.trim(),
|
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;
|
editingId.value = null;
|
||||||
toast.show("System updated");
|
toast.show("System updated");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -236,65 +161,6 @@ async function confirmDelete() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 -->
|
<!-- Toolbar -->
|
||||||
<div class="systems-toolbar">
|
<div class="systems-toolbar">
|
||||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
<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">
|
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
|
||||||
<input
|
<input
|
||||||
v-model="newName"
|
v-model="newName"
|
||||||
class="fs-input system-input"
|
class="system-input"
|
||||||
placeholder="System name"
|
placeholder="System name"
|
||||||
aria-label="System name"
|
aria-label="System name"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -318,25 +184,11 @@ async function confirmDelete() {
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newDescription"
|
v-model="newDescription"
|
||||||
class="fs-input system-textarea"
|
class="system-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="What is this subsystem responsible for? (optional)"
|
placeholder="What is this subsystem responsible for? (optional)"
|
||||||
aria-label="System description"
|
aria-label="System description"
|
||||||
></textarea>
|
></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">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||||
{{ creating ? "Creating…" : "Create" }}
|
{{ creating ? "Creating…" : "Create" }}
|
||||||
@@ -375,7 +227,7 @@ async function confirmDelete() {
|
|||||||
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
||||||
<input
|
<input
|
||||||
v-model="editName"
|
v-model="editName"
|
||||||
class="fs-input system-input"
|
class="system-input"
|
||||||
placeholder="System name"
|
placeholder="System name"
|
||||||
aria-label="System name"
|
aria-label="System name"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -383,20 +235,11 @@ async function confirmDelete() {
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="editDescription"
|
v-model="editDescription"
|
||||||
class="fs-input system-textarea"
|
class="system-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Description (optional)"
|
placeholder="Description (optional)"
|
||||||
aria-label="System description"
|
aria-label="System description"
|
||||||
></textarea>
|
></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">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||||
{{ savingEdit ? "Saving…" : "Save" }}
|
{{ savingEdit ? "Saving…" : "Save" }}
|
||||||
@@ -410,7 +253,7 @@ async function confirmDelete() {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span
|
<span
|
||||||
class="system-swatch"
|
class="system-swatch"
|
||||||
:style="{ background: system.color || 'var(--fs-text-tertiary)' }"
|
:style="{ background: system.color || 'var(--color-text-muted)' }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<div class="system-body">
|
<div class="system-body">
|
||||||
@@ -421,13 +264,6 @@ async function confirmDelete() {
|
|||||||
:title="`${system.open_issue_count} open issue(s)`"
|
:title="`${system.open_issue_count} open issue(s)`"
|
||||||
>{{ system.open_issue_count }} open</span>
|
>{{ system.open_issue_count }} open</span>
|
||||||
<span v-if="system.status === 'archived'" class="archived-badge">Archived</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>
|
</div>
|
||||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,126 +325,41 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
/* ── Open issues ──────────────────────────────────────────────── */
|
/* ── Open issues ──────────────────────────────────────────────── */
|
||||||
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
|
.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-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 { 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(--fs-surface-raised); }
|
.issue-link:hover { background: var(--color-bg-secondary); }
|
||||||
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
|
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
||||||
.issue-mark.imk-in_progress { color: var(--fs-accent); }
|
.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-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-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; }
|
.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; }
|
||||||
|
|
||||||
/* ── 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||||
.btn-add-system {
|
.btn-add-system {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px dashed var(--fs-border-color);
|
border: 1px dashed var(--color-border);
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
padding: 0.28rem 0.65rem;
|
padding: 0.28rem 0.65rem;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.btn-add-system:hover { 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(--fs-accent); color: var(--fs-accent); }
|
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|
||||||
.archived-toggle {
|
.archived-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.archived-checkbox { accent-color: var(--fs-accent); cursor: pointer; }
|
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
|
||||||
|
|
||||||
/* ── Create / edit form ───────────────────────────────────────── */
|
/* ── Create / edit form ───────────────────────────────────────── */
|
||||||
.system-form {
|
.system-form {
|
||||||
@@ -616,52 +367,26 @@ async function confirmDelete() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||||
/* The input itself is the .fs-input canon (components.css); only the
|
.system-input, .system-textarea {
|
||||||
layout remainder lives here. */
|
padding: 0.4rem 0.6rem;
|
||||||
.system-input, .system-textarea { box-sizing: border-box; width: 100%; }
|
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-textarea { resize: vertical; }
|
||||||
|
|
||||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
.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-card--archived { opacity: 0.6; }
|
||||||
|
|
||||||
.system-swatch {
|
.system-swatch {
|
||||||
@@ -673,13 +398,13 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.system-body { flex: 1; min-width: 0; }
|
.system-body { flex: 1; min-width: 0; }
|
||||||
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
.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 {
|
.issue-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0.05rem 0.45rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -689,15 +414,15 @@ async function confirmDelete() {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--fs-text-tertiary-fg);
|
color: var(--color-text-muted);
|
||||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0.05rem 0.45rem;
|
||||||
}
|
}
|
||||||
.system-description {
|
.system-description {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -712,15 +437,15 @@ async function confirmDelete() {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: background 0.12s, color 0.12s;
|
transition: background 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
.action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
|
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
||||||
.action-btn:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 1px; opacity: 1; }
|
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
||||||
.action-delete:hover { color: var(--fs-error); }
|
.action-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||||
|
|
||||||
/* ── Empty ────────────────────────────────────────────────────── */
|
/* ── Empty ────────────────────────────────────────────────────── */
|
||||||
.systems-empty {
|
.systems-empty {
|
||||||
@@ -730,29 +455,61 @@ async function confirmDelete() {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px dashed var(--fs-border-color);
|
border: 1px dashed var(--color-border);
|
||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
/* remainders over the shared recipes (components.css, m302) */
|
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
|
||||||
.empty-title { margin: 0; color: var(--fs-text-primary); }
|
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
|
||||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; max-width: 32ch; }
|
|
||||||
|
|
||||||
|
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||||
|
|
||||||
/* ── Skeleton ─────────────────────────────────────────────────── */
|
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||||
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.skel-row {
|
.skel-row {
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--radius-md);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--fs-surface-raised) 25%,
|
var(--color-bg-secondary) 25%,
|
||||||
color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
|
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
||||||
var(--fs-surface-raised) 75%
|
var(--color-bg-secondary) 75%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: skel-shine 1.5s ease infinite;
|
animation: skel-shine 1.5s ease infinite;
|
||||||
}
|
}
|
||||||
.skel-row--short { width: 65%; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function scrollTo(id: string) {
|
|||||||
.toc-title {
|
.toc-title {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -73,11 +73,11 @@ function scrollTo(id: string) {
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
.toc-link {
|
.toc-link {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.toc-link:hover {
|
.toc-link:hover {
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -154,9 +154,9 @@ function focusInput() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
cursor: text;
|
cursor: text;
|
||||||
min-height: 2.25rem;
|
min-height: 2.25rem;
|
||||||
}
|
}
|
||||||
@@ -166,9 +166,9 @@ function focusInput() {
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
border: 1px solid var(--fs-accent);
|
border: 1px solid var(--color-primary);
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ function focusInput() {
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -205,9 +205,9 @@ function focusInput() {
|
|||||||
left: 0;
|
left: 0;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -218,11 +218,11 @@ function focusInput() {
|
|||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.tag-autocomplete-item:hover,
|
.tag-autocomplete-item:hover,
|
||||||
.tag-autocomplete-item.selected {
|
.tag-autocomplete-item.selected {
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ defineEmits<{
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
background: var(--fs-accent-soft);
|
background: var(--color-tag-bg);
|
||||||
color: var(--fs-accent);
|
color: var(--color-tag-text);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -39,13 +39,13 @@ defineEmits<{
|
|||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.tag-pill:hover {
|
.tag-pill:hover {
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
background: var(--fs-accent-soft);
|
background: var(--color-primary-tint);
|
||||||
}
|
}
|
||||||
.dismiss {
|
.dismiss {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--fs-accent);
|
color: var(--color-tag-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1;
|
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 { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import type { TaskLog } from "@/types/task";
|
import type { TaskLog } from "@/types/task";
|
||||||
import { fmtStamp } from "@/utils/dateFormat";
|
|
||||||
|
|
||||||
const props = defineProps<{ taskId: number }>();
|
const props = defineProps<{ taskId: number }>();
|
||||||
|
|
||||||
@@ -16,6 +15,13 @@ const editingId = ref<number | null>(null);
|
|||||||
const editContent = ref("");
|
const editContent = ref("");
|
||||||
const editDuration = 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 {
|
function formatDuration(minutes: number): string {
|
||||||
if (minutes < 60) return `${minutes} min`;
|
if (minutes < 60) return `${minutes} min`;
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
@@ -122,7 +128,7 @@ onMounted(loadLogs);
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="log-entry-meta">
|
<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">
|
<span v-if="log.duration_minutes" class="log-duration-badge">
|
||||||
{{ formatDuration(log.duration_minutes) }}
|
{{ formatDuration(log.duration_minutes) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -169,9 +175,9 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.log-section {
|
.log-section {
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -181,7 +187,7 @@ onMounted(loadLogs);
|
|||||||
.log-header {
|
.log-header {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
@@ -189,11 +195,11 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
.log-empty {
|
.log-empty {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry {
|
.log-entry {
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border);
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +212,11 @@ onMounted(loadLogs);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-date {
|
.log-date {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-duration-badge {
|
.log-duration-badge {
|
||||||
background: var(--fs-accent);
|
background: var(--color-primary);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
@@ -229,10 +235,10 @@ onMounted(loadLogs);
|
|||||||
.log-textarea {
|
.log-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -241,7 +247,7 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
.log-textarea:focus {
|
.log-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-add-controls,
|
.log-add-controls,
|
||||||
@@ -253,7 +259,7 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
.log-duration-label {
|
.log-duration-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -262,16 +268,16 @@ onMounted(loadLogs);
|
|||||||
.log-duration-input {
|
.log-duration-input {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
padding: 0.3rem 0.4rem;
|
padding: 0.3rem 0.4rem;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-duration-input:focus {
|
.log-duration-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ defineExpose({ editor });
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.editor-error {
|
.editor-error {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: var(--fs-error);
|
color: var(--color-danger);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ const toastStore = useToastStore();
|
|||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.toast--success {
|
.toast--success {
|
||||||
background: var(--fs-success);
|
background: var(--color-toast-success);
|
||||||
}
|
}
|
||||||
.toast--error {
|
.toast--error {
|
||||||
background: var(--fs-error);
|
background: var(--color-toast-error);
|
||||||
}
|
}
|
||||||
.toast--warning {
|
.toast--warning {
|
||||||
background: var(--fs-warning);
|
background: var(--color-warning);
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
.toast--warning .toast-close {
|
.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>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
import type { DiffLine } from "@/composables/useAssist";
|
||||||
|
|
||||||
interface NoteVersion {
|
interface NoteVersion {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -31,7 +31,25 @@ const loadingDetail = ref(false);
|
|||||||
|
|
||||||
const diff = computed<DiffLine[]>(() => {
|
const diff = computed<DiffLine[]>(() => {
|
||||||
if (!selectedVersion.value?.body) return [];
|
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 {
|
function formatDate(iso: string): string {
|
||||||
@@ -146,7 +164,7 @@ function restore() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.vh-section {
|
.vh-section {
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-header {
|
.vh-header {
|
||||||
@@ -161,19 +179,19 @@ function restore() {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.vh-header:hover { background: var(--fs-surface-raised); }
|
.vh-header:hover { background: var(--color-bg-secondary); }
|
||||||
|
|
||||||
.vh-title {
|
.vh-title {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-chevron {
|
.vh-chevron {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-body {
|
.vh-body {
|
||||||
@@ -183,20 +201,20 @@ function restore() {
|
|||||||
.vh-empty {
|
.vh-empty {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-item {
|
.vh-item {
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
}
|
}
|
||||||
.vh-item:hover {
|
.vh-item:hover {
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-secondary);
|
||||||
border-left-color: var(--fs-accent);
|
border-left-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-diff-actions {
|
.vh-diff-actions {
|
||||||
@@ -207,20 +225,20 @@ function restore() {
|
|||||||
|
|
||||||
.vh-btn-back {
|
.vh-btn-back {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--fs-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
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 {
|
.vh-btn-restore {
|
||||||
background: var(--fs-action-primary);
|
background: var(--color-action-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ const label = computed(() => {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.word-count:hover { color: var(--fs-text-primary); }
|
.word-count:hover { color: var(--color-text); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import TagInput from "@/components/TagInput.vue";
|
|||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import WordCount from "@/components/WordCount.vue";
|
import WordCount from "@/components/WordCount.vue";
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
import { Trash2, X } from "lucide-vue-next";
|
||||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number;
|
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(noteTitle, () => { dirty.value = true; });
|
||||||
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
||||||
watch(noteTags, () => { dirty.value = true; });
|
watch(noteTags, () => { dirty.value = true; });
|
||||||
@@ -333,7 +346,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
>
|
>
|
||||||
<div class="note-row-main">
|
<div class="note-row-main">
|
||||||
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
|
<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>
|
||||||
<div v-if="note.tags?.length" class="note-row-tags">
|
<div v-if="note.tags?.length" class="note-row-tags">
|
||||||
<span
|
<span
|
||||||
@@ -439,8 +452,8 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-surface);
|
||||||
border-left: 1px solid var(--fs-border-color);
|
border-left: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Left rail ── */
|
/* ── Left rail ── */
|
||||||
@@ -450,7 +463,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--fs-surface-raised);
|
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-header {
|
.rail-header {
|
||||||
@@ -458,12 +471,12 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-title {
|
.rail-title {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -471,12 +484,13 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.rail-search-input {
|
.rail-search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -489,7 +503,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
.rail-state {
|
.rail-state {
|
||||||
padding: 1rem 0.65rem;
|
padding: 1rem 0.65rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Note list */
|
/* Note list */
|
||||||
@@ -505,16 +519,16 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.4rem 0.6rem;
|
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;
|
cursor: pointer;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
border-right: 2px solid transparent;
|
border-right: 2px solid transparent;
|
||||||
transition: background 0.12s;
|
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 {
|
.note-row.active {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||||
border-right-color: var(--fs-accent);
|
border-right-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-row-main {
|
.note-row-main {
|
||||||
@@ -530,12 +544,12 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-row-age {
|
.note-row-age {
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -548,8 +562,8 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
.note-tag-pill {
|
.note-tag-pill {
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0 0.3rem;
|
padding: 0 0.3rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -558,13 +572,13 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
max-width: 5rem;
|
max-width: 5rem;
|
||||||
}
|
}
|
||||||
.note-tag-pill.tag-match {
|
.note-tag-pill.tag-match {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||||
outline: 1px solid var(--fs-accent);
|
outline: 1px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-tag-more {
|
.note-tag-more {
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +588,8 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-row:hover .btn-delete { opacity: 1; }
|
||||||
|
|
||||||
/* Editor UI */
|
/* Editor UI */
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -590,8 +606,8 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unsaved { font-size: 0.72rem; color: var(--fs-text-tertiary); }
|
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||||
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
|
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||||
|
|
||||||
/* Moss action-primary per Hybrid */
|
/* Moss action-primary per Hybrid */
|
||||||
|
|
||||||
@@ -602,14 +618,14 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: 'Fraunces', serif;
|
font-family: 'Fraunces', serif;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.note-title-input:focus { outline: none; }
|
.note-title-input:focus { outline: none; }
|
||||||
.note-title-input::placeholder {
|
.note-title-input::placeholder {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-row {
|
.tag-row {
|
||||||
@@ -621,45 +637,48 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
}
|
}
|
||||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
||||||
|
|
||||||
.tag-suggestions {
|
.tag-suggestions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.35rem 0.6rem;
|
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;
|
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 {
|
.btn-tag-suggestion {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.15rem 0.55rem;
|
padding: 0.15rem 0.55rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
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 {
|
.btn-tag-suggestion.applied {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--color-primary);
|
||||||
color: var(--fs-accent-fg);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.link-suggest-strip {
|
.link-suggest-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.3rem 0.6rem;
|
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;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.link-suggest-label {
|
.link-suggest-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -669,15 +688,15 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
.btn-chip-link {
|
.btn-chip-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--fs-accent);
|
border: 1px solid var(--color-primary);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--fs-accent);
|
color: var(--color-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: nowrap;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import { RouterLink } from "vue-router";
|
|||||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||||
import KindBadge from "@/components/KindBadge.vue";
|
|
||||||
import type { TaskKind } from "@/types/note";
|
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
import { Trash2, X } from "lucide-vue-next";
|
||||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>();
|
const props = defineProps<{ projectId: number }>();
|
||||||
|
|
||||||
@@ -30,7 +27,6 @@ interface Task {
|
|||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
task_kind?: TaskKind;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks = ref<Task[]>([]);
|
const tasks = ref<Task[]>([]);
|
||||||
@@ -202,6 +198,20 @@ function cancelDeleteTask() {
|
|||||||
deleteConfirmPending.value = false;
|
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);
|
onMounted(loadAll);
|
||||||
defineExpose({ reload: loadAll });
|
defineExpose({ reload: loadAll });
|
||||||
</script>
|
</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>
|
<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 v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
<span 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 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>
|
||||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
||||||
</ul>
|
</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>
|
<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 v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
<span 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 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>
|
||||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -286,7 +294,7 @@ defineExpose({ reload: loadAll });
|
|||||||
<div v-if="activeTask" class="task-detail">
|
<div v-if="activeTask" class="task-detail">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||||
<span :class="['status-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("_", " ") }}
|
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||||
</span>
|
</span>
|
||||||
<template v-if="deleteConfirmPending">
|
<template v-if="deleteConfirmPending">
|
||||||
@@ -336,8 +344,8 @@ defineExpose({ reload: loadAll });
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-surface);
|
||||||
border-right: 1px solid var(--fs-border-color);
|
border-right: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── List view ── */
|
/* ── List view ── */
|
||||||
@@ -352,17 +360,17 @@ defineExpose({ reload: loadAll });
|
|||||||
flex: 0 0 44%;
|
flex: 0 0 44%;
|
||||||
}
|
}
|
||||||
.task-active {
|
.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 {
|
.panel-header {
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -373,20 +381,20 @@ defineExpose({ reload: loadAll });
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-add-input {
|
.task-add-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-input-bg, var(--color-bg));
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.28rem 0.5rem;
|
padding: 0.28rem 0.5rem;
|
||||||
font-size: 0.83rem;
|
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 */
|
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
||||||
|
|
||||||
@@ -396,7 +404,7 @@ defineExpose({ reload: loadAll });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ms-group {
|
.ms-group {
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ms-group-header {
|
.ms-group-header {
|
||||||
@@ -405,18 +413,18 @@ defineExpose({ reload: loadAll });
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.4rem 0.65rem;
|
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;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.8rem;
|
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-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 {
|
.ms-status {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
@@ -424,8 +432,8 @@ defineExpose({ reload: loadAll });
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-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(--fs-success) 15%, transparent); color: var(--fs-success-fg); }
|
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
|
||||||
|
|
||||||
.task-items {
|
.task-items {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -439,9 +447,9 @@ defineExpose({ reload: loadAll });
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
||||||
cursor: pointer;
|
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; }
|
.task-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -449,7 +457,7 @@ defineExpose({ reload: loadAll });
|
|||||||
width: 1.35rem;
|
width: 1.35rem;
|
||||||
height: 1.35rem;
|
height: 1.35rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1.5px solid var(--fs-border-color);
|
border: 1.5px solid var(--color-border);
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
@@ -457,8 +465,8 @@ defineExpose({ reload: loadAll });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
|
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
|
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
|
||||||
|
|
||||||
.task-title {
|
.task-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -466,20 +474,20 @@ defineExpose({ reload: loadAll });
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.task-age {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
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) ── */
|
/* ── Detail pane (bottom split) ── */
|
||||||
.task-detail {
|
.task-detail {
|
||||||
@@ -488,8 +496,8 @@ defineExpose({ reload: loadAll });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-surface);
|
||||||
border-top: 2px solid var(--fs-border-color);
|
border-top: 2px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
@@ -497,34 +505,31 @@ defineExpose({ reload: loadAll });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An interactive CYCLER, not a chip: it is clickable, outlined and
|
.status-badge {
|
||||||
transparent. It shared a name with the task chip and was never the same
|
|
||||||
shape (#3132). */
|
|
||||||
.status-cycler {
|
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.2rem 0.55rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1.5px solid var(--fs-border-color);
|
border: 1.5px solid var(--color-border);
|
||||||
background: none;
|
background: none;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin-left: auto;
|
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-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 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-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 { margin-left: 0.25rem; }
|
||||||
.btn-edit-task:hover { text-decoration: underline; }
|
.btn-edit-task:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.detail-body {
|
.detail-body {
|
||||||
padding: 0.5rem 0.75rem 0.5rem;
|
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;
|
flex-shrink: 0;
|
||||||
max-height: 40%;
|
max-height: 40%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -532,17 +537,17 @@ defineExpose({ reload: loadAll });
|
|||||||
|
|
||||||
.body-loading {
|
.body-loading {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-body .prose {
|
.detail-body .prose {
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--fs-text-primary);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete-task { margin-left: 0.25rem; }
|
.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; }
|
.btn-delete-confirm { margin-left: 0.25rem; }
|
||||||
|
|
||||||
@@ -558,9 +563,9 @@ defineExpose({ reload: loadAll });
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,20 +573,20 @@ defineExpose({ reload: loadAll });
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border);
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
}
|
}
|
||||||
.milestone-select:disabled { opacity: 0.5; cursor: default; }
|
.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 {
|
.detail-log {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 0.6rem 0.6rem;
|
padding: 0 0.6rem 0.6rem;
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail fade transition */
|
/* Detail fade transition */
|
||||||
@@ -604,12 +609,12 @@ defineExpose({ reload: loadAll });
|
|||||||
/* Due date on task rows */
|
/* Due date on task rows */
|
||||||
.task-due {
|
.task-due {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--color-text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.task-due.overdue {
|
.task-due.overdue {
|
||||||
color: var(--fs-error);
|
color: var(--color-danger, #e74c3c);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,19 +46,13 @@ watch(() => props.projectId, load);
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.plan-rules {
|
.plan-rules {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
.plan-rules h3 {
|
.plan-rules h3 {
|
||||||
font-size: 0.9em; opacity: 0.7;
|
font-size: 0.9em; opacity: 0.7;
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
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 h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
||||||
.rb h5 {
|
.rb h5 {
|
||||||
font-size: 0.8em; opacity: 0.7;
|
font-size: 0.8em; opacity: 0.7;
|
||||||
@@ -66,7 +60,7 @@ watch(() => props.projectId, load);
|
|||||||
}
|
}
|
||||||
.plan-rules ul {
|
.plan-rules ul {
|
||||||
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
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; }
|
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
||||||
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
||||||
|
|||||||
@@ -2,18 +2,10 @@
|
|||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
getProjectApplicableRules,
|
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||||
subscribeProject,
|
listRulebooks, getRule, createProjectRule, deleteRule,
|
||||||
unsubscribeProject,
|
suppressRuleForProject, unsuppressRuleForProject,
|
||||||
listRulebooks,
|
suppressTopicForProject, unsuppressTopicForProject,
|
||||||
getRule,
|
|
||||||
createProjectRule,
|
|
||||||
deleteRule,
|
|
||||||
suppressRuleForProject,
|
|
||||||
unsuppressRuleForProject,
|
|
||||||
suppressTopicForProject,
|
|
||||||
unsuppressTopicForProject,
|
|
||||||
includeAlwaysOnRulebook,
|
|
||||||
} from "@/api/rulebooks";
|
} from "@/api/rulebooks";
|
||||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
@@ -24,16 +16,10 @@ const allRulebooks = ref<Rulebook[]>([]);
|
|||||||
const showPicker = ref(false);
|
const showPicker = ref(false);
|
||||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
const expandedRuleIds = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const ruleDetails = ref<Record<number, {
|
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||||
why: string; how_to_apply: string;
|
|
||||||
verify_with: string; expires_when: string; verified_at: string | null;
|
|
||||||
}>>({});
|
|
||||||
|
|
||||||
const showProjectRuleForm = ref(false);
|
const showProjectRuleForm = ref(false);
|
||||||
const newProjectRule = ref({
|
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
||||||
title: "", statement: "", why: "", how_to_apply: "",
|
|
||||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||||
@@ -49,11 +35,6 @@ async function subscribe(rulebookId: number) {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function includeBack(rulebookId: number) {
|
|
||||||
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unsubscribe(rulebookId: number) {
|
async function unsubscribe(rulebookId: number) {
|
||||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||||
await unsubscribeProject(props.projectId, rulebookId);
|
await unsubscribeProject(props.projectId, rulebookId);
|
||||||
@@ -70,9 +51,6 @@ async function toggleRuleExpand(ruleId: number) {
|
|||||||
ruleDetails.value[ruleId] = {
|
ruleDetails.value[ruleId] = {
|
||||||
why: rule.why || "",
|
why: rule.why || "",
|
||||||
how_to_apply: rule.how_to_apply || "",
|
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);
|
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) {
|
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||||
const query: Record<string, string> = { rb: String(rulebookId) };
|
const query: Record<string, string> = { rb: String(rulebookId) };
|
||||||
if (ruleId) query.rule = String(ruleId);
|
if (ruleId) query.rule = String(ruleId);
|
||||||
@@ -104,20 +77,14 @@ interface RulebookGroup {
|
|||||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||||
const byRulebook = new Map<number, RulebookGroup>();
|
const byRulebook = new Map<number, RulebookGroup>();
|
||||||
for (const r of rules) {
|
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);
|
let rb = byRulebook.get(r.rulebook_id);
|
||||||
if (!rb) {
|
if (!rb) {
|
||||||
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||||
byRulebook.set(r.rulebook_id, rb);
|
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) {
|
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);
|
rb.topics.push(topic);
|
||||||
}
|
}
|
||||||
topic.rules.push(r);
|
topic.rules.push(r);
|
||||||
@@ -133,13 +100,8 @@ async function submitProjectRule() {
|
|||||||
title: newProjectRule.value.title.trim() || undefined,
|
title: newProjectRule.value.title.trim() || undefined,
|
||||||
why: newProjectRule.value.why.trim() || undefined,
|
why: newProjectRule.value.why.trim() || undefined,
|
||||||
how_to_apply: newProjectRule.value.how_to_apply.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 = {
|
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
||||||
title: "", statement: "", why: "", how_to_apply: "",
|
|
||||||
when_to_apply: "", tier: "always_on",
|
|
||||||
};
|
|
||||||
showProjectRuleForm.value = false;
|
showProjectRuleForm.value = false;
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
@@ -210,17 +172,6 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="project-rules">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>Project rules</h3>
|
<h3>Project rules</h3>
|
||||||
@@ -244,24 +195,6 @@ watch(() => props.projectId, load);
|
|||||||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||||
rows="2"
|
rows="2"
|
||||||
></textarea>
|
></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
|
<textarea
|
||||||
v-model="newProjectRule.why"
|
v-model="newProjectRule.why"
|
||||||
placeholder="Why (optional) — the rationale"
|
placeholder="Why (optional) — the rationale"
|
||||||
@@ -290,16 +223,6 @@ watch(() => props.projectId, load);
|
|||||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
</div>
|
</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>
|
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -353,13 +276,6 @@ watch(() => props.projectId, load);
|
|||||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
</div>
|
</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
|
<button
|
||||||
class="edit-link"
|
class="edit-link"
|
||||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||||
@@ -405,14 +321,6 @@ watch(() => props.projectId, load);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; }
|
.rules-tab { padding: 1rem; }
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
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; }
|
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
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;
|
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||||||
}
|
}
|
||||||
.chip a { cursor: pointer; }
|
.chip a { cursor: pointer; }
|
||||||
@@ -429,47 +337,38 @@ h3 {
|
|||||||
.chip-remove:hover { opacity: 1; }
|
.chip-remove:hover { opacity: 1; }
|
||||||
.add {
|
.add {
|
||||||
background: none;
|
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;
|
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
background: var(--fs-surface-page); color: inherit;
|
background: var(--color-bg, #111113); color: inherit;
|
||||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
.applicable { margin-top: 2rem; }
|
.applicable { margin-top: 2rem; }
|
||||||
.rb-group { margin-bottom: 1.5rem; }
|
.rb-group { margin-bottom: 1.5rem; }
|
||||||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.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 {
|
.topic-group h5 {
|
||||||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
ul { list-style: none; padding: 0; margin: 0; }
|
ul { list-style: none; padding: 0; margin: 0; }
|
||||||
.rule {
|
.rule {
|
||||||
border-left: 2px solid var(--fs-accent);
|
border-left: 2px solid var(--color-primary, #6366f1);
|
||||||
padding-left: 0.75rem; margin: 0.5rem 0;
|
padding-left: 0.75rem; margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.rule-head { cursor: pointer; }
|
.rule-head { cursor: pointer; }
|
||||||
.rule-title { font-weight: 500; }
|
.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-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||||
.rule-detail {
|
.rule-detail {
|
||||||
margin-top: 0.5rem; padding: 0.5rem;
|
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; }
|
.rule-detail > div { margin-bottom: 0.5rem; }
|
||||||
.edit-link {
|
.edit-link {
|
||||||
background: none; border: none; cursor: pointer;
|
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, .truncated { opacity: 0.7; font-style: italic; }
|
||||||
.empty a { cursor: pointer; text-decoration: underline; }
|
.empty a { cursor: pointer; text-decoration: underline; }
|
||||||
@@ -478,18 +377,18 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.new-rule-form {
|
.new-rule-form {
|
||||||
display: flex; flex-direction: column; gap: 0.5rem;
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
padding: 0.75rem; margin: 0.5rem 0;
|
padding: 0.75rem; margin: 0.5rem 0;
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg, #111113);
|
||||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
}
|
}
|
||||||
.new-rule-form input, .new-rule-form textarea {
|
.new-rule-form input, .new-rule-form textarea {
|
||||||
background: var(--fs-surface-hover); color: inherit;
|
background: var(--color-surface, #18181b); color: inherit;
|
||||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
padding: 0.5rem; font: inherit; resize: vertical;
|
padding: 0.5rem; font: inherit; resize: vertical;
|
||||||
}
|
}
|
||||||
.rule-list { margin-top: 0.5rem; }
|
.rule-list { margin-top: 0.5rem; }
|
||||||
.delete-link {
|
.delete-link {
|
||||||
background: none; border: none; cursor: pointer;
|
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 */
|
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||||
.topic-group h5 {
|
.topic-group h5 {
|
||||||
@@ -501,14 +400,14 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.rule-head-text { flex: 1; cursor: pointer; }
|
.rule-head-text { flex: 1; cursor: pointer; }
|
||||||
.skip-btn {
|
.skip-btn {
|
||||||
background: none; border: none; cursor: pointer;
|
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;
|
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.topic-group h5:hover .skip-btn,
|
.topic-group h5:hover .skip-btn,
|
||||||
.rule:hover .skip-btn,
|
.rule:hover .skip-btn,
|
||||||
.skip-btn:focus { opacity: 1; }
|
.skip-btn:focus { opacity: 1; }
|
||||||
.skip-btn:hover { color: var(--fs-destructive); }
|
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
||||||
/* Suppressed section */
|
/* Suppressed section */
|
||||||
.suppressed { margin-top: 1.5rem; }
|
.suppressed { margin-top: 1.5rem; }
|
||||||
.suppressed-toggle {
|
.suppressed-toggle {
|
||||||
@@ -527,13 +426,13 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.suppressed-kind {
|
.suppressed-kind {
|
||||||
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||||
background: var(--fs-surface-page);
|
background: var(--color-bg, #111113);
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--color-border, #2a2a2e);
|
||||||
}
|
}
|
||||||
.suppressed-path { flex: 1; }
|
.suppressed-path { flex: 1; }
|
||||||
.reenable-btn {
|
.reenable-btn {
|
||||||
background: none; border: none; cursor: pointer;
|
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; }
|
.reenable-btn:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,67 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted } from "vue";
|
import { ref, watch, onMounted } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
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 props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const canon = useCanonicalSystemsStore();
|
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const statement = ref("");
|
const statement = ref("");
|
||||||
const whenToApply = ref("");
|
|
||||||
const tier = ref<RuleTier>("always_on");
|
|
||||||
const systemIds = ref<number[]>([]);
|
|
||||||
const why = ref("");
|
const why = ref("");
|
||||||
const howToApply = 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);
|
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() {
|
async function load() {
|
||||||
if (props.ruleId !== null) {
|
if (props.ruleId !== null) {
|
||||||
await store.fetchRule(props.ruleId);
|
await store.fetchRule(props.ruleId);
|
||||||
@@ -69,26 +20,15 @@ async function load() {
|
|||||||
if (r) {
|
if (r) {
|
||||||
title.value = r.title;
|
title.value = r.title;
|
||||||
statement.value = r.statement;
|
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 || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
verifyWith.value = r.verify_with || "";
|
|
||||||
expiresWhen.value = r.expires_when || "";
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
title.value = "";
|
title.value = "";
|
||||||
statement.value = "";
|
statement.value = "";
|
||||||
whenToApply.value = "";
|
|
||||||
tier.value = "always_on";
|
|
||||||
systemIds.value = [];
|
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
verifyWith.value = "";
|
|
||||||
expiresWhen.value = "";
|
|
||||||
}
|
}
|
||||||
await canon.fetchCatalog();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -96,26 +36,16 @@ async function save() {
|
|||||||
emit("close");
|
emit("close");
|
||||||
return;
|
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) {
|
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) {
|
} 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");
|
emit("close");
|
||||||
}
|
}
|
||||||
@@ -147,108 +77,6 @@ watch(() => props.ruleId, load);
|
|||||||
Statement <span class="required">*</span>
|
Statement <span class="required">*</span>
|
||||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Why
|
Why
|
||||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||||
@@ -257,17 +85,6 @@ watch(() => props.ruleId, load);
|
|||||||
How to apply
|
How to apply
|
||||||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||||||
</label>
|
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -281,8 +98,8 @@ watch(() => props.ruleId, load);
|
|||||||
.slide-over {
|
.slide-over {
|
||||||
position: fixed; top: 0; right: 0; bottom: 0;
|
position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
width: min(520px, 90vw);
|
width: min(520px, 90vw);
|
||||||
background: var(--fs-surface-hover);
|
background: var(--color-surface, #18181b);
|
||||||
border-left: 2px solid var(--fs-accent);
|
border-left: 2px solid var(--color-primary, #6366f1);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||||
@@ -293,56 +110,14 @@ header h2 {
|
|||||||
font-family: Fraunces, serif; font-style: italic;
|
font-family: Fraunces, serif; font-style: italic;
|
||||||
}
|
}
|
||||||
label { display: block; margin-bottom: 1rem; }
|
label { display: block; margin-bottom: 1rem; }
|
||||||
.required { color: var(--fs-accent); }
|
.required { color: var(--color-primary, #6366f1); }
|
||||||
input, textarea {
|
input, textarea {
|
||||||
width: 100%; margin-top: 0.25rem;
|
width: 100%; margin-top: 0.25rem;
|
||||||
background: var(--fs-surface-page); color: inherit;
|
background: var(--color-bg, #111113); color: inherit;
|
||||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
padding: 0.5rem; font: inherit;
|
padding: 0.5rem; font: inherit;
|
||||||
font-family: 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, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||||
.trash:hover, .close:hover { opacity: 1; }
|
.trash:hover, .close:hover { opacity: 1; }
|
||||||
</style>
|
</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>
|
|
||||||
@@ -13,57 +13,28 @@ const emit = defineEmits<{
|
|||||||
<header><h2>Rules</h2></header>
|
<header><h2>Rules</h2></header>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||||
<div class="title">
|
<div class="title">{{ r.title }}</div>
|
||||||
{{ 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>
|
|
||||||
</div>
|
|
||||||
<div class="statement">{{ r.statement }}</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style src="@/assets/rules-shared.css" />
|
|
||||||
<style scoped>
|
<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; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li {
|
li {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border-left: 2px solid var(--fs-accent);
|
border-left: 2px solid var(--color-primary, #6366f1);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
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; }
|
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
.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; }
|
.new-rule { cursor: pointer; }
|
||||||
</style>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style src="@/assets/rules-shared.css" />
|
|
||||||
<style scoped>
|
<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 { 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 {
|
.always-on-toggle {
|
||||||
display: flex; align-items: center; gap: 0.4rem;
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
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; }
|
.always-on-toggle input { cursor: pointer; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--fs-accent-soft); }
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
li:hover { background: var(--fs-surface-hover); }
|
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||||
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
|
|
||||||
button-or-form whose children style themselves; the second is a `<ul>`, and
|
|
||||||
the bare `ul` rule above already gives it list-style, padding and margin —
|
|
||||||
a base a class-name check cannot see, since it comes from an element
|
|
||||||
selector. Both namespace descendant rules and assume nothing about layout. */
|
|
||||||
.new-topic input {
|
.new-topic input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
background: var(--fs-surface-page); color: inherit;
|
background: var(--color-bg, #111113); color: inherit;
|
||||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
.subscriptions {
|
.subscriptions {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
.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 { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import type { Rulebook } from "@/api/rulebooks";
|
import type { Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
||||||
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
const emit = defineEmits<{ select: [id: number] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const isCreating = ref(false);
|
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>
|
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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">
|
<div class="new-rulebook">
|
||||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||||
<form v-else @submit.prevent="submitNew">
|
<form v-else @submit.prevent="submitNew">
|
||||||
@@ -59,37 +47,30 @@ async function submitNew() {
|
|||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style src="@/assets/rules-shared.css" />
|
|
||||||
<style scoped>
|
<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; }
|
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 { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
li.active { background: var(--fs-accent-soft); }
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
li:hover { background: var(--fs-surface-hover); }
|
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||||
.always-on-badge {
|
.always-on-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--fs-accent);
|
background: var(--color-accent, rgba(91,74,138,0.25));
|
||||||
color: var(--fs-text-on-action);
|
color: var(--color-accent-fg, inherit);
|
||||||
margin-left: auto;
|
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 { margin-top: 1rem; }
|
||||||
.new-rulebook input {
|
.new-rulebook input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
background: var(--fs-surface-page); color: inherit;
|
background: var(--color-bg, #111113); color: inherit;
|
||||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ref, computed, watch, type Ref } from "vue";
|
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 { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import {
|
import {
|
||||||
@@ -10,16 +9,17 @@ import {
|
|||||||
export type AssistState = "idle" | "streaming" | "review";
|
export type AssistState = "idle" | "streaming" | "review";
|
||||||
export type ScopeMode = "document" | "section";
|
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 {
|
export interface AssistTarget {
|
||||||
text: string;
|
text: string;
|
||||||
startOffset: number;
|
startOffset: number;
|
||||||
endOffset: number;
|
endOffset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiffLine {
|
||||||
|
type: 'equal' | 'delete' | 'insert';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NoteDraft {
|
export interface NoteDraft {
|
||||||
id: number;
|
id: number;
|
||||||
note_id: number;
|
note_id: number;
|
||||||
@@ -31,6 +31,27 @@ export interface NoteDraft {
|
|||||||
updated_at: string;
|
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>) {
|
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|||||||
@@ -9,15 +9,3 @@ export function relativeTime(iso: string): string {
|
|||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
return `${days}d ago`;
|
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"),
|
component: () => import("@/views/RulesView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The design systems this install RECORDS — for the projects it tracks,
|
// Meta-surface, same family as /rules: it describes the app rather than
|
||||||
// not for the install itself. There was a sibling `/design` that read the
|
// holding the operator's records.
|
||||||
// running app's own stylesheet out of the browser; it could only ever
|
path: "/design",
|
||||||
// inspect the instance it was served from, which made it a mirror rather
|
name: "design",
|
||||||
// than a tool (#274).
|
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",
|
path: "/design-systems",
|
||||||
name: "design-systems",
|
name: "design-systems",
|
||||||
component: () => import("@/views/DesignSystemsView.vue"),
|
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;
|
project_id?: number | null;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
note_type?: string;
|
note_type?: string;
|
||||||
verify_with?: string;
|
|
||||||
expires_when?: string;
|
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Note>("/api/notes", data);
|
return await apiPost<Note>("/api/notes", data);
|
||||||
@@ -44,11 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function updateNote(
|
async function updateNote(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<Pick<
|
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||||
Note,
|
|
||||||
"title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type"
|
|
||||||
| "verify_with" | "expires_when"
|
|
||||||
>>
|
|
||||||
): Promise<Note> {
|
): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
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 topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
||||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||||
const currentRule = ref<Rule | null>(null);
|
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);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function fetchRulebooks() {
|
async function fetchRulebooks() {
|
||||||
@@ -40,7 +35,9 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
async function fetchRules(topicId: number) {
|
async function fetchRules(topicId: number) {
|
||||||
try {
|
try {
|
||||||
const rules = await api.listRules({ topic_id: topicId });
|
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) {
|
} catch (e) {
|
||||||
useToastStore().show("Failed to load rules", "error");
|
useToastStore().show("Failed to load rules", "error");
|
||||||
throw e;
|
throw e;
|
||||||
@@ -101,100 +98,24 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
delete rulesByTopic.value[id];
|
delete rulesByTopic.value[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) {
|
||||||
* 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 }) {
|
|
||||||
const rule = await api.createRule(topicId, data);
|
const rule = await api.createRule(topicId, data);
|
||||||
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
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;
|
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);
|
const rule = await api.updateRule(id, data);
|
||||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||||
const list = rulesByTopic.value[Number(tid)];
|
const list = rulesByTopic.value[Number(tid)];
|
||||||
const idx = list.findIndex((r) => r.id === id);
|
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;
|
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) {
|
async function deleteRule(id: number) {
|
||||||
await api.deleteRule(id);
|
await api.deleteRule(id);
|
||||||
if (currentRule.value?.id === id) currentRule.value = null;
|
if (currentRule.value?.id === id) currentRule.value = null;
|
||||||
@@ -204,11 +125,10 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
createRule, updateRule, deleteRule,
|
||||||
fetchRulesDue, verifyRule,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
|||||||
|
|
||||||
async function createSystem(
|
async function createSystem(
|
||||||
projectId: number,
|
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);
|
const system = await api.createSystem(projectId, data);
|
||||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
|
|
||||||
async function updateTask(
|
async function updateTask(
|
||||||
id: number,
|
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<
|
data: Partial<
|
||||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||||
> & IssueFields
|
> & IssueFields
|
||||||
|
|||||||
@@ -2,16 +2,7 @@ import type { System } from "@/api/systems";
|
|||||||
|
|
||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
/**
|
export type TaskKind = "work" | "plan" | "issue";
|
||||||
* 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 NoteType = "note" | "process" | "snippet";
|
export type NoteType = "note" | "process" | "snippet";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
@@ -19,6 +10,7 @@ export interface Note {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
consolidated_at: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
parent_title?: string | null;
|
parent_title?: string | null;
|
||||||
@@ -34,15 +26,6 @@ export interface Note {
|
|||||||
is_task: boolean;
|
is_task: boolean;
|
||||||
note_type: NoteType;
|
note_type: NoteType;
|
||||||
task_kind?: TaskKind;
|
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[];
|
systems?: System[];
|
||||||
arose_from_id?: number | null;
|
arose_from_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -1,32 +1,65 @@
|
|||||||
|
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
|
||||||
|
|
||||||
|
function _isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "9:30 AM" */
|
||||||
|
export function fmtTime(dt: string): string {
|
||||||
|
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
|
||||||
|
export function fmtDateTime(dt: string, allDay: boolean): string {
|
||||||
|
const d = new Date(dt)
|
||||||
|
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
||||||
|
if (allDay) return datePart
|
||||||
|
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared date/time formatting — one rule per display shape. Views import
|
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
|
||||||
* these instead of carrying a local formatDate(): the 2026-08 shape audit
|
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||||
* found eight copies across views/components, three of them byte-identical.
|
|
||||||
* (The previous Calendar/Home helpers in this file had no callers left and
|
|
||||||
* were removed in the same pass.)
|
|
||||||
*
|
|
||||||
* Relative forms ("5m ago") live next door in composables/useRelativeTime.
|
|
||||||
*/
|
*/
|
||||||
|
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(dt)
|
||||||
|
const now = new Date()
|
||||||
|
const tomorrow = new Date(now)
|
||||||
|
tomorrow.setDate(now.getDate() + 1)
|
||||||
|
|
||||||
/** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
|
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
||||||
export function fmtDate(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
if (_isSameDay(d, now)) return `Today${timeStr}`
|
||||||
year: "numeric", month: "short", day: "numeric",
|
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
|
||||||
});
|
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
|
||||||
|
} catch {
|
||||||
|
return dt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Jan 15, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
|
/**
|
||||||
export function fmtStamp(iso: string): string {
|
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
*/
|
||||||
month: "short", day: "numeric", year: "numeric",
|
export function fmtDayLabel(dt: string): string {
|
||||||
hour: "2-digit", minute: "2-digit",
|
try {
|
||||||
});
|
const d = new Date(dt)
|
||||||
|
const now = new Date()
|
||||||
|
const tomorrow = new Date(now)
|
||||||
|
tomorrow.setDate(now.getDate() + 1)
|
||||||
|
if (_isSameDay(d, now)) return "Today"
|
||||||
|
if (_isSameDay(d, tomorrow)) return "Tomorrow"
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
||||||
|
} catch {
|
||||||
|
return dt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
|
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
|
||||||
export function fmtLogStamp(iso: string): string {
|
export function fmtCompact(dt: string, allDay: boolean): string {
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
const d = new Date(dt)
|
||||||
month: "short", day: "numeric",
|
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
||||||
|
*
|
||||||
|
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
||||||
|
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
||||||
|
* can assert on it. What's left here is set arithmetic over live token values,
|
||||||
|
* which is the one thing the browser knows and the server doesn't.
|
||||||
|
*
|
||||||
|
* SCOPE, and it is a real limit rather than an omission. This compares the
|
||||||
|
* rulebook against the TOKENS. It cannot see the third category of drift — a
|
||||||
|
* literal hardcoded in a component where a token should be referenced (#2275,
|
||||||
|
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
||||||
|
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
||||||
|
*
|
||||||
|
* Catching it needs the component sources, which would mean bundling every SFC
|
||||||
|
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
|
||||||
|
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
|
||||||
|
* matters: a drift report that silently omits a category invites the reader to
|
||||||
|
* conclude the category is clean.
|
||||||
|
*/
|
||||||
|
import type { DesignToken } from "@/utils/designTokens";
|
||||||
|
|
||||||
|
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
||||||
|
|
||||||
|
export interface Expectation {
|
||||||
|
kind: ExpectationKind;
|
||||||
|
value: string;
|
||||||
|
rule_id: number;
|
||||||
|
rule_title: string;
|
||||||
|
context: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpectationResponse {
|
||||||
|
rulebook_id: number | null;
|
||||||
|
expectations: Expectation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FindingStatus = "ok" | "missing" | "violated";
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
expectation: Expectation;
|
||||||
|
status: FindingStatus;
|
||||||
|
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
||||||
|
matches: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a colour for comparison — the client-side twin of
|
||||||
|
* `normalize_hex` in services/design_system.py.
|
||||||
|
*
|
||||||
|
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
||||||
|
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
||||||
|
* spellings of one colour, and a comparison that misses any of them under-reports
|
||||||
|
* rather than erroring. The rgb() case is browser-specific and therefore has no
|
||||||
|
* server-side counterpart, which is exactly why it is handled here.
|
||||||
|
*/
|
||||||
|
export function normalizeColour(value: string): string | null {
|
||||||
|
const raw = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
||||||
|
if (hex) {
|
||||||
|
let digits = hex[1];
|
||||||
|
if (digits.length === 3 || digits.length === 4) {
|
||||||
|
digits = digits.split("").map((c) => c + c).join("");
|
||||||
|
}
|
||||||
|
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
|
||||||
|
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||||
|
if (rgb) {
|
||||||
|
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const channels = parts.slice(0, 3).map((p) => Number(p));
|
||||||
|
if (channels.some((n) => !Number.isFinite(n))) return null;
|
||||||
|
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
||||||
|
const base = `#${channels.map(hexOf).join("")}`;
|
||||||
|
if (parts.length === 3) return base;
|
||||||
|
const alpha = Number(parts[3]);
|
||||||
|
if (!Number.isFinite(alpha) || alpha >= 1) return base;
|
||||||
|
return `${base}${hexOf(alpha * 255)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
||||||
|
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
||||||
|
const index = new Map<string, string[]>();
|
||||||
|
for (const token of tokens) {
|
||||||
|
const colour = normalizeColour(token.value);
|
||||||
|
if (!colour) continue;
|
||||||
|
const names = index.get(colour);
|
||||||
|
if (names) names.push(token.name);
|
||||||
|
else index.set(colour, [token.name]);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare claims against the live tokens.
|
||||||
|
*
|
||||||
|
* A `token` claim asks whether a custom property of that name exists.
|
||||||
|
* A `color` claim asks whether any token resolves to that value.
|
||||||
|
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
||||||
|
*/
|
||||||
|
export function compareToTokens(
|
||||||
|
expectations: Expectation[],
|
||||||
|
tokens: DesignToken[],
|
||||||
|
): Finding[] {
|
||||||
|
const names = new Set(tokens.map((t) => t.name));
|
||||||
|
const colours = colourIndex(tokens);
|
||||||
|
|
||||||
|
return expectations.map((expectation) => {
|
||||||
|
if (expectation.kind === "token") {
|
||||||
|
const present = names.has(expectation.value);
|
||||||
|
return {
|
||||||
|
expectation,
|
||||||
|
status: present ? "ok" : "missing",
|
||||||
|
matches: present ? [expectation.value] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = colours.get(expectation.value) ?? [];
|
||||||
|
if (expectation.kind === "prohibited_color") {
|
||||||
|
return {
|
||||||
|
expectation,
|
||||||
|
status: matches.length ? "violated" : "ok",
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
expectation,
|
||||||
|
status: matches.length ? "ok" : "missing",
|
||||||
|
matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriftSummary {
|
||||||
|
ok: number;
|
||||||
|
missing: number;
|
||||||
|
violated: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarise(findings: Finding[]): DriftSummary {
|
||||||
|
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
||||||
|
for (const finding of findings) summary[finding.status] += 1;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findings worth leading with.
|
||||||
|
*
|
||||||
|
* A panel that opens with every row gets closed and never reopened — the same
|
||||||
|
* principle the auto-inject menu is built on: a short list that gets read beats
|
||||||
|
* a complete one that doesn't. Violations first (something is actively wrong),
|
||||||
|
* then missing (something was never built), and `ok` rows are not "findings" at
|
||||||
|
* all — they belong behind an expansion.
|
||||||
|
*/
|
||||||
|
export function rankFindings(findings: Finding[]): Finding[] {
|
||||||
|
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
||||||
|
return [...findings].sort((a, b) => {
|
||||||
|
const byStatus = order[a.status] - order[b.status];
|
||||||
|
if (byStatus !== 0) return byStatus;
|
||||||
|
return a.expectation.rule_id - b.expectation.rule_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||||
|
*
|
||||||
|
* Foundation for the design explorer (milestone #251): the gallery renders
|
||||||
|
* against these, and the drift panel compares them to the design rulebook.
|
||||||
|
*
|
||||||
|
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||||
|
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||||
|
* its VALUE is not: values contain nested parens, commas inside rgba(),
|
||||||
|
* `var()` references to other tokens, multi-part shadows, and gradients — and
|
||||||
|
* `theme.css` has all of those today. So we take the names from the source and
|
||||||
|
* ask the BROWSER for every value.
|
||||||
|
*
|
||||||
|
* That is not just easier, it is more correct. getComputedStyle reports what
|
||||||
|
* actually won the cascade, resolves `var()` chains, and — critically for this
|
||||||
|
* milestone — reflects live overrides set on a container, which is exactly what
|
||||||
|
* the preview surface needs (see #2261). Parsing the source would report what
|
||||||
|
* the file says rather than what the user is looking at.
|
||||||
|
*
|
||||||
|
* It also means this module needs no unit tests to be trustworthy: the only
|
||||||
|
* logic here is a name regex and a group lookup. The frontend has no test
|
||||||
|
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
|
||||||
|
* error-prone half in the browser rather than in our code is deliberate.
|
||||||
|
*/
|
||||||
|
import themeCss from "@/assets/theme.css?raw";
|
||||||
|
|
||||||
|
export type TokenGroup =
|
||||||
|
| "color"
|
||||||
|
| "radius"
|
||||||
|
| "gradient"
|
||||||
|
| "glow"
|
||||||
|
| "focus"
|
||||||
|
| "layout"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
export interface DesignToken {
|
||||||
|
/** Full custom-property name, including the leading `--`. */
|
||||||
|
name: string;
|
||||||
|
/** Coarse family, derived from the name prefix. */
|
||||||
|
group: TokenGroup;
|
||||||
|
/** Resolved value in the requested context, straight from the browser. */
|
||||||
|
value: string;
|
||||||
|
/** True when the declaration appears inside the dark block in source. */
|
||||||
|
overriddenInDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
|
||||||
|
*
|
||||||
|
* The discriminator is the COLON, not the preceding character. A declaration is
|
||||||
|
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
|
||||||
|
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
|
||||||
|
* actively wrong: an earlier version required the match to follow `{` or `;`,
|
||||||
|
* which silently dropped every declaration that came after a comment —
|
||||||
|
* including `--color-bg`, the first and most-used token in the file.
|
||||||
|
*/
|
||||||
|
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
||||||
|
|
||||||
|
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||||
|
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||||
|
|
||||||
|
/** The dark block's selector, as written in theme.css. */
|
||||||
|
const DARK_SELECTOR = '[data-theme="dark"]';
|
||||||
|
|
||||||
|
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||||
|
["--color-", "color"],
|
||||||
|
["--radius-", "radius"],
|
||||||
|
["--gradient-", "gradient"],
|
||||||
|
["--glow-", "glow"],
|
||||||
|
["--focus-", "focus"],
|
||||||
|
["--page-", "layout"],
|
||||||
|
["--sidebar-", "layout"],
|
||||||
|
["--chat-", "layout"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function groupFor(name: string): TokenGroup {
|
||||||
|
for (const [prefix, group] of GROUP_PREFIXES) {
|
||||||
|
if (name.startsWith(prefix)) return group;
|
||||||
|
}
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
|
||||||
|
export function tokenNames(css: string = themeCss): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
|
||||||
|
const name = match[1];
|
||||||
|
if (!seen.has(name)) {
|
||||||
|
seen.add(name);
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
||||||
|
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
||||||
|
const bare = css.replace(COMMENT, "");
|
||||||
|
const start = bare.indexOf(DARK_SELECTOR);
|
||||||
|
if (start === -1) return new Set();
|
||||||
|
const open = bare.indexOf("{", start);
|
||||||
|
const close = bare.indexOf("}", open);
|
||||||
|
if (open === -1 || close === -1) return new Set();
|
||||||
|
return new Set(tokenNames(bare.slice(open, close)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the resolved value of every token in `host`'s context.
|
||||||
|
*
|
||||||
|
* Pass a container to read the tokens as they apply INSIDE it — which is how
|
||||||
|
* the preview surface reads a scoped override without disturbing the page.
|
||||||
|
* Defaults to the document root, i.e. the app-wide values.
|
||||||
|
*/
|
||||||
|
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||||
|
const computed = getComputedStyle(host);
|
||||||
|
const dark = darkOverriddenNames();
|
||||||
|
return tokenNames().map((name) => ({
|
||||||
|
name,
|
||||||
|
group: groupFor(name),
|
||||||
|
value: computed.getPropertyValue(name).trim(),
|
||||||
|
overriddenInDark: dark.has(name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read tokens as they would resolve in a given mode, without touching the page.
|
||||||
|
*
|
||||||
|
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
|
||||||
|
* mutated to take a reading.
|
||||||
|
*
|
||||||
|
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
||||||
|
* function: light is declared on `:root` while dark is declared on
|
||||||
|
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
||||||
|
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
||||||
|
* back. So reading "light" from inside a dark page returns the dark values —
|
||||||
|
* the probe has nothing to match.
|
||||||
|
*
|
||||||
|
* Concretely: dark-inside-light previews work, light-inside-dark previews do
|
||||||
|
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
||||||
|
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
||||||
|
* should treat a cross-mode read as best-effort.
|
||||||
|
*/
|
||||||
|
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.setAttribute("data-theme", mode);
|
||||||
|
probe.style.display = "none";
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
try {
|
||||||
|
return readTokens(probe);
|
||||||
|
} finally {
|
||||||
|
probe.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tokens grouped by family, preserving source order within each group. */
|
||||||
|
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
|
||||||
|
const out = new Map<TokenGroup, DesignToken[]>();
|
||||||
|
for (const token of tokens) {
|
||||||
|
const bucket = out.get(token.group);
|
||||||
|
if (bucket) bucket.push(token);
|
||||||
|
else out.set(token.group, [token]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens declared in the stylesheet that nothing references with `var()`.
|
||||||
|
*
|
||||||
|
* Dead tokens are drift too: `--chat-reading-width` and
|
||||||
|
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
|
||||||
|
* in the MCP-first pivot, and nothing has referenced them since. Takes the
|
||||||
|
* corpus of source files to search as an argument so the caller decides what
|
||||||
|
* "used" means — this module has no opinion about the project layout.
|
||||||
|
*/
|
||||||
|
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
|
||||||
|
const haystack = sources.join("\n");
|
||||||
|
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Turning a design system's RECORDED values into ones you can look at.
|
|
||||||
*
|
|
||||||
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
|
|
||||||
* running app's own stylesheet — names out of a bundled `theme.css`, values out
|
|
||||||
* of `getComputedStyle(document.documentElement)`. That could only ever describe
|
|
||||||
* the install serving the page, and the design surface is for the projects an
|
|
||||||
* install TRACKS (#274). What is left here works on any system's record,
|
|
||||||
* including one for an app this browser has never loaded.
|
|
||||||
*
|
|
||||||
* Nothing in this module reads the document's own tokens or mutates the page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
|
|
||||||
export const BASE_MODE = "base";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which declared value applies in `mode`.
|
|
||||||
*
|
|
||||||
* Falls back to base, which is the storage model rather than a convenience: a
|
|
||||||
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
|
||||||
* mode is not missing — it is inheriting, exactly as the generated sheet has it.
|
|
||||||
*/
|
|
||||||
export function valueForMode(
|
|
||||||
valueByMode: Record<string, string>,
|
|
||||||
mode: string,
|
|
||||||
): string {
|
|
||||||
const own = valueByMode[mode];
|
|
||||||
if (own !== undefined && own !== "") return own;
|
|
||||||
return valueByMode[BASE_MODE] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every mode any token in the set declares, base first then the rest by name. */
|
|
||||||
export function modesPresent(
|
|
||||||
tokens: { value_by_mode: Record<string, string> }[],
|
|
||||||
): string[] {
|
|
||||||
const modes = new Set<string>();
|
|
||||||
for (const token of tokens) {
|
|
||||||
for (const [mode, value] of Object.entries(token.value_by_mode)) {
|
|
||||||
if (value) modes.add(mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
|
|
||||||
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve declared values the way a browser would, without applying them.
|
|
||||||
*
|
|
||||||
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
|
|
||||||
* as text that is a string; shown as a swatch it needs `var()` substituted and
|
|
||||||
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
|
|
||||||
* offscreen probe and read them back — the substitution is done by the
|
|
||||||
* implementation that would do it for real.
|
|
||||||
*
|
|
||||||
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
|
|
||||||
* sitting in this page would resolve any reference the record leaves undeclared
|
|
||||||
* against the surrounding app's own tokens. Previewing another project's system
|
|
||||||
* would then quietly borrow this one's palette wherever that system was
|
|
||||||
* incomplete, and a token the record already knows is broken (it shows up under
|
|
||||||
* `unknown_refs`) would render as though it were fine.
|
|
||||||
*
|
|
||||||
* So every name referenced but not declared is blanked on the probe first. It
|
|
||||||
* resolves to nothing, which is what the record says it is.
|
|
||||||
*/
|
|
||||||
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
|
||||||
|
|
||||||
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
|
|
||||||
const probe = document.createElement("div");
|
|
||||||
probe.style.display = "none";
|
|
||||||
for (const value of declared.values()) {
|
|
||||||
for (const match of value.matchAll(VAR_REFERENCE)) {
|
|
||||||
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [name, value] of declared) probe.style.setProperty(name, value);
|
|
||||||
document.body.appendChild(probe);
|
|
||||||
try {
|
|
||||||
const computed = getComputedStyle(probe);
|
|
||||||
const out = new Map<string, string>();
|
|
||||||
for (const name of declared.keys()) {
|
|
||||||
out.set(name, computed.getPropertyValue(name).trim());
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
} finally {
|
|
||||||
probe.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* Line diff — one copy, for every surface that shows what changed.
|
|
||||||
*
|
|
||||||
* WHY THIS FILE EXISTS. The same LCS walk was written out three times:
|
|
||||||
* privately in `useAssist.ts`, and again inside `HistoryPanel.vue` and
|
|
||||||
* `VersionHistorySection.vue`. The three were character-identical apart from
|
|
||||||
* quote style — nobody had diverged them on purpose, they were simply copied
|
|
||||||
* because `computeDiff` was never exported. Milestone 323 needed a fourth
|
|
||||||
* consumer (a rule's edit history), and a fourth copy is the cost #3207
|
|
||||||
* records: a fix or an improvement now has to be found in N places by someone
|
|
||||||
* who does not know N.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DiffLine {
|
|
||||||
type: "equal" | "delete" | "insert";
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Diff `a` against `b`, line by line.
|
|
||||||
*
|
|
||||||
* `delete` lines come from `a`, `insert` lines from `b` — so the caller
|
|
||||||
* decides which side reads as "before" by which argument it passes. Every
|
|
||||||
* caller here passes the CURRENT text as `a` and the older text as `b`, so a
|
|
||||||
* deletion is what the old version had and an insertion is what replaced it.
|
|
||||||
*
|
|
||||||
* O(m·n) in time and memory: fine for a note or a rule, and deliberately not
|
|
||||||
* generalised further, since nothing here diffs a file of thousands of lines.
|
|
||||||
*/
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/** Cyclic color palette for milestone progress bars. */
|
/** Cyclic color palette for milestone progress bars. */
|
||||||
export const MILESTONE_PALETTE = [
|
export const MILESTONE_PALETTE = [
|
||||||
'var(--fs-accent)',
|
'var(--color-primary)',
|
||||||
'var(--fs-success, #22c55e)',
|
'var(--color-success, #22c55e)',
|
||||||
'#c98a00',
|
'#c98a00',
|
||||||
'#8b5cf6',
|
'#8b5cf6',
|
||||||
'var(--fs-error, #ef4444)',
|
'var(--color-danger, #ef4444)',
|
||||||
'#06b6d4',
|
'#06b6d4',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user