Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0caf7d23a | ||
|
|
dc2f32cc6f | ||
|
|
10c63f49d8 | ||
|
|
00c7badc3f | ||
|
|
c7a58bb610 | ||
|
|
227aef3dbf | ||
|
|
34734bf84a | ||
|
|
ff5f6438c4 | ||
|
|
e9b8f525c8 | ||
|
|
5415bff85c | ||
|
|
aba16583ab | ||
|
|
f0c915a6bc | ||
|
|
6a0f8ad328 | ||
|
|
d4c7b0e48d | ||
|
|
bfe5a461b4 | ||
|
|
3849c6fff3 | ||
|
|
1a8e5787e8 | ||
|
|
d01201539b | ||
|
|
1209e1c2d9 | ||
|
|
57d68c9355 | ||
|
|
1126bbe84f | ||
|
|
1ab614bfbe | ||
|
|
9abc4443fb | ||
|
|
3a4031d7f8 | ||
|
|
141246ac2c | ||
|
|
520381e22b | ||
|
|
58c074a324 | ||
|
|
2a6c55dacb | ||
|
|
7d48eb0b1b | ||
|
|
92e38ff17b | ||
|
|
64c641ce80 | ||
|
|
c211e12b61 | ||
|
|
b0eda32575 | ||
|
|
848ce1592e | ||
|
|
77bb3729a3 | ||
|
|
ac6f248bf9 | ||
|
|
bbee0d0db1 | ||
|
|
ac1c2625af | ||
|
|
dad56a51bd | ||
|
|
79f0283608 | ||
|
|
efc650390e | ||
|
|
d74a244b3a | ||
|
|
386b27e422 | ||
|
|
ba0030e51d | ||
|
|
a9e1cddba7 | ||
|
|
475f0857c9 | ||
|
|
9d5b89d785 | ||
|
|
3e21978a9c | ||
|
|
e2a084f1fb | ||
|
|
fd101d24ae | ||
|
|
9f1a52a035 | ||
|
|
5265d11a6a | ||
|
|
d50ebbad66 | ||
|
|
942edd1eb5 | ||
|
|
9d92df2825 | ||
|
|
9b1597a3c9 | ||
|
|
19fdc9aa89 | ||
|
|
c1cb8fb183 | ||
|
|
1faf8f3ece | ||
|
|
5c14c2621c | ||
|
|
7a5e2b18d9 | ||
|
|
d6c9f08a59 | ||
|
|
25411cf223 | ||
|
|
3162332a13 | ||
|
|
9f57cfad71 | ||
|
|
765635bbf2 | ||
|
|
cbccb6bd5d | ||
|
|
89b07f7857 | ||
|
|
eb760eb440 | ||
|
|
2fce57847b | ||
|
|
13e428c596 | ||
|
|
7d26a3fc6a | ||
|
|
1e7f66e72d | ||
|
|
8407368c0c | ||
|
|
2d58e74ec7 | ||
|
|
e05a660471 | ||
|
|
4107b17727 | ||
|
|
fb757fb4ba | ||
|
|
77acee9239 | ||
|
|
5503580875 | ||
|
|
5cb7cfe706 | ||
|
|
9c68faa0bd | ||
|
|
66c4fd1b88 | ||
|
|
266d8b8117 | ||
|
|
75bdb9c148 | ||
|
|
3455f9cb9a | ||
|
|
56b1952de5 | ||
|
|
e80f2f233b | ||
|
|
3ff8803593 | ||
|
|
854aae252a | ||
|
|
272b7dbddf | ||
|
|
063494094a | ||
|
|
23cb396e65 | ||
|
|
041d8defbc | ||
|
|
0e70a3896b | ||
|
|
6b5043a69c | ||
|
|
875f6a7b5e | ||
|
|
4ba544e2af | ||
|
|
2d1e26f38f | ||
|
|
9b3874b657 | ||
|
|
45ba4aab25 | ||
|
|
f11a547cd2 | ||
|
|
baa2492053 | ||
|
|
52bf40de4f | ||
|
|
d7039dc17c | ||
|
|
914b701a50 | ||
|
|
3f1523b19f | ||
|
|
6c4c1bccfc | ||
|
|
f20c019f2a | ||
|
|
984407f931 | ||
|
|
8d9e96cc6d | ||
|
|
5dcb738ce8 | ||
|
|
45c6b1c88a | ||
|
|
bbba0b3ae3 | ||
|
|
7defc6897c | ||
|
|
3f26aa9485 | ||
|
|
fd7097c6b7 | ||
|
|
24d071619b | ||
|
|
c18139622c | ||
|
|
ac1ce0a7f0 | ||
|
|
ffd08507f1 | ||
|
|
63c213b617 | ||
|
|
07bf58de46 | ||
|
|
11c243c0fa | ||
|
|
a6d6550483 | ||
|
|
46271ccaa7 | ||
|
|
6ac821178f | ||
|
|
4a9744172f | ||
|
|
d8dd017994 | ||
|
|
8087ba4db0 | ||
|
|
7b0984579d | ||
|
|
dcd4efcea0 | ||
|
|
4d2be27935 | ||
|
|
5b824c1626 | ||
|
|
841506b10c | ||
|
|
c34454b840 | ||
|
|
bd60d679d9 | ||
|
|
174ec8af46 | ||
|
|
4852b0d3df | ||
|
|
22f907c44d |
@@ -178,6 +178,15 @@ jobs:
|
||||
- name: Design token check
|
||||
run: python3 scripts/check_design_tokens.py --report-literals
|
||||
|
||||
# Dangling styles: an element whose classes have only modifier rules and
|
||||
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
|
||||
# this way (a link rendering as raw browser blue, a flex row whose parent
|
||||
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
|
||||
# style typechecks perfectly. Reported, not gated — a bare wrapper is
|
||||
# legitimate, so the signal is the count growing.
|
||||
- name: Dangling style check
|
||||
run: python3 scripts/check_dangling_styles.py
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -216,6 +225,16 @@ jobs:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
|
||||
# The hook-EXECUTION tests (test_write_path_trigger's nudge pair) run the
|
||||
# real bash hook, which exits silently without jq — and those tests skip
|
||||
# rather than fail when it's absent, so without this step they would
|
||||
# quietly never be verified anywhere (ci-python ships without jq; same
|
||||
# install the Plugin hooks job does).
|
||||
- name: Install jq for hook execution tests
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends jq
|
||||
|
||||
- name: Run tests
|
||||
# Integration tests (real Postgres) run in the `integration` job below.
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||
|
||||
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
|
||||
|
||||
## Features
|
||||
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, 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 (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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""retire the two settings that designated a design source for the app itself
|
||||
|
||||
Revision ID: 0075
|
||||
Revises: 0074
|
||||
Create Date: 2026-08-03
|
||||
|
||||
Two keys, retired for the same reason a week apart, so they go in one change
|
||||
rather than one migration each:
|
||||
|
||||
design_rulebook_id which rulebook described how this app should look
|
||||
ui_design_system_id which design system this app's own UI was built from
|
||||
|
||||
Both named a design source for THE RUNNING INSTALL. The design surface is for
|
||||
the projects an install tracks, and a project already carries its own pointer
|
||||
(`projects.design_system_id`) — so an install-wide designation had nothing left
|
||||
to mean. `ui_design_system_id` was introduced by this same migration's first
|
||||
draft and never reached a deployed database; it is listed here rather than
|
||||
undone by an 0076 that would reverse a change nobody ran.
|
||||
|
||||
Deleting settings rows by key is safe in a way dropping a column is not — the
|
||||
table is free-form key/value, so an install that never designated one simply has
|
||||
no row to delete.
|
||||
|
||||
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
|
||||
lie: the pointer lives on the project now, and always did for anyone who set it
|
||||
there.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0075"
|
||||
down_revision = "0074"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM settings "
|
||||
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,115 @@
|
||||
"""note_supersessions; drop the never-written notes.consolidated_at
|
||||
|
||||
Revision ID: 0076
|
||||
Revises: 0075
|
||||
Create Date: 2026-08-07
|
||||
|
||||
Step 1 of milestone #278. Structure only — nothing reads or writes the new
|
||||
table yet, and nothing behaves differently after this runs.
|
||||
|
||||
## What the table is for
|
||||
|
||||
Old records outrank newer ones on the same subject, because a similarity score
|
||||
cannot tell time. A note that accurately described how something worked in June
|
||||
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
|
||||
that, so nothing could act on it.
|
||||
|
||||
The claim points FORWARD — the newer record names what it overtakes — because
|
||||
the older one cannot know it has been overtaken. Many-to-many and partial: a
|
||||
note may supersede parts of several others and be overtaken piecemeal by
|
||||
several later ones, which is why this is a table rather than a column. Both
|
||||
directions are queried: `superseded_id` answers "has this been overtaken?" at
|
||||
ranking time, `superseder_id` answers "what does this replace?" in a record
|
||||
view. An array column could serve one and not the other.
|
||||
|
||||
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
|
||||
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
|
||||
them back. The cascade fires only on `purge_trash`, where the row genuinely
|
||||
goes — and a claim about a row that no longer exists is not actionable.
|
||||
|
||||
## What is being dropped, and why now
|
||||
|
||||
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
|
||||
— while being serialised into every note and task payload as `null`. It cost a
|
||||
column, a line in every response, and worse: it IMPLIED a capability. A reader
|
||||
reasonably concludes notes can be consolidated and this records when.
|
||||
|
||||
That reading was reasonable precisely because merge/unmerge exists for snippets
|
||||
and not for notes, so the column looked like the notes-side half of that
|
||||
feature, modelled and abandoned.
|
||||
|
||||
It is dropped rather than repurposed for supersession, and the distinction is
|
||||
the point (#2483): consolidation folds several records into one survivor and
|
||||
destroys the originals. Merging two snippets is lossless — one helper, several
|
||||
call sites. Folding two dev-logs means writing a summary and losing what each
|
||||
actually said. Supersession is the opposite act: both records survive, and the
|
||||
older one is merely ranked behind. Smuggling one in under a column named for
|
||||
the other would have buried that difference in schema.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
|
||||
restores the shape, not the (nonexistent) data. Drops the table; any recorded
|
||||
supersession claims are lost, which costs ranking its input and nothing else,
|
||||
since no note's own content depends on them.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0076"
|
||||
down_revision = "0075"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"note_supersessions",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"superseder_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"superseded_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
|
||||
),
|
||||
# Declaring that a note supersedes ITSELF is meaningless, and under flat
|
||||
# demotion it would demote a record on its own authority. Refused in the
|
||||
# service too, with a message — this is the backstop that holds when
|
||||
# something writes rows directly.
|
||||
sa.CheckConstraint(
|
||||
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
|
||||
)
|
||||
|
||||
op.drop_column("notes", "consolidated_at")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
|
||||
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
|
||||
op.drop_table("note_supersessions")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Chunked embeddings: one note_embeddings row per chunk (#280)
|
||||
|
||||
Revision ID: 0077
|
||||
Revises: 0076
|
||||
Create Date: 2026-08-09
|
||||
|
||||
The embedding model reads at most 512 tokens and fastembed truncates the rest
|
||||
silently, so the old one-row-per-note shape permanently lost everything past
|
||||
~400 words of a record. A note now stores one row per chunk of
|
||||
`embeddings.chunk_document`: PK (note_id, chunk_index), plus the chunk's text
|
||||
(inspectability + future "matched section" surfacing) and the chunker version
|
||||
that produced it (so later shape changes re-embed by version comparison
|
||||
instead of repeating this wipe).
|
||||
|
||||
Embeddings are DERIVED data (0067 precedent): rows are cleared here and the
|
||||
startup backfill regenerates the whole corpus at the new shape on next boot.
|
||||
The HNSW index is untouched — it indexes chunk rows exactly as it indexed
|
||||
note rows.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0077"
|
||||
down_revision = "0076"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Derived data — the version-aware startup backfill re-embeds everything
|
||||
# at the chunked shape. Old whole-document rows would be indistinguishable
|
||||
# from properly-chunked single-chunk notes, so they cannot be carried over.
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
|
||||
# Empty table, so NOT NULL columns need no defaults and the PK swap is
|
||||
# instant.
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_index integer NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_text text NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunker_version integer NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
|
||||
op.execute(
|
||||
"ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id, chunk_index)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Same reasoning in reverse: chunk rows make no sense to a whole-document
|
||||
# reader, so clear and let the old backfill regenerate.
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_index")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_text")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunker_version")
|
||||
op.execute("ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id)")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Forge connections move to the user level (#2778)
|
||||
|
||||
Revision ID: 0078
|
||||
Revises: 0077
|
||||
Create Date: 2026-08-19
|
||||
|
||||
A forge token is a user's credential, not an instance's: the single
|
||||
admin-settings config meant every user's snippet-freshness and coverage reads
|
||||
ran under the operator's token. Each user now owns a keyring of connections —
|
||||
one per forge host — and projects resolve forge reads on their OWNER's
|
||||
keyring, with an optional per-project pin (projects.forge_connection_id).
|
||||
|
||||
The data move carries the existing admin config into a connection row for the
|
||||
first admin user (host parsed from the base URL), then deletes the old
|
||||
setting keys outright — no legacy dual-read (rule #22). The env-var channel
|
||||
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
|
||||
survives as an implicit keyring entry for admin users only.
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0078"
|
||||
down_revision = "0077"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"forge_connections",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("base_url", sa.Text(), nullable=False),
|
||||
sa.Column("host", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
|
||||
)
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column(
|
||||
"forge_connection_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey(
|
||||
"forge_connections.id",
|
||||
ondelete="SET NULL",
|
||||
name="fk_projects_forge_connection_id",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Data move: the admin-settings config becomes the first admin's keyring
|
||||
# row. All three values must be present — a partial config never produced
|
||||
# an adapter, so carrying it over would invent a connection that never
|
||||
# worked.
|
||||
conn = op.get_bind()
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT s.key, s.value FROM settings s"
|
||||
" JOIN users u ON u.id = s.user_id"
|
||||
" WHERE u.role = 'admin' AND s.key IN :keys"
|
||||
" AND s.user_id = ("
|
||||
" SELECT MIN(id) FROM users WHERE role = 'admin'"
|
||||
" )"
|
||||
).bindparams(sa.bindparam("keys", expanding=True)),
|
||||
{"keys": list(_SETTING_KEYS)},
|
||||
).fetchall()
|
||||
values = {key: (value or "").strip() for key, value in row}
|
||||
kind = values.get("forge_kind", "").lower()
|
||||
base_url = values.get("forge_base_url", "").rstrip("/")
|
||||
token = values.get("forge_token", "")
|
||||
host = (urlsplit(base_url).hostname or "").lower()
|
||||
if kind and base_url and token and host:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO forge_connections"
|
||||
" (user_id, kind, base_url, host, token, created_at, updated_at)"
|
||||
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
|
||||
" FROM users WHERE role = 'admin'"
|
||||
),
|
||||
{"kind": kind, "base_url": base_url, "host": host, "token": token},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DELETE FROM settings WHERE key IN :keys"
|
||||
).bindparams(sa.bindparam("keys", expanding=True)),
|
||||
{"keys": list(_SETTING_KEYS)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse data move: the first admin's row (if any) becomes the admin
|
||||
# settings again. Other users' rows have no pre-0078 representation and
|
||||
# are dropped with the table.
|
||||
conn = op.get_bind()
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT user_id, kind, base_url, token FROM forge_connections"
|
||||
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
|
||||
" ORDER BY id LIMIT 1"
|
||||
)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
for key, value in (
|
||||
("forge_kind", row.kind),
|
||||
("forge_base_url", row.base_url),
|
||||
("forge_token", row.token),
|
||||
):
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO settings (user_id, key, value)"
|
||||
" VALUES (:uid, :key, :value)"
|
||||
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
|
||||
),
|
||||
{"uid": row.user_id, "key": key, "value": value},
|
||||
)
|
||||
op.drop_column("projects", "forge_connection_id")
|
||||
op.drop_table("forge_connections")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""The shape ledger: code_shapes (#2787, milestone 294)
|
||||
|
||||
Revision ID: 0079
|
||||
Revises: 0078
|
||||
Create Date: 2026-08-19
|
||||
|
||||
The accounting half of the pattern system (governing note 2786): the snippet
|
||||
library records canon (small); this table accounts for EVERY shape the
|
||||
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
|
||||
from the coverage sync (step 2) and gain judgments — canonical / instance /
|
||||
variant / exempt — from audits, hooks, and the mechanical proposer.
|
||||
Unclassified IS the todo list.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0079"
|
||||
down_revision = "0078"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_shapes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("repo_key", sa.Text(), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="unclassified"),
|
||||
sa.Column(
|
||||
"snippet_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("classified_by", sa.Text(), nullable=True),
|
||||
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"project_id", "repo_key", "path", "symbol", "kind",
|
||||
name="uq_code_shapes_identity",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
|
||||
)
|
||||
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
|
||||
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
|
||||
op.drop_table("code_shapes")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
|
||||
|
||||
Revision ID: 0080
|
||||
Revises: 0079
|
||||
Create Date: 2026-08-21
|
||||
|
||||
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
|
||||
(definition line + a whitespace/comment-insensitive hash of its block) so the
|
||||
proposer can match on content and a later drift recheck can notice change,
|
||||
without the ledger ever storing code. The proposal columns carry the
|
||||
proposer's standing suggestion for an unclassified row — instance-of-#N with
|
||||
a basis and score, or a derive-first group key — and `proposed_sha`
|
||||
remembers the content it was judged at so a refresh re-examines only what
|
||||
changed. Mechanical and recomputable: a restore that lacks them loses
|
||||
nothing the next refresh does not rebuild.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0080"
|
||||
down_revision = "0079"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column(
|
||||
"code_shapes",
|
||||
sa.Column(
|
||||
"proposed_snippet_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
|
||||
op.create_index(
|
||||
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
|
||||
for col in (
|
||||
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
|
||||
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
|
||||
):
|
||||
op.drop_column("code_shapes", col)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
|
||||
|
||||
Revision ID: 0081
|
||||
Revises: 0080
|
||||
Create Date: 2026-08-21
|
||||
|
||||
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
|
||||
a judgment was made at so a later body change under an instance/variant can
|
||||
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
|
||||
the previous refresh, where one canon dominates its directory+kind, and not
|
||||
proposed as that canon). `code_shape_events` is the what-was-used-when
|
||||
record: every classification, vanish, reappearance, and drift as it
|
||||
happened — history the row alone cannot keep once it moves on.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0081"
|
||||
down_revision = "0080"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
|
||||
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column(
|
||||
"code_shapes",
|
||||
sa.Column(
|
||||
"diverges_from",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
|
||||
op.create_table(
|
||||
"code_shape_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"shape_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("project_id", sa.Integer(), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=True),
|
||||
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("classified_by", sa.Text(), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
|
||||
op.create_index(
|
||||
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
|
||||
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
|
||||
op.drop_table("code_shape_events")
|
||||
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
|
||||
for col in ("diverges_from", "recheck_at", "classified_sha"):
|
||||
op.drop_column("code_shapes", col)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
|
||||
|
||||
Revision ID: 0082
|
||||
Revises: 0081
|
||||
Create Date: 2026-08-21
|
||||
|
||||
A repo binding used to imply the repo's default branch; the shape ledger
|
||||
therefore only saw work after a merge to main, while the operator's work
|
||||
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
|
||||
NULL keeps today's behaviour (the forge's default branch).
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0082"
|
||||
down_revision = "0081"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("repo_bindings", "ref")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
|
||||
|
||||
Revision ID: 0083
|
||||
Revises: 0082
|
||||
Create Date: 2026-08-21
|
||||
|
||||
The 2026-08 audit wrote the same free-text reason thousands of times
|
||||
("scoped rule — styles one element of this view"); a judgment's WHY stays
|
||||
prose, but an optional code from a fixed catalogue makes the ledger
|
||||
filterable and aggregable ("how many pure helpers, how many test helpers").
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0083"
|
||||
down_revision = "0082"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("code_shapes", "reason_code")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
|
||||
|
||||
Revision ID: 0084
|
||||
Revises: 0083
|
||||
Create Date: 2026-08-21
|
||||
|
||||
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
|
||||
a canon). But a shape can also CALL several canonical helpers — 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")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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")
|
||||
@@ -76,7 +76,9 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
|
||||
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
|
||||
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
|
||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
||||
@@ -118,6 +120,7 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
||||
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
|
||||
|
||||
## Sharing
|
||||
|
||||
|
||||
+17
-17
@@ -254,7 +254,7 @@ onUnmounted(() => {
|
||||
left: 0.5rem;
|
||||
z-index: 9999;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 0 0 4px 4px;
|
||||
font-size: 0.875rem;
|
||||
@@ -290,7 +290,7 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
opacity: 0.45;
|
||||
user-select: none;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -300,17 +300,17 @@ onUnmounted(() => {
|
||||
.shortcuts-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
||||
background: var(--fs-overlay);
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.shortcuts-panel {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
width: min(420px, 92vw);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -319,25 +319,25 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.shortcuts-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.shortcuts-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.shortcuts-close:hover {
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.shortcuts-body {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
@@ -350,7 +350,7 @@ onUnmounted(() => {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.shortcut-row {
|
||||
@@ -365,23 +365,23 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
min-width: 1.8rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.shortcut-key-sep {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.shortcut-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,20 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's `{"error": "..."}` message from a failed call, or `fallback`
|
||||
* when the failure carried none (network error, non-JSON body). The one place
|
||||
* the error envelope is unpacked on the client — views used to restate this
|
||||
* as a six-line `"body" in e` branch at every catch site.
|
||||
*/
|
||||
export function apiErrorMessage(e: unknown, fallback: string): string {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: unknown } }).body;
|
||||
if (body && typeof body.error === "string" && body.error) return body.error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
return handleResponse<T>(res, path);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { apiGet } from "@/api/client";
|
||||
import type { ExpectationResponse } from "@/utils/designDrift";
|
||||
|
||||
/** Checkable claims from the rulebook this install designated as its design system.
|
||||
*
|
||||
* `rulebook_id: null` means none has been designated — the normal state for a
|
||||
* fresh install, not an error. The caller shows an explanatory empty state. */
|
||||
export const fetchDesignExpectations = () =>
|
||||
apiGet<ExpectationResponse>("/api/design/expectations");
|
||||
@@ -74,11 +74,28 @@ export const fetchDesignSystems = () =>
|
||||
export const fetchDesignSystem = (id: number) =>
|
||||
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
||||
|
||||
export interface StarterRoleGroup {
|
||||
group: string;
|
||||
description: string;
|
||||
token_count: number;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
/** The starter token ROLES offered at creation — names and purposes, never
|
||||
* values. A default palette would be one install's taste shipped as product
|
||||
* (rule #115), so the values are always the operator's to fill. */
|
||||
export const listStarterRoleGroups = () =>
|
||||
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
|
||||
"/api/design-systems/starter-roles",
|
||||
);
|
||||
|
||||
export const createDesignSystem = (body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
starter_role_groups?: string[];
|
||||
token_prefix?: string;
|
||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||
|
||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||
@@ -183,6 +200,14 @@ export interface SnippetCheck {
|
||||
findings: SnippetFinding[];
|
||||
}
|
||||
|
||||
/** Which recorded snippets disagree with this design system's sheet. */
|
||||
export const checkSnippets = (id: number) =>
|
||||
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
||||
/** Which recorded snippets disagree with this design system's sheet.
|
||||
*
|
||||
* `projectId` narrows to the snippets one project owns — which is how a
|
||||
* project asks about its OWN code. Omit it to check every project, which is
|
||||
* the right default from the system's side: a component recorded elsewhere
|
||||
* still has to use the same tags. */
|
||||
export const checkSnippets = (id: number, projectId?: number) =>
|
||||
apiGet<SnippetCheck>(
|
||||
`/api/design-systems/${id}/snippet-check`
|
||||
+ (projectId ? `?project_id=${projectId}` : ""),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** 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 });
|
||||
@@ -71,6 +71,8 @@ export interface ApplicableRules {
|
||||
}[];
|
||||
truncated: boolean;
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||
excluded_always_on: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||
@@ -181,3 +183,14 @@ export async function suppressTopicForProject(projectId: number, topicId: number
|
||||
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||
}
|
||||
|
||||
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
|
||||
|
||||
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
|
||||
}
|
||||
|
||||
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
|
||||
The five auth views used to carry byte-identical copies of these rules in
|
||||
their scoped blocks (2026-08 shape audit). Loaded per view with
|
||||
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
|
||||
rules are scoped under .auth-card so nothing leaks into the app's other
|
||||
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
|
||||
stay in the view. */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-hint {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-hint a {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
/* A centred status paragraph block: registration closed, invalid/expired
|
||||
token, "check your inbox". One rule — the views used to name it
|
||||
.closed-msg / .error-block / .success-msg with identical bodies. */
|
||||
.auth-note {
|
||||
text-align: center;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.auth-note p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.auth-loading {
|
||||
text-align: center;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.95rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.auth-card .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-card .field label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.auth-card .input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.auth-card .input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.auth-card .input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.auth-card .input-error,
|
||||
.auth-card .input-error:focus {
|
||||
border-color: var(--fs-error);
|
||||
}
|
||||
.auth-card .field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.auth-card .error-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.auth-card .error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
@@ -36,7 +36,8 @@
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger,
|
||||
.btn-danger-outline {
|
||||
.btn-danger-outline,
|
||||
.btn-cta {
|
||||
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
||||
border: none;
|
||||
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
||||
@@ -46,6 +47,14 @@
|
||||
line-height: var(--fs-leading-body);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
/* So a button carrying an icon centres it against the label without each
|
||||
caller re-inventing the flex row — the shape they all reached for
|
||||
separately, and the reason icon buttons sat a pixel or two off. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--fs-space-2);
|
||||
text-decoration: none;
|
||||
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||
color var(--fs-dur-fast) var(--fs-ease);
|
||||
@@ -58,7 +67,8 @@
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-danger-outline:disabled {
|
||||
.btn-danger-outline:disabled,
|
||||
.btn-cta:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -67,7 +77,8 @@
|
||||
.btn-secondary:focus-visible,
|
||||
.btn-ghost:focus-visible,
|
||||
.btn-danger:focus-visible,
|
||||
.btn-danger-outline:focus-visible {
|
||||
.btn-danger-outline:focus-visible,
|
||||
.btn-cta:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
@@ -78,19 +89,19 @@
|
||||
* are universal across the family so a Save button looks identical in every
|
||||
* app — the accent is identity, not action. */
|
||||
.btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
background: var(--fs-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
background: var(--fs-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-action-secondary);
|
||||
background: var(--fs-action-secondary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-secondary:not(:disabled):hover {
|
||||
background: var(--color-action-secondary-hover);
|
||||
background: var(--fs-action-secondary-hover);
|
||||
}
|
||||
|
||||
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
|
||||
@@ -101,21 +112,21 @@
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.btn-ghost:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--color-hover);
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
|
||||
/* Destructive is NOT the error colour: an error is a failure that happened, a
|
||||
* destructive action is one about to happen. Pair with an icon. */
|
||||
.btn-danger {
|
||||
background: var(--color-action-destructive);
|
||||
background: var(--fs-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-danger:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
background: var(--fs-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* A bare text button: no fill, no border. The most common shape in the dense
|
||||
@@ -125,7 +136,7 @@
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-tiny);
|
||||
@@ -133,7 +144,7 @@
|
||||
cursor: pointer;
|
||||
transition: color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.btn-text:not(:disabled):hover { color: var(--color-text); }
|
||||
.btn-text:not(:disabled):hover { color: var(--fs-text-primary); }
|
||||
.btn-text:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
|
||||
@@ -142,14 +153,33 @@
|
||||
* a one-off: it is what a delete looks like when it must not shout. */
|
||||
.btn-danger-outline {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border: 1px solid var(--fs-action-destructive);
|
||||
color: var(--fs-action-destructive);
|
||||
}
|
||||
.btn-danger-outline:not(:disabled):hover {
|
||||
background: var(--color-action-destructive);
|
||||
background: var(--fs-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
/* The one place the accent is allowed on a button: a deliberate brand moment,
|
||||
* never an ordinary action. The system carries `--fs-gradient-cta` and
|
||||
* `--fs-glow-cta` for exactly this and nothing else was using them.
|
||||
*
|
||||
* It exists because ProjectView's Workspace link WAS this button, defined in a
|
||||
* scoped block that the migration deleted — leaving a `:hover` rule with no
|
||||
* base and a link that rendered as raw browser blue. A variant living in one
|
||||
* view is a variant waiting to be deleted by someone tidying another; this is
|
||||
* the shared home so the next sweep can't strand it. */
|
||||
.btn-cta {
|
||||
background: var(--fs-gradient-cta);
|
||||
color: var(--fs-text-on-action);
|
||||
box-shadow: var(--fs-glow-cta);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-cta:not(:disabled):hover {
|
||||
box-shadow: var(--fs-glow-cta-hover);
|
||||
}
|
||||
|
||||
/* --- size modifiers ------------------------------------------------------
|
||||
*
|
||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
||||
@@ -184,10 +214,86 @@
|
||||
/* Full width, for a form's single submitting action — the auth screens. Width
|
||||
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
||||
.btn-block {
|
||||
display: block;
|
||||
display: flex; /* not `block` — the shared shape centres with flex */
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
||||
because a full-width button
|
||||
is the page's main action */
|
||||
font-size: var(--fs-size-body-sm);
|
||||
}
|
||||
|
||||
|
||||
/* ── Modal ─────────────────────────────────────────────────────────────────
|
||||
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
|
||||
the create-project / merge-snippet / systems dialogs, the editors' confirm
|
||||
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
|
||||
styles of its own, so these must be loaded with the app, not with whichever
|
||||
view happens to be open. Views add only their own overrides (a wider card,
|
||||
a form layout). Destructive = action-destructive per the Hybrid rule. */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--fs-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
}
|
||||
.modal-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.modal-message {
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover {
|
||||
background: var(--fs-surface-page);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--fs-action-primary);
|
||||
border-color: var(--fs-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
background: var(--fs-action-primary-hover);
|
||||
}
|
||||
.modal-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.modal-btn-danger {
|
||||
background: var(--fs-action-destructive);
|
||||
border-color: var(--fs-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-danger:hover {
|
||||
background: var(--fs-action-destructive-hover);
|
||||
border-color: var(--fs-action-destructive-hover);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
@@ -41,36 +41,36 @@
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--color-primary);
|
||||
border-bottom-color: var(--fs-accent);
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.tab {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
border-bottom-color: var(--fs-accent);
|
||||
}
|
||||
.preview-pane {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
min-height: 200px;
|
||||
background: var(--color-bg-card);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
/* ── Tag suggestions ── */
|
||||
@@ -85,21 +85,21 @@
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border: 1px solid var(--fs-accent);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.tag-pill:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.tag-pill.applied {
|
||||
background: var(--color-success, #2ecc71);
|
||||
border-color: var(--color-success, #2ecc71);
|
||||
background: var(--fs-success);
|
||||
border-color: var(--fs-success);
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: default;
|
||||
}
|
||||
@@ -111,8 +111,8 @@
|
||||
.assist-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -123,13 +123,13 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.assist-panel-title {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -149,13 +149,13 @@
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.assist-sections {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
@@ -165,46 +165,46 @@
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.assist-section-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.assist-section-item.selected {
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
border-left-color: var(--fs-accent);
|
||||
background: var(--fs-surface-raised);
|
||||
font-weight: 500;
|
||||
}
|
||||
.assist-empty,
|
||||
.assist-hint {
|
||||
padding: 0.6rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.assist-target-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assist-target-preview em {
|
||||
font-style: normal;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.assist-instruction {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.88rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
box-sizing: border-box;
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
@@ -216,22 +216,22 @@
|
||||
/* Streaming */
|
||||
.assist-streaming-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assist-preview-box {
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
font-size: 0.9rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.typing-indicator {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.15em;
|
||||
animation: blink 1s step-end infinite;
|
||||
@@ -244,18 +244,18 @@
|
||||
.assist-active-hint {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.assist-error {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||
border: 1px solid var(--fs-error);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Review / diff */
|
||||
@@ -265,12 +265,12 @@
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.diff-view {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
font-size: 0.82rem;
|
||||
font-family: monospace;
|
||||
max-height: 340px;
|
||||
@@ -284,15 +284,15 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
.diff-delete {
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.diff-equal {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.diff-marker {
|
||||
flex-shrink: 0;
|
||||
@@ -308,7 +308,7 @@
|
||||
}
|
||||
.diff-empty {
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.assist-actions {
|
||||
@@ -316,63 +316,16 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
}
|
||||
.modal-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.modal-message {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.modal-btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Floating inline assist button (teleported to body) ── */
|
||||
.inline-assist-btn {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-primary);
|
||||
background: var(--fs-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
@@ -384,12 +337,12 @@
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
@@ -408,7 +361,7 @@
|
||||
.sb-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -416,10 +369,10 @@
|
||||
.sb-input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
@@ -427,11 +380,11 @@
|
||||
.sb-select:focus,
|
||||
.sb-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.sb-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
background: var(--fs-border-color);
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
@@ -452,8 +405,8 @@
|
||||
width: auto;
|
||||
flex: 0 0 45%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
|
||||
}
|
||||
.editor-header {
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
@@ -480,14 +433,14 @@
|
||||
.btn-accept,
|
||||
.btn-generate,
|
||||
.btn-save {
|
||||
background: var(--color-action-primary);
|
||||
background: var(--fs-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-accept:not(:disabled):hover,
|
||||
.btn-generate:not(:disabled):hover,
|
||||
.btn-save:not(:disabled):hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
background: var(--fs-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
@@ -497,7 +450,7 @@
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.btn-back:not(:disabled):hover,
|
||||
.btn-clear:not(:disabled):hover,
|
||||
@@ -505,16 +458,16 @@
|
||||
.btn-proofread:not(:disabled):hover,
|
||||
.btn-suggest-tags:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--color-hover);
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: var(--color-action-destructive);
|
||||
background: var(--fs-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-delete:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
background: var(--fs-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* Shared geometry for every alias above. */
|
||||
@@ -541,12 +494,12 @@
|
||||
.btn-dismiss-tags {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 2px var(--fs-space-1);
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-dismiss-tags:hover { color: var(--color-text); }
|
||||
.btn-dismiss-tags:hover { color: var(--fs-text-primary); }
|
||||
|
||||
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
|
||||
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: var(--color-code-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-code);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background: var(--color-code-inline-bg);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: 3px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
@@ -73,25 +73,25 @@
|
||||
|
||||
.prose th,
|
||||
.prose td {
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose thead th {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose tbody tr:nth-child(even) {
|
||||
background: var(--color-table-stripe);
|
||||
background: var(--fs-table-stripe);
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
border-left: 3px solid var(--fs-border-color);
|
||||
margin: 0 0 0.6rem;
|
||||
padding: 0.25rem 0 0.25rem 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.prose blockquote p:last-child {
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -119,8 +119,8 @@
|
||||
}
|
||||
|
||||
.prose .inline-tag {
|
||||
color: var(--color-tag-text);
|
||||
background: var(--color-tag-bg);
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
@@ -133,8 +133,8 @@
|
||||
}
|
||||
|
||||
.prose .wikilink {
|
||||
color: var(--color-wikilink);
|
||||
background: var(--color-wikilink-bg);
|
||||
color: var(--fs-wikilink);
|
||||
background: var(--fs-accent-soft);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
@@ -168,7 +168,7 @@
|
||||
|
||||
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
accent-color: var(--fs-accent);
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
margin: 0;
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Interactive checkboxes — marked output in the list-note viewer */
|
||||
@@ -196,7 +196,7 @@
|
||||
}
|
||||
.prose--checklist li input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--color-primary);
|
||||
accent-color: var(--fs-accent);
|
||||
cursor: pointer;
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
@@ -205,7 +205,7 @@
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
|
||||
text-decoration: none; /* don't strike through the checkbox itself */
|
||||
@@ -219,7 +219,7 @@
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--color-text-muted, var(--color-text-secondary));
|
||||
color: var(--fs-text-tertiary);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
@@ -227,12 +227,12 @@
|
||||
}
|
||||
|
||||
.tiptap-wrapper {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.tiptap-wrapper:focus-within {
|
||||
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
+11
-116
@@ -188,126 +188,21 @@
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
|
||||
|
||||
These exist so ~55 components keep working while they migrate to --fs-*
|
||||
one at a time. Every one is a plain var() reference, which is what lets this
|
||||
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
|
||||
--color-bg follows, because the alias resolves at use time.
|
||||
|
||||
That is why this file lost 48 of its 60 dark-mode overrides — they were all
|
||||
restating relationships the aliases now express directly.
|
||||
|
||||
Removing this block is a rename sweep across the components, tracked
|
||||
separately. Nothing new should reference a --color-* name.
|
||||
The compatibility-alias block that lived here is GONE (#2533). It let ~55
|
||||
components keep their historical --color-* names while theme.css was
|
||||
repointed at the design system; the rename sweep it promised ran on
|
||||
2026-08-08 and every component now references --fs-* directly. Do not
|
||||
reintroduce app-local alias names — the design system's tokens are the
|
||||
vocabulary, and check_snippets_against_design_system can only see through
|
||||
names the system actually declares.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* surfaces */
|
||||
--color-bg: var(--fs-surface-page);
|
||||
--color-bg-secondary: var(--fs-surface-raised);
|
||||
--color-bg-card: var(--fs-surface-raised);
|
||||
--color-surface: var(--fs-surface-hover);
|
||||
--color-code-bg: var(--fs-surface-code);
|
||||
--color-code-inline-bg: var(--fs-surface-code-inline);
|
||||
--color-table-stripe: var(--fs-table-stripe);
|
||||
--color-overlay: var(--fs-overlay);
|
||||
|
||||
/* text */
|
||||
--color-text: var(--fs-text-primary);
|
||||
--color-text-secondary: var(--fs-text-secondary);
|
||||
--color-text-muted: var(--fs-text-tertiary);
|
||||
|
||||
/* lines */
|
||||
--color-border: var(--fs-border-color);
|
||||
--color-input-border: var(--fs-border-color);
|
||||
--focus-ring: var(--fs-focus-ring);
|
||||
|
||||
/* brand */
|
||||
--color-primary: var(--fs-accent);
|
||||
--color-primary-solid: var(--fs-accent);
|
||||
--color-primary-deep: var(--fs-accent-deep);
|
||||
--color-primary-faint: var(--fs-accent-faint);
|
||||
--color-primary-tint: var(--fs-accent-soft);
|
||||
--color-primary-wash: var(--fs-accent-wash);
|
||||
--color-tag-bg: var(--fs-accent-soft);
|
||||
--color-tag-text: var(--fs-accent);
|
||||
--color-wikilink: var(--fs-wikilink);
|
||||
--color-wikilink-bg: var(--fs-accent-soft);
|
||||
--gradient-cta: var(--fs-gradient-cta);
|
||||
--glow-cta: var(--fs-glow-cta);
|
||||
--glow-cta-hover: var(--fs-glow-cta-hover);
|
||||
|
||||
/* actions */
|
||||
--color-action-primary: var(--fs-action-primary);
|
||||
--color-action-primary-hover: var(--fs-action-primary-hover);
|
||||
--color-action-secondary: var(--fs-action-secondary);
|
||||
--color-action-secondary-hover: var(--fs-action-secondary-hover);
|
||||
--color-action-destructive: var(--fs-action-destructive);
|
||||
--color-action-destructive-hover: var(--fs-action-destructive-hover);
|
||||
|
||||
/* semantic */
|
||||
--color-success: var(--fs-success);
|
||||
--color-warning: var(--fs-warning);
|
||||
--color-danger: var(--fs-error);
|
||||
--color-overdue: var(--fs-overdue);
|
||||
--color-toast-success: var(--fs-success);
|
||||
--color-toast-error: var(--fs-error);
|
||||
/* A VALUE, not an alias — the one survivor of the alias block. The design
|
||||
system has no shadow-colour token yet, so this is a recorded gap: when a
|
||||
second app needs it, promote it to an --fs-* token in the system and
|
||||
regenerate, rather than copying this line. */
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* task status + priority */
|
||||
--color-status-todo: var(--fs-status-todo);
|
||||
--color-status-todo-bg: var(--fs-status-todo-bg);
|
||||
--color-status-in-progress: var(--fs-status-in-progress);
|
||||
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
|
||||
--color-status-done: var(--fs-status-done);
|
||||
--color-status-done-bg: var(--fs-status-done-bg);
|
||||
--color-priority-low: var(--fs-priority-low);
|
||||
--color-priority-low-bg: var(--fs-priority-low-bg);
|
||||
--color-priority-medium: var(--fs-priority-medium);
|
||||
--color-priority-medium-bg: var(--fs-priority-medium-bg);
|
||||
--color-priority-high: var(--fs-priority-high);
|
||||
--color-priority-high-bg: var(--fs-priority-high-bg);
|
||||
|
||||
/* geometry */
|
||||
--radius-sm: var(--fs-radius-sm);
|
||||
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
|
||||
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
|
||||
--page-max-width: var(--fs-layout-page-max);
|
||||
--page-padding-x: var(--fs-layout-page-pad);
|
||||
--sidebar-width: var(--fs-layout-sidebar);
|
||||
--header-height: var(--fs-layout-header);
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Names components reference that were NEVER declared anywhere.
|
||||
|
||||
Each of these was reached for with a hardcoded fallback, so the page
|
||||
rendered — but the fallback was what rendered, always, and several were
|
||||
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
|
||||
to a brick that is not the oxblood, --color-status-cancelled to a grey from
|
||||
no palette in this system.
|
||||
|
||||
Wiring them to real tokens is the whole point of the exercise. Expect small
|
||||
visual shifts exactly where a fallback had drifted; that shift IS the fix.
|
||||
------------------------------------------------------------------ */
|
||||
--color-accent: var(--fs-accent);
|
||||
/* Foreground ON the accent, so it follows the accent's mode-independence,
|
||||
not the page text's. Pointing this at --fs-text-primary made it invert to
|
||||
obsidian on light — over a mid-tone accent, well under the AA floor. */
|
||||
--color-accent-fg: var(--fs-text-on-action);
|
||||
--color-hover: var(--fs-surface-hover);
|
||||
--color-bg-hover: var(--fs-surface-hover);
|
||||
--color-bg-tertiary: var(--fs-surface-hover);
|
||||
--color-surface-2: var(--fs-surface-hover);
|
||||
--color-surface-alt: var(--fs-surface-hover);
|
||||
--color-surface-raised: var(--fs-surface-raised);
|
||||
--color-input-bg: var(--fs-surface-page);
|
||||
--color-muted: var(--fs-text-tertiary);
|
||||
--color-destructive: var(--fs-destructive);
|
||||
--color-primary-bg: var(--fs-accent-soft);
|
||||
--color-status-cancelled: var(--fs-status-cancelled);
|
||||
--font-display: var(--fs-font-display);
|
||||
--font-mono: var(--fs-font-mono);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
|
||||
@@ -15,27 +15,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ctx-crumb-parent {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.ctx-crumb-parent:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.ctx-crumb-project {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ctx-crumb-project:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||
}
|
||||
.ctx-crumb-milestone {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
|
||||
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
@@ -50,6 +50,12 @@ router.afterEach(() => {
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<!-- A design system is a RECORD you author, not a setting. It sat in
|
||||
the utility cluster with Trash and Settings while /design was a
|
||||
read-only gallery, and stayed there after it became a record type
|
||||
with its own table, sharing and MCP tools. Content, by the same
|
||||
rule that puts Snippets and Rulebooks here. -->
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,16 +70,6 @@ router.afterEach(() => {
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Design. An icon rather than a sixth primary nav link: it's a
|
||||
meta-surface like Trash and Settings, but hiding it entirely would
|
||||
defeat the point of having somewhere the design system is visible.
|
||||
Points at the RECORD, not the live-token view — the record is what
|
||||
you work with; the live view is the check on it, and it's a tab
|
||||
away. -->
|
||||
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
|
||||
<Palette :size="16" />
|
||||
</router-link>
|
||||
|
||||
<!-- Trash link -->
|
||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||
<Trash2 :size="16" />
|
||||
@@ -106,9 +102,9 @@ router.afterEach(() => {
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
@@ -128,20 +124,35 @@ router.afterEach(() => {
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
background: linear-gradient(180deg, var(--fs-surface-hover), var(--fs-surface-page));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
/* Three tracks, not a flex row with an absolutely-centred overlay.
|
||||
*
|
||||
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
|
||||
* not participate in layout: when the header ran out of room it OVERLAPPED the
|
||||
* brand and the utility cluster rather than pushing them, and nothing wrapped
|
||||
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
|
||||
* is an ordinary window on any monitor.
|
||||
*
|
||||
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
|
||||
* neither side can be squeezed below its content, and the two side tracks stay
|
||||
* equal to each other — which is what keeps the bar centred in the viewport
|
||||
* rather than merely centred in the leftover space. Overflow becomes the
|
||||
* header growing, not two things sharing pixels. */
|
||||
.nav {
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left — brand */
|
||||
.nav-brand {
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
@@ -159,9 +170,7 @@ router.afterEach(() => {
|
||||
|
||||
/* Center — pill bar */
|
||||
.nav-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -169,21 +178,23 @@ router.afterEach(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--color-primary-faint);
|
||||
background: var(--fs-accent-faint);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Right */
|
||||
.nav-right {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
@@ -191,14 +202,14 @@ router.afterEach(() => {
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--fs-accent-soft);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary-solid);
|
||||
color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -218,7 +229,7 @@ router.afterEach(() => {
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||
they want to read as vital (Moss/Warning/Error are too muted for
|
||||
@@ -228,7 +239,7 @@ router.afterEach(() => {
|
||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: #ef4444; }
|
||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
||||
.status-gray .status-dot { background: var(--fs-text-tertiary); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
@@ -241,12 +252,12 @@ router.afterEach(() => {
|
||||
/* Icon buttons (?, theme, gear) */
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -254,9 +265,9 @@ router.afterEach(() => {
|
||||
}
|
||||
.btn-icon:hover,
|
||||
.btn-icon.active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
/* User info */
|
||||
@@ -266,36 +277,42 @@ router.afterEach(() => {
|
||||
gap: 0.4rem;
|
||||
margin-left: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.username {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-weight: 500;
|
||||
/* The widest thing on the right and the only one that can give: a long
|
||||
username shouldn't be what decides where the nav bar sits. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 12ch;
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.btn-logout {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
border-color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Hamburger — mobile only */
|
||||
@@ -313,7 +330,7 @@ router.afterEach(() => {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--color-text);
|
||||
background: var(--fs-text-primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
@@ -322,13 +339,13 @@ router.afterEach(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.mobile-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
background: var(--fs-border-color);
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.mobile-actions {
|
||||
@@ -342,10 +359,25 @@ router.afterEach(() => {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* The grid above means running out of room can no longer cause a collision —
|
||||
but it can still make the header wider than the window, and a horizontally
|
||||
scrolling header is its own defect. So shed width before that happens. The
|
||||
wordmark goes first: the logo beside it says the same thing and is still the
|
||||
link home. */
|
||||
@media (max-width: 1280px) {
|
||||
.brand-text {
|
||||
display: none;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-center {
|
||||
display: none;
|
||||
@@ -366,7 +398,7 @@ router.afterEach(() => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mobile-menu .nav-link.router-link-active {
|
||||
background: var(--color-primary-wash);
|
||||
background: var(--fs-accent-wash);
|
||||
box-shadow: none;
|
||||
}
|
||||
.mobile-user .btn-logout {
|
||||
|
||||
@@ -13,8 +13,8 @@ defineProps<{ size?: number }>();
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
||||
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
||||
<stop offset="0%" stop-color="var(--fs-accent)" />
|
||||
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Book body -->
|
||||
@@ -44,12 +44,12 @@ defineProps<{ size?: number }>();
|
||||
<style scoped>
|
||||
.logo-book {
|
||||
fill: url(#logo-gradient);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
stroke: color-mix(in srgb, var(--fs-accent) 70%, transparent);
|
||||
}
|
||||
.logo-spine {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke: var(--fs-text-secondary);
|
||||
}
|
||||
.logo-lines {
|
||||
stroke: var(--color-text-muted);
|
||||
stroke: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sub-navigation for the Design surface.
|
||||
*
|
||||
* There are two pages here and they are halves of ONE thing: the record that
|
||||
* decides the styling, and what the browser is actually rendering from it. They
|
||||
* were briefly two top-level nav entries, which put the read-only diagnostic
|
||||
* first and buried the editable record under it — backwards, since the record
|
||||
* is the thing you work with and the live view is the check on it.
|
||||
*
|
||||
* A component rather than the same markup pasted into both views: two copies of
|
||||
* a tab bar diverge the moment a third tab appears, and that is the exact shape
|
||||
* of duplication this whole surface exists to make visible.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="design-tabs" aria-label="Design views">
|
||||
<router-link to="/design-systems" class="design-tab">Design system</router-link>
|
||||
<router-link to="/design" class="design-tab">Live tokens</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.design-tab {
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.design-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
|
||||
exact matching would drop the highlight on any future child route. */
|
||||
.design-tab.router-link-active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -90,9 +90,9 @@ function markerFor(type: DiffLine['type']): string {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -103,15 +103,15 @@ function markerFor(type: DiffLine['type']): string {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.78rem;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-summary-ins { color: var(--color-success, #2ecc71); }
|
||||
.diff-summary-del { color: var(--color-danger, #e74c3c); }
|
||||
.diff-summary-ins { color: var(--fs-success); }
|
||||
.diff-summary-del { color: var(--fs-error); }
|
||||
|
||||
.diff-scroll {
|
||||
flex: 1;
|
||||
@@ -123,7 +123,7 @@ function markerFor(type: DiffLine['type']): string {
|
||||
.diff-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -136,24 +136,24 @@ function markerFor(type: DiffLine['type']): string {
|
||||
}
|
||||
|
||||
.diff-delete {
|
||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
|
||||
color: var(--color-success, #2ecc71);
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
|
||||
.diff-equal {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.diff-collapse {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
opacity: 0.6;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
border-top: 1px dashed var(--fs-border-color);
|
||||
border-bottom: 1px dashed var(--fs-border-color);
|
||||
padding-top: 0.2rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
|
||||
interface NoteVersion {
|
||||
id: number;
|
||||
@@ -56,14 +57,6 @@ const diff = computed<DiffLine[]>(() => {
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -212,7 +205,7 @@ onMounted(loadVersions);
|
||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||
class="history-item-label"
|
||||
>{{ v.pin_label }}</div>
|
||||
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
|
||||
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -309,7 +302,7 @@ onMounted(loadVersions);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
@@ -322,11 +315,11 @@ onMounted(loadVersions);
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.history-close:hover { color: var(--color-text); }
|
||||
.history-close:hover { color: var(--fs-text-primary); }
|
||||
|
||||
.history-body {
|
||||
flex: 1;
|
||||
@@ -338,7 +331,7 @@ onMounted(loadVersions);
|
||||
.history-list {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--fs-border-color);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -346,18 +339,18 @@ onMounted(loadVersions);
|
||||
padding: 0.6rem 0.9rem;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.history-item:hover { background: var(--color-bg-secondary); }
|
||||
.history-item:hover { background: var(--fs-surface-raised); }
|
||||
.history-item.selected {
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
border-left-color: var(--fs-accent);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
.history-item-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -365,7 +358,7 @@ onMounted(loadVersions);
|
||||
|
||||
.history-item-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
@@ -381,7 +374,7 @@ onMounted(loadVersions);
|
||||
.history-empty {
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.history-footer {
|
||||
@@ -390,7 +383,7 @@ onMounted(loadVersions);
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -403,12 +396,12 @@ onMounted(loadVersions);
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
||||
.pin-badge-manual { color: var(--fs-accent); }
|
||||
.pin-badge-auto { color: var(--fs-text-tertiary); }
|
||||
|
||||
.history-item-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-primary, #6366f1);
|
||||
color: var(--fs-accent);
|
||||
font-style: italic;
|
||||
margin-top: 0.15rem;
|
||||
overflow: hidden;
|
||||
@@ -419,7 +412,7 @@ onMounted(loadVersions);
|
||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
||||
.version-pin-controls {
|
||||
padding: 0.4rem 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pin-actions {
|
||||
@@ -430,7 +423,7 @@ onMounted(loadVersions);
|
||||
}
|
||||
.pin-state {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
color: var(--fs-text-tertiary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -442,13 +435,13 @@ onMounted(loadVersions);
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.btn-unpin:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
@@ -463,27 +456,27 @@ onMounted(loadVersions);
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: inherit;
|
||||
}
|
||||
.pin-label-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.btn-pin-save, .btn-pin-cancel {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin-save:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<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; }
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
</style>
|
||||
@@ -74,11 +74,11 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
<style scoped>
|
||||
.iap {
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-page);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
@@ -88,16 +88,16 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
/* ── Streaming ── */
|
||||
.iap-streaming {
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.iap-streaming .iap-header {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
||||
}
|
||||
|
||||
.iap-pulse {
|
||||
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
flex-shrink: 0;
|
||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@@ -117,7 +117,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
.iap-label {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -125,9 +125,9 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
.iap-btn-cancel {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.iap-btn-cancel:hover {
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--fs-error);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
.iap-stream-preview {
|
||||
@@ -150,29 +150,29 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
.iap-waiting {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Review ── */
|
||||
.iap-review-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.iap-btn-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.iap-btn-toggle:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.iap-actions {
|
||||
@@ -183,7 +183,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
.iap-btn-accept,
|
||||
.iap-btn-reject {
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.2rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
font-weight: var(--fs-weight-medium);
|
||||
}
|
||||
.iap-btn-accept {
|
||||
background: var(--color-success, #22c55e);
|
||||
background: var(--fs-success);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.iap-btn-accept:hover { opacity: 0.85; }
|
||||
|
||||
.iap-btn-reject {
|
||||
background: var(--color-bg-card, var(--color-bg));
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-secondary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.iap-btn-reject:hover {
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--fs-error);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* ── Diff ── */
|
||||
@@ -224,14 +224,14 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.iap-diff-equal { color: var(--color-text-muted); }
|
||||
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
||||
.iap-diff-delete {
|
||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.iap-diff-insert {
|
||||
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
|
||||
color: var(--color-success, #22c55e);
|
||||
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
|
||||
.iap-diff-marker {
|
||||
@@ -243,7 +243,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
.iap-diff-empty {
|
||||
padding: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -111,9 +111,9 @@ const groups = [
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ const groups = [
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--color-border);
|
||||
background: var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
margin: 0 3px;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ const groups = [
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||
@@ -149,19 +149,19 @@ const groups = [
|
||||
}
|
||||
|
||||
.md-btn:hover {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.md-btn.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
|
||||
color: var(--fs-accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.md-btn.active:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
|
||||
@@ -66,10 +66,10 @@ function onChange(e: Event) {
|
||||
<style scoped>
|
||||
.milestone-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
@@ -77,7 +77,7 @@ function onChange(e: Event) {
|
||||
}
|
||||
.milestone-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.milestone-select:disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -60,15 +60,15 @@ function goEdit() {
|
||||
.note-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
background: var(--fs-surface-raised);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -78,18 +78,18 @@ function goEdit() {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
background: var(--fs-surface-raised);
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
transform: none !important;
|
||||
}
|
||||
.note-card.compact:first-child {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(91, 74, 138, 0.04);
|
||||
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
@@ -108,7 +108,7 @@ function goEdit() {
|
||||
}
|
||||
.timestamp-compact {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -133,20 +133,20 @@ function goEdit() {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-secondary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.note-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
max-height: 7.5em;
|
||||
overflow: hidden;
|
||||
@@ -163,6 +163,6 @@ function goEdit() {
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,11 +60,11 @@ onUnmounted(() => {
|
||||
|
||||
.btn-bell {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -72,16 +72,16 @@ onUnmounted(() => {
|
||||
}
|
||||
.btn-bell:hover,
|
||||
.btn-bell.active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
background: var(--fs-error);
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -85,9 +85,9 @@ onMounted(() => store.fetchAll())
|
||||
width: 340px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--fs-surface-hover);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-xl);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
z-index: 500;
|
||||
}
|
||||
@@ -97,10 +97,10 @@ onMounted(() => store.fetchAll())
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface);
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
|
||||
.notif-panel-title {
|
||||
@@ -114,12 +114,12 @@ onMounted(() => store.fetchAll())
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item:hover { background: var(--color-hover); }
|
||||
.notif-item:hover { background: var(--fs-surface-hover); }
|
||||
|
||||
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
|
||||
|
||||
@@ -127,10 +127,10 @@ onMounted(() => store.fetchAll())
|
||||
.notif-msg {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
|
||||
.notif-time { font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -74,27 +74,27 @@ function goToPage(page: number) {
|
||||
}
|
||||
.page-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.ellipsis {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,15 +33,15 @@ const labels: Record<TaskPriority, string> = {
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.priority-low {
|
||||
background: var(--color-priority-low-bg);
|
||||
color: var(--color-priority-low);
|
||||
background: var(--fs-priority-low-bg);
|
||||
color: var(--fs-priority-low);
|
||||
}
|
||||
.priority-medium {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
}
|
||||
.priority-high {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A project's own code, checked against the design system it is bound to (#2432).
|
||||
*
|
||||
* This is what the design surface is FOR: a project's recorded components
|
||||
* measured against the sheet they are supposed to use. The check itself is not
|
||||
* new — `check_snippets_against_system` has taken a project id since it was
|
||||
* written, and the route has always read `?project_id=`. Nothing on this side
|
||||
* ever passed one, so the capability shipped and stayed unreachable.
|
||||
*
|
||||
* The finding that matters most is the quiet one. `local_definitions` is a
|
||||
* snippet minting its own custom property instead of reaching for the shared
|
||||
* one — the codebase re-solving a solved problem, one component at a time.
|
||||
* Nothing breaks, no test fails, and the duplication only becomes visible when
|
||||
* someone changes the shared value and half the components don't move.
|
||||
*
|
||||
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
|
||||
* snippets — because that is the code Scribe holds. A repository's own sources
|
||||
* are checked where they live, by that project's CI.
|
||||
*/
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
|
||||
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
|
||||
|
||||
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
|
||||
|
||||
const check = ref<SnippetCheck | null>(null);
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
async function run() {
|
||||
check.value = null;
|
||||
failed.value = false;
|
||||
if (props.designSystemId === null) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
check.value = await checkSnippets(props.designSystemId, props.projectId);
|
||||
} catch {
|
||||
// Said out loud rather than rendered as an empty result. "Couldn't check"
|
||||
// and "nothing to report" look identical if you let them, and that is how
|
||||
// a check comes to sit dead without anyone noticing (#2419).
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(run);
|
||||
watch(() => [props.projectId, props.designSystemId], run);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pdt">
|
||||
<div v-if="designSystemId === null" class="pdt-note">
|
||||
<strong>No design system for this project.</strong>
|
||||
<p>
|
||||
Bind one in the sidebar and this tab reports where the project's recorded
|
||||
components disagree with it — references to tokens the system doesn't
|
||||
have, literals it says to stop writing, and properties a component mints
|
||||
for itself instead of reusing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets…</p>
|
||||
|
||||
<div v-else-if="failed" class="pdt-note">
|
||||
<strong>The check couldn't run.</strong>
|
||||
<p>Nothing was compared — this is a failure, not a clean result.</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="check">
|
||||
<p v-if="!check.checked" class="pdt-muted">
|
||||
This project has no recorded snippets, so nothing was checked. Record the
|
||||
components you reuse and they get measured against the sheet.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!check.findings.length" class="pdt-clean">
|
||||
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
|
||||
every reference resolves, and none mints a property of its own.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="pdt-summary">
|
||||
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
|
||||
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
|
||||
</p>
|
||||
|
||||
<ul class="pdt-list">
|
||||
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
|
||||
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
|
||||
{{ f.title || "Untitled snippet" }}
|
||||
</router-link>
|
||||
|
||||
<!-- Renders as nothing at all: no error, no failing test, just an
|
||||
element that quietly isn't styled. Leads for that reason. -->
|
||||
<div v-if="f.unknown.length" class="pdt-row">
|
||||
<span class="pdt-tag unknown">no such token</span>
|
||||
<span class="pdt-detail">
|
||||
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="f.local_definitions.length" class="pdt-row">
|
||||
<span class="pdt-tag local">defines its own</span>
|
||||
<span class="pdt-detail">
|
||||
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="f.superseded_literals.length" class="pdt-row">
|
||||
<span class="pdt-tag superseded">write the token</span>
|
||||
<span class="pdt-detail">
|
||||
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
|
||||
<code>{{ s.literal }}</code> → <code>{{ s.use_instead }}</code>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pdt {
|
||||
padding: var(--fs-space-2) 0;
|
||||
}
|
||||
|
||||
.pdt-note {
|
||||
background: var(--fs-surface-hover);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-left: 3px solid var(--fs-warning);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: var(--fs-space-3) var(--fs-space-4);
|
||||
}
|
||||
|
||||
.pdt-note p {
|
||||
margin: var(--fs-space-2) 0 0;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
line-height: var(--fs-leading-body);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.pdt-muted,
|
||||
.pdt-clean,
|
||||
.pdt-summary {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.pdt-clean {
|
||||
color: var(--fs-status-done);
|
||||
}
|
||||
|
||||
.pdt-summary {
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.pdt-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
|
||||
.pdt-finding {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdt-title {
|
||||
display: block;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--fs-space-2);
|
||||
}
|
||||
.pdt-title:hover { color: var(--fs-accent); }
|
||||
|
||||
.pdt-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--fs-space-2);
|
||||
flex-wrap: wrap;
|
||||
padding: 0.15rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdt-tag {
|
||||
font-size: var(--fs-size-tiny);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--fs-tracking-tiny);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.pdt-tag.unknown {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
}
|
||||
|
||||
.pdt-tag.local {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
}
|
||||
|
||||
.pdt-tag.superseded {
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.pdt-detail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-2);
|
||||
font-size: var(--fs-size-code);
|
||||
color: var(--fs-text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pdt-swap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -51,10 +51,10 @@ function onChange(e: Event) {
|
||||
<style scoped>
|
||||
.project-selector {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
@@ -62,6 +62,6 @@ function onChange(e: Event) {
|
||||
}
|
||||
.project-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -153,19 +153,19 @@ const calendarDayMax = computed(() =>
|
||||
}
|
||||
.rec-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
.rec-num-input {
|
||||
width: 4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--color-input-border, var(--color-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.rec-num-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.rec-unit { min-width: 6rem; }
|
||||
</style>
|
||||
|
||||
@@ -28,14 +28,14 @@ defineExpose({ focus: () => inputRef.value?.focus() });
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -206,9 +206,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.share-dialog {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-xl);
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
@@ -220,7 +220,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.share-title {
|
||||
@@ -228,7 +228,7 @@ onMounted(async () => {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -240,17 +240,17 @@ onMounted(async () => {
|
||||
|
||||
.share-tab {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.share-tab.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
@@ -269,23 +269,23 @@ onMounted(async () => {
|
||||
.share-input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.share-input:focus { border-color: var(--color-primary); }
|
||||
.share-input:focus { border-color: var(--fs-accent); }
|
||||
|
||||
.user-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 6px;
|
||||
margin-top: 2px;
|
||||
list-style: none;
|
||||
@@ -304,24 +304,24 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.user-result-item:hover { background: var(--color-bg-secondary); }
|
||||
.user-result-item:hover { background: var(--fs-surface-raised); }
|
||||
|
||||
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
||||
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
|
||||
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||||
|
||||
.perm-select {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
background: var(--fs-gradient-cta);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -342,7 +342,7 @@ onMounted(async () => {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ onMounted(async () => {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
@@ -369,10 +369,10 @@ onMounted(async () => {
|
||||
|
||||
.perm-select-inline {
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The starter token ROLES offered when a design system is created (#2349).
|
||||
*
|
||||
* WHY THIS IS A COMPONENT
|
||||
* DesignSystemsView has two creation forms — the empty state and the one inside
|
||||
* the body — because the empty state is a sibling branch, not a parent. Putting
|
||||
* the checklist inline would make it the third thing in this codebase defined
|
||||
* twice and free to drift, which is what the whole button migration was about.
|
||||
*
|
||||
* WHAT IT OFFERS
|
||||
* Names and purposes, never values. A role is a question the operator answers
|
||||
* with their own palette; a default palette would be one install's taste
|
||||
* shipped as product (rule #115). Every group is individually skippable —
|
||||
* an operator who wants three tokens should get three.
|
||||
*
|
||||
* All groups are checked by default. That default lives HERE rather than in the
|
||||
* service, because the service must never seed rows into a system whose caller
|
||||
* did not ask; a UI default is visible and reversible before the click.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
|
||||
|
||||
// props + emit rather than defineModel, matching TagInput and the rest of
|
||||
// components/ — being the only file using a different binding idiom costs more
|
||||
// than the few lines it saves.
|
||||
const props = defineProps<{ selected: string[]; prefix: string }>();
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [value: string[]];
|
||||
"update:prefix": [value: string];
|
||||
}>();
|
||||
|
||||
const groups = ref<StarterRoleGroup[]>([]);
|
||||
const defaultPrefix = ref("--ds-");
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listStarterRoleGroups();
|
||||
groups.value = data.groups;
|
||||
defaultPrefix.value = data.default_prefix;
|
||||
if (!props.prefix) emit("update:prefix", data.default_prefix);
|
||||
// Everything on by default — see the note above.
|
||||
if (!props.selected.length) {
|
||||
emit("update:selected", data.groups.map((g) => g.group));
|
||||
}
|
||||
} catch {
|
||||
// A creation form must still work when this fails. Roles are an
|
||||
// accelerator, not a prerequisite: the operator can add tokens by hand.
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggle(group: string) {
|
||||
emit(
|
||||
"update:selected",
|
||||
props.selected.includes(group)
|
||||
? props.selected.filter((g) => g !== group)
|
||||
: [...props.selected, group],
|
||||
);
|
||||
}
|
||||
|
||||
const totalTokens = () =>
|
||||
groups.value
|
||||
.filter((g) => props.selected.includes(g.group))
|
||||
.reduce((n, g) => n + g.token_count, 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="srp-note">Loading starter roles…</div>
|
||||
|
||||
<!-- Failure is not fatal and should not read as one. -->
|
||||
<div v-else-if="failed" class="srp-note">
|
||||
Starter roles unavailable — you can add tokens by hand after creating.
|
||||
</div>
|
||||
|
||||
<fieldset v-else-if="groups.length" class="srp">
|
||||
<legend class="srp-legend">Start with these token roles</legend>
|
||||
<p class="srp-intro">
|
||||
Named now, valued later. A role you haven't filled in shows as
|
||||
<em>to be decided</em>; a role that doesn't exist is what gets written as a
|
||||
literal instead. Uncheck anything this system won't have.
|
||||
</p>
|
||||
|
||||
<div class="srp-grid">
|
||||
<label v-for="g in groups" :key="g.group" class="srp-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="props.selected.includes(g.group)"
|
||||
@change="toggle(g.group)"
|
||||
/>
|
||||
<span class="srp-name">{{ g.group }}</span>
|
||||
<span class="srp-count">{{ g.token_count }}</span>
|
||||
<span class="srp-desc">{{ g.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="srp-footer">
|
||||
<label class="srp-prefix">
|
||||
<span>Prefix</span>
|
||||
<input
|
||||
:value="props.prefix" class="input srp-prefix-input" type="text"
|
||||
:placeholder="defaultPrefix"
|
||||
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span class="srp-total">
|
||||
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.srp {
|
||||
border: var(--fs-border);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-4);
|
||||
margin: 0 0 var(--fs-space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.srp-legend {
|
||||
font-size: var(--fs-size-label);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
color: var(--fs-text-primary);
|
||||
padding: 0 var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-intro,
|
||||
.srp-note {
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: var(--fs-leading-body);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.srp-note {
|
||||
margin-bottom: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: baseline;
|
||||
gap: var(--fs-space-2);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
.srp-item:hover { background: var(--fs-surface-hover); }
|
||||
|
||||
.srp-name {
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.srp-count {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The description is the useful part on a wide card and the first thing worth
|
||||
dropping on a narrow one — the group name alone still identifies the row. */
|
||||
.srp-desc {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.srp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-3);
|
||||
margin-top: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.srp-prefix-input {
|
||||
width: 8rem;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-code);
|
||||
}
|
||||
|
||||
.srp-total {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -38,20 +38,20 @@ const labels: Record<TaskStatus, string> = {
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.status-todo {
|
||||
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
|
||||
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%);
|
||||
}
|
||||
.status-in_progress {
|
||||
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
|
||||
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%);
|
||||
}
|
||||
.status-done {
|
||||
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
|
||||
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%);
|
||||
}
|
||||
.status-cancelled {
|
||||
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -70,9 +70,9 @@ defineExpose({ onKeyDown });
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
@@ -85,6 +85,6 @@ defineExpose({ onKeyDown });
|
||||
}
|
||||
.ac-item:hover,
|
||||
.ac-item.active {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -253,7 +253,7 @@ async function confirmDelete() {
|
||||
<template v-else>
|
||||
<span
|
||||
class="system-swatch"
|
||||
:style="{ background: system.color || 'var(--color-text-muted)' }"
|
||||
:style="{ background: system.color || 'var(--fs-text-tertiary)' }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="system-body">
|
||||
@@ -325,41 +325,41 @@ async function confirmDelete() {
|
||||
|
||||
/* ── Open issues ──────────────────────────────────────────────── */
|
||||
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fs-text-tertiary); }
|
||||
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||
.issue-link:hover { background: var(--color-bg-secondary); }
|
||||
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--fs-radius-sm); text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
|
||||
.issue-link:hover { background: var(--fs-surface-raised); }
|
||||
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--fs-accent); }
|
||||
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.btn-add-system {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-add-system:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.btn-add-system:focus-visible { outline: none; border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
.archived-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
|
||||
.archived-checkbox { accent-color: var(--fs-accent); cursor: pointer; }
|
||||
|
||||
/* ── Create / edit form ───────────────────────────────────────── */
|
||||
.system-form {
|
||||
@@ -367,26 +367,61 @@ async function confirmDelete() {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||
.system-input, .system-textarea {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
|
||||
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.system-textarea { resize: vertical; }
|
||||
|
||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||
|
||||
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
|
||||
`--archived` modifier and the `:hover .system-actions` reveal survived.
|
||||
The card WAS a flex row and every child still says so — `.system-swatch`
|
||||
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
|
||||
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
|
||||
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
|
||||
of text rather than centred against the whole card.
|
||||
|
||||
The list had no rule at all, so it rendered with browser bullets and
|
||||
indent — invisible to the dangling-style check, which can only see a class
|
||||
that is PARTLY styled. A class with no rules anywhere looks exactly like a
|
||||
semantic-only hook.
|
||||
|
||||
Surface values match `.system-form` above, which is the same card shape in
|
||||
this file and the reason they can be recovered rather than guessed. */
|
||||
.systems-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.system-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
.system-card--archived { opacity: 0.6; }
|
||||
|
||||
.system-swatch {
|
||||
@@ -398,13 +433,13 @@ async function confirmDelete() {
|
||||
}
|
||||
.system-body { flex: 1; min-width: 0; }
|
||||
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.system-name { font-weight: 500; color: var(--color-text); word-break: break-word; }
|
||||
.system-name { font-weight: 500; color: var(--fs-text-primary); word-break: break-word; }
|
||||
.issue-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
color: var(--fs-accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
@@ -414,15 +449,15 @@ async function confirmDelete() {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
}
|
||||
.system-description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -437,15 +472,15 @@ async function confirmDelete() {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
||||
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
||||
.action-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
.action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
|
||||
.action-btn:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 1px; opacity: 1; }
|
||||
.action-delete:hover { color: var(--fs-error); }
|
||||
|
||||
/* ── Empty ────────────────────────────────────────────────────── */
|
||||
.systems-empty {
|
||||
@@ -455,61 +490,29 @@ async function confirmDelete() {
|
||||
gap: 0.4rem;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
|
||||
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.skel-row {
|
||||
height: 3rem;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 25%,
|
||||
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
||||
var(--color-bg-secondary) 75%
|
||||
var(--fs-surface-raised) 25%,
|
||||
color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
|
||||
var(--fs-surface-raised) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
}
|
||||
.skel-row--short { width: 65%; }
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
}
|
||||
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
|
||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@ function scrollTo(id: string) {
|
||||
.toc-title {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -73,11 +73,11 @@ function scrollTo(id: string) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.toc-link {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toc-link:hover {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -154,9 +154,9 @@ function focusInput() {
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
cursor: text;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
@@ -166,9 +166,9 @@ function focusInput() {
|
||||
gap: 0.2rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
border: 1px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
border: 1px solid var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -195,7 +195,7 @@ function focusInput() {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -205,9 +205,9 @@ function focusInput() {
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -218,11 +218,11 @@ function focusInput() {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.tag-autocomplete-item:hover,
|
||||
.tag-autocomplete-item.selected {
|
||||
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
||||
color: var(--color-primary);
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,8 +29,8 @@ defineEmits<{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--color-tag-bg);
|
||||
color: var(--color-tag-text);
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
@@ -39,13 +39,13 @@ defineEmits<{
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tag-pill:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
}
|
||||
.dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-tag-text);
|
||||
color: var(--fs-accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
|
||||
@@ -108,15 +108,15 @@ function isOverdue(): boolean {
|
||||
.task-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
background: var(--fs-surface-raised);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot-todo {
|
||||
background: var(--color-status-todo, #94a3b8);
|
||||
border: 2px solid var(--color-status-todo, #94a3b8);
|
||||
background: var(--fs-status-todo);
|
||||
border: 2px solid var(--fs-status-todo);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-text-muted);
|
||||
border: 2px solid var(--fs-text-tertiary);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--color-status-in-progress, #3b82f6);
|
||||
background: var(--fs-status-in-progress);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
background: var(--fs-status-done);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-status-cancelled, #6b7280);
|
||||
background: var(--fs-status-cancelled);
|
||||
}
|
||||
|
||||
.task-title-compact {
|
||||
@@ -170,10 +170,10 @@ function isOverdue(): boolean {
|
||||
}
|
||||
.project-crumb {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.1rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
@@ -185,12 +185,12 @@ function isOverdue(): boolean {
|
||||
}
|
||||
.due-compact {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
color: var(--fs-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Full layout */
|
||||
@@ -211,7 +211,7 @@ function isOverdue(): boolean {
|
||||
}
|
||||
.task-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
max-height: 7.5em;
|
||||
overflow: hidden;
|
||||
@@ -224,15 +224,15 @@ function isOverdue(): boolean {
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--color-overdue);
|
||||
color: var(--fs-overdue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import type { TaskLog } from "@/types/task";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
|
||||
const props = defineProps<{ taskId: number }>();
|
||||
|
||||
@@ -15,13 +16,6 @@ const editingId = ref<number | null>(null);
|
||||
const editContent = ref("");
|
||||
const editDuration = ref("");
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${datePart}, ${timePart}`;
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
@@ -128,7 +122,7 @@ onMounted(loadLogs);
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="log-entry-meta">
|
||||
<span class="log-date">{{ formatDate(log.created_at) }}</span>
|
||||
<span class="log-date">{{ fmtStamp(log.created_at) }}</span>
|
||||
<span v-if="log.duration_minutes" class="log-duration-badge">
|
||||
{{ formatDuration(log.duration_minutes) }}
|
||||
</span>
|
||||
@@ -175,9 +169,9 @@ onMounted(loadLogs);
|
||||
|
||||
<style scoped>
|
||||
.log-section {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -187,7 +181,7 @@ onMounted(loadLogs);
|
||||
.log-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.15rem;
|
||||
@@ -195,11 +189,11 @@ onMounted(loadLogs);
|
||||
|
||||
.log-empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -212,11 +206,11 @@ onMounted(loadLogs);
|
||||
}
|
||||
|
||||
.log-date {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.log-duration-badge {
|
||||
background: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 99px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
@@ -235,10 +229,10 @@ onMounted(loadLogs);
|
||||
.log-textarea {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
@@ -247,7 +241,7 @@ onMounted(loadLogs);
|
||||
|
||||
.log-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.log-add-controls,
|
||||
@@ -259,7 +253,7 @@ onMounted(loadLogs);
|
||||
|
||||
.log-duration-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
@@ -268,16 +262,16 @@ onMounted(loadLogs);
|
||||
.log-duration-input {
|
||||
width: 5rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.log-duration-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ defineExpose({ editor });
|
||||
<style scoped>
|
||||
.editor-error {
|
||||
padding: 1rem;
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,13 +57,13 @@ const toastStore = useToastStore();
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.toast--success {
|
||||
background: var(--color-toast-success);
|
||||
background: var(--fs-success);
|
||||
}
|
||||
.toast--error {
|
||||
background: var(--color-toast-error);
|
||||
background: var(--fs-error);
|
||||
}
|
||||
.toast--warning {
|
||||
background: var(--color-warning);
|
||||
background: var(--fs-warning);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.toast--warning .toast-close {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A design system's tokens, drawn rather than listed (#2431).
|
||||
*
|
||||
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
|
||||
* Every value is resolved on an offscreen probe carrying only this system's
|
||||
* declarations (`resolveDeclared`), never read from the page. So a token like
|
||||
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
|
||||
* accent, not the accent of the app you happen to be looking at. Previewing
|
||||
* another project's palette from here is the point; a preview that quietly
|
||||
* borrows the host app's values would be worse than no preview, because it
|
||||
* would look right.
|
||||
*
|
||||
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
|
||||
* A colour is drawn as a swatch, a length as a rule of that length, a font
|
||||
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
|
||||
* naming convention, because the convention is the install's (rule #115) — a
|
||||
* system that calls its spacing `--gap-N` gets the same treatment.
|
||||
*
|
||||
* A token with no value for the chosen mode is shown as undecided rather than
|
||||
* skipped. A named role awaiting a decision is information; a gap in a grid
|
||||
* is not.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { ResolvedToken } from "@/api/designSystems";
|
||||
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
|
||||
|
||||
const props = defineProps<{ tokens: ResolvedToken[] }>();
|
||||
|
||||
const modes = computed(() => modesPresent(props.tokens));
|
||||
const mode = ref(BASE_MODE);
|
||||
|
||||
/** Values as the browser would compute them, for the chosen mode. */
|
||||
const rendered = ref<Map<string, string>>(new Map());
|
||||
|
||||
function recompute() {
|
||||
const declared = new Map<string, string>();
|
||||
for (const token of props.tokens) {
|
||||
const value = valueForMode(token.value_by_mode, mode.value);
|
||||
if (value) declared.set(token.name, value);
|
||||
}
|
||||
rendered.value = resolveDeclared(declared);
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.tokens, mode],
|
||||
() => {
|
||||
// Keep the selection only while it still exists — switching systems can
|
||||
// drop a mode, and a stale one would silently render as base.
|
||||
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
|
||||
recompute();
|
||||
},
|
||||
{ immediate: true, deep: false },
|
||||
);
|
||||
|
||||
type Shape = "colour" | "surface" | "length" | "font" | "plain";
|
||||
|
||||
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
|
||||
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
|
||||
const GRADIENT = /gradient\(/;
|
||||
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
|
||||
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
|
||||
/** A stack of family names: commas, no functions, no digits. */
|
||||
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
|
||||
|
||||
function shapeOf(value: string): Shape {
|
||||
const v = value.trim();
|
||||
if (!v) return "plain";
|
||||
if (COLOUR.test(v)) return "colour";
|
||||
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
|
||||
if (LENGTH.test(v)) return "length";
|
||||
if (FONT_STACK.test(v)) return "font";
|
||||
return "plain";
|
||||
}
|
||||
|
||||
interface Specimen {
|
||||
name: string;
|
||||
declared: string;
|
||||
rendered: string;
|
||||
shape: Shape;
|
||||
purpose: string | null;
|
||||
/** True when `var()` substitution changed the value — worth showing on hover. */
|
||||
substituted: boolean;
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
const out = new Map<string, Specimen[]>();
|
||||
for (const token of props.tokens) {
|
||||
const declared = valueForMode(token.value_by_mode, mode.value);
|
||||
const value = rendered.value.get(token.name) ?? "";
|
||||
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
|
||||
bucket.push({
|
||||
name: token.name,
|
||||
declared,
|
||||
rendered: value,
|
||||
shape: shapeOf(value),
|
||||
purpose: token.purpose,
|
||||
substituted: Boolean(declared) && value !== declared,
|
||||
});
|
||||
out.set(token.group_name ?? "ungrouped", bucket);
|
||||
}
|
||||
return [...out.entries()];
|
||||
});
|
||||
|
||||
/**
|
||||
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
|
||||
* are visibly different — but a stray `100vw` can't stretch the row.
|
||||
*/
|
||||
function ruleWidth(value: string): string {
|
||||
return `min(${value}, 12rem)`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tp">
|
||||
<div v-if="modes.length > 1" class="tp-modes">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m"
|
||||
class="tp-mode"
|
||||
:class="{ active: m === mode }"
|
||||
@click="mode = m"
|
||||
>{{ m }}</button>
|
||||
<span class="tp-modes-note">
|
||||
The system's own modes — independent of the theme this app is in.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
|
||||
<h3 class="tp-group-heading">{{ group }}</h3>
|
||||
<ul class="tp-grid">
|
||||
<li v-for="s in specimens" :key="s.name" class="tp-item">
|
||||
<div
|
||||
class="tp-specimen"
|
||||
:class="`is-${s.shape}`"
|
||||
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
|
||||
>
|
||||
<span
|
||||
v-if="s.shape === 'colour'"
|
||||
class="tp-swatch"
|
||||
:style="{ '--tp-fill': s.rendered }"
|
||||
/>
|
||||
<span
|
||||
v-else-if="s.shape === 'surface'"
|
||||
class="tp-surface"
|
||||
:style="s.rendered.includes('gradient(')
|
||||
? { background: s.rendered }
|
||||
: { boxShadow: s.rendered }"
|
||||
/>
|
||||
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
|
||||
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
|
||||
<span class="tp-rule-label">{{ s.rendered }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="s.shape === 'font'"
|
||||
class="tp-font"
|
||||
:style="{ fontFamily: s.rendered }"
|
||||
>Ag</span>
|
||||
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
|
||||
<span v-else class="tp-plain">{{ s.rendered }}</span>
|
||||
</div>
|
||||
|
||||
<code class="tp-name">{{ s.name }}</code>
|
||||
<span class="tp-value" :title="s.declared">{{ s.declared || "—" }}</span>
|
||||
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tp-modes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.tp-mode {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font: inherit;
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tp-mode:hover { color: var(--fs-text-primary); }
|
||||
.tp-mode.active {
|
||||
color: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
background: var(--fs-accent-faint);
|
||||
}
|
||||
|
||||
.tp-modes-note {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.tp-group { margin-bottom: var(--fs-space-6); }
|
||||
|
||||
.tp-group-heading {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--fs-tracking-tiny);
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
padding-bottom: var(--fs-space-2);
|
||||
border-bottom: var(--fs-border);
|
||||
}
|
||||
|
||||
.tp-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: var(--fs-space-4) var(--fs-space-3);
|
||||
}
|
||||
|
||||
.tp-item {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
|
||||
*
|
||||
* The stage itself is plain. An earlier version put the checkerboard here, so
|
||||
* every specimen — including opaque colours and plain text — sat inside a
|
||||
* frame of checks, and the pattern read as the loudest thing on the page. The
|
||||
* checks belong to the ONE case that needs them: a colour that might be
|
||||
* translucent. */
|
||||
.tp-specimen {
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: var(--fs-space-1);
|
||||
overflow: hidden;
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
/* Text-bearing specimens get no box at all — a border around a value is a
|
||||
frame around nothing, which is most of what made the grid feel busy. */
|
||||
.tp-specimen.is-plain,
|
||||
.tp-specimen.is-length,
|
||||
.tp-specimen.is-font {
|
||||
background: none;
|
||||
padding: 0 var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* Checks UNDER the colour, not around it: an opaque value hides them
|
||||
completely, and a 15% tint shows exactly as much of them as it should.
|
||||
Layering the fill as a gradient is what lets one element do both. */
|
||||
.tp-swatch {
|
||||
/* Declared here, overridden inline per swatch. Two reasons it is a real
|
||||
default rather than a formality: a token that resolves to nothing renders
|
||||
as bare checks instead of an invalid gradient, and a custom property that
|
||||
exists ONLY as an inline style is invisible to the CI token check — which
|
||||
reads it as an unresolvable reference, correctly, since nothing in any
|
||||
stylesheet declares it. */
|
||||
--tp-fill: transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||
background-image:
|
||||
linear-gradient(var(--tp-fill), var(--tp-fill)),
|
||||
repeating-conic-gradient(
|
||||
var(--fs-border-color) 0% 25%,
|
||||
var(--fs-surface-raised) 0% 50%
|
||||
);
|
||||
background-size: auto, 10px 10px;
|
||||
}
|
||||
|
||||
.tp-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
.tp-rule-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tp-rule {
|
||||
height: 0.4rem;
|
||||
min-width: 1px;
|
||||
flex: none;
|
||||
background: var(--fs-accent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tp-rule-label {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tp-font {
|
||||
font-size: 1.4rem;
|
||||
color: var(--fs-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tp-plain {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-code);
|
||||
color: var(--fs-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tp-undecided {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* One line each, with the full text on hover.
|
||||
*
|
||||
* These wrapped freely at first, so a card was two lines tall or five depending
|
||||
* on how long its `color-mix()` happened to be, and the grid lost any rhythm —
|
||||
* which is most of what "messy" was. A derived value is not something anyone
|
||||
* reads character by character in a gallery; it is something you check the
|
||||
* shape of and open if it matters. */
|
||||
.tp-name,
|
||||
.tp-value,
|
||||
.tp-purpose {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tp-name {
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.tp-value {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.tp-purpose {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -164,7 +164,7 @@ function restore() {
|
||||
|
||||
<style scoped>
|
||||
.vh-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.vh-header {
|
||||
@@ -179,19 +179,19 @@ function restore() {
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.vh-header:hover { background: var(--color-bg-secondary); }
|
||||
.vh-header:hover { background: var(--fs-surface-raised); }
|
||||
|
||||
.vh-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.vh-chevron {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.vh-body {
|
||||
@@ -201,20 +201,20 @@ function restore() {
|
||||
.vh-empty {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.vh-item {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.vh-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-left-color: var(--color-primary);
|
||||
background: var(--fs-surface-raised);
|
||||
border-left-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.vh-diff-actions {
|
||||
@@ -225,20 +225,20 @@ function restore() {
|
||||
|
||||
.vh-btn-back {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.vh-btn-back:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
.vh-btn-restore {
|
||||
background: var(--color-action-primary);
|
||||
background: var(--fs-action-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-on-action);
|
||||
|
||||
@@ -50,11 +50,11 @@ const label = computed(() => {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.word-count:hover { color: var(--color-text); }
|
||||
.word-count:hover { color: var(--fs-text-primary); }
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,7 @@ import TagInput from "@/components/TagInput.vue";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import WordCount from "@/components/WordCount.vue";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
@@ -252,20 +253,6 @@ async function confirmDelete(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHrs < 24) return `${diffHrs}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
watch(noteTitle, () => { dirty.value = true; });
|
||||
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
||||
watch(noteTags, () => { dirty.value = true; });
|
||||
@@ -346,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
>
|
||||
<div class="note-row-main">
|
||||
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
|
||||
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
|
||||
<span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
|
||||
</div>
|
||||
<div v-if="note.tags?.length" class="note-row-tags">
|
||||
<span
|
||||
@@ -452,8 +439,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-hover);
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
/* ── Left rail ── */
|
||||
@@ -463,7 +450,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
@@ -471,12 +458,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
@@ -490,7 +477,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -503,7 +490,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.rail-state {
|
||||
padding: 1rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
@@ -519,16 +506,16 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 60%, transparent);
|
||||
cursor: pointer;
|
||||
gap: 0.15rem;
|
||||
border-right: 2px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
||||
.note-row.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-right-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
|
||||
border-right-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.note-row-main {
|
||||
@@ -544,12 +531,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.note-row-age {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -562,8 +549,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.note-tag-pill {
|
||||
font-size: 0.58rem;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.3rem;
|
||||
white-space: nowrap;
|
||||
@@ -572,13 +559,13 @@ defineExpose({ reload: loadProjectNotes });
|
||||
max-width: 5rem;
|
||||
}
|
||||
.note-tag-pill.tag-match {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
outline: 1px solid var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
||||
outline: 1px solid var(--fs-accent);
|
||||
}
|
||||
|
||||
.note-tag-more {
|
||||
font-size: 0.58rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -606,8 +593,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
.unsaved { font-size: 0.72rem; color: var(--fs-text-tertiary); }
|
||||
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
|
||||
@@ -618,14 +605,14 @@ defineExpose({ reload: loadProjectNotes });
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
padding: 0;
|
||||
font-family: 'Fraunces', serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
@@ -645,25 +632,25 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.tag-suggestions-label { font-size: 0.72rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||
|
||||
.btn-tag-suggestion {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-tag-suggestion:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-tag-suggestion:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.btn-tag-suggestion.applied {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -673,12 +660,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.link-suggest-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
@@ -688,15 +675,15 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.btn-chip-link {
|
||||
background: none;
|
||||
border: 1px solid var(--color-primary);
|
||||
border: 1px solid var(--fs-accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
@@ -198,20 +199,6 @@ function cancelDeleteTask() {
|
||||
deleteConfirmPending.value = false;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHrs < 24) return `${diffHrs}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
onMounted(loadAll);
|
||||
defineExpose({ reload: loadAll });
|
||||
</script>
|
||||
@@ -256,7 +243,7 @@ defineExpose({ reload: loadAll });
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
@@ -281,7 +268,7 @@ defineExpose({ reload: loadAll });
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
@@ -344,8 +331,8 @@ defineExpose({ reload: loadAll });
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-hover);
|
||||
border-right: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
/* ── List view ── */
|
||||
@@ -360,17 +347,17 @@ defineExpose({ reload: loadAll });
|
||||
flex: 0 0 44%;
|
||||
}
|
||||
.task-active {
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important;
|
||||
background: color-mix(in srgb, var(--fs-accent) 6%, var(--fs-surface-hover)) !important;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
@@ -381,20 +368,20 @@ defineExpose({ reload: loadAll });
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-add-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 0.28rem 0.5rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.task-add-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||
|
||||
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
||||
|
||||
@@ -404,7 +391,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
|
||||
.ms-group {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.ms-group-header {
|
||||
@@ -413,18 +400,18 @@ defineExpose({ reload: loadAll });
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.65rem;
|
||||
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
|
||||
background: var(--fs-surface-raised);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
|
||||
.ms-group-header:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover)); }
|
||||
|
||||
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
|
||||
.ms-chevron { font-size: 0.6rem; color: var(--fs-text-tertiary); width: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
||||
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
|
||||
.ms-count { font-size: 0.72rem; color: var(--fs-text-tertiary); background: var(--fs-surface-page); border-radius: 10px; padding: 0 0.4rem; }
|
||||
|
||||
.ms-status {
|
||||
font-size: 0.68rem;
|
||||
@@ -432,8 +419,8 @@ defineExpose({ reload: loadAll });
|
||||
border-radius: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
|
||||
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
|
||||
|
||||
.task-items {
|
||||
list-style: none;
|
||||
@@ -447,9 +434,9 @@ defineExpose({ reload: loadAll });
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 50%, transparent);
|
||||
}
|
||||
.task-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.task-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
||||
.task-row:last-child { border-bottom: none; }
|
||||
|
||||
.status-dot {
|
||||
@@ -457,7 +444,7 @@ defineExpose({ reload: loadAll });
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border: 1.5px solid var(--fs-border-color);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.62rem;
|
||||
@@ -465,8 +452,8 @@ defineExpose({ reload: loadAll });
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
|
||||
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
|
||||
|
||||
.task-title {
|
||||
flex: 1;
|
||||
@@ -474,20 +461,20 @@ defineExpose({ reload: loadAll });
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.task-title.done { text-decoration: line-through; color: var(--color-text-muted); }
|
||||
.task-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
|
||||
|
||||
.task-age {
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* ── Detail pane (bottom split) ── */
|
||||
.task-detail {
|
||||
@@ -496,8 +483,8 @@ defineExpose({ reload: loadAll });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
border-top: 2px solid var(--color-border);
|
||||
background: var(--fs-surface-hover);
|
||||
border-top: 2px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
@@ -505,7 +492,7 @@ defineExpose({ reload: loadAll });
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -515,21 +502,21 @@ defineExpose({ reload: loadAll });
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border: 1.5px solid var(--fs-border-color);
|
||||
background: none;
|
||||
text-transform: capitalize;
|
||||
user-select: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
||||
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
|
||||
.status-badge.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
.status-badge.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
||||
|
||||
.btn-edit-task { margin-left: 0.25rem; }
|
||||
.btn-edit-task:hover { text-decoration: underline; }
|
||||
|
||||
.detail-body {
|
||||
padding: 0.5rem 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
max-height: 40%;
|
||||
overflow-y: auto;
|
||||
@@ -537,17 +524,17 @@ defineExpose({ reload: loadAll });
|
||||
|
||||
.body-loading {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.detail-body .prose {
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.btn-delete-task { margin-left: 0.25rem; }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
.btn-delete-task:hover { color: var(--fs-action-destructive); }
|
||||
|
||||
.btn-delete-confirm { margin-left: 0.25rem; }
|
||||
|
||||
@@ -563,9 +550,9 @@ defineExpose({ reload: loadAll });
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
@@ -573,20 +560,20 @@ defineExpose({ reload: loadAll });
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-tertiary);
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
}
|
||||
.milestone-select:disabled { opacity: 0.5; cursor: default; }
|
||||
.milestone-select:focus { outline: none; border-color: var(--color-primary); }
|
||||
.milestone-select:focus { outline: none; border-color: var(--fs-accent); }
|
||||
|
||||
.detail-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.6rem 0.6rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
/* Detail fade transition */
|
||||
@@ -609,12 +596,12 @@ defineExpose({ reload: loadAll });
|
||||
/* Due date on task rows */
|
||||
.task-due {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
color: var(--fs-error);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,13 +46,19 @@ watch(() => props.projectId, load);
|
||||
<style scoped>
|
||||
.plan-rules {
|
||||
margin-top: 1.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.plan-rules h3 {
|
||||
font-size: 0.9em; opacity: 0.7;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
|
||||
below, and its children carry their own spacing (the h4 keeps the UA
|
||||
margin-top that separates one rulebook group from the next). Nothing here
|
||||
assumes a flex or grid parent, which is the tell that distinguishes this
|
||||
from a base rule someone deleted (#2444). Stated so the next reader doesn't
|
||||
re-open the question. */
|
||||
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
||||
.rb h5 {
|
||||
font-size: 0.8em; opacity: 0.7;
|
||||
@@ -60,7 +66,7 @@ watch(() => props.projectId, load);
|
||||
}
|
||||
.plan-rules ul {
|
||||
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
}
|
||||
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
||||
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
||||
|
||||
@@ -2,10 +2,18 @@
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||
listRulebooks, getRule, createProjectRule, deleteRule,
|
||||
suppressRuleForProject, unsuppressRuleForProject,
|
||||
suppressTopicForProject, unsuppressTopicForProject,
|
||||
getProjectApplicableRules,
|
||||
subscribeProject,
|
||||
unsubscribeProject,
|
||||
listRulebooks,
|
||||
getRule,
|
||||
createProjectRule,
|
||||
deleteRule,
|
||||
suppressRuleForProject,
|
||||
unsuppressRuleForProject,
|
||||
suppressTopicForProject,
|
||||
unsuppressTopicForProject,
|
||||
includeAlwaysOnRulebook,
|
||||
} from "@/api/rulebooks";
|
||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||
|
||||
@@ -35,6 +43,11 @@ async function subscribe(rulebookId: number) {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function includeBack(rulebookId: number) {
|
||||
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function unsubscribe(rulebookId: number) {
|
||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||
await unsubscribeProject(props.projectId, rulebookId);
|
||||
@@ -172,6 +185,17 @@ watch(() => props.projectId, load);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="applicable.excluded_always_on?.length" class="excluded">
|
||||
<h3>Excluded always-on rulebooks</h3>
|
||||
<p class="excluded-note">Opted out at inception — these do not bind this project.</p>
|
||||
<div class="chips">
|
||||
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
|
||||
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
||||
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again">↩</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="project-rules">
|
||||
<div class="section-head">
|
||||
<h3>Project rules</h3>
|
||||
@@ -321,6 +345,9 @@ watch(() => props.projectId, load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||
.chip-excluded .chip-remove { text-decoration: none; }
|
||||
.rules-tab { padding: 1rem; }
|
||||
h3 {
|
||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
@@ -329,7 +356,7 @@ h3 {
|
||||
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
background: var(--color-primary-bg, rgba(99,102,241,0.15));
|
||||
background: var(--fs-accent-soft);
|
||||
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||||
}
|
||||
.chip a { cursor: pointer; }
|
||||
@@ -337,25 +364,29 @@ h3 {
|
||||
.chip-remove:hover { opacity: 1; }
|
||||
.add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border, #2a2a2e);
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
select {
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.applicable { margin-top: 2rem; }
|
||||
.rb-group { margin-bottom: 1.5rem; }
|
||||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
||||
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
|
||||
one and the flex row further down), with the h5's own margin-top doing the
|
||||
separating. Its children assume nothing about it, which is what tells it
|
||||
apart from a base rule someone deleted (#2444). */
|
||||
.topic-group h5 {
|
||||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule {
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
padding-left: 0.75rem; margin: 0.5rem 0;
|
||||
}
|
||||
.rule-head { cursor: pointer; }
|
||||
@@ -363,12 +394,12 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||
.rule-detail {
|
||||
margin-top: 0.5rem; padding: 0.5rem;
|
||||
background: var(--color-bg, #111113); border-radius: 6px;
|
||||
background: var(--fs-surface-page); border-radius: 6px;
|
||||
}
|
||||
.rule-detail > div { margin-bottom: 0.5rem; }
|
||||
.edit-link {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
|
||||
color: var(--fs-accent); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||
.empty a { cursor: pointer; text-decoration: underline; }
|
||||
@@ -377,18 +408,18 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.new-rule-form {
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
padding: 0.75rem; margin: 0.5rem 0;
|
||||
background: var(--color-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
}
|
||||
.new-rule-form input, .new-rule-form textarea {
|
||||
background: var(--color-surface, #18181b); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--fs-surface-hover); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem; font: inherit; resize: vertical;
|
||||
}
|
||||
.rule-list { margin-top: 0.5rem; }
|
||||
.delete-link {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
||||
color: var(--fs-destructive); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||
.topic-group h5 {
|
||||
@@ -400,14 +431,14 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule-head-text { flex: 1; cursor: pointer; }
|
||||
.skip-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-muted, #888); font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary); font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topic-group h5:hover .skip-btn,
|
||||
.rule:hover .skip-btn,
|
||||
.skip-btn:focus { opacity: 1; }
|
||||
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
||||
.skip-btn:hover { color: var(--fs-destructive); }
|
||||
/* Suppressed section */
|
||||
.suppressed { margin-top: 1.5rem; }
|
||||
.suppressed-toggle {
|
||||
@@ -426,13 +457,13 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.suppressed-kind {
|
||||
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||
background: var(--color-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.suppressed-path { flex: 1; }
|
||||
.reenable-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
||||
color: var(--fs-accent); font-size: 0.85em;
|
||||
}
|
||||
.reenable-btn:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
|
||||
.slide-over {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: min(520px, 90vw);
|
||||
background: var(--color-surface, #18181b);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
background: var(--fs-surface-hover);
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||
@@ -110,11 +110,11 @@ header h2 {
|
||||
font-family: Fraunces, serif; font-style: italic;
|
||||
}
|
||||
label { display: block; margin-bottom: 1rem; }
|
||||
.required { color: var(--color-primary, #6366f1); }
|
||||
.required { color: var(--fs-accent); }
|
||||
input, textarea {
|
||||
width: 100%; margin-top: 0.25rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem; font: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -22,18 +22,18 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
margin-bottom: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.new-rule { cursor: pointer; }
|
||||
|
||||
@@ -122,7 +122,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
.always-on-toggle {
|
||||
@@ -133,18 +133,23 @@ header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem
|
||||
.always-on-toggle input { cursor: pointer; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
|
||||
button-or-form whose children style themselves; the second is a `<ul>`, and
|
||||
the bare `ul` rule above already gives it list-style, padding and margin —
|
||||
a base a class-name check cannot see, since it comes from an element
|
||||
selector. Both namespace descendant rules and assume nothing about layout. */
|
||||
.new-topic input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
.subscriptions {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
@@ -48,27 +48,27 @@ async function submitNew() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
.always-on-badge {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-accent, rgba(91,74,138,0.25));
|
||||
color: var(--color-accent-fg, inherit);
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-on-action);
|
||||
margin-left: auto;
|
||||
}
|
||||
.new-rulebook { margin-top: 1rem; }
|
||||
.new-rulebook input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--fs-surface-page); color: inherit;
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
|
||||
@@ -9,3 +9,15 @@ export function relativeTime(iso: string): string {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* relativeTime() for the recent past, a short date once it's a week old —
|
||||
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
|
||||
* panels used to carry identical copies of this.
|
||||
*/
|
||||
export function relativeTimeOrDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
||||
if (days < 7) return relativeTime(iso);
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
@@ -110,15 +110,11 @@ const router = createRouter({
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// Meta-surface, same family as /rules: it describes the app rather than
|
||||
// holding the operator's records.
|
||||
path: "/design",
|
||||
name: "design",
|
||||
component: () => import("@/views/DesignView.vue"),
|
||||
},
|
||||
{
|
||||
// The editable half of the same surface: /design is what the browser
|
||||
// renders, /design-systems is the record that ought to decide it.
|
||||
// The design systems this install RECORDS — for the projects it tracks,
|
||||
// not for the install itself. There was a sibling `/design` that read the
|
||||
// running app's own stylesheet out of the browser; it could only ever
|
||||
// inspect the instance it was served from, which made it a mirror rather
|
||||
// than a tool (#274).
|
||||
path: "/design-systems",
|
||||
name: "design-systems",
|
||||
component: () => import("@/views/DesignSystemsView.vue"),
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface Note {
|
||||
title: string;
|
||||
body: string;
|
||||
description: string | null;
|
||||
consolidated_at: string | null;
|
||||
tags: string[];
|
||||
parent_id: number | null;
|
||||
parent_title?: string | null;
|
||||
|
||||
@@ -1,65 +1,32 @@
|
||||
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
|
||||
|
||||
function _isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
/** "9:30 AM" */
|
||||
export function fmtTime(dt: string): string {
|
||||
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
||||
}
|
||||
|
||||
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
|
||||
export function fmtDateTime(dt: string, allDay: boolean): string {
|
||||
const d = new Date(dt)
|
||||
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
||||
if (allDay) return datePart
|
||||
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
||||
}
|
||||
|
||||
/**
|
||||
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
|
||||
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||
* Shared date/time formatting — one rule per display shape. Views import
|
||||
* these instead of carrying a local formatDate(): the 2026-08 shape audit
|
||||
* found eight copies across views/components, three of them byte-identical.
|
||||
* (The previous Calendar/Home helpers in this file had no callers left and
|
||||
* were removed in the same pass.)
|
||||
*
|
||||
* Relative forms ("5m ago") live next door in composables/useRelativeTime.
|
||||
*/
|
||||
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
|
||||
try {
|
||||
const d = new Date(dt)
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(now.getDate() + 1)
|
||||
|
||||
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
||||
|
||||
if (_isSameDay(d, now)) return `Today${timeStr}`
|
||||
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
/** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
|
||||
export function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric", month: "short", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
|
||||
*/
|
||||
export function fmtDayLabel(dt: string): string {
|
||||
try {
|
||||
const d = new Date(dt)
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(now.getDate() + 1)
|
||||
if (_isSameDay(d, now)) return "Today"
|
||||
if (_isSameDay(d, tomorrow)) return "Tomorrow"
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
/** "Jan 15, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
|
||||
export function fmtStamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
|
||||
export function fmtCompact(dt: string, allDay: boolean): string {
|
||||
const d = new Date(dt)
|
||||
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
||||
/** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
|
||||
export function fmtLogStamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
||||
*
|
||||
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
||||
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
||||
* can assert on it. What's left here is set arithmetic over live token values,
|
||||
* which is the one thing the browser knows and the server doesn't.
|
||||
*
|
||||
* SCOPE, and it is a real limit rather than an omission. This compares the
|
||||
* rulebook against the TOKENS. It cannot see the third category of drift — a
|
||||
* literal hardcoded in a component where a token should be referenced (#2275,
|
||||
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
||||
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
||||
*
|
||||
* Catching it needs the component sources, which would mean bundling every SFC
|
||||
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
|
||||
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
|
||||
* matters: a drift report that silently omits a category invites the reader to
|
||||
* conclude the category is clean.
|
||||
*/
|
||||
import type { DesignToken } from "@/utils/designTokens";
|
||||
|
||||
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
||||
|
||||
export interface Expectation {
|
||||
kind: ExpectationKind;
|
||||
value: string;
|
||||
rule_id: number;
|
||||
rule_title: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface ExpectationResponse {
|
||||
rulebook_id: number | null;
|
||||
expectations: Expectation[];
|
||||
}
|
||||
|
||||
export type FindingStatus = "ok" | "missing" | "violated";
|
||||
|
||||
export interface Finding {
|
||||
expectation: Expectation;
|
||||
status: FindingStatus;
|
||||
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a colour for comparison — the client-side twin of
|
||||
* `normalize_hex` in services/design_system.py.
|
||||
*
|
||||
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
||||
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
||||
* spellings of one colour, and a comparison that misses any of them under-reports
|
||||
* rather than erroring. The rgb() case is browser-specific and therefore has no
|
||||
* server-side counterpart, which is exactly why it is handled here.
|
||||
*/
|
||||
export function normalizeColour(value: string): string | null {
|
||||
const raw = value.trim().toLowerCase();
|
||||
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
||||
if (hex) {
|
||||
let digits = hex[1];
|
||||
if (digits.length === 3 || digits.length === 4) {
|
||||
digits = digits.split("").map((c) => c + c).join("");
|
||||
}
|
||||
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
||||
}
|
||||
|
||||
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
|
||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||
if (rgb) {
|
||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const channels = parts.slice(0, 3).map((p) => Number(p));
|
||||
if (channels.some((n) => !Number.isFinite(n))) return null;
|
||||
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
||||
const base = `#${channels.map(hexOf).join("")}`;
|
||||
if (parts.length === 3) return base;
|
||||
const alpha = Number(parts[3]);
|
||||
if (!Number.isFinite(alpha) || alpha >= 1) return base;
|
||||
return `${base}${hexOf(alpha * 255)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
||||
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
||||
const index = new Map<string, string[]>();
|
||||
for (const token of tokens) {
|
||||
const colour = normalizeColour(token.value);
|
||||
if (!colour) continue;
|
||||
const names = index.get(colour);
|
||||
if (names) names.push(token.name);
|
||||
else index.set(colour, [token.name]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare claims against the live tokens.
|
||||
*
|
||||
* A `token` claim asks whether a custom property of that name exists.
|
||||
* A `color` claim asks whether any token resolves to that value.
|
||||
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
||||
*/
|
||||
export function compareToTokens(
|
||||
expectations: Expectation[],
|
||||
tokens: DesignToken[],
|
||||
): Finding[] {
|
||||
const names = new Set(tokens.map((t) => t.name));
|
||||
const colours = colourIndex(tokens);
|
||||
|
||||
return expectations.map((expectation) => {
|
||||
if (expectation.kind === "token") {
|
||||
const present = names.has(expectation.value);
|
||||
return {
|
||||
expectation,
|
||||
status: present ? "ok" : "missing",
|
||||
matches: present ? [expectation.value] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const matches = colours.get(expectation.value) ?? [];
|
||||
if (expectation.kind === "prohibited_color") {
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "violated" : "ok",
|
||||
matches,
|
||||
};
|
||||
}
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "ok" : "missing",
|
||||
matches,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface DriftSummary {
|
||||
ok: number;
|
||||
missing: number;
|
||||
violated: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarise(findings: Finding[]): DriftSummary {
|
||||
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
||||
for (const finding of findings) summary[finding.status] += 1;
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings worth leading with.
|
||||
*
|
||||
* A panel that opens with every row gets closed and never reopened — the same
|
||||
* principle the auto-inject menu is built on: a short list that gets read beats
|
||||
* a complete one that doesn't. Violations first (something is actively wrong),
|
||||
* then missing (something was never built), and `ok` rows are not "findings" at
|
||||
* all — they belong behind an expansion.
|
||||
*/
|
||||
export function rankFindings(findings: Finding[]): Finding[] {
|
||||
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
||||
return [...findings].sort((a, b) => {
|
||||
const byStatus = order[a.status] - order[b.status];
|
||||
if (byStatus !== 0) return byStatus;
|
||||
return a.expectation.rule_id - b.expectation.rule_id;
|
||||
});
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||
*
|
||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
||||
* against these, and the drift panel compares them to the design rulebook.
|
||||
*
|
||||
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||
* its VALUE is not: values contain nested parens, commas inside rgba(),
|
||||
* `var()` references to other tokens, multi-part shadows, and gradients — and
|
||||
* `theme.css` has all of those today. So we take the names from the source and
|
||||
* ask the BROWSER for every value.
|
||||
*
|
||||
* That is not just easier, it is more correct. getComputedStyle reports what
|
||||
* actually won the cascade, resolves `var()` chains, and — critically for this
|
||||
* milestone — reflects live overrides set on a container, which is exactly what
|
||||
* the preview surface needs (see #2261). Parsing the source would report what
|
||||
* the file says rather than what the user is looking at.
|
||||
*
|
||||
* It also means this module needs no unit tests to be trustworthy: the only
|
||||
* logic here is a name regex and a group lookup. The frontend has no test
|
||||
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
|
||||
* error-prone half in the browser rather than in our code is deliberate.
|
||||
*/
|
||||
import themeCss from "@/assets/theme.css?raw";
|
||||
|
||||
export type TokenGroup =
|
||||
| "color"
|
||||
| "radius"
|
||||
| "gradient"
|
||||
| "glow"
|
||||
| "focus"
|
||||
| "layout"
|
||||
| "other";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export interface DesignToken {
|
||||
/** Full custom-property name, including the leading `--`. */
|
||||
name: string;
|
||||
/** Coarse family, derived from the name prefix. */
|
||||
group: TokenGroup;
|
||||
/** Resolved value in the requested context, straight from the browser. */
|
||||
value: string;
|
||||
/** True when the declaration appears inside the dark block in source. */
|
||||
overriddenInDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
|
||||
*
|
||||
* The discriminator is the COLON, not the preceding character. A declaration is
|
||||
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
|
||||
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
|
||||
* actively wrong: an earlier version required the match to follow `{` or `;`,
|
||||
* which silently dropped every declaration that came after a comment —
|
||||
* including `--color-bg`, the first and most-used token in the file.
|
||||
*/
|
||||
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
||||
|
||||
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||
|
||||
/** The dark block's selector, as written in theme.css. */
|
||||
const DARK_SELECTOR = '[data-theme="dark"]';
|
||||
|
||||
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||
["--color-", "color"],
|
||||
["--radius-", "radius"],
|
||||
["--gradient-", "gradient"],
|
||||
["--glow-", "glow"],
|
||||
["--focus-", "focus"],
|
||||
["--page-", "layout"],
|
||||
["--sidebar-", "layout"],
|
||||
["--chat-", "layout"],
|
||||
];
|
||||
|
||||
export function groupFor(name: string): TokenGroup {
|
||||
for (const [prefix, group] of GROUP_PREFIXES) {
|
||||
if (name.startsWith(prefix)) return group;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
|
||||
export function tokenNames(css: string = themeCss): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
|
||||
const name = match[1];
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
||||
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
||||
const bare = css.replace(COMMENT, "");
|
||||
const start = bare.indexOf(DARK_SELECTOR);
|
||||
if (start === -1) return new Set();
|
||||
const open = bare.indexOf("{", start);
|
||||
const close = bare.indexOf("}", open);
|
||||
if (open === -1 || close === -1) return new Set();
|
||||
return new Set(tokenNames(bare.slice(open, close)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved value of every token in `host`'s context.
|
||||
*
|
||||
* Pass a container to read the tokens as they apply INSIDE it — which is how
|
||||
* the preview surface reads a scoped override without disturbing the page.
|
||||
* Defaults to the document root, i.e. the app-wide values.
|
||||
*/
|
||||
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||
const computed = getComputedStyle(host);
|
||||
const dark = darkOverriddenNames();
|
||||
return tokenNames().map((name) => ({
|
||||
name,
|
||||
group: groupFor(name),
|
||||
value: computed.getPropertyValue(name).trim(),
|
||||
overriddenInDark: dark.has(name),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tokens as they would resolve in a given mode, without touching the page.
|
||||
*
|
||||
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
|
||||
* mutated to take a reading.
|
||||
*
|
||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
||||
* function: light is declared on `:root` while dark is declared on
|
||||
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
||||
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
||||
* back. So reading "light" from inside a dark page returns the dark values —
|
||||
* the probe has nothing to match.
|
||||
*
|
||||
* Concretely: dark-inside-light previews work, light-inside-dark previews do
|
||||
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
||||
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
||||
* should treat a cross-mode read as best-effort.
|
||||
*/
|
||||
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||
const probe = document.createElement("div");
|
||||
probe.setAttribute("data-theme", mode);
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
try {
|
||||
return readTokens(probe);
|
||||
} finally {
|
||||
probe.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens grouped by family, preserving source order within each group. */
|
||||
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
|
||||
const out = new Map<TokenGroup, DesignToken[]>();
|
||||
for (const token of tokens) {
|
||||
const bucket = out.get(token.group);
|
||||
if (bucket) bucket.push(token);
|
||||
else out.set(token.group, [token]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens declared in the stylesheet that nothing references with `var()`.
|
||||
*
|
||||
* Dead tokens are drift too: `--chat-reading-width` and
|
||||
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
|
||||
* in the MCP-first pivot, and nothing has referenced them since. Takes the
|
||||
* corpus of source files to search as an argument so the caller decides what
|
||||
* "used" means — this module has no opinion about the project layout.
|
||||
*/
|
||||
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
|
||||
const haystack = sources.join("\n");
|
||||
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Turning a design system's RECORDED values into ones you can look at.
|
||||
*
|
||||
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
|
||||
* running app's own stylesheet — names out of a bundled `theme.css`, values out
|
||||
* of `getComputedStyle(document.documentElement)`. That could only ever describe
|
||||
* the install serving the page, and the design surface is for the projects an
|
||||
* install TRACKS (#274). What is left here works on any system's record,
|
||||
* including one for an app this browser has never loaded.
|
||||
*
|
||||
* Nothing in this module reads the document's own tokens or mutates the page.
|
||||
*/
|
||||
|
||||
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
|
||||
export const BASE_MODE = "base";
|
||||
|
||||
/**
|
||||
* Which declared value applies in `mode`.
|
||||
*
|
||||
* Falls back to base, which is the storage model rather than a convenience: a
|
||||
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
||||
* mode is not missing — it is inheriting, exactly as the generated sheet has it.
|
||||
*/
|
||||
export function valueForMode(
|
||||
valueByMode: Record<string, string>,
|
||||
mode: string,
|
||||
): string {
|
||||
const own = valueByMode[mode];
|
||||
if (own !== undefined && own !== "") return own;
|
||||
return valueByMode[BASE_MODE] ?? "";
|
||||
}
|
||||
|
||||
/** Every mode any token in the set declares, base first then the rest by name. */
|
||||
export function modesPresent(
|
||||
tokens: { value_by_mode: Record<string, string> }[],
|
||||
): string[] {
|
||||
const modes = new Set<string>();
|
||||
for (const token of tokens) {
|
||||
for (const [mode, value] of Object.entries(token.value_by_mode)) {
|
||||
if (value) modes.add(mode);
|
||||
}
|
||||
}
|
||||
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
|
||||
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve declared values the way a browser would, without applying them.
|
||||
*
|
||||
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
|
||||
* as text that is a string; shown as a swatch it needs `var()` substituted and
|
||||
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
|
||||
* offscreen probe and read them back — the substitution is done by the
|
||||
* implementation that would do it for real.
|
||||
*
|
||||
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
|
||||
* sitting in this page would resolve any reference the record leaves undeclared
|
||||
* against the surrounding app's own tokens. Previewing another project's system
|
||||
* would then quietly borrow this one's palette wherever that system was
|
||||
* incomplete, and a token the record already knows is broken (it shows up under
|
||||
* `unknown_refs`) would render as though it were fine.
|
||||
*
|
||||
* So every name referenced but not declared is blanked on the probe first. It
|
||||
* resolves to nothing, which is what the record says it is.
|
||||
*/
|
||||
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
||||
|
||||
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.display = "none";
|
||||
for (const value of declared.values()) {
|
||||
for (const match of value.matchAll(VAR_REFERENCE)) {
|
||||
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
|
||||
}
|
||||
}
|
||||
for (const [name, value] of declared) probe.style.setProperty(name, value);
|
||||
document.body.appendChild(probe);
|
||||
try {
|
||||
const computed = getComputedStyle(probe);
|
||||
const out = new Map<string, string>();
|
||||
for (const name of declared.keys()) {
|
||||
out.set(name, computed.getPropertyValue(name).trim());
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
probe.remove();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Cyclic color palette for milestone progress bars. */
|
||||
export const MILESTONE_PALETTE = [
|
||||
'var(--color-primary)',
|
||||
'var(--color-success, #22c55e)',
|
||||
'var(--fs-accent)',
|
||||
'var(--fs-success, #22c55e)',
|
||||
'#c98a00',
|
||||
'#8b5cf6',
|
||||
'var(--color-danger, #ef4444)',
|
||||
'var(--fs-error, #ef4444)',
|
||||
'#06b6d4',
|
||||
]
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ onMounted(async () => {
|
||||
|
||||
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
|
||||
<header class="proj-head">
|
||||
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||
<span class="proj-dot" :style="{ background: p.color || 'var(--fs-accent)' }" />
|
||||
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
|
||||
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
|
||||
</header>
|
||||
@@ -156,7 +156,7 @@ onMounted(async () => {
|
||||
:to="`/projects/${p.id}`"
|
||||
class="pstat-row"
|
||||
>
|
||||
<span class="pstat-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||
<span class="pstat-dot" :style="{ background: p.color || 'var(--fs-accent)' }" />
|
||||
<span class="pstat-title">{{ p.title }}</span>
|
||||
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
|
||||
</router-link>
|
||||
@@ -176,69 +176,72 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||
/* `.dash-head` is deliberately bare: a block header whose two children carry
|
||||
all the spacing between them (h1 zeroed, .dash-sub margined). Nothing in it
|
||||
assumes a flex parent, which is the tell for a deleted rule (#2444). */
|
||||
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
||||
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
||||
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
||||
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
||||
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--fs-text-tertiary); font-size: 0.9rem; }
|
||||
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fs-text-tertiary); margin-bottom: 0.6rem; }
|
||||
.dash-empty { color: var(--fs-text-tertiary); padding: 1rem 0; }
|
||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--fs-border-color); border-radius: 10px; }
|
||||
|
||||
.done-recent { margin-bottom: 1.5rem; }
|
||||
.done-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||
.done-row:hover { background: var(--color-hover); }
|
||||
.done-mark { color: var(--color-primary); flex-shrink: 0; }
|
||||
.done-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.86rem; }
|
||||
.done-row:hover { background: var(--fs-surface-hover); }
|
||||
.done-mark { color: var(--fs-accent); flex-shrink: 0; }
|
||||
.done-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.done-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||
.done-more { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--color-primary); font-family: inherit; }
|
||||
.done-meta { font-size: 0.72rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
|
||||
.done-more { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--fs-accent); font-family: inherit; }
|
||||
.done-more:hover { text-decoration: underline; }
|
||||
|
||||
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||
.dash-main { flex: 1.7; min-width: 0; }
|
||||
.dash-rail { flex: 1; min-width: 240px; }
|
||||
|
||||
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
|
||||
.proj-panel { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
|
||||
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
|
||||
.proj-title:hover { color: var(--color-primary); }
|
||||
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
|
||||
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
|
||||
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
|
||||
.proj-title { font-weight: 700; color: var(--fs-text-primary); text-decoration: none; }
|
||||
.proj-title:hover { color: var(--fs-accent); }
|
||||
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--fs-text-tertiary); }
|
||||
.bar { height: 5px; background: var(--fs-border-color); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
|
||||
.bar-fill { height: 5px; background: var(--fs-accent); border-radius: 3px; }
|
||||
|
||||
.ms-block { margin-top: 0.7rem; }
|
||||
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
|
||||
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
|
||||
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
|
||||
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
|
||||
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--fs-text-primary); }
|
||||
.ms-title.ms-none { color: var(--fs-text-tertiary); font-weight: 500; }
|
||||
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||
.task-row:hover { background: var(--color-hover); }
|
||||
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
|
||||
.task-mark { color: var(--color-muted); }
|
||||
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.86rem; }
|
||||
.task-row:hover { background: var(--fs-surface-hover); }
|
||||
.task-inprogress { border-left: 3px solid var(--fs-accent); padding-left: calc(0.55rem - 3px); }
|
||||
.task-mark { color: var(--fs-text-tertiary); }
|
||||
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
|
||||
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--fs-border-color); color: var(--fs-text-tertiary); }
|
||||
.pri-high { color: #c0556b; border-color: #c0556b66; }
|
||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--fs-accent); text-decoration: none; }
|
||||
|
||||
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||
.rail-card { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||
.stats-sub { color: var(--fs-text-tertiary); font-size: 0.78rem; }
|
||||
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||
.pstat-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||
.pstat-row:hover { background: var(--color-hover); }
|
||||
.pstat-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
|
||||
.pstat-row:hover { background: var(--fs-surface-hover); }
|
||||
.pstat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.pstat-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
||||
.pstat-counts { font-size: 0.74rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||
.pstat-counts { font-size: 0.74rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
|
||||
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.qa-btn { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--fs-text-primary); text-decoration: none; }
|
||||
.qa-btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
.issues-card { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||
.issue-row:hover { background: var(--color-hover); }
|
||||
.issue-mark { color: var(--color-muted); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
|
||||
.issue-row:hover { background: var(--fs-surface-hover); }
|
||||
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--fs-accent); }
|
||||
.issue-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.issue-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||
.issue-meta { font-size: 0.72rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Design systems — editing the stylesheet Scribe holds (milestone #254 step 5).
|
||||
* Design systems — the stylesheets this install RECORDS, for the projects it
|
||||
* tracks (milestone #254 step 5).
|
||||
*
|
||||
* Sibling of /design, which shows the system as the BROWSER has it. This page
|
||||
* shows it as the RECORD has it, which is the half you can change.
|
||||
* It had a sibling, `/design`, which showed the system as the BROWSER had it —
|
||||
* names out of a bundled stylesheet, values out of `getComputedStyle`. That
|
||||
* could only ever describe the install serving the page, so it was a mirror
|
||||
* rather than a tool and was retired (#274). Everything here works on a system
|
||||
* whose app this browser has never loaded.
|
||||
*
|
||||
* The layout follows the model rather than decorating it. A system with a
|
||||
* parent holds only what it changes, so this page has two lists and they are
|
||||
@@ -39,9 +43,10 @@ import {
|
||||
type SnippetCheck,
|
||||
type StylesheetResult,
|
||||
} from "@/api/designSystems";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
||||
import TokenPreview from "@/components/TokenPreview.vue";
|
||||
|
||||
const toast = useToastStore();
|
||||
|
||||
@@ -148,6 +153,10 @@ const newTitle = ref("");
|
||||
const newDescription = ref("");
|
||||
const newParentId = ref<number | null>(null);
|
||||
const creating = ref(false);
|
||||
// Starter roles (#2349). The picker fills these on mount; empty means the
|
||||
// operator unchecked everything, which is a real answer.
|
||||
const starterGroups = ref<string[]>([]);
|
||||
const tokenPrefix = ref("");
|
||||
|
||||
async function submitCreate() {
|
||||
const title = newTitle.value.trim();
|
||||
@@ -158,11 +167,16 @@ async function submitCreate() {
|
||||
title,
|
||||
description: newDescription.value.trim() || undefined,
|
||||
parent_id: newParentId.value,
|
||||
starter_role_groups: starterGroups.value.length ? starterGroups.value : undefined,
|
||||
token_prefix: tokenPrefix.value.trim() || undefined,
|
||||
});
|
||||
newTitle.value = "";
|
||||
newDescription.value = "";
|
||||
newParentId.value = null;
|
||||
showCreate.value = false;
|
||||
// NOT reset: the picker owns these and re-seeds on mount. Clearing them
|
||||
// here would race the next mount and silently create the following system
|
||||
// with no roles at all.
|
||||
await loadSystems();
|
||||
selectedId.value = created.id;
|
||||
toast.show(`Created ${created.title}`);
|
||||
@@ -479,14 +493,28 @@ watch(selectedId, () => {
|
||||
snippetCheck.value = null;
|
||||
});
|
||||
|
||||
function isColourish(value: string): boolean {
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||
/**
|
||||
* A colour this row can draw HONESTLY — self-contained, no `var()` inside.
|
||||
*
|
||||
* The provenance list below shows values as the record states them, and a
|
||||
* `var()` reference states nothing on its own: rendering
|
||||
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves
|
||||
* `--accent` against THIS app, so a system that isn't the one Scribe runs on
|
||||
* would be drawn in Scribe's palette. It looked right, which is why it went
|
||||
* unnoticed (#274).
|
||||
*
|
||||
* The preview above resolves values properly, on a probe carrying only that
|
||||
* system's declarations. So this list draws only what needs no resolving, and
|
||||
* leaves the rest to the surface built for it.
|
||||
*/
|
||||
function isSelfContainedColour(value: string): boolean {
|
||||
const v = value.trim();
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(v) && !v.includes("var(");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ds-view">
|
||||
<DesignTabs />
|
||||
|
||||
<header class="ds-header">
|
||||
<h1>Design systems</h1>
|
||||
@@ -540,6 +568,10 @@ function isColourish(value: string): boolean {
|
||||
placeholder="What it covers"
|
||||
/>
|
||||
</div>
|
||||
<StarterRolePicker
|
||||
v-model:selected="starterGroups"
|
||||
v-model:prefix="tokenPrefix"
|
||||
/>
|
||||
<div class="row-actions">
|
||||
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
@@ -601,6 +633,10 @@ function isColourish(value: string): boolean {
|
||||
A system with a parent stores only its differences from it.
|
||||
</p>
|
||||
</div>
|
||||
<StarterRolePicker
|
||||
v-model:selected="starterGroups"
|
||||
v-model:prefix="tokenPrefix"
|
||||
/>
|
||||
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
</button>
|
||||
@@ -750,7 +786,7 @@ function isColourish(value: string): boolean {
|
||||
<ul class="dupe-list">
|
||||
<li v-for="[value, names] in duplicateEntries" :key="value">
|
||||
<span
|
||||
v-if="isColourish(value)" class="swatch"
|
||||
v-if="isSelfContainedColour(value)" class="swatch"
|
||||
:style="{ background: value }" aria-hidden="true"
|
||||
/>
|
||||
<code>{{ value }}</code> — {{ names.join(", ") }}
|
||||
@@ -908,7 +944,7 @@ function isColourish(value: string): boolean {
|
||||
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
|
||||
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
||||
<span
|
||||
v-if="isColourish(row.value)" class="swatch"
|
||||
v-if="isSelfContainedColour(row.value)" class="swatch"
|
||||
:style="{ background: row.value }" aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
@@ -946,7 +982,7 @@ function isColourish(value: string): boolean {
|
||||
<span class="token-values">
|
||||
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
|
||||
<span
|
||||
v-if="isColourish(value)" class="swatch"
|
||||
v-if="isSelfContainedColour(value)" class="swatch"
|
||||
:style="{ background: value }" aria-hidden="true"
|
||||
/>
|
||||
<span class="mode-name">{{ mode }}</span>
|
||||
@@ -969,6 +1005,18 @@ function isColourish(value: string): boolean {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- What it looks like. Drawn from the record on an isolated probe,
|
||||
so this is THIS system's palette even when the app around it is
|
||||
running a different one. -->
|
||||
<section v-if="resolved.length" class="ds-section">
|
||||
<h2>Preview</h2>
|
||||
<p class="section-note">
|
||||
{{ selected.title }} as it would render — resolved from the record,
|
||||
not from the stylesheet this app happens to be running.
|
||||
</p>
|
||||
<TokenPreview :tokens="resolved" />
|
||||
</section>
|
||||
|
||||
<!-- Effective set -->
|
||||
<section class="ds-section">
|
||||
<h2>Effective tokens</h2>
|
||||
@@ -1002,7 +1050,7 @@ function isColourish(value: string): boolean {
|
||||
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
|
||||
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
|
||||
<span
|
||||
v-if="isColourish(origin.value)" class="swatch"
|
||||
v-if="isSelfContainedColour(origin.value)" class="swatch"
|
||||
:style="{ background: origin.value }" aria-hidden="true"
|
||||
/>
|
||||
<span class="mode-name">{{ origin.mode }}</span>
|
||||
@@ -1023,7 +1071,7 @@ function isColourish(value: string): boolean {
|
||||
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
|
||||
<span class="mode-chip">
|
||||
<span
|
||||
v-if="isColourish(origin.value)" class="swatch"
|
||||
v-if="isSelfContainedColour(origin.value)" class="swatch"
|
||||
:style="{ background: origin.value }" aria-hidden="true"
|
||||
/>
|
||||
<span class="mode-name">{{ origin.mode }}</span>
|
||||
@@ -1054,6 +1102,12 @@ function isColourish(value: string): boolean {
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
/* Restored with the same sweep that took `.milestone-header` — only the `h1`
|
||||
descendant rule survived, so the header had no spacing of its own. */
|
||||
.ds-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ds-header h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
@@ -1061,7 +1115,7 @@ function isColourish(value: string): boolean {
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
max-width: 70ch;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1.5rem;
|
||||
@@ -1083,9 +1137,9 @@ function isColourish(value: string): boolean {
|
||||
/* Sidebar ---------------------------------------------------------------- */
|
||||
|
||||
.ds-sidebar {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -1102,7 +1156,7 @@ function isColourish(value: string): boolean {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.system-list {
|
||||
@@ -1119,22 +1173,22 @@ function isColourish(value: string): boolean {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.5rem 0.6rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.system-btn:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
.system-btn.active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--fs-accent);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
|
||||
@@ -1144,7 +1198,7 @@ function isColourish(value: string): boolean {
|
||||
|
||||
.system-kind {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Sections --------------------------------------------------------------- */
|
||||
@@ -1157,9 +1211,9 @@ function isColourish(value: string): boolean {
|
||||
}
|
||||
|
||||
.ds-section {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
@@ -1178,7 +1232,7 @@ function isColourish(value: string): boolean {
|
||||
|
||||
.section-note,
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
@@ -1193,17 +1247,17 @@ function isColourish(value: string): boolean {
|
||||
}
|
||||
|
||||
.chain-link {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.chain-link.self {
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chain-arrow,
|
||||
.chain-note {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Fields ----------------------------------------------------------------- */
|
||||
@@ -1228,24 +1282,24 @@ function isColourish(value: string): boolean {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -1274,15 +1328,15 @@ textarea.input {
|
||||
|
||||
.confirm-copy {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
/* Tokens ----------------------------------------------------------------- */
|
||||
|
||||
.token-form {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -1317,7 +1371,7 @@ textarea.input {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.token-name {
|
||||
@@ -1332,7 +1386,7 @@ textarea.input {
|
||||
}
|
||||
|
||||
.token-meta {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1351,18 +1405,18 @@ textarea.input {
|
||||
|
||||
.finding-list li {
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.finding-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.finding-line {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -1374,24 +1428,24 @@ textarea.input {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-top: 0.75rem;
|
||||
overflow-x: auto;
|
||||
@@ -1418,7 +1472,7 @@ textarea.input {
|
||||
|
||||
.supersedes {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -1430,7 +1484,7 @@ textarea.input {
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -1441,7 +1495,7 @@ textarea.input {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -1456,12 +1510,12 @@ textarea.input {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.resolved-list li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.resolved-head {
|
||||
@@ -1497,33 +1551,33 @@ textarea.input {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.origin-badge.own {
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--color-primary);
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
/* Notices ---------------------------------------------------------------- */
|
||||
|
||||
.notice {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.notice-warn {
|
||||
border-left: 3px solid var(--color-warning);
|
||||
border-left: 3px solid var(--fs-warning);
|
||||
}
|
||||
|
||||
.notice p {
|
||||
margin: 0.5rem 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Design explorer — the gallery (milestone #251 step 3).
|
||||
*
|
||||
* Renders the design system against the tokens that are actually live, read at
|
||||
* runtime rather than parsed from source, so what you see here is what the app
|
||||
* is using right now.
|
||||
*
|
||||
* HONESTY RULE, and the reason parts of this page say "not implemented":
|
||||
* a gallery of hand-written look-alikes drifts from the app within a month and
|
||||
* then lies — which is the same failure this whole surface exists to catch. So
|
||||
* every specimen below is either a REAL component imported from the app, or a
|
||||
* real token read from the browser, or it is explicitly marked as missing.
|
||||
*
|
||||
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
|
||||
* in five `<style scoped>` blocks, all five drifted, and this page reported it
|
||||
* as a gap because drawing a look-alike would have made it a sixth copy.
|
||||
* `assets/components.css` is now the single definition (#2273), so the
|
||||
* specimens below are the app's real classes — they cannot drift from the app
|
||||
* without drifting the app itself.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { fetchDesignExpectations } from "@/api/design";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import {
|
||||
compareToTokens,
|
||||
rankFindings,
|
||||
summarise,
|
||||
type Expectation,
|
||||
type Finding,
|
||||
} from "@/utils/designDrift";
|
||||
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
||||
|
||||
const tokens = ref<DesignToken[]>([]);
|
||||
const expectations = ref<Expectation[]>([]);
|
||||
const designRulebookId = ref<number | null>(null);
|
||||
const driftLoaded = ref(false);
|
||||
const showCleanRows = ref(false);
|
||||
|
||||
/**
|
||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
||||
* which needs the app's stylesheets applied and the theme attribute set.
|
||||
*/
|
||||
onMounted(async () => {
|
||||
tokens.value = readTokens();
|
||||
try {
|
||||
const response = await fetchDesignExpectations();
|
||||
designRulebookId.value = response.rulebook_id;
|
||||
expectations.value = response.expectations;
|
||||
} catch {
|
||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
||||
// "no drift data" rather than taking the page down with it.
|
||||
designRulebookId.value = null;
|
||||
} finally {
|
||||
driftLoaded.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const findings = computed<Finding[]>(() =>
|
||||
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
||||
);
|
||||
const driftSummary = computed(() => summarise(findings.value));
|
||||
const visibleFindings = computed(() =>
|
||||
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
||||
);
|
||||
|
||||
const grouped = computed(() => groupTokens(tokens.value));
|
||||
|
||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
||||
const orderedGroups = computed(() =>
|
||||
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
|
||||
);
|
||||
|
||||
/** A token whose value reads as a colour is worth showing as a swatch. */
|
||||
function isColourish(value: string): boolean {
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
|
||||
const RULEBOOK_BUTTONS = [
|
||||
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
|
||||
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
|
||||
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
|
||||
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
|
||||
];
|
||||
|
||||
const TYPE_SPECIMENS = [
|
||||
{ token: "Display", spec: "40 / 500 / Fraunces" },
|
||||
{ token: "H1", spec: "32 / 500 / Fraunces" },
|
||||
{ token: "H2", spec: "24 / 500 / Fraunces" },
|
||||
{ token: "H3", spec: "18 / 500 / Inter" },
|
||||
{ token: "Body", spec: "15 / 400 / Inter" },
|
||||
{ token: "Body small", spec: "13 / 400 / Inter" },
|
||||
{ token: "Label", spec: "12 / 500 / Inter" },
|
||||
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
|
||||
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design-view">
|
||||
<DesignTabs />
|
||||
|
||||
<header class="design-header">
|
||||
<h1>Live tokens</h1>
|
||||
<p class="lede">
|
||||
The system as it actually is. Token values are read from the browser at
|
||||
runtime, so this page reflects the live cascade rather than what the
|
||||
stylesheet says. Components shown are the real ones — where a piece of
|
||||
the system has no shared implementation, it is marked missing rather
|
||||
than mocked up.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
||||
<section class="design-section">
|
||||
<h2>Rulebook drift</h2>
|
||||
|
||||
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
||||
|
||||
<div v-else-if="designRulebookId === null" class="gap-notice">
|
||||
<strong>No design rulebook designated.</strong>
|
||||
<p>
|
||||
This install hasn't said which rulebook describes its design system, so
|
||||
there is nothing to check the tokens against. Designate one in
|
||||
<router-link to="/settings">Settings</router-link> and this panel will
|
||||
compare every colour and token the rulebook names against what the
|
||||
stylesheet actually resolves to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="section-note">
|
||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
||||
claims in rulebook #{{ designRulebookId }}.
|
||||
</p>
|
||||
|
||||
<div class="gap-notice">
|
||||
<strong>This compares the rulebook against the TOKENS only.</strong>
|
||||
<p>
|
||||
A value hardcoded in a component — where a token should have been
|
||||
referenced — is invisible here, because the drift isn't in the tokens
|
||||
at all. Reading it would mean bundling every component's source into
|
||||
the app. That check belongs in CI and is tracked separately, so treat
|
||||
a clean panel as "the tokens agree", not "the app agrees".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!findings.length" class="muted">
|
||||
The rulebook names nothing this panel can check. Rules that state values
|
||||
— colours, token names — produce claims; rules that state judgement
|
||||
don't, by design.
|
||||
</p>
|
||||
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
||||
<span class="spec-name">
|
||||
<span
|
||||
v-if="finding.expectation.kind !== 'token'"
|
||||
class="swatch"
|
||||
:style="{ background: finding.expectation.value }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<code>{{ finding.expectation.value }}</code>
|
||||
</span>
|
||||
<span class="spec-detail">
|
||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
||||
<span v-if="finding.matches.length" class="matches">
|
||||
· {{ finding.matches.join(", ") }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="finding.status">
|
||||
{{ finding.status === "violated" ? "forbidden, but present"
|
||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="findings.length && driftSummary.ok"
|
||||
class="reveal-toggle"
|
||||
@click="showCleanRows = !showCleanRows"
|
||||
>
|
||||
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Real components: these are imported, not recreated. -->
|
||||
<section class="design-section">
|
||||
<h2>Components</h2>
|
||||
<p class="section-note">Imported from the app. What you see is what ships.</p>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Status badge</span>
|
||||
<div class="specimen-row">
|
||||
<StatusBadge status="todo" />
|
||||
<StatusBadge status="in_progress" />
|
||||
<StatusBadge status="done" />
|
||||
<StatusBadge status="cancelled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Priority badge</span>
|
||||
<div class="specimen-row">
|
||||
<PriorityBadge priority="low" />
|
||||
<PriorityBadge priority="medium" />
|
||||
<PriorityBadge priority="high" />
|
||||
<span class="muted">(<code>none</code> renders nothing, by design)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Tag pill</span>
|
||||
<div class="specimen-row">
|
||||
<TagPill tag="design-system" />
|
||||
<TagPill tag="dismissible" dismissible />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- No longer a gap: these are the app's real classes, from the shared
|
||||
sheet. Nothing here is a look-alike — change components.css and these
|
||||
specimens change with it, which is the only way this page stays true. -->
|
||||
<section class="design-section">
|
||||
<h2>Buttons</h2>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Save</button>
|
||||
<button class="btn-secondary">Detect</button>
|
||||
<button class="btn-ghost">Cancel</button>
|
||||
<button class="btn-danger">Delete</button>
|
||||
<button class="btn-primary" disabled>Disabled</button>
|
||||
</div>
|
||||
<p class="spec-caption">
|
||||
Three sizes, because the app has three kinds of button: a page action, a
|
||||
row action, and an affordance that sits inside a card without disturbing
|
||||
its rhythm.
|
||||
</p>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Default — page action</button>
|
||||
<button class="btn-primary btn-compact">Compact — row action</button>
|
||||
<button class="btn-primary btn-inline">Inline</button>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
|
||||
<span class="spec-name">{{ b.name }}</span>
|
||||
<span class="spec-detail">{{ b.spec }}</span>
|
||||
<span class="spec-status ok">shared</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
||||
<section class="design-section">
|
||||
<h2>Type scale</h2>
|
||||
<div class="gap-notice">
|
||||
<strong>Families load; the scale has no tokens.</strong>
|
||||
<p>
|
||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
||||
scale is not expressed as custom properties, so sizes and weights are
|
||||
set ad hoc per component. Listed here as specification, not as a live
|
||||
specimen — there is nothing to read.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
||||
<span class="spec-name">{{ t.token }}</span>
|
||||
<span class="spec-detail">{{ t.spec }}</span>
|
||||
<span class="spec-status missing">no token</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Tokens: entirely real, read live. -->
|
||||
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
|
||||
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
|
||||
<ul class="token-list">
|
||||
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
|
||||
<span
|
||||
v-if="isColourish(token.value)"
|
||||
class="swatch"
|
||||
:style="{ background: token.value }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<code class="token-value">{{ token.value || "—" }}</code>
|
||||
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
||||
mode-aware
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!tokens.length" class="muted">Reading tokens…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-view {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--page-padding-x) 4rem;
|
||||
}
|
||||
|
||||
.design-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 60ch;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.design-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.design-section h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.token-group-heading {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.section-note,
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Specimens -------------------------------------------------------------- */
|
||||
|
||||
.specimen {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.specimen-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.specimen-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Gaps ------------------------------------------------------------------- */
|
||||
|
||||
.spec-caption {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Layout only. The buttons inside style themselves from the shared sheet —
|
||||
adding any appearance rule here would recreate the copy this section
|
||||
just stopped being. */
|
||||
.button-specimens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-3);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice p {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.spec-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spec-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spec-name {
|
||||
min-width: 8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spec-detail {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spec-status {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.ok {
|
||||
background: var(--color-status-done-bg);
|
||||
color: var(--color-status-done);
|
||||
}
|
||||
|
||||
.spec-name .swatch {
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.matches {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reveal-toggle {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reveal-toggle:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Tokens ----------------------------------------------------------------- */
|
||||
|
||||
.token-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.swatch-none {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
var(--color-border) 3px,
|
||||
var(--color-border) 4px
|
||||
);
|
||||
}
|
||||
|
||||
.token-name {
|
||||
min-width: 16rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-flag {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.token-name {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, apiErrorMessage } from "@/api/client";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
const email = ref("");
|
||||
@@ -15,12 +15,7 @@ async function handleSubmit() {
|
||||
await apiPost("/api/auth/forgot-password", { email: email.value });
|
||||
submitted.value = true;
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Something went wrong";
|
||||
} else {
|
||||
error.value = "Something went wrong";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Something went wrong");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -55,7 +50,7 @@ async function handleSubmit() {
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<div v-else class="success-msg">
|
||||
<div v-else class="auth-note">
|
||||
<p>If an account exists with that email address, you will receive a password reset link shortly.</p>
|
||||
<p>Check your email and follow the instructions to reset your password.</p>
|
||||
</div>
|
||||
@@ -67,83 +62,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-hint {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.success-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.success-msg p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -208,7 +208,7 @@ function initGraph() {
|
||||
.attr("orient", "auto")
|
||||
.append("path")
|
||||
.attr("d", "M0,-4L8,0L0,4")
|
||||
.attr("fill", "var(--color-primary)")
|
||||
.attr("fill", "var(--fs-accent)")
|
||||
.attr("opacity", "0.6");
|
||||
|
||||
// Zoom layer
|
||||
@@ -237,7 +237,7 @@ function initGraph() {
|
||||
.append("line")
|
||||
.attr("class", "graph-edge")
|
||||
.attr("stroke", (d: any) =>
|
||||
d.type === "wikilink" ? "var(--color-primary)" : "var(--color-text-muted)"
|
||||
d.type === "wikilink" ? "var(--fs-accent)" : "var(--fs-text-tertiary)"
|
||||
)
|
||||
.attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
|
||||
.attr("stroke-dasharray", null)
|
||||
@@ -295,11 +295,11 @@ function initGraph() {
|
||||
.append("circle")
|
||||
.attr("r", (d: GraphNode) => d.radius ?? 8)
|
||||
.attr("fill", (d: GraphNode) => {
|
||||
if (d.type === "tag") return "color-mix(in srgb, var(--color-primary) 20%, transparent)";
|
||||
return d.project_color ?? "var(--color-bg-secondary)";
|
||||
if (d.type === "tag") return "color-mix(in srgb, var(--fs-accent) 20%, transparent)";
|
||||
return d.project_color ?? "var(--fs-surface-raised)";
|
||||
})
|
||||
.attr("stroke", (d: GraphNode) =>
|
||||
d.type === "tag" ? "var(--color-primary)" : "var(--color-border)"
|
||||
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-border-color)"
|
||||
)
|
||||
.attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
|
||||
.attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
|
||||
@@ -317,7 +317,7 @@ function initGraph() {
|
||||
.attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
|
||||
.attr("font-size", "10px")
|
||||
.attr("fill", (d: GraphNode) =>
|
||||
d.type === "tag" ? "var(--color-primary)" : "var(--color-text-secondary)"
|
||||
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-text-secondary)"
|
||||
)
|
||||
.attr("pointer-events", "none");
|
||||
|
||||
@@ -600,7 +600,7 @@ onUnmounted(() => {
|
||||
.graph-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--header-height, 52px));
|
||||
height: calc(100vh - var(--fs-layout-header));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -609,17 +609,17 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.graph-select {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
cursor: pointer;
|
||||
@@ -631,27 +631,27 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.graph-toggle input {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
accent-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
margin-left: auto;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.graph-physics-btn {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
padding: 0.3rem 0.7rem;
|
||||
@@ -659,12 +659,12 @@ onUnmounted(() => {
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.graph-physics-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.graph-physics-btn.active {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.graph-physics-panel {
|
||||
@@ -672,8 +672,8 @@ onUnmounted(() => {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -688,20 +688,20 @@ onUnmounted(() => {
|
||||
|
||||
.knob-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.knob-label em {
|
||||
font-style: normal;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.physics-knob input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--color-primary);
|
||||
accent-color: var(--fs-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -709,7 +709,7 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
background: var(--fs-surface-page);
|
||||
}
|
||||
|
||||
.graph-svg {
|
||||
@@ -728,15 +728,15 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.graph-empty p {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border: 3px solid var(--fs-border-color);
|
||||
border-top-color: var(--fs-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@@ -747,10 +747,10 @@ onUnmounted(() => {
|
||||
|
||||
.graph-tooltip {
|
||||
position: absolute;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15));
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
padding: 0.5rem 0.75rem;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
@@ -760,7 +760,7 @@ onUnmounted(() => {
|
||||
.tooltip-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -773,15 +773,15 @@ onUnmounted(() => {
|
||||
|
||||
.tag-chip {
|
||||
font-size: 0.7rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.tooltip-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
@@ -792,8 +792,8 @@ onUnmounted(() => {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 340px;
|
||||
background: var(--color-bg-card);
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -815,7 +815,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -826,10 +826,10 @@ onUnmounted(() => {
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.peek-type-task { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
.peek-actions {
|
||||
display: flex;
|
||||
@@ -840,7 +840,7 @@ onUnmounted(() => {
|
||||
|
||||
.peek-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.peek-link:hover { text-decoration: underline; }
|
||||
@@ -848,20 +848,20 @@ onUnmounted(() => {
|
||||
.peek-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.peek-close:hover { color: var(--color-text); }
|
||||
.peek-close:hover { color: var(--fs-text-primary); }
|
||||
|
||||
.peek-title {
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.75rem 0.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -877,7 +877,7 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.peek-prose {
|
||||
@@ -887,7 +887,7 @@ onUnmounted(() => {
|
||||
.peek-loading,
|
||||
.peek-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -901,7 +901,7 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -921,14 +921,14 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.peek-linked-item:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.peek-linked-type {
|
||||
@@ -937,12 +937,12 @@ onUnmounted(() => {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,6 +46,50 @@ const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── Near-duplicate report ────────────────────────────────────────────────────
|
||||
// On demand, never automatic — a corpus-wide pairwise scan, and most visits to
|
||||
// this page aren't a tidy-up (same reasoning as SnippetListView's report).
|
||||
|
||||
interface DupMember {
|
||||
id: number; title: string;
|
||||
created_at?: string | null; updated_at?: string | null;
|
||||
task_kind?: string | null;
|
||||
}
|
||||
interface DupGroup {
|
||||
note_ids: number[];
|
||||
members: DupMember[];
|
||||
top_score: number;
|
||||
existing_supersessions?: { superseder_id: number; superseded_id: number }[];
|
||||
}
|
||||
|
||||
const dupGroups = ref<DupGroup[]>([]);
|
||||
const dupSuggestion = ref("");
|
||||
const dupLoading = ref(false);
|
||||
const dupChecked = ref(false);
|
||||
// The report follows the type filter: viewing tasks checks tasks. Anything
|
||||
// else (all / plan / process) checks notes — the kind with the most to find.
|
||||
const dupKind = computed(() => (activeType.value === "task" ? "task" : "note"));
|
||||
|
||||
async function loadDuplicates() {
|
||||
dupLoading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ groups: DupGroup[]; suggestion: string }>(
|
||||
`/api/notes/duplicates?kind=${dupKind.value}`
|
||||
);
|
||||
dupGroups.value = data.groups;
|
||||
dupSuggestion.value = data.suggestion;
|
||||
dupChecked.value = true;
|
||||
} catch {
|
||||
dupChecked.value = false;
|
||||
} finally {
|
||||
dupLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A stale report is worse than none: switching the type filter changes which
|
||||
// kind the button checks, so the old kind's groups must not linger under it.
|
||||
watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
|
||||
|
||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||
@@ -385,6 +429,51 @@ onUnmounted(() => {
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
:disabled="dupLoading"
|
||||
title="Find notes or tasks already recorded that closely resemble each other — the report proposes, it never changes anything"
|
||||
@click="loadDuplicates"
|
||||
>
|
||||
{{ dupLoading ? "Checking…" : "Find duplicates" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Near-duplicate report. A proposal surface only: unlike snippets
|
||||
(which merge losslessly), notes are never merged — the right fix is
|
||||
supersession, extraction into a reference note, or leaving parallel
|
||||
records alone, and choosing needs the records READ. That reading is
|
||||
the assistant's job; this panel shows the human what exists. -->
|
||||
<div v-if="dupChecked && !dupLoading" class="dup-panel">
|
||||
<p v-if="!dupGroups.length" class="dup-empty">
|
||||
No near-duplicate {{ dupKind }}s found — nothing recorded resembles
|
||||
anything else closely enough to flag.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="dup-head">
|
||||
{{ dupGroups.length }} possible duplicate
|
||||
{{ dupGroups.length > 1 ? "sets" : "set" }} among your
|
||||
{{ dupKind }}s. {{ dupSuggestion }}
|
||||
</p>
|
||||
<div v-for="(g, i) in dupGroups" :key="i" class="dup-group">
|
||||
<div class="dup-members">
|
||||
<router-link
|
||||
v-for="m in g.members"
|
||||
:key="m.id"
|
||||
class="dup-member"
|
||||
:to="dupKind === 'task' ? `/tasks/${m.id}` : `/notes/${m.id}`"
|
||||
>
|
||||
#{{ m.id }} {{ m.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
||||
<span
|
||||
v-if="g.existing_supersessions?.length"
|
||||
class="dup-claimed"
|
||||
title="A supersession has already been declared inside this set — it is not an open question"
|
||||
>already ruled on</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Loading / empty -->
|
||||
@@ -490,7 +579,7 @@ onUnmounted(() => {
|
||||
.knowledge-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--header-height, 56px));
|
||||
height: calc(100vh - var(--fs-layout-header));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -501,8 +590,8 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 20px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
background: var(--fs-surface-raised);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.82rem;
|
||||
flex-wrap: wrap;
|
||||
@@ -518,7 +607,7 @@ onUnmounted(() => {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.today-link {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
@@ -536,19 +625,19 @@ onUnmounted(() => {
|
||||
|
||||
/* ── Filter panel ────────────────────────────────────────── */
|
||||
.filter-panel {
|
||||
width: var(--sidebar-width);
|
||||
width: var(--fs-layout-sidebar);
|
||||
flex-shrink: 0;
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-right: 1px solid var(--fs-border-color);
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
.filter-section + .filter-section::before {
|
||||
content: '· · ·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(91, 74, 138, 0.3);
|
||||
color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.4em;
|
||||
padding: 4px 0 12px;
|
||||
@@ -556,7 +645,7 @@ onUnmounted(() => {
|
||||
.filter-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -573,14 +662,14 @@ onUnmounted(() => {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--gradient-cta);
|
||||
background: var(--fs-gradient-cta);
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
|
||||
.btn-new-note:hover { box-shadow: var(--fs-glow-cta-hover); }
|
||||
.btn-new-icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
@@ -591,8 +680,8 @@ onUnmounted(() => {
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
z-index: 50;
|
||||
@@ -607,15 +696,15 @@ onUnmounted(() => {
|
||||
padding: 9px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
text-align: left;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.new-note-menu button:hover {
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--color-primary);
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.new-note-menu button svg {
|
||||
flex-shrink: 0;
|
||||
@@ -623,7 +712,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.new-note-menu button:hover svg {
|
||||
opacity: 1;
|
||||
stroke: var(--color-primary);
|
||||
stroke: var(--fs-accent);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
@@ -636,7 +725,7 @@ onUnmounted(() => {
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 2px;
|
||||
@@ -645,8 +734,8 @@ onUnmounted(() => {
|
||||
}
|
||||
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
|
||||
.filter-btn.active {
|
||||
background: var(--color-primary-wash);
|
||||
color: var(--color-primary);
|
||||
background: var(--fs-accent-wash);
|
||||
color: var(--fs-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.filter-btn-label { flex: 1; }
|
||||
@@ -655,15 +744,15 @@ onUnmounted(() => {
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-weight: 500;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(91, 74, 138, 0.2);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
|
||||
@@ -683,7 +772,7 @@ onUnmounted(() => {
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.search-wrap {
|
||||
flex: 1;
|
||||
@@ -694,27 +783,27 @@ onUnmounted(() => {
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 7px 12px 7px 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--color-primary); }
|
||||
.search-input:focus { border-color: var(--fs-accent); }
|
||||
.sort-select {
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
@@ -735,9 +824,9 @@ onUnmounted(() => {
|
||||
|
||||
.k-card {
|
||||
position: relative;
|
||||
background: var(--color-surface, rgba(255,255,255,0.03));
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.07));
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
background: var(--fs-surface-hover);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-xl);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||
@@ -749,12 +838,12 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
box-shadow: 0 8px 28px color-mix(in srgb, var(--fs-accent) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||
.k-card--note { border-color: color-mix(in srgb, var(--fs-accent) 20%, transparent); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||
|
||||
/* Top gradient bars */
|
||||
@@ -769,7 +858,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card--note::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
|
||||
background: linear-gradient(90deg, var(--fs-accent), #7A6DA8);
|
||||
}
|
||||
.k-card--task::before {
|
||||
right: 0;
|
||||
@@ -788,7 +877,7 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
||||
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
|
||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||
|
||||
@@ -805,7 +894,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card-snippet {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -825,9 +914,9 @@ onUnmounted(() => {
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
||||
.k-card-date { font-size: 0.72rem; color: var(--fs-text-secondary); white-space: nowrap; opacity: 0.7; }
|
||||
/* Only rendered for a record another user owns, so an unmarked card is
|
||||
unambiguously the viewer's own. */
|
||||
.shared-tag {
|
||||
@@ -835,8 +924,8 @@ onUnmounted(() => {
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
@@ -856,10 +945,10 @@ onUnmounted(() => {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
|
||||
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
|
||||
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
|
||||
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
|
||||
.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); }
|
||||
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); }
|
||||
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); text-decoration: line-through; }
|
||||
|
||||
.priority-badge {
|
||||
font-size: 0.7rem;
|
||||
@@ -867,16 +956,16 @@ onUnmounted(() => {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
|
||||
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
|
||||
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
|
||||
.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low); }
|
||||
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); }
|
||||
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.task-overdue {
|
||||
color: var(--color-overdue);
|
||||
color: var(--fs-overdue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -888,7 +977,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -896,7 +985,7 @@ onUnmounted(() => {
|
||||
.empty-narrator {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -910,17 +999,17 @@ onUnmounted(() => {
|
||||
}
|
||||
.sentinel-loading {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Graph panel ─────────────────────────────────────────── */
|
||||
.graph-panel {
|
||||
width: 500px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.graph-panel.expanded {
|
||||
@@ -933,12 +1022,83 @@ onUnmounted(() => {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
|
||||
`flex-shrink: 0`, so this is the item that takes the remaining height — and
|
||||
without it the `height: 100%` below resolves against `auto` and does
|
||||
nothing, which made the comment underneath a spec for a rule that could not
|
||||
work. `min-height: 0` is the companion that lets a flex item shrink under
|
||||
its content instead of overflowing the panel. */
|
||||
.graph-embed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||
.graph-embed :deep(.graph-page) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* ── Near-duplicate report ──────────────────────────────────────────────────
|
||||
Mirrors SnippetListView's panel so the two reports read as one feature.
|
||||
Scoped styles can't be shared across SFCs; if a third view ever grows this
|
||||
panel, promote the family to components.css and record it (#2464's rule:
|
||||
two-or-more is when a recipe earns the shared sheet). */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.dup-empty { margin-bottom: 0; }
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dup-member:hover { background: var(--fs-surface-hover); }
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
||||
.dup-claimed {
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -30,12 +31,7 @@ async function handleSubmit() {
|
||||
const redirect = (route.query.redirect as string) || "/";
|
||||
router.push(redirect);
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Login failed";
|
||||
} else {
|
||||
error.value = "Login failed";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Login failed");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -112,92 +108,21 @@ function loginWithOAuth() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-hint {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-hint a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--color-primary);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.forgot-link {
|
||||
text-align: right;
|
||||
@@ -205,9 +130,9 @@ function loginWithOAuth() {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.forgot-link a {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.forgot-link a:hover {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, watch } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import { fmtLogStamp } from "@/utils/dateFormat";
|
||||
|
||||
const toastStore = useToastStore();
|
||||
|
||||
@@ -98,17 +99,6 @@ function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDetails(details: string | null): string {
|
||||
if (!details) return "";
|
||||
try {
|
||||
@@ -210,7 +200,7 @@ function clearFilters() {
|
||||
:class="{ 'row-expanded': expandedId === entry.id }"
|
||||
@click="toggleExpand(entry.id)"
|
||||
>
|
||||
<td class="cell-time">{{ formatTime(entry.created_at) }}</td>
|
||||
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
|
||||
<td>
|
||||
<span class="category-badge" :class="'cat-' + entry.category">
|
||||
{{ entry.category }}
|
||||
@@ -263,9 +253,9 @@ function clearFilters() {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -292,23 +282,23 @@ function clearFilters() {
|
||||
.stat-count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.stat-audit {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.stat-usage {
|
||||
color: var(--color-success);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.stat-error {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
@@ -321,10 +311,10 @@ function clearFilters() {
|
||||
.filter-input,
|
||||
.filter-date {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filter-select {
|
||||
@@ -342,7 +332,7 @@ function clearFilters() {
|
||||
.loading-msg,
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
@@ -356,13 +346,13 @@ function clearFilters() {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.logs-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.logs-table tbody tr:last-child td {
|
||||
@@ -373,18 +363,18 @@ function clearFilters() {
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.log-row:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.row-expanded {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.cell-time {
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cell-user {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.cell-action {
|
||||
max-width: 280px;
|
||||
@@ -399,22 +389,22 @@ function clearFilters() {
|
||||
.cell-ip {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-duration {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-ip {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.text-error {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
@@ -425,19 +415,19 @@ function clearFilters() {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.cat-audit {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
}
|
||||
.cat-usage {
|
||||
color: var(--color-success);
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--fs-success);
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
}
|
||||
.cat-error {
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--fs-error);
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Method tag */
|
||||
@@ -448,22 +438,24 @@ function clearFilters() {
|
||||
font-family: monospace;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-muted);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Detail row */
|
||||
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
|
||||
cells don't carry, and the row exists to scope the rule below (#2444). */
|
||||
.detail-row td {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.detail-json {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -633,13 +633,13 @@ onUnmounted(() => assist.clearSelection());
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: inline-flex;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
@@ -653,14 +653,14 @@ onUnmounted(() => assist.clearSelection());
|
||||
padding: 0.22rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.tab:hover { color: var(--color-text); }
|
||||
.tab:hover { color: var(--fs-text-primary); }
|
||||
.tab.active {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
@@ -679,14 +679,14 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
@@ -699,7 +699,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
.note-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -708,17 +708,17 @@ onUnmounted(() => assist.clearSelection());
|
||||
.sb-select, .sb-input {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
|
||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||
color: var(--color-text);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sb-select:focus, .sb-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
@@ -742,13 +742,13 @@ onUnmounted(() => assist.clearSelection());
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid var(--fs-accent);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-accent);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-link-all:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
.btn-link-all:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
.link-suggest-list {
|
||||
display: flex;
|
||||
@@ -766,14 +766,14 @@ onUnmounted(() => assist.clearSelection());
|
||||
.link-suggest-title {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-suggest-count {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.72rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -783,13 +783,13 @@ onUnmounted(() => assist.clearSelection());
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-apply-link:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Writing Assistant section */
|
||||
.assist-section {
|
||||
@@ -801,7 +801,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -810,19 +810,19 @@ onUnmounted(() => assist.clearSelection());
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.prompt-editor {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
margin-top: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-hover);
|
||||
color: var(--fs-text-primary);
|
||||
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
@@ -830,7 +830,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
outline: none;
|
||||
}
|
||||
.prompt-editor:focus {
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
/* Narrow screen: sidebar collapses */
|
||||
@media (max-width: 720px) {
|
||||
@@ -839,7 +839,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
.note-sidebar {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ async function convertToTask() {
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.meta-item {
|
||||
@@ -359,7 +359,7 @@ async function convertToTask() {
|
||||
}
|
||||
.backlinks {
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.backlinks-heading {
|
||||
@@ -370,14 +370,14 @@ async function convertToTask() {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.backlinks-count {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1.4;
|
||||
@@ -392,18 +392,18 @@ async function convertToTask() {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.backlink-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.backlink-type-badge {
|
||||
font-size: 0.68rem;
|
||||
@@ -415,9 +415,9 @@ async function convertToTask() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
@@ -446,12 +446,12 @@ async function convertToTask() {
|
||||
.skel-title,
|
||||
.skel-meta,
|
||||
.skel-line {
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 25%,
|
||||
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
|
||||
var(--color-bg-secondary) 75%
|
||||
var(--fs-surface-raised) 25%,
|
||||
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
|
||||
var(--fs-surface-raised) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
@@ -463,7 +463,7 @@ async function convertToTask() {
|
||||
}
|
||||
.skel-btn { width: 70px; height: 32px; }
|
||||
.skel-btn--wide { width: 90px; }
|
||||
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--radius-md); }
|
||||
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--fs-radius-lg); }
|
||||
.skel-meta { height: 0.85rem; width: 40%; }
|
||||
.skel-line { height: 0.9rem; }
|
||||
.skel-line--short { width: 55%; }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||
import { emptyChoices, type InceptionChoices } from "@/api/inception";
|
||||
import InceptionCard from "@/components/InceptionCard.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { milestoneColor } from "@/utils/palette";
|
||||
|
||||
@@ -47,6 +49,9 @@ const newTitle = ref("");
|
||||
const newDescription = ref("");
|
||||
const newGoal = ref("");
|
||||
const creating = ref(false);
|
||||
// Step 2 of the modal (milestone 297): what the new project inherits.
|
||||
const modalStep = ref<1 | 2>(1);
|
||||
const newInception = ref<InceptionChoices>(emptyChoices());
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (activeTab.value === "all") return projects.value;
|
||||
@@ -73,6 +78,8 @@ function openNewProjectModal() {
|
||||
newTitle.value = "";
|
||||
newDescription.value = "";
|
||||
newGoal.value = "";
|
||||
modalStep.value = 1;
|
||||
newInception.value = emptyChoices();
|
||||
showNewProjectModal.value = true;
|
||||
}
|
||||
|
||||
@@ -88,13 +95,15 @@ async function createProject() {
|
||||
title: newTitle.value.trim(),
|
||||
description: newDescription.value.trim() || undefined,
|
||||
goal: newGoal.value.trim() || undefined,
|
||||
// The decision rides the create: a project made here is never undecided.
|
||||
inception: newInception.value,
|
||||
});
|
||||
projects.value.unshift(project);
|
||||
showNewProjectModal.value = false;
|
||||
toast.show("Project created");
|
||||
router.push(`/projects/${project.id}`);
|
||||
} catch {
|
||||
toast.show("Failed to create project", "error");
|
||||
} catch (e: unknown) {
|
||||
toast.show(apiErrorMessage(e, "Failed to create project"), "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
@@ -266,8 +275,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
<teleport to="body">
|
||||
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-card">
|
||||
<h3 class="modal-title">New Project</h3>
|
||||
<div class="modal-field">
|
||||
<h3 class="modal-title">{{ modalStep === 1 ? "New Project" : "New Project — what it inherits" }}</h3>
|
||||
<InceptionCard v-if="modalStep === 2" mode="create" v-model:choices="newInception" />
|
||||
<div v-if="modalStep === 1" class="modal-field">
|
||||
<label>Title <span class="required">*</span></label>
|
||||
<input
|
||||
v-model="newTitle"
|
||||
@@ -275,11 +285,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
class="modal-input"
|
||||
placeholder="Project title"
|
||||
autofocus
|
||||
@keydown.enter="createProject"
|
||||
@keydown.enter="modalStep = 2"
|
||||
@keydown.escape="closeModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<div v-if="modalStep === 1" class="modal-field">
|
||||
<label>Goal</label>
|
||||
<input
|
||||
v-model="newGoal"
|
||||
@@ -289,7 +299,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
@keydown.escape="closeModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<div v-if="modalStep === 1" class="modal-field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="newDescription"
|
||||
@@ -301,7 +311,17 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="closeModal">Cancel</button>
|
||||
<button v-if="modalStep === 2" class="modal-btn" @click="modalStep = 1">Back</button>
|
||||
<button
|
||||
v-if="modalStep === 1"
|
||||
class="modal-btn modal-btn-primary"
|
||||
@click="modalStep = 2"
|
||||
:disabled="!newTitle.trim()"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="modal-btn modal-btn-primary"
|
||||
@click="createProject"
|
||||
:disabled="!newTitle.trim() || creating"
|
||||
@@ -317,9 +337,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
<style scoped>
|
||||
.projects-list {
|
||||
max-width: var(--page-max-width);
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@@ -340,7 +360,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tab-btn {
|
||||
@@ -350,40 +370,40 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-family: inherit;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
border-bottom-color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
.error-msg {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
|
||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-action-primary); border-radius: var(--radius-sm); color: var(--color-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--fs-action-primary); border-radius: var(--fs-radius-sm); color: var(--fs-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
.skeleton-card {
|
||||
height: 140px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: linear-gradient(90deg, var(--fs-surface-raised) 25%, var(--fs-border-color) 50%, var(--fs-surface-raised) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
@@ -404,9 +424,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1rem 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
||||
@@ -415,7 +435,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
@@ -429,7 +449,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
@@ -446,32 +466,32 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-active {
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.status-completed {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.status-archived {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.project-goal {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -486,18 +506,18 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.overall-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--color-border);
|
||||
background: var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.overall-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.overall-bar-pct {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
@@ -517,7 +537,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.milestone-bar-label {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
min-width: 0;
|
||||
flex: 0 0 30%;
|
||||
overflow: hidden;
|
||||
@@ -527,7 +547,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.milestone-bar-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--color-border);
|
||||
background: var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -537,7 +557,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.milestone-bar-pct {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
@@ -546,7 +566,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
/* Deliberately quiet — it is a footnote about what is not shown, not another
|
||||
row competing with the bars above it. */
|
||||
.milestone-more {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: var(--fs-size-tiny);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
@@ -556,27 +576,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
}
|
||||
.meta-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
@@ -593,18 +597,18 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.modal-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.required {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.modal-input,
|
||||
.modal-textarea {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
@@ -613,41 +617,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.modal-input:focus,
|
||||
.modal-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.modal-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.modal-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-grid {
|
||||
|
||||
+680
-173
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
@@ -68,12 +68,7 @@ async function handleSubmit() {
|
||||
await authStore.checkAuth();
|
||||
router.push("/");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Registration failed";
|
||||
} else {
|
||||
error.value = "Registration failed";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Registration failed");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -85,9 +80,9 @@ async function handleSubmit() {
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
|
||||
|
||||
<div v-if="validating" class="loading-msg">Validating invitation...</div>
|
||||
<div v-if="validating" class="auth-loading">Validating invitation...</div>
|
||||
|
||||
<div v-else-if="!token || !valid" class="error-block">
|
||||
<div v-else-if="!token || !valid" class="auth-note">
|
||||
<p>This invitation link is invalid or has expired.</p>
|
||||
<p class="auth-footer">
|
||||
<router-link to="/login">Back to Sign In</router-link>
|
||||
@@ -157,103 +152,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.error-block {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.error-block p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.input-error:focus {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.error-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
@@ -39,12 +40,7 @@ async function handleSubmit() {
|
||||
await authStore.register(username.value, password.value, email.value || undefined);
|
||||
router.push("/");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Registration failed";
|
||||
} else {
|
||||
error.value = "Registration failed";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Registration failed");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -56,9 +52,9 @@ async function handleSubmit() {
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
|
||||
|
||||
<div v-if="checking" class="loading-msg">Checking registration status...</div>
|
||||
<div v-if="checking" class="auth-loading">Checking registration status...</div>
|
||||
|
||||
<div v-else-if="!authStore.registrationOpen" class="closed-msg">
|
||||
<div v-else-if="!authStore.registrationOpen" class="auth-note">
|
||||
<p>Registration is currently closed.</p>
|
||||
<p>Contact an administrator to get an account.</p>
|
||||
<p class="auth-footer">
|
||||
@@ -130,99 +126,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.closed-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.closed-msg p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.input-error:focus {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.error-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, apiErrorMessage } from "@/api/client";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -35,12 +35,7 @@ async function handleSubmit() {
|
||||
});
|
||||
success.value = true;
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
error.value = body?.error || "Failed to reset password";
|
||||
} else {
|
||||
error.value = "Failed to reset password";
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Failed to reset password");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -52,7 +47,7 @@ async function handleSubmit() {
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div>
|
||||
|
||||
<div v-if="!token" class="error-block">
|
||||
<div v-if="!token" class="auth-note">
|
||||
<p>Invalid reset link. Please request a new password reset.</p>
|
||||
<p class="auth-footer">
|
||||
<router-link to="/forgot-password">Request new link</router-link>
|
||||
@@ -94,7 +89,7 @@ async function handleSubmit() {
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<div v-else class="success-msg">
|
||||
<div v-else class="auth-note">
|
||||
<p>Your password has been reset successfully.</p>
|
||||
<p>You can now sign in with your new password.</p>
|
||||
</div>
|
||||
@@ -106,102 +101,4 @@ async function handleSubmit() {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.error-block {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.error-block p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.success-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.success-msg p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.input-error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.input-error:focus {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.error-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
<style src="@/assets/auth-shared.css" />
|
||||
|
||||
@@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute);
|
||||
grid-template-columns: 280px 300px 1fr;
|
||||
height: 100vh;
|
||||
gap: 1px;
|
||||
background: var(--color-border, #2a2a2e);
|
||||
background: var(--fs-border-color);
|
||||
}
|
||||
.pane.empty {
|
||||
background: var(--color-surface, #18181b);
|
||||
background: var(--fs-surface-hover);
|
||||
padding: 1rem;
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
|
||||
+493
-283
File diff suppressed because it is too large
Load Diff
@@ -106,11 +106,11 @@ onMounted(async () => {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ onMounted(async () => {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
@@ -136,9 +136,9 @@ onMounted(async () => {
|
||||
|
||||
.shared-card {
|
||||
display: flex;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--fs-surface-hover);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-xl);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
@@ -151,7 +151,7 @@ onMounted(async () => {
|
||||
.card-color-bar {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-border);
|
||||
background: var(--fs-border-color);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@@ -163,7 +163,7 @@ onMounted(async () => {
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
margin-bottom: 0.3rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -179,12 +179,12 @@ onMounted(async () => {
|
||||
|
||||
.card-owner {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -203,25 +203,25 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-hover);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.shared-row:hover {
|
||||
background: var(--color-hover);
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.row-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -229,7 +229,7 @@ onMounted(async () => {
|
||||
}
|
||||
.row-owner {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -242,12 +242,12 @@ onMounted(async () => {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
||||
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
|
||||
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
|
||||
.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
||||
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
|
||||
@@ -205,7 +205,7 @@ async function confirmDelete() {
|
||||
.snippet-detail {
|
||||
max-width: 820px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@@ -213,20 +213,20 @@ async function confirmDelete() {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -239,7 +239,7 @@ async function confirmDelete() {
|
||||
}
|
||||
.snippet-name {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 1.4rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -253,7 +253,7 @@ async function confirmDelete() {
|
||||
.when-to-use {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -262,12 +262,12 @@ async function confirmDelete() {
|
||||
.shared-notice {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-left: 3px solid var(--color-text-muted);
|
||||
border-left: 3px solid var(--fs-text-tertiary);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
@@ -280,23 +280,23 @@ async function confirmDelete() {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.meta-grid dd {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.meta-grid code,
|
||||
.tag-row + * code {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.82rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
.location-list {
|
||||
@@ -317,7 +317,7 @@ async function confirmDelete() {
|
||||
align-items: baseline;
|
||||
}
|
||||
.merged-hint {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.merged-entry {
|
||||
@@ -328,15 +328,15 @@ async function confirmDelete() {
|
||||
.unmerge-btn {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.unmerge-btn:hover:not(:disabled) {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--fs-text-primary);
|
||||
border-color: var(--fs-text-tertiary);
|
||||
}
|
||||
.unmerge-btn:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -344,30 +344,30 @@ async function confirmDelete() {
|
||||
}
|
||||
.unmerge-na {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
/* Cursor cues that the explanation is in the tooltip. */
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
background: var(--fs-surface-page);
|
||||
}
|
||||
.code-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.5rem 0.4rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.code-lang {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.btn-copy {
|
||||
padding: 0.2rem 0.65rem;
|
||||
@@ -379,10 +379,10 @@ async function confirmDelete() {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.code-block code {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -396,9 +396,9 @@ async function confirmDelete() {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-secondary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
@@ -365,7 +365,7 @@ function cancel() {
|
||||
.snippet-editor {
|
||||
max-width: 760px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.snippet-editor h1 {
|
||||
@@ -376,19 +376,19 @@ function cancel() {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -415,27 +415,27 @@ function cancel() {
|
||||
.location-set legend {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.required {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
.hint-inline {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
@@ -443,11 +443,11 @@ function cancel() {
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
}
|
||||
.code-area {
|
||||
resize: vertical;
|
||||
@@ -459,8 +459,8 @@ function cancel() {
|
||||
}
|
||||
|
||||
.location-set {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -477,32 +477,32 @@ function cancel() {
|
||||
.loc-remove {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fs-text-tertiary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.loc-remove:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
border-color: var(--fs-error);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.loc-add {
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.loc-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -514,7 +514,7 @@ function cancel() {
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.systems {
|
||||
display: flex;
|
||||
@@ -528,11 +528,11 @@ function cancel() {
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.system-opt input {
|
||||
accent-color: var(--color-primary);
|
||||
accent-color: var(--fs-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -541,25 +541,25 @@ function cancel() {
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-warning, var(--color-primary));
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-left: 3px solid var(--fs-warning);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.duplicate-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.duplicate-body {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.duplicate-body a {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.duplicate-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -519,9 +519,9 @@ function usageTitle(s: SnippetListItem): string {
|
||||
|
||||
<style scoped>
|
||||
.snippets-list {
|
||||
max-width: var(--page-max-width);
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
.page-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
@@ -553,8 +553,8 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
/* Filter is engaged — the accent marks "you are here", per the design system. */
|
||||
.filter-on {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.location-row {
|
||||
@@ -569,12 +569,12 @@ function usageTitle(s: SnippetListItem): string {
|
||||
min-width: 0;
|
||||
max-width: 12rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.loc-input-wide {
|
||||
@@ -583,46 +583,46 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
.loc-input::placeholder {
|
||||
font-family: inherit;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.loc-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
.loc-clear {
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.loc-clear:hover {
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -630,10 +630,10 @@ function usageTitle(s: SnippetListItem): string {
|
||||
.empty-state-rich {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.empty-icon {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.35;
|
||||
@@ -641,7 +641,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
.empty-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.empty-sub {
|
||||
@@ -654,16 +654,16 @@ function usageTitle(s: SnippetListItem): string {
|
||||
.empty-action {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--color-action-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-action-primary);
|
||||
border: 1px solid var(--fs-action-primary);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-action-primary);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.empty-action:hover {
|
||||
background: var(--color-action-primary);
|
||||
background: var(--fs-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
@@ -675,8 +675,8 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 96px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: linear-gradient(90deg, var(--fs-surface-raised) 25%, var(--fs-border-color) 50%, var(--fs-surface-raised) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
@@ -686,9 +686,9 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
|
||||
.snippet-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 0.9rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
||||
@@ -697,14 +697,14 @@ function usageTitle(s: SnippetListItem): string {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.snippet-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.snippet-card:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@@ -716,11 +716,11 @@ function usageTitle(s: SnippetListItem): string {
|
||||
.snippet-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
}
|
||||
|
||||
/* Language tag — accent pill per the design system's tag treatment. */
|
||||
@@ -731,13 +731,13 @@ function usageTitle(s: SnippetListItem): string {
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.snippet-when {
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -751,7 +751,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
.meta-date {
|
||||
font-size: 0.73rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
/* Marks a record someone else owns. Present only on shared rows, so an
|
||||
unmarked card is unambiguously the operator's own. */
|
||||
@@ -760,24 +760,24 @@ function usageTitle(s: SnippetListItem): string {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Near-duplicate report */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-alt, var(--color-surface));
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.dup-empty {
|
||||
@@ -790,7 +790,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.dup-members {
|
||||
@@ -805,14 +805,14 @@ function usageTitle(s: SnippetListItem): string {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
/* Long snippet names must not push the row into a horizontal scroll. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -828,8 +828,8 @@ function usageTitle(s: SnippetListItem): string {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
|
||||
color: var(--color-danger, #b91c1c);
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
.usage-tag {
|
||||
@@ -838,15 +838,15 @@ function usageTitle(s: SnippetListItem): string {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||
than the danger one, because the record isn't broken, just unearned. */
|
||||
.usage-tag.usage-dead {
|
||||
background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
|
||||
color: var(--color-warning, #b45309);
|
||||
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
|
||||
color: var(--fs-warning);
|
||||
}
|
||||
|
||||
/* Header + select-mode */
|
||||
@@ -858,7 +858,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
|
||||
/* Selected card = 2px accent border per the design system (featured/active). */
|
||||
.snippet-card.selected {
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
border-width: 2px;
|
||||
padding: calc(0.9rem - 1px) calc(1rem - 1px);
|
||||
}
|
||||
@@ -867,14 +867,14 @@ function usageTitle(s: SnippetListItem): string {
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.15rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.select-box.on {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--fs-accent);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.select-bar {
|
||||
@@ -885,41 +885,26 @@ function usageTitle(s: SnippetListItem): string {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
}
|
||||
.select-count {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.select-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Merge modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
@@ -931,7 +916,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
.modal-desc {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.merge-choices {
|
||||
@@ -944,18 +929,18 @@ function usageTitle(s: SnippetListItem): string {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.merge-choice.chosen {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border-color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
||||
}
|
||||
.merge-choice-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.85rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -963,39 +948,9 @@ function usageTitle(s: SnippetListItem): string {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.modal-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.snippets-grid {
|
||||
|
||||
@@ -41,7 +41,6 @@ const toast = useToastStore();
|
||||
const title = ref("");
|
||||
const body = ref("");
|
||||
const description = ref("");
|
||||
const consolidatedAt = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const status = ref<TaskStatus>("todo");
|
||||
const priority = ref<TaskPriority>("none");
|
||||
@@ -303,7 +302,6 @@ onMounted(async () => {
|
||||
title.value = store.currentTask.title;
|
||||
body.value = store.currentTask.body;
|
||||
description.value = store.currentTask.description ?? "";
|
||||
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
|
||||
tags.value = [...(store.currentTask.tags || [])];
|
||||
status.value = store.currentTask.status as TaskStatus;
|
||||
priority.value = store.currentTask.priority as TaskPriority;
|
||||
@@ -832,7 +830,7 @@ useEditorGuards(dirty, save);
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
/* .task-main is a flex column; without flex-shrink: 0, long body content
|
||||
@@ -854,7 +852,7 @@ useEditorGuards(dirty, save);
|
||||
.task-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -868,21 +866,21 @@ useEditorGuards(dirty, save);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-clear-parent:hover { color: var(--fs-error); }
|
||||
.parent-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
@@ -892,21 +890,21 @@ useEditorGuards(dirty, save);
|
||||
padding: 0.4rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
|
||||
.parent-empty { color: var(--color-text-muted); cursor: default; }
|
||||
.parent-dropdown-item:hover { background: var(--fs-surface-raised); }
|
||||
.parent-empty { color: var(--fs-text-tertiary); cursor: default; }
|
||||
.parent-empty:hover { background: none; }
|
||||
|
||||
/* Sub-tasks */
|
||||
.subtasks-section {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.subtasks-header {
|
||||
display: flex;
|
||||
@@ -917,23 +915,23 @@ useEditorGuards(dirty, save);
|
||||
.subtasks-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
||||
.subtask-title {
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subtask-title:hover { color: var(--color-primary); }
|
||||
.subtask-title.done { text-decoration: line-through; color: var(--color-text-muted); }
|
||||
.subtasks-empty { font-size: 0.78rem; color: var(--color-text-muted); margin: 0; }
|
||||
.subtask-title:hover { color: var(--fs-accent); }
|
||||
.subtask-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
|
||||
.subtasks-empty { font-size: 0.78rem; color: var(--fs-text-tertiary); margin: 0; }
|
||||
.subtask-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -943,19 +941,19 @@ useEditorGuards(dirty, save);
|
||||
.subtask-input {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.83rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
.main-diff {
|
||||
@@ -965,9 +963,9 @@ useEditorGuards(dirty, save);
|
||||
|
||||
/* Systems multi-select (in sidebar) */
|
||||
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
||||
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--color-text); cursor: pointer; }
|
||||
.sb-system-opt input { accent-color: var(--color-primary); cursor: pointer; }
|
||||
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
|
||||
.sb-system-opt input { accent-color: var(--fs-accent); cursor: pointer; }
|
||||
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* Writing Assistant section (in sidebar) */
|
||||
.assist-section {
|
||||
@@ -978,7 +976,7 @@ useEditorGuards(dirty, save);
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -1009,11 +1007,11 @@ useEditorGuards(dirty, save);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sb-ts-label {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-ts-value {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -1024,7 +1022,7 @@ useEditorGuards(dirty, save);
|
||||
.task-sidebar {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
@@ -1037,13 +1035,13 @@ useEditorGuards(dirty, save);
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
.task-goal-label {
|
||||
font-family: var(--font-display, "Fraunces", serif);
|
||||
font-family: var(--fs-font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.task-goal-input {
|
||||
width: 100%;
|
||||
@@ -1053,32 +1051,14 @@ useEditorGuards(dirty, save);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text, inherit);
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--fs-text-primary);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.task-goal-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
|
||||
.auto-summary-banner-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
.auto-summary-banner-editor .auto-summary-icon {
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -365,13 +365,6 @@ const subTaskProgress = computed(() => {
|
||||
<p class="goal-text">{{ store.currentTask.description }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.currentTask.consolidated_at"
|
||||
class="auto-summary-banner"
|
||||
>
|
||||
<span class="auto-summary-icon" aria-hidden="true">✦</span>
|
||||
Auto-summarized from work logs.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="body prose"
|
||||
@@ -482,7 +475,7 @@ const subTaskProgress = computed(() => {
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.meta-item {
|
||||
@@ -501,10 +494,10 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--color-overdue);
|
||||
color: var(--fs-overdue);
|
||||
font-weight: 500;
|
||||
}
|
||||
.task-meta-row {
|
||||
@@ -515,10 +508,10 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.task-meta-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.task-meta-recurrence {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tags {
|
||||
@@ -531,7 +524,7 @@ const subTaskProgress = computed(() => {
|
||||
/* Sub-tasks */
|
||||
.subtasks {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.subtasks-header {
|
||||
@@ -547,21 +540,21 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.subtasks-progress {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.subtasks-pct {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.subtasks-track {
|
||||
height: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.subtasks-fill {
|
||||
height: 100%;
|
||||
background: var(--color-status-done, #22c55e);
|
||||
background: var(--fs-status-done);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@@ -578,10 +571,10 @@ const subTaskProgress = computed(() => {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.subtask-row:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.sub-dot {
|
||||
flex-shrink: 0;
|
||||
@@ -599,21 +592,21 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.dot-todo {
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-text-muted);
|
||||
border: 2px solid var(--fs-text-tertiary);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--color-status-in-progress, #3b82f6);
|
||||
background: var(--fs-status-in-progress);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
background: var(--fs-status-done);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-text-muted, #6b7280);
|
||||
background: var(--fs-text-tertiary);
|
||||
}
|
||||
.sub-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -621,21 +614,21 @@ const subTaskProgress = computed(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub-title:hover {
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.sub-title.sub-done {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.sub-due {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backlinks {
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.backlinks-heading {
|
||||
@@ -646,14 +639,14 @@ const subTaskProgress = computed(() => {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.backlinks-count {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1.4;
|
||||
@@ -668,18 +661,18 @@ const subTaskProgress = computed(() => {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
color: var(--fs-text-primary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.backlink-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
color: var(--color-primary);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.backlink-type-badge {
|
||||
font-size: 0.68rem;
|
||||
@@ -691,9 +684,9 @@ const subTaskProgress = computed(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
@@ -723,12 +716,12 @@ const subTaskProgress = computed(() => {
|
||||
.skel-meta,
|
||||
.skel-badges,
|
||||
.skel-line {
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 25%,
|
||||
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
|
||||
var(--color-bg-secondary) 75%
|
||||
var(--fs-surface-raised) 25%,
|
||||
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
|
||||
var(--fs-surface-raised) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
@@ -740,7 +733,7 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.skel-btn { width: 70px; height: 32px; }
|
||||
.skel-btn--wide { width: 90px; }
|
||||
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--radius-md); }
|
||||
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--fs-radius-lg); }
|
||||
.skel-meta { height: 0.85rem; width: 45%; }
|
||||
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
|
||||
.skel-line { height: 0.9rem; }
|
||||
@@ -749,39 +742,26 @@ const subTaskProgress = computed(() => {
|
||||
|
||||
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
||||
.task-goal-display {
|
||||
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-left: 2px solid var(--fs-border-color);
|
||||
padding: 0.4rem 0 0.4rem 0.9rem;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.goal-label {
|
||||
font-family: var(--font-display, "Fraunces", serif);
|
||||
font-family: var(--fs-font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.goal-text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text, inherit);
|
||||
color: var(--fs-text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.auto-summary-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auto-summary-icon {
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,7 +68,7 @@ onMounted(() => store.fetchTrash());
|
||||
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
|
||||
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
|
||||
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
|
||||
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
|
||||
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--fs-border-color); background: none; color: inherit; }
|
||||
.btn-restore:hover { border-color: var(--fs-action-primary); color: var(--fs-action-primary); }
|
||||
.btn-purge:hover { border-color: var(--fs-action-destructive); color: var(--fs-action-destructive); }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { User } from "@/types/auth";
|
||||
import { fmtDate } from "@/utils/dateFormat";
|
||||
|
||||
interface Invitation {
|
||||
id: number;
|
||||
@@ -69,12 +70,7 @@ async function sendInvite() {
|
||||
inviteEmail.value = "";
|
||||
await fetchInvitations();
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to send invitation", "error");
|
||||
} else {
|
||||
toastStore.show("Failed to send invitation", "error");
|
||||
}
|
||||
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
|
||||
} finally {
|
||||
sendingInvite.value = false;
|
||||
}
|
||||
@@ -128,24 +124,11 @@ async function deleteUser(userId: number) {
|
||||
users.value = users.value.filter((u) => u.id !== userId);
|
||||
toastStore.show("User deleted");
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to delete user", "error");
|
||||
} else {
|
||||
toastStore.show("Failed to delete user", "error");
|
||||
}
|
||||
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||||
} finally {
|
||||
deleting.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -212,8 +195,8 @@ function formatDate(iso: string): string {
|
||||
<tbody>
|
||||
<tr v-for="inv in invitations" :key="inv.id">
|
||||
<td class="cell-email">{{ inv.email }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@@ -255,7 +238,7 @@ function formatDate(iso: string): string {
|
||||
{{ u.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<template v-if="u.id === authStore.user?.id">
|
||||
<span class="you-label">You</span>
|
||||
@@ -297,9 +280,9 @@ function formatDate(iso: string): string {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -317,16 +300,16 @@ function formatDate(iso: string): string {
|
||||
.invite-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.invite-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.invite-list {
|
||||
margin-top: 1rem;
|
||||
@@ -334,7 +317,7 @@ function formatDate(iso: string): string {
|
||||
.invite-list h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
/* Registration toggle */
|
||||
@@ -352,33 +335,33 @@ function formatDate(iso: string): string {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
/* The one genuine override: 'close registration' must NOT read as the
|
||||
primary action it sits on. Scoped, so it beats the shared variant. */
|
||||
.btn-toggle-close {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.btn-toggle-close:hover:not(:disabled) {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
border-color: var(--fs-warning);
|
||||
color: var(--fs-warning);
|
||||
}
|
||||
|
||||
/* Users table */
|
||||
.loading-msg,
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
@@ -392,13 +375,13 @@ function formatDate(iso: string): string {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.users-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.users-table tbody tr:last-child td {
|
||||
@@ -408,10 +391,10 @@ function formatDate(iso: string): string {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-email {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.cell-date {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cell-actions {
|
||||
@@ -426,21 +409,21 @@ function formatDate(iso: string): string {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.role-admin {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
}
|
||||
.role-user {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.you-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user