Compare commits

..
Author SHA1 Message Date
Renovate Bot 5bf55fc488 Add renovate.json 2026-08-04 04:01:43 +00:00
280 changed files with 6650 additions and 21650 deletions
-19
View File
@@ -178,15 +178,6 @@ jobs:
- name: Design token check
run: python3 scripts/check_design_tokens.py --report-literals
# Dangling styles: an element whose classes have only modifier rules and
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
# this way (a link rendering as raw browser blue, a flex row whose parent
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
# style typechecks perfectly. Reported, not gated — a bare wrapper is
# legitimate, so the signal is the count growing.
- name: Dangling style check
run: python3 scripts/check_dangling_styles.py
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
@@ -225,16 +216,6 @@ jobs:
UV_PROJECT_ENVIRONMENT: /opt/venv
run: uv sync --locked --extra dev
# The hook-EXECUTION tests (test_write_path_trigger's nudge pair) run the
# real bash hook, which exits silently without jq — and those tests skip
# rather than fail when it's absent, so without this step they would
# quietly never be verified anywhere (ci-python ships without jq; same
# install the Plugin hooks job does).
- name: Install jq for hook execution tests
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends jq
- name: Run tests
# Integration tests (real Postgres) run in the `integration` job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
+1 -1
View File
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
## Features
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (with an inception step that decides what each project inherits), and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
## Quick Start
@@ -1,47 +0,0 @@
"""retire the two settings that designated a design source for the app itself
Revision ID: 0075
Revises: 0074
Create Date: 2026-08-03
Two keys, retired for the same reason a week apart, so they go in one change
rather than one migration each:
design_rulebook_id which rulebook described how this app should look
ui_design_system_id which design system this app's own UI was built from
Both named a design source for THE RUNNING INSTALL. The design surface is for
the projects an install tracks, and a project already carries its own pointer
(`projects.design_system_id`) — so an install-wide designation had nothing left
to mean. `ui_design_system_id` was introduced by this same migration's first
draft and never reached a deployed database; it is listed here rather than
undone by an 0076 that would reverse a change nobody ran.
Deleting settings rows by key is safe in a way dropping a column is not — the
table is free-form key/value, so an install that never designated one simply has
no row to delete.
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
lie: the pointer lives on the project now, and always did for anyone who set it
there.
"""
from alembic import op
import sqlalchemy as sa
revision = "0075"
down_revision = "0074"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM settings "
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
)
)
def downgrade() -> None:
pass
-115
View File
@@ -1,115 +0,0 @@
"""note_supersessions; drop the never-written notes.consolidated_at
Revision ID: 0076
Revises: 0075
Create Date: 2026-08-07
Step 1 of milestone #278. Structure only — nothing reads or writes the new
table yet, and nothing behaves differently after this runs.
## What the table is for
Old records outrank newer ones on the same subject, because a similarity score
cannot tell time. A note that accurately described how something worked in June
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
that, so nothing could act on it.
The claim points FORWARD — the newer record names what it overtakes — because
the older one cannot know it has been overtaken. Many-to-many and partial: a
note may supersede parts of several others and be overtaken piecemeal by
several later ones, which is why this is a table rather than a column. Both
directions are queried: `superseded_id` answers "has this been overtaken?" at
ranking time, `superseder_id` answers "what does this replace?" in a record
view. An array column could serve one and not the other.
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
them back. The cascade fires only on `purge_trash`, where the row genuinely
goes — and a claim about a row that no longer exists is not actionable.
## What is being dropped, and why now
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
— while being serialised into every note and task payload as `null`. It cost a
column, a line in every response, and worse: it IMPLIED a capability. A reader
reasonably concludes notes can be consolidated and this records when.
That reading was reasonable precisely because merge/unmerge exists for snippets
and not for notes, so the column looked like the notes-side half of that
feature, modelled and abandoned.
It is dropped rather than repurposed for supersession, and the distinction is
the point (#2483): consolidation folds several records into one survivor and
destroys the originals. Merging two snippets is lossless — one helper, several
call sites. Folding two dev-logs means writing a summary and losing what each
actually said. Supersession is the opposite act: both records survive, and the
older one is merely ranked behind. Smuggling one in under a column named for
the other would have buried that difference in schema.
## Downgrade
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
restores the shape, not the (nonexistent) data. Drops the table; any recorded
supersession claims are lost, which costs ranking its input and nothing else,
since no note's own content depends on them.
"""
import sqlalchemy as sa
from alembic import op
revision = "0076"
down_revision = "0075"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"note_supersessions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"superseder_id",
sa.Integer,
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"superseded_id",
sa.Integer,
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint(
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
),
# Declaring that a note supersedes ITSELF is meaningless, and under flat
# demotion it would demote a record on its own authority. Refused in the
# service too, with a message — this is the backstop that holds when
# something writes rows directly.
sa.CheckConstraint(
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
),
)
op.create_index(
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
)
op.create_index(
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
)
op.drop_column("notes", "consolidated_at")
def downgrade() -> None:
op.add_column(
"notes",
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
)
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
op.drop_table("note_supersessions")
@@ -1,54 +0,0 @@
"""Chunked embeddings: one note_embeddings row per chunk (#280)
Revision ID: 0077
Revises: 0076
Create Date: 2026-08-09
The embedding model reads at most 512 tokens and fastembed truncates the rest
silently, so the old one-row-per-note shape permanently lost everything past
~400 words of a record. A note now stores one row per chunk of
`embeddings.chunk_document`: PK (note_id, chunk_index), plus the chunk's text
(inspectability + future "matched section" surfacing) and the chunker version
that produced it (so later shape changes re-embed by version comparison
instead of repeating this wipe).
Embeddings are DERIVED data (0067 precedent): rows are cleared here and the
startup backfill regenerates the whole corpus at the new shape on next boot.
The HNSW index is untouched — it indexes chunk rows exactly as it indexed
note rows.
"""
from alembic import op
revision = "0077"
down_revision = "0076"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Derived data — the version-aware startup backfill re-embeds everything
# at the chunked shape. Old whole-document rows would be indistinguishable
# from properly-chunked single-chunk notes, so they cannot be carried over.
op.execute("DELETE FROM note_embeddings")
# Empty table, so NOT NULL columns need no defaults and the PK swap is
# instant.
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_index integer NOT NULL")
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_text text NOT NULL")
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunker_version integer NOT NULL")
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
op.execute(
"ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id, chunk_index)"
)
def downgrade() -> None:
# Same reasoning in reverse: chunk rows make no sense to a whole-document
# reader, so clear and let the old backfill regenerate.
op.execute("DELETE FROM note_embeddings")
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_index")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_text")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunker_version")
op.execute("ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id)")
-130
View File
@@ -1,130 +0,0 @@
"""Forge connections move to the user level (#2778)
Revision ID: 0078
Revises: 0077
Create Date: 2026-08-19
A forge token is a user's credential, not an instance's: the single
admin-settings config meant every user's snippet-freshness and coverage reads
ran under the operator's token. Each user now owns a keyring of connections —
one per forge host — and projects resolve forge reads on their OWNER's
keyring, with an optional per-project pin (projects.forge_connection_id).
The data move carries the existing admin config into a connection row for the
first admin user (host parsed from the base URL), then deletes the old
setting keys outright — no legacy dual-read (rule #22). The env-var channel
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
survives as an implicit keyring entry for admin users only.
"""
from urllib.parse import urlsplit
import sqlalchemy as sa
from alembic import op
revision = "0078"
down_revision = "0077"
branch_labels = None
depends_on = None
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
def upgrade() -> None:
op.create_table(
"forge_connections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("base_url", sa.Text(), nullable=False),
sa.Column("host", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
op.add_column(
"projects",
sa.Column(
"forge_connection_id",
sa.BigInteger(),
sa.ForeignKey(
"forge_connections.id",
ondelete="SET NULL",
name="fk_projects_forge_connection_id",
),
nullable=True,
),
)
# Data move: the admin-settings config becomes the first admin's keyring
# row. All three values must be present — a partial config never produced
# an adapter, so carrying it over would invent a connection that never
# worked.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT s.key, s.value FROM settings s"
" JOIN users u ON u.id = s.user_id"
" WHERE u.role = 'admin' AND s.key IN :keys"
" AND s.user_id = ("
" SELECT MIN(id) FROM users WHERE role = 'admin'"
" )"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
).fetchall()
values = {key: (value or "").strip() for key, value in row}
kind = values.get("forge_kind", "").lower()
base_url = values.get("forge_base_url", "").rstrip("/")
token = values.get("forge_token", "")
host = (urlsplit(base_url).hostname or "").lower()
if kind and base_url and token and host:
conn.execute(
sa.text(
"INSERT INTO forge_connections"
" (user_id, kind, base_url, host, token, created_at, updated_at)"
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
" FROM users WHERE role = 'admin'"
),
{"kind": kind, "base_url": base_url, "host": host, "token": token},
)
conn.execute(
sa.text(
"DELETE FROM settings WHERE key IN :keys"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
)
def downgrade() -> None:
# Reverse data move: the first admin's row (if any) becomes the admin
# settings again. Other users' rows have no pre-0078 representation and
# are dropped with the table.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT user_id, kind, base_url, token FROM forge_connections"
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
" ORDER BY id LIMIT 1"
)
).fetchone()
if row is not None:
for key, value in (
("forge_kind", row.kind),
("forge_base_url", row.base_url),
("forge_token", row.token),
):
conn.execute(
sa.text(
"INSERT INTO settings (user_id, key, value)"
" VALUES (:uid, :key, :value)"
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
),
{"uid": row.user_id, "key": key, "value": value},
)
op.drop_column("projects", "forge_connection_id")
op.drop_table("forge_connections")
@@ -1,66 +0,0 @@
"""The shape ledger: code_shapes (#2787, milestone 294)
Revision ID: 0079
Revises: 0078
Create Date: 2026-08-19
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small); this table accounts for EVERY shape the
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
from the coverage sync (step 2) and gain judgments — canonical / instance /
variant / exempt — from audits, hooks, and the mechanical proposer.
Unclassified IS the todo list.
"""
import sqlalchemy as sa
from alembic import op
revision = "0079"
down_revision = "0078"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shapes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"project_id",
sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_key", sa.Text(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="unclassified"),
sa.Column(
"snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
)
op.create_index(
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
)
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
op.drop_table("code_shapes")
@@ -1,54 +0,0 @@
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
Revision ID: 0080
Revises: 0079
Create Date: 2026-08-21
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
(definition line + a whitespace/comment-insensitive hash of its block) so the
proposer can match on content and a later drift recheck can notice change,
without the ledger ever storing code. The proposal columns carry the
proposer's standing suggestion for an unclassified row — instance-of-#N with
a basis and score, or a derive-first group key — and `proposed_sha`
remembers the content it was judged at so a refresh re-examines only what
changed. Mechanical and recomputable: a restore that lacks them loses
nothing the next refresh does not rebuild.
"""
import sqlalchemy as sa
from alembic import op
revision = "0080"
down_revision = "0079"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
op.add_column(
"code_shapes",
sa.Column(
"proposed_snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
op.create_index(
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
)
def downgrade() -> None:
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
for col in (
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
):
op.drop_column("code_shapes", col)
@@ -1,70 +0,0 @@
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
Revision ID: 0081
Revises: 0080
Create Date: 2026-08-21
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
a judgment was made at so a later body change under an instance/variant can
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
the previous refresh, where one canon dominates its directory+kind, and not
proposed as that canon). `code_shape_events` is the what-was-used-when
record: every classification, vanish, reappearance, and drift as it
happened — history the row alone cannot keep once it moves on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0081"
down_revision = "0080"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"code_shapes",
sa.Column(
"diverges_from",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
op.create_table(
"code_shape_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"shape_id",
sa.Integer(),
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=True),
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
op.create_index(
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
)
def downgrade() -> None:
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
op.drop_table("code_shape_events")
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
for col in ("diverges_from", "recheck_at", "classified_sha"):
op.drop_column("code_shapes", col)
-26
View File
@@ -1,26 +0,0 @@
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
Revision ID: 0082
Revises: 0081
Create Date: 2026-08-21
A repo binding used to imply the repo's default branch; the shape ledger
therefore only saw work after a merge to main, while the operator's work
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
NULL keeps today's behaviour (the forge's default branch).
"""
import sqlalchemy as sa
from alembic import op
revision = "0082"
down_revision = "0081"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("repo_bindings", "ref")
@@ -1,26 +0,0 @@
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
Revision ID: 0083
Revises: 0082
Create Date: 2026-08-21
The 2026-08 audit wrote the same free-text reason thousands of times
("scoped rule — styles one element of this view"); a judgment's WHY stays
prose, but an optional code from a fixed catalogue makes the ledger
filterable and aggregable ("how many pure helpers, how many test helpers").
"""
import sqlalchemy as sa
from alembic import op
revision = "0083"
down_revision = "0082"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("code_shapes", "reason_code")
-40
View File
@@ -1,40 +0,0 @@
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
Revision ID: 0084
Revises: 0083
Create Date: 2026-08-21
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
a canon). But a shape can also CALL several canonical helpers — e.g. a
service function both conforming to the service-function convention and
consuming hash_token. The 2026-08 audit had to pick one; hook evidence
("pulled #N then wrote code referencing it") was stamped as instance when it
is a uses fact. This table holds the many-valued relation: shape → snippet,
with the basis and the evidence. Cascades with the shape and the snippet.
"""
import sqlalchemy as sa
from alembic import op
revision = "0084"
down_revision = "0083"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shape_uses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("basis", sa.Text(), nullable=False),
sa.Column("evidence", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
)
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
op.drop_table("code_shape_uses")
@@ -1,74 +0,0 @@
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
Revision ID: 0085
Revises: 0084
Create Date: 2026-08-22
`projects.inception` is the WHY a project inherits what it does — NULL until
someone decides, at which point enter_project stops asking. The new
association `project_rulebook_exclusions` is the opt-out of a whole always-on
rulebook for one project (the sibling of the rule/topic suppressions).
Backfill: every project that exists when this runs is stamped
via="legacy" with its CURRENT standing (no exclusions, its subscriptions,
its design_system_id, no seed) — so the ask fires only for projects created
after the step shipped, and nothing a running install relies on changes.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0085"
down_revision = "0084"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("inception", postgresql.JSONB(), nullable=True),
)
op.create_table(
"project_rulebook_exclusions",
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"rulebook_id", sa.BigInteger(),
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
)
# Legacy stamp: what each existing project inherits today, recorded as a
# decision so the inception ask does not fire on a project that has been
# running for months.
op.execute(sa.text("""
UPDATE projects p SET inception = jsonb_build_object(
'via', 'legacy',
'decided_at', to_jsonb(now()),
'decided_by', NULL,
'choices', jsonb_build_object(
'exclude_always_on_rulebooks', '[]'::jsonb,
'subscribe_rulebooks', COALESCE(
(SELECT jsonb_agg(s.rulebook_id ORDER BY s.rulebook_id)
FROM project_rulebook_subscriptions s
WHERE s.project_id = p.id),
'[]'::jsonb),
'design_system_id', to_jsonb(p.design_system_id),
'seed_systems', false
)
)
WHERE p.inception IS NULL
"""))
def downgrade() -> None:
op.drop_table("project_rulebook_exclusions")
op.drop_column("projects", "inception")
+1 -4
View File
@@ -76,9 +76,7 @@ endpoint at `/mcp`, not these REST routes.
| Method | Path | Description |
|--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
@@ -120,7 +118,6 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
## Sharing
+17 -17
View File
@@ -254,7 +254,7 @@ onUnmounted(() => {
left: 0.5rem;
z-index: 9999;
padding: 0.4rem 0.75rem;
background: var(--fs-accent);
background: var(--color-primary);
color: var(--fs-text-on-action);
border-radius: 0 0 4px 4px;
font-size: 0.875rem;
@@ -290,7 +290,7 @@ onUnmounted(() => {
text-align: center;
padding: 0.2rem 0;
font-size: 0.68rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
opacity: 0.45;
user-select: none;
letter-spacing: 0.03em;
@@ -300,17 +300,17 @@ onUnmounted(() => {
.shortcuts-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
.shortcuts-panel {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
box-shadow: 0 8px 32px var(--color-shadow);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
width: min(420px, 92vw);
overflow: hidden;
}
@@ -319,25 +319,25 @@ onUnmounted(() => {
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.shortcuts-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--fs-text-primary);
color: var(--color-text);
}
.shortcuts-close {
background: none;
border: none;
font-size: 1.4rem;
line-height: 1;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
cursor: pointer;
padding: 0 0.25rem;
}
.shortcuts-close:hover {
color: var(--fs-text-primary);
color: var(--color-text);
}
.shortcuts-body {
padding: 0.75rem 1rem 1rem;
@@ -350,7 +350,7 @@ onUnmounted(() => {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.shortcut-row {
@@ -365,23 +365,23 @@ onUnmounted(() => {
justify-content: center;
min-width: 1.8rem;
padding: 0.15rem 0.4rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-bottom-width: 2px;
border-radius: 4px;
font-size: 0.78rem;
font-family: ui-monospace, monospace;
color: var(--fs-text-primary);
color: var(--color-text);
white-space: nowrap;
user-select: none;
}
.shortcut-key-sep {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.shortcut-desc {
font-size: 0.875rem;
color: var(--fs-text-primary);
color: var(--color-text);
margin-left: 0.25rem;
}
-14
View File
@@ -38,20 +38,6 @@ 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);
+9
View File
@@ -0,0 +1,9 @@
import { apiGet } from "@/api/client";
import type { ExpectationResponse } from "@/utils/designDrift";
/** Checkable claims from the rulebook this install designated as its design system.
*
* `rulebook_id: null` means none has been designated — the normal state for a
* fresh install, not an error. The caller shows an explanatory empty state. */
export const fetchDesignExpectations = () =>
apiGet<ExpectationResponse>("/api/design/expectations");
+3 -28
View File
@@ -74,28 +74,11 @@ export const fetchDesignSystems = () =>
export const fetchDesignSystem = (id: number) =>
apiGet<DesignSystem>(`/api/design-systems/${id}`);
export interface StarterRoleGroup {
group: string;
description: string;
token_count: number;
names: string[];
}
/** The starter token ROLES offered at creation — names and purposes, never
* values. A default palette would be one install's taste shipped as product
* (rule #115), so the values are always the operator's to fill. */
export const listStarterRoleGroups = () =>
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
"/api/design-systems/starter-roles",
);
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
starter_role_groups?: string[];
token_prefix?: string;
}) => apiPost<DesignSystem>("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
@@ -200,14 +183,6 @@ export interface SnippetCheck {
findings: SnippetFinding[];
}
/** Which recorded snippets disagree with this design system's sheet.
*
* `projectId` narrows to the snippets one project owns — which is how a
* project asks about its OWN code. Omit it to check every project, which is
* the right default from the system's side: a component recorded elsewhere
* still has to use the same tags. */
export const checkSnippets = (id: number, projectId?: number) =>
apiGet<SnippetCheck>(
`/api/design-systems/${id}/snippet-check`
+ (projectId ? `?project_id=${projectId}` : ""),
);
/** Which recorded snippets disagree with this design system's sheet. */
export const checkSnippets = (id: number) =>
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
-42
View File
@@ -1,42 +0,0 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices {
exclude_always_on_rulebooks: number[];
subscribe_rulebooks: number[];
design_system_id: number | null;
seed_systems: boolean;
}
export interface InceptionRecord {
decided_at: string;
decided_by: number | null;
via: "mcp" | "ui" | "legacy";
choices: InceptionChoices;
}
export interface InceptionDefaults {
always_on_rulebooks: { id: number; title: string }[];
other_rulebooks: { id: number; title: string }[];
excluded_always_on: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
}
export const emptyChoices = (): InceptionChoices => ({
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
});
export const fetchInceptionDefaults = (projectId: number) =>
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
export const decideInception = (projectId: number, choices: InceptionChoices) =>
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
-13
View File
@@ -71,8 +71,6 @@ export interface ApplicableRules {
}[];
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
excluded_always_on: { id: number; title: string }[];
}
// ── Rulebooks ───────────────────────────────────────────────────────
@@ -183,14 +181,3 @@ export async function suppressTopicForProject(projectId: number, topicId: number
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
}
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
}
-115
View File
@@ -1,115 +0,0 @@
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
The five auth views used to carry byte-identical copies of these rules in
their scoped blocks (2026-08 shape audit). Loaded per view with
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
rules are scoped under .auth-card so nothing leaks into the app's other
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
stay in the view. */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
/* A centred status paragraph block: registration closed, invalid/expired
token, "check your inbox". One rule — the views used to name it
.closed-msg / .error-block / .success-msg with identical bodies. */
.auth-note {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.auth-note p {
margin: 0.5rem 0;
}
.auth-loading {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.auth-card .field {
margin-bottom: 1rem;
}
.auth-card .field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.auth-card .input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.auth-card .input:focus {
outline: none;
border-color: var(--fs-accent);
}
.auth-card .input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .input-error,
.auth-card .input-error:focus {
border-color: var(--fs-error);
}
.auth-card .field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.auth-card .error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.auth-card .error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
+17 -123
View File
@@ -36,8 +36,7 @@
.btn-secondary,
.btn-ghost,
.btn-danger,
.btn-danger-outline,
.btn-cta {
.btn-danger-outline {
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
border: none;
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
@@ -47,14 +46,6 @@
line-height: var(--fs-leading-body);
white-space: nowrap;
cursor: pointer;
/* So a button carrying an icon centres it against the label without each
caller re-inventing the flex row — the shape they all reached for
separately, and the reason icon buttons sat a pixel or two off. */
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--fs-space-2);
text-decoration: none;
transition: background var(--fs-dur-fast) var(--fs-ease),
border-color var(--fs-dur-fast) var(--fs-ease),
color var(--fs-dur-fast) var(--fs-ease);
@@ -67,8 +58,7 @@
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled,
.btn-danger-outline:disabled,
.btn-cta:disabled {
.btn-danger-outline:disabled {
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
@@ -77,8 +67,7 @@
.btn-secondary:focus-visible,
.btn-ghost:focus-visible,
.btn-danger:focus-visible,
.btn-danger-outline:focus-visible,
.btn-cta:focus-visible {
.btn-danger-outline:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
@@ -89,19 +78,19 @@
* are universal across the family so a Save button looks identical in every
* app — the accent is identity, not action. */
.btn-primary {
background: var(--fs-action-primary);
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.btn-primary:not(:disabled):hover {
background: var(--fs-action-primary-hover);
background: var(--color-action-primary-hover);
}
.btn-secondary {
background: var(--fs-action-secondary);
background: var(--color-action-secondary);
color: var(--fs-text-on-action);
}
.btn-secondary:not(:disabled):hover {
background: var(--fs-action-secondary-hover);
background: var(--color-action-secondary-hover);
}
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
@@ -112,21 +101,21 @@
.btn-ghost {
background: none;
border: var(--fs-border);
color: var(--fs-text-primary);
color: var(--color-text);
}
.btn-ghost:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--fs-surface-hover);
background: var(--color-hover);
}
/* Destructive is NOT the error colour: an error is a failure that happened, a
* destructive action is one about to happen. Pair with an icon. */
.btn-danger {
background: var(--fs-action-destructive);
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
}
.btn-danger:not(:disabled):hover {
background: var(--fs-action-destructive-hover);
background: var(--color-action-destructive-hover);
}
/* A bare text button: no fill, no border. The most common shape in the dense
@@ -136,7 +125,7 @@
.btn-text {
background: none;
border: none;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding: var(--fs-space-1) var(--fs-space-2);
font-family: var(--fs-font-body);
font-size: var(--fs-size-tiny);
@@ -144,7 +133,7 @@
cursor: pointer;
transition: color var(--fs-dur-fast) var(--fs-ease);
}
.btn-text:not(:disabled):hover { color: var(--fs-text-primary); }
.btn-text:not(:disabled):hover { color: var(--color-text); }
.btn-text:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
@@ -153,33 +142,14 @@
* a one-off: it is what a delete looks like when it must not shout. */
.btn-danger-outline {
background: none;
border: 1px solid var(--fs-action-destructive);
color: var(--fs-action-destructive);
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
}
.btn-danger-outline:not(:disabled):hover {
background: var(--fs-action-destructive);
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
}
/* The one place the accent is allowed on a button: a deliberate brand moment,
* never an ordinary action. The system carries `--fs-gradient-cta` and
* `--fs-glow-cta` for exactly this and nothing else was using them.
*
* It exists because ProjectView's Workspace link WAS this button, defined in a
* scoped block that the migration deleted — leaving a `:hover` rule with no
* base and a link that rendered as raw browser blue. A variant living in one
* view is a variant waiting to be deleted by someone tidying another; this is
* the shared home so the next sweep can't strand it. */
.btn-cta {
background: var(--fs-gradient-cta);
color: var(--fs-text-on-action);
box-shadow: var(--fs-glow-cta);
text-decoration: none;
}
.btn-cta:not(:disabled):hover {
box-shadow: var(--fs-glow-cta-hover);
}
/* --- size modifiers ------------------------------------------------------
*
* THREE sizes, because the app genuinely has three. Measured across the ~100
@@ -214,86 +184,10 @@
/* Full width, for a form's single submitting action — the auth screens. Width
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
.btn-block {
display: flex; /* not `block` — the shared shape centres with flex */
display: block;
width: 100%;
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
because a full-width button
is the page's main action */
font-size: var(--fs-size-body-sm);
}
/* ── Modal ─────────────────────────────────────────────────────────────────
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
the create-project / merge-snippet / systems dialogs, the editors' confirm
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
styles of its own, so these must be loaded with the app, not with whichever
view happens to be open. Views add only their own overrides (a wider card,
a form layout). Destructive = action-destructive per the Hybrid rule. */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.modal-message {
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 0 0 1.25rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.modal-btn-danger {
background: var(--fs-action-destructive);
border-color: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
.modal-btn-danger:hover {
background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover);
}
+123 -76
View File
@@ -13,7 +13,7 @@
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.5rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.editor-body {
flex: 1;
@@ -41,36 +41,36 @@
with a Trash icon at the call site to reinforce intent. */
.title-input:focus {
outline: none;
border-bottom-color: var(--fs-accent);
border-bottom-color: var(--color-primary);
}
.title-input::placeholder {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-weight: 400;
}
.editor-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 0.45rem 1rem;
border: none;
background: none;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--fs-accent);
border-bottom-color: var(--fs-accent);
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
min-height: 200px;
background: var(--fs-surface-raised);
background: var(--color-bg-card);
}
/* ── Tag suggestions ── */
@@ -85,21 +85,21 @@
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0.55rem;
border: 1px solid var(--fs-accent);
border: 1px solid var(--color-primary);
border-radius: 999px;
background: transparent;
color: var(--fs-accent);
color: var(--color-primary);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tag-pill:hover:not(:disabled) {
background: var(--fs-accent);
background: var(--color-primary);
color: var(--fs-text-on-action);
}
.tag-pill.applied {
background: var(--fs-success);
border-color: var(--fs-success);
background: var(--color-success, #2ecc71);
border-color: var(--color-success, #2ecc71);
color: var(--fs-text-on-action);
cursor: default;
}
@@ -111,8 +111,8 @@
.assist-panel {
width: 320px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
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(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-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(--fs-text-tertiary);
color: var(--color-text-muted);
margin-bottom: 0.2rem;
}
.assist-sections {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
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(--fs-text-primary);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-section-item:hover {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.assist-section-item.selected {
border-left-color: var(--fs-accent);
background: var(--fs-surface-raised);
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
font-weight: 500;
}
.assist-empty,
.assist-hint {
padding: 0.6rem 0.7rem;
font-size: 0.82rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-target-preview em {
font-style: normal;
color: var(--fs-text-primary);
color: var(--color-text);
}
.assist-instruction {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 0.88rem;
font-family: inherit;
resize: vertical;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
min-height: 3.5rem;
}
@@ -216,22 +216,22 @@
/* Streaming */
.assist-streaming-label {
font-size: 0.8rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-preview-box {
padding: 0.65rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
font-size: 0.9rem;
max-height: 300px;
overflow-y: auto;
}
.typing-indicator {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
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(--fs-text-tertiary);
color: var(--color-text-muted);
text-align: center;
}
/* Error */
.assist-error {
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
border: 1px solid var(--fs-error);
border-radius: var(--fs-radius-sm);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: var(--fs-error);
color: var(--color-danger);
}
/* Review / diff */
@@ -265,12 +265,12 @@
justify-content: space-between;
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.diff-view {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
font-size: 0.82rem;
font-family: monospace;
max-height: 340px;
@@ -284,15 +284,15 @@
line-height: 1.5;
}
.diff-delete {
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error);
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
}
.diff-insert {
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success);
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
}
.diff-equal {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.diff-marker {
flex-shrink: 0;
@@ -308,7 +308,7 @@
}
.diff-empty {
padding: 0.5rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.82rem;
}
.assist-actions {
@@ -316,16 +316,63 @@
gap: 0.5rem;
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--color-danger);
color: var(--fs-text-on-action);
border-color: var(--color-danger);
}
/* ── Floating inline assist button (teleported to body) ── */
.inline-assist-btn {
position: fixed;
z-index: 100;
transform: translateX(-50%);
padding: 0.3rem 0.75rem;
background: var(--fs-action-primary);
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
box-shadow: 0 2px 8px var(--color-shadow);
@@ -337,12 +384,12 @@
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
@@ -361,7 +408,7 @@
.sb-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -369,10 +416,10 @@
.sb-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
@@ -380,11 +427,11 @@
.sb-select:focus,
.sb-input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--fs-border-color);
background: var(--color-border);
margin: 0.15rem 0;
}
@media (max-width: 720px) {
@@ -405,8 +452,8 @@
width: auto;
flex: 0 0 45%;
border-left: none;
border-top: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
border-top: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.editor-header {
padding: 0.75rem 1rem 0.5rem;
@@ -433,14 +480,14 @@
.btn-accept,
.btn-generate,
.btn-save {
background: var(--fs-action-primary);
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
}
.btn-accept:not(:disabled):hover,
.btn-generate:not(:disabled):hover,
.btn-save:not(:disabled):hover {
background: var(--fs-action-primary-hover);
background: var(--color-action-primary-hover);
}
.btn-back,
@@ -450,7 +497,7 @@
.btn-suggest-tags {
background: none;
border: var(--fs-border);
color: var(--fs-text-primary);
color: var(--color-text);
}
.btn-back:not(:disabled):hover,
.btn-clear:not(:disabled):hover,
@@ -458,16 +505,16 @@
.btn-proofread:not(:disabled):hover,
.btn-suggest-tags:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--fs-surface-hover);
background: var(--color-hover);
}
.btn-delete {
background: var(--fs-action-destructive);
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
border: none;
}
.btn-delete:not(:disabled):hover {
background: var(--fs-action-destructive-hover);
background: var(--color-action-destructive-hover);
}
/* Shared geometry for every alias above. */
@@ -494,12 +541,12 @@
.btn-dismiss-tags {
background: none;
border: none;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding: 2px var(--fs-space-1);
font-size: var(--fs-size-tiny);
line-height: 1;
}
.btn-dismiss-tags:hover { color: var(--fs-text-primary); }
.btn-dismiss-tags:hover { color: var(--color-text); }
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
+24 -24
View File
@@ -41,8 +41,8 @@
}
.prose pre {
background: var(--fs-surface-code);
border: 1px solid var(--fs-border-color);
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.75rem;
overflow-x: auto;
@@ -57,7 +57,7 @@
}
.prose code {
background: var(--fs-surface-code-inline);
background: var(--color-code-inline-bg);
border-radius: 3px;
padding: 0.15rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
@@ -73,25 +73,25 @@
.prose th,
.prose td {
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
padding: 0.4rem 0.6rem;
text-align: left;
}
.prose thead th {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
font-weight: 600;
}
.prose tbody tr:nth-child(even) {
background: var(--fs-table-stripe);
background: var(--color-table-stripe);
}
.prose blockquote {
border-left: 3px solid var(--fs-border-color);
border-left: 3px solid var(--color-border);
margin: 0 0 0.6rem;
padding: 0.25rem 0 0.25rem 0.75rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.prose blockquote p:last-child {
@@ -100,7 +100,7 @@
.prose hr {
border: none;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
margin: 1rem 0;
}
@@ -110,7 +110,7 @@
}
.prose a {
color: var(--fs-accent);
color: var(--color-primary);
text-decoration: none;
}
@@ -119,8 +119,8 @@
}
.prose .inline-tag {
color: var(--fs-accent);
background: var(--fs-accent-soft);
color: var(--color-tag-text);
background: var(--color-tag-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
@@ -133,8 +133,8 @@
}
.prose .wikilink {
color: var(--fs-wikilink);
background: var(--fs-accent-soft);
color: var(--color-wikilink);
background: var(--color-wikilink-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
@@ -168,7 +168,7 @@
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
cursor: pointer;
accent-color: var(--fs-accent);
accent-color: var(--color-primary);
width: 0.95em;
height: 0.95em;
margin: 0;
@@ -180,7 +180,7 @@
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* Interactive checkboxes — marked output in the list-note viewer */
@@ -196,7 +196,7 @@
}
.prose--checklist li input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--fs-accent);
accent-color: var(--color-primary);
cursor: pointer;
width: 0.95em;
height: 0.95em;
@@ -205,7 +205,7 @@
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
.prose--checklist li:has(input[type="checkbox"]:checked) {
text-decoration: line-through;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
text-decoration: none; /* don't strike through the checkbox itself */
@@ -219,7 +219,7 @@
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--fs-text-tertiary);
color: var(--color-text-muted, var(--color-text-secondary));
content: attr(data-placeholder);
float: left;
height: 0;
@@ -227,12 +227,12 @@
}
.tiptap-wrapper {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
}
.tiptap-wrapper:focus-within {
box-shadow: var(--fs-focus-ring);
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
}
+116 -11
View File
@@ -188,21 +188,126 @@
*/
/* ==========================================================================
The compatibility-alias block that lived here is GONE (#2533). It let ~55
components keep their historical --color-* names while theme.css was
repointed at the design system; the rename sweep it promised ran on
2026-08-08 and every component now references --fs-* directly. Do not
reintroduce app-local alias names — the design system's tokens are the
vocabulary, and check_snippets_against_design_system can only see through
names the system actually declares.
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
These exist so ~55 components keep working while they migrate to --fs-*
one at a time. Every one is a plain var() reference, which is what lets this
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
--color-bg follows, because the alias resolves at use time.
That is why this file lost 48 of its 60 dark-mode overrides — they were all
restating relationships the aliases now express directly.
Removing this block is a rename sweep across the components, tracked
separately. Nothing new should reference a --color-* name.
========================================================================== */
:root {
/* A VALUE, not an alias — the one survivor of the alias block. The design
system has no shadow-colour token yet, so this is a recorded gap: when a
second app needs it, promote it to an --fs-* token in the system and
regenerate, rather than copying this line. */
/* surfaces */
--color-bg: var(--fs-surface-page);
--color-bg-secondary: var(--fs-surface-raised);
--color-bg-card: var(--fs-surface-raised);
--color-surface: var(--fs-surface-hover);
--color-code-bg: var(--fs-surface-code);
--color-code-inline-bg: var(--fs-surface-code-inline);
--color-table-stripe: var(--fs-table-stripe);
--color-overlay: var(--fs-overlay);
/* text */
--color-text: var(--fs-text-primary);
--color-text-secondary: var(--fs-text-secondary);
--color-text-muted: var(--fs-text-tertiary);
/* lines */
--color-border: var(--fs-border-color);
--color-input-border: var(--fs-border-color);
--focus-ring: var(--fs-focus-ring);
/* brand */
--color-primary: var(--fs-accent);
--color-primary-solid: var(--fs-accent);
--color-primary-deep: var(--fs-accent-deep);
--color-primary-faint: var(--fs-accent-faint);
--color-primary-tint: var(--fs-accent-soft);
--color-primary-wash: var(--fs-accent-wash);
--color-tag-bg: var(--fs-accent-soft);
--color-tag-text: var(--fs-accent);
--color-wikilink: var(--fs-wikilink);
--color-wikilink-bg: var(--fs-accent-soft);
--gradient-cta: var(--fs-gradient-cta);
--glow-cta: var(--fs-glow-cta);
--glow-cta-hover: var(--fs-glow-cta-hover);
/* actions */
--color-action-primary: var(--fs-action-primary);
--color-action-primary-hover: var(--fs-action-primary-hover);
--color-action-secondary: var(--fs-action-secondary);
--color-action-secondary-hover: var(--fs-action-secondary-hover);
--color-action-destructive: var(--fs-action-destructive);
--color-action-destructive-hover: var(--fs-action-destructive-hover);
/* semantic */
--color-success: var(--fs-success);
--color-warning: var(--fs-warning);
--color-danger: var(--fs-error);
--color-overdue: var(--fs-overdue);
--color-toast-success: var(--fs-success);
--color-toast-error: var(--fs-error);
--color-shadow: rgba(0, 0, 0, 0.4);
/* task status + priority */
--color-status-todo: var(--fs-status-todo);
--color-status-todo-bg: var(--fs-status-todo-bg);
--color-status-in-progress: var(--fs-status-in-progress);
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
--color-status-done: var(--fs-status-done);
--color-status-done-bg: var(--fs-status-done-bg);
--color-priority-low: var(--fs-priority-low);
--color-priority-low-bg: var(--fs-priority-low-bg);
--color-priority-medium: var(--fs-priority-medium);
--color-priority-medium-bg: var(--fs-priority-medium-bg);
--color-priority-high: var(--fs-priority-high);
--color-priority-high-bg: var(--fs-priority-high-bg);
/* geometry */
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
--page-max-width: var(--fs-layout-page-max);
--page-padding-x: var(--fs-layout-page-pad);
--sidebar-width: var(--fs-layout-sidebar);
--header-height: var(--fs-layout-header);
/* ------------------------------------------------------------------
Names components reference that were NEVER declared anywhere.
Each of these was reached for with a hardcoded fallback, so the page
rendered — but the fallback was what rendered, always, and several were
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
to a brick that is not the oxblood, --color-status-cancelled to a grey from
no palette in this system.
Wiring them to real tokens is the whole point of the exercise. Expect small
visual shifts exactly where a fallback had drifted; that shift IS the fix.
------------------------------------------------------------------ */
--color-accent: var(--fs-accent);
/* Foreground ON the accent, so it follows the accent's mode-independence,
not the page text's. Pointing this at --fs-text-primary made it invert to
obsidian on light — over a mid-tone accent, well under the AA floor. */
--color-accent-fg: var(--fs-text-on-action);
--color-hover: var(--fs-surface-hover);
--color-bg-hover: var(--fs-surface-hover);
--color-bg-tertiary: var(--fs-surface-hover);
--color-surface-2: var(--fs-surface-hover);
--color-surface-alt: var(--fs-surface-hover);
--color-surface-raised: var(--fs-surface-raised);
--color-input-bg: var(--fs-surface-page);
--color-muted: var(--fs-text-tertiary);
--color-destructive: var(--fs-destructive);
--color-primary-bg: var(--fs-accent-soft);
--color-status-cancelled: var(--fs-status-cancelled);
--font-display: var(--fs-font-display);
--font-mono: var(--fs-font-mono);
}
/* ==========================================================================
+12 -12
View File
@@ -15,27 +15,27 @@
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--fs-accent);
border-color: var(--fs-accent);
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--fs-accent) 18%, transparent);
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--fs-text-secondary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
+50 -82
View File
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -50,12 +50,6 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<!-- A design system is a RECORD you author, not a setting. It sat in
the utility cluster with Trash and Settings while /design was a
read-only gallery, and stayed there after it became a record type
with its own table, sharing and MCP tools. Content, by the same
rule that puts Snippets and Rulebooks here. -->
<router-link to="/design-systems" class="nav-link">Design</router-link>
</div>
</div>
@@ -70,6 +64,16 @@ router.afterEach(() => {
<Moon v-else :size="16" />
</button>
<!-- Design. An icon rather than a sixth primary nav link: it's a
meta-surface like Trash and Settings, but hiding it entirely would
defeat the point of having somewhere the design system is visible.
Points at the RECORD, not the live-token view — the record is what
you work with; the live view is the check on it, and it's a tab
away. -->
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
<Palette :size="16" />
</router-link>
<!-- Trash link -->
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
<Trash2 :size="16" />
@@ -102,9 +106,9 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div>
@@ -124,35 +128,20 @@ router.afterEach(() => {
<style scoped>
.app-header {
background: linear-gradient(180deg, var(--fs-surface-hover), var(--fs-surface-page));
border-bottom: 1px solid color-mix(in srgb, var(--fs-accent) 18%, transparent);
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
position: relative;
}
/* Three tracks, not a flex row with an absolutely-centred overlay.
*
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
* not participate in layout: when the header ran out of room it OVERLAPPED the
* brand and the utility cluster rather than pushing them, and nothing wrapped
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
* is an ordinary window on any monitor.
*
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
* neither side can be squeezed below its content, and the two side tracks stay
* equal to each other — which is what keeps the bar centred in the viewport
* rather than merely centred in the leftover space. Overflow becomes the
* header growing, not two things sharing pixels. */
.nav {
padding: 0.6rem 1.5rem;
display: grid;
grid-template-columns: 1fr auto 1fr;
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
position: relative;
}
/* Left — brand */
.nav-brand {
justify-self: start;
display: flex;
align-items: center;
gap: 0.45rem;
@@ -170,7 +159,9 @@ router.afterEach(() => {
/* Center — pill bar */
.nav-center {
justify-self: center;
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
}
@@ -178,23 +169,21 @@ router.afterEach(() => {
display: flex;
align-items: center;
gap: 2px;
background: var(--fs-accent-faint);
background: var(--color-primary-faint);
border-radius: 10px;
padding: 3px;
}
/* Right */
.nav-right {
justify-self: end;
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
min-width: 0;
}
.nav-link {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
@@ -202,14 +191,14 @@ router.afterEach(() => {
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--fs-text-primary);
background: var(--fs-accent-soft);
color: var(--color-text);
background: var(--color-primary-tint);
}
.nav-link.router-link-active {
color: var(--fs-accent);
color: var(--color-primary-solid);
font-weight: 500;
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
background: rgba(91, 74, 138, 0.25);
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
}
/* Status indicator */
@@ -229,7 +218,7 @@ router.afterEach(() => {
.status-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* Status dots are indicator lights, not semantic-palette buttons —
they want to read as vital (Moss/Warning/Error are too muted for
@@ -239,7 +228,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(--fs-text-tertiary); animation: pulse-dot 2s infinite; }
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
@@ -252,12 +241,12 @@ router.afterEach(() => {
/* Icon buttons (?, theme, gear) */
.btn-icon {
background: none;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.45rem;
cursor: pointer;
font-size: 0.95rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
line-height: 1;
display: flex;
align-items: center;
@@ -265,9 +254,9 @@ router.afterEach(() => {
}
.btn-icon:hover,
.btn-icon.active {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-color: var(--fs-accent);
background: var(--color-bg-card);
color: var(--color-text);
border-color: var(--color-primary);
}
/* User info */
@@ -277,42 +266,36 @@ router.afterEach(() => {
gap: 0.4rem;
margin-left: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid var(--fs-border-color);
border-left: 1px solid var(--color-border);
}
.username {
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-weight: 500;
/* The widest thing on the right and the only one that can give: a long
username shouldn't be what decides where the nav bar sits. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12ch;
}
.admin-badge {
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
padding: 0.1rem 0.35rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
}
.btn-logout {
background: none;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-family: inherit;
}
.btn-logout:hover {
color: var(--fs-error);
border-color: var(--fs-error);
color: var(--color-danger);
border-color: var(--color-danger);
}
/* Hamburger — mobile only */
@@ -330,7 +313,7 @@ router.afterEach(() => {
display: block;
width: 20px;
height: 2px;
background: var(--fs-text-primary);
background: var(--color-text);
border-radius: 1px;
}
@@ -339,13 +322,13 @@ router.afterEach(() => {
display: flex;
flex-direction: column;
padding: 0.5rem 1rem 0.75rem;
border-top: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
gap: 0.1rem;
}
.mobile-divider {
height: 1px;
background: var(--fs-border-color);
background: var(--color-border);
margin: 0.4rem 0;
}
.mobile-actions {
@@ -359,25 +342,10 @@ router.afterEach(() => {
align-items: center;
gap: 0.5rem;
padding-top: 0.4rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
margin-top: 0.25rem;
}
/* The grid above means running out of room can no longer cause a collision —
but it can still make the header wider than the window, and a horizontally
scrolling header is its own defect. So shed width before that happens. The
wordmark goes first: the logo beside it says the same thing and is still the
link home. */
@media (max-width: 1280px) {
.brand-text {
display: none;
}
.nav-link {
padding: 0.3rem 0.5rem;
font-size: 0.78rem;
}
}
@media (max-width: 768px) {
.nav-center {
display: none;
@@ -398,7 +366,7 @@ router.afterEach(() => {
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: var(--fs-accent-wash);
background: var(--color-primary-wash);
box-shadow: none;
}
.mobile-user .btn-logout {
+5 -5
View File
@@ -13,8 +13,8 @@ defineProps<{ size?: number }>();
>
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--fs-accent)" />
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
</linearGradient>
</defs>
<!-- Book body -->
@@ -44,12 +44,12 @@ defineProps<{ size?: number }>();
<style scoped>
.logo-book {
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--fs-accent) 70%, transparent);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
.logo-spine {
stroke: var(--fs-text-secondary);
stroke: var(--color-text-secondary);
}
.logo-lines {
stroke: var(--fs-text-tertiary);
stroke: var(--color-text-muted);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
/**
* Sub-navigation for the Design surface.
*
* There are two pages here and they are halves of ONE thing: the record that
* decides the styling, and what the browser is actually rendering from it. They
* were briefly two top-level nav entries, which put the read-only diagnostic
* first and buried the editable record under it — backwards, since the record
* is the thing you work with and the live view is the check on it.
*
* A component rather than the same markup pasted into both views: two copies of
* a tab bar diverge the moment a third tab appears, and that is the exact shape
* of duplication this whole surface exists to make visible.
*/
</script>
<template>
<nav class="design-tabs" aria-label="Design views">
<router-link to="/design-systems" class="design-tab">Design system</router-link>
<router-link to="/design" class="design-tab">Live tokens</router-link>
</nav>
</template>
<style scoped>
.design-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.design-tab {
padding: 0.5rem 0.9rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.design-tab:hover {
color: var(--color-text);
}
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
exact matching would drop the highlight on any future child route. */
.design-tab.router-link-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+16 -16
View File
@@ -90,9 +90,9 @@ function markerFor(type: DiffLine['type']): string {
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
overflow: hidden;
}
@@ -103,15 +103,15 @@ function markerFor(type: DiffLine['type']): string {
display: flex;
gap: 1rem;
padding: 0.4rem 0.75rem;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
font-size: 0.78rem;
font-family: monospace;
font-weight: 600;
}
.diff-summary-ins { color: var(--fs-success); }
.diff-summary-del { color: var(--fs-error); }
.diff-summary-ins { color: var(--color-success, #2ecc71); }
.diff-summary-del { color: var(--color-danger, #e74c3c); }
.diff-scroll {
flex: 1;
@@ -123,7 +123,7 @@ function markerFor(type: DiffLine['type']): string {
.diff-empty {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.diff-line {
@@ -136,24 +136,24 @@ function markerFor(type: DiffLine['type']): string {
}
.diff-delete {
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error);
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
color: var(--color-danger, #e74c3c);
}
.diff-insert {
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success);
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
color: var(--color-success, #2ecc71);
}
.diff-equal {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.diff-collapse {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
opacity: 0.6;
border-top: 1px dashed var(--fs-border-color);
border-bottom: 1px dashed var(--fs-border-color);
border-top: 1px dashed var(--color-border);
border-bottom: 1px dashed var(--color-border);
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
+35 -28
View File
@@ -3,7 +3,6 @@ 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;
@@ -57,6 +56,14 @@ 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 {
@@ -205,7 +212,7 @@ onMounted(loadVersions);
v-if="v.pin_kind === 'manual' && v.pin_label"
class="history-item-label"
>{{ v.pin_label }}</div>
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
</div>
</div>
@@ -302,7 +309,7 @@ onMounted(loadVersions);
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.history-title {
@@ -315,11 +322,11 @@ onMounted(loadVersions);
border: none;
font-size: 1.25rem;
cursor: pointer;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
line-height: 1;
padding: 0.1rem 0.3rem;
}
.history-close:hover { color: var(--fs-text-primary); }
.history-close:hover { color: var(--color-text); }
.history-body {
flex: 1;
@@ -331,7 +338,7 @@ onMounted(loadVersions);
.history-list {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--fs-border-color);
border-right: 1px solid var(--color-border);
overflow-y: auto;
}
@@ -339,18 +346,18 @@ onMounted(loadVersions);
padding: 0.6rem 0.9rem;
cursor: pointer;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.history-item:hover { background: var(--fs-surface-raised); }
.history-item:hover { background: var(--color-bg-secondary); }
.history-item.selected {
border-left-color: var(--fs-accent);
background: var(--fs-surface-raised);
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.history-item-title {
font-size: 0.85rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -358,7 +365,7 @@ onMounted(loadVersions);
.history-item-date {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin-top: 0.15rem;
}
@@ -374,7 +381,7 @@ onMounted(loadVersions);
.history-empty {
padding: 1rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.history-footer {
@@ -383,7 +390,7 @@ onMounted(loadVersions);
gap: 0.5rem;
justify-content: flex-end;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
}
@@ -396,12 +403,12 @@ onMounted(loadVersions);
font-size: 0.85em;
line-height: 1;
}
.pin-badge-manual { color: var(--fs-accent); }
.pin-badge-auto { color: var(--fs-text-tertiary); }
.pin-badge-manual { color: var(--color-primary, #6366f1); }
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
.history-item-label {
font-size: 0.72rem;
color: var(--fs-accent);
color: var(--color-primary, #6366f1);
font-style: italic;
margin-top: 0.15rem;
overflow: hidden;
@@ -412,7 +419,7 @@ onMounted(loadVersions);
/* ── Pin controls above the diff ────────────────────────────────────────── */
.version-pin-controls {
padding: 0.4rem 0.5rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
font-size: 0.82rem;
}
.pin-actions {
@@ -423,7 +430,7 @@ onMounted(loadVersions);
}
.pin-state {
font-style: italic;
color: var(--fs-text-tertiary);
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
flex: 1;
min-width: 0;
overflow: hidden;
@@ -435,13 +442,13 @@ onMounted(loadVersions);
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: 999px;
cursor: pointer;
}
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--fs-accent);
border-color: var(--color-primary, #6366f1);
}
.btn-unpin:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.10);
@@ -456,27 +463,27 @@ onMounted(loadVersions);
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
color: inherit;
}
.pin-label-input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary, #6366f1);
}
.btn-pin-save, .btn-pin-cancel {
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
cursor: pointer;
}
.btn-pin-save:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--fs-accent);
border-color: var(--color-primary, #6366f1);
}
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
-189
View File
@@ -1,189 +0,0 @@
<script setup lang="ts">
/**
* The inception form (milestone 297): "what does this project inherit?"
*
* Two homes, one component. mode="create" rides the New-project modal's
* second step and only emits the choices (the project does not exist yet);
* mode="decide" sits on ProjectView for an undecided project, loads that
* project's current defaults, and records the decision itself.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception";
import { listRulebooks } from "@/api/rulebooks";
const props = withDefaults(defineProps<{
mode: "create" | "decide";
projectId?: number;
choices?: InceptionChoices;
}>(), { projectId: 0, choices: undefined });
const emit = defineEmits<{
"update:choices": [value: InceptionChoices];
decided: [decision: InceptionDecision];
}>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const alwaysOn = ref<{ id: number; title: string }[]>([]);
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const loading = ref(true);
const saving = ref(false);
const error = ref("");
function emitChoices() {
emit("update:choices", { ...local.value });
}
watch(local, emitChoices, { deep: true });
async function load() {
loading.value = true;
error.value = "";
try {
if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
alwaysOn.value = d.always_on_rulebooks;
others.value = d.other_rulebooks;
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all.
local.value = {
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
} else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
} finally {
loading.value = false;
}
}
function inherits(id: number): boolean {
return !local.value.exclude_always_on_rulebooks.includes(id);
}
function toggleInherit(id: number) {
const list = local.value.exclude_always_on_rulebooks;
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
function subscribed(id: number): boolean {
return local.value.subscribe_rulebooks.includes(id);
}
function toggleSubscribe(id: number) {
const list = local.value.subscribe_rulebooks;
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
const nothingToDecide = computed(
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
);
async function record() {
if (!props.projectId) return;
saving.value = true;
error.value = "";
try {
const decision = await decideInception(props.projectId, local.value);
emit("decided", decision);
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not record the decision");
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="inception" aria-labelledby="inception-title">
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded,
every always-on rulebook binds, nothing is subscribed, and there is no design
system or Systems.
</p>
<p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else>
<div v-if="alwaysOn.length" class="inception-group">
<h4>Always-on rulebooks</h4>
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div v-if="others.length" class="inception-group">
<h4>Subscribe to rulebooks</h4>
<label v-for="rb in others" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div class="inception-group">
<h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<div class="inception-group">
<label class="inception-choice">
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
<span>
Seed the standard starter Systems (CI &amp; Release, Auth &amp; Access, Data Model &amp; 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>
+34 -34
View File
@@ -74,11 +74,11 @@ const markers: Record<DiffLine["type"], string> = {
<style scoped>
.iap {
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-page);
border: 1px solid var(--color-border);
background: var(--color-bg);
}
/* ── Header ── */
@@ -88,16 +88,16 @@ const markers: Record<DiffLine["type"], string> = {
gap: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
border-bottom: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
/* ── Streaming ── */
.iap-streaming {
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.iap-streaming .iap-header {
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.iap-pulse {
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fs-accent);
background: var(--color-primary);
flex-shrink: 0;
animation: iap-pulse 1.2s ease-in-out infinite;
}
@@ -117,7 +117,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-label {
flex: 1;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -125,9 +125,9 @@ const markers: Record<DiffLine["type"], string> = {
.iap-btn-cancel {
background: none;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
flex-shrink: 0;
}
.iap-btn-cancel:hover {
border-color: var(--fs-error);
color: var(--fs-error);
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
}
.iap-stream-preview {
@@ -150,29 +150,29 @@ const markers: Record<DiffLine["type"], string> = {
.iap-waiting {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* ── Review ── */
.iap-review-title {
flex: 1;
font-weight: 600;
color: var(--fs-text-primary);
color: var(--color-text);
}
.iap-btn-toggle {
background: none;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.iap-btn-toggle:hover {
border-color: var(--fs-accent);
color: var(--fs-accent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.iap-actions {
@@ -183,7 +183,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-btn-accept,
.iap-btn-reject {
border: none;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
padding: 0.2rem 0.65rem;
cursor: pointer;
font-size: 0.8rem;
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
background: var(--fs-success);
background: var(--color-success, #22c55e);
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
.iap-btn-reject {
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-card, var(--color-bg));
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.iap-btn-reject:hover {
border-color: var(--fs-error);
color: var(--fs-error);
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
}
/* ── Diff ── */
@@ -224,14 +224,14 @@ const markers: Record<DiffLine["type"], string> = {
word-break: break-word;
}
.iap-diff-equal { color: var(--fs-text-tertiary); }
.iap-diff-equal { color: var(--color-text-muted); }
.iap-diff-delete {
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
color: var(--fs-error);
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
color: var(--color-danger, #e74c3c);
}
.iap-diff-insert {
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
color: var(--fs-success);
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
color: var(--color-success, #22c55e);
}
.iap-diff-marker {
@@ -243,7 +243,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-diff-empty {
padding: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.85rem;
font-family: inherit;
}
+11 -11
View File
@@ -111,9 +111,9 @@ const groups = [
align-items: center;
gap: 2px;
flex-wrap: wrap;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 3px 4px;
}
@@ -127,7 +127,7 @@ const groups = [
display: block;
width: 1px;
height: 18px;
background: var(--fs-border-color);
background: var(--color-border);
flex-shrink: 0;
margin: 0 3px;
}
@@ -141,7 +141,7 @@ const groups = [
border: none;
border-radius: 5px;
background: transparent;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
@@ -149,19 +149,19 @@ const groups = [
}
.md-btn:hover {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
background: var(--color-bg-card);
color: var(--color-text);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.md-btn.active {
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
color: var(--fs-accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
}
.md-btn.active:hover {
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.btn-icon {
@@ -66,10 +66,10 @@ function onChange(e: Event) {
<style scoped>
.milestone-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
@@ -77,7 +77,7 @@ function onChange(e: Event) {
}
.milestone-select:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.milestone-select:disabled {
opacity: 0.5;
+17 -17
View File
@@ -60,15 +60,15 @@ function goEdit() {
.note-card {
display: block;
padding: 1rem;
border-radius: var(--fs-radius-lg);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--fs-surface-raised);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
transform: translateY(-2px);
}
@@ -78,18 +78,18 @@ function goEdit() {
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: var(--fs-surface-raised);
background: var(--color-bg-card);
box-shadow: none;
border-radius: 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
transform: none !important;
}
.note-card.compact:first-child {
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
}
.note-card.compact:hover {
box-shadow: none;
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
background: rgba(91, 74, 138, 0.04);
transform: none;
}
.note-title-compact {
@@ -108,7 +108,7 @@ function goEdit() {
}
.timestamp-compact {
font-size: 0.72rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
flex-shrink: 0;
white-space: nowrap;
}
@@ -133,20 +133,20 @@ function goEdit() {
flex-shrink: 0;
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-edit:hover {
color: var(--fs-accent);
border-color: var(--fs-accent);
color: var(--color-primary);
border-color: var(--color-primary);
}
.note-preview {
margin: 0 0 0.5rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
@@ -163,6 +163,6 @@ function goEdit() {
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
</style>
+7 -7
View File
@@ -60,11 +60,11 @@ onUnmounted(() => {
.btn-bell {
background: none;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.45rem;
cursor: pointer;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
@@ -72,16 +72,16 @@ onUnmounted(() => {
}
.btn-bell:hover,
.btn-bell.active {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-color: var(--fs-accent);
background: var(--color-bg-card);
color: var(--color-text);
border-color: var(--color-primary);
}
.bell-badge {
position: absolute;
top: -5px;
right: -5px;
background: var(--fs-error);
background: var(--color-danger, #ef4444);
color: var(--fs-text-on-action);
font-size: 0.6rem;
font-weight: 700;
@@ -85,9 +85,9 @@ onMounted(() => store.fetchAll())
width: 340px;
max-height: 400px;
overflow-y: auto;
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
z-index: 500;
}
@@ -97,10 +97,10 @@ onMounted(() => store.fetchAll())
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--fs-surface-hover);
background: var(--color-surface);
}
.notif-panel-title {
@@ -114,12 +114,12 @@ onMounted(() => store.fetchAll())
align-items: flex-start;
gap: 0.6rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.1s;
}
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: var(--fs-surface-hover); }
.notif-item:hover { background: var(--color-hover); }
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
@@ -127,10 +127,10 @@ onMounted(() => store.fetchAll())
.notif-msg {
margin: 0 0 0.2rem;
font-size: 0.85rem;
color: var(--fs-text-primary);
color: var(--color-text);
line-height: 1.4;
word-break: break-word;
}
.notif-time { font-size: 0.75rem; color: var(--fs-text-tertiary); }
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
</style>
+8 -8
View File
@@ -74,27 +74,27 @@ function goToPage(page: number) {
}
.page-btn {
padding: 0.35rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.85rem;
}
.page-btn:hover:not(:disabled) {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.page-btn:disabled {
opacity: 0.4;
cursor: default;
}
.page-btn.active {
background: var(--fs-accent);
background: var(--color-primary);
color: var(--fs-text-on-action);
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.ellipsis {
padding: 0 0.25rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
</style>
+6 -6
View File
@@ -33,15 +33,15 @@ const labels: Record<TaskPriority, string> = {
letter-spacing: 0.025em;
}
.priority-low {
background: var(--fs-priority-low-bg);
color: var(--fs-priority-low);
background: var(--color-priority-low-bg);
color: var(--color-priority-low);
}
.priority-medium {
background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium);
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.priority-high {
background: var(--fs-priority-high-bg);
color: var(--fs-priority-high);
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
</style>
@@ -1,234 +0,0 @@
<script setup lang="ts">
/**
* A project's own code, checked against the design system it is bound to (#2432).
*
* This is what the design surface is FOR: a project's recorded components
* measured against the sheet they are supposed to use. The check itself is not
* new — `check_snippets_against_system` has taken a project id since it was
* written, and the route has always read `?project_id=`. Nothing on this side
* ever passed one, so the capability shipped and stayed unreachable.
*
* The finding that matters most is the quiet one. `local_definitions` is a
* snippet minting its own custom property instead of reaching for the shared
* one — the codebase re-solving a solved problem, one component at a time.
* Nothing breaks, no test fails, and the duplication only becomes visible when
* someone changes the shared value and half the components don't move.
*
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
* snippets — because that is the code Scribe holds. A repository's own sources
* are checked where they live, by that project's CI.
*/
import { onMounted, ref, watch } from "vue";
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
const check = ref<SnippetCheck | null>(null);
const loading = ref(false);
const failed = ref(false);
async function run() {
check.value = null;
failed.value = false;
if (props.designSystemId === null) return;
loading.value = true;
try {
check.value = await checkSnippets(props.designSystemId, props.projectId);
} catch {
// Said out loud rather than rendered as an empty result. "Couldn't check"
// and "nothing to report" look identical if you let them, and that is how
// a check comes to sit dead without anyone noticing (#2419).
failed.value = true;
} finally {
loading.value = false;
}
}
onMounted(run);
watch(() => [props.projectId, props.designSystemId], run);
</script>
<template>
<div class="pdt">
<div v-if="designSystemId === null" class="pdt-note">
<strong>No design system for this project.</strong>
<p>
Bind one in the sidebar and this tab reports where the project's recorded
components disagree with it — references to tokens the system doesn't
have, literals it says to stop writing, and properties a component mints
for itself instead of reusing.
</p>
</div>
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets</p>
<div v-else-if="failed" class="pdt-note">
<strong>The check couldn't run.</strong>
<p>Nothing was compared — this is a failure, not a clean result.</p>
</div>
<template v-else-if="check">
<p v-if="!check.checked" class="pdt-muted">
This project has no recorded snippets, so nothing was checked. Record the
components you reuse and they get measured against the sheet.
</p>
<p v-else-if="!check.findings.length" class="pdt-clean">
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
every reference resolves, and none mints a property of its own.
</p>
<template v-else>
<p class="pdt-summary">
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
</p>
<ul class="pdt-list">
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
{{ f.title || "Untitled snippet" }}
</router-link>
<!-- Renders as nothing at all: no error, no failing test, just an
element that quietly isn't styled. Leads for that reason. -->
<div v-if="f.unknown.length" class="pdt-row">
<span class="pdt-tag unknown">no such token</span>
<span class="pdt-detail">
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
</span>
</div>
<div v-if="f.local_definitions.length" class="pdt-row">
<span class="pdt-tag local">defines its own</span>
<span class="pdt-detail">
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
</span>
</div>
<div v-if="f.superseded_literals.length" class="pdt-row">
<span class="pdt-tag superseded">write the token</span>
<span class="pdt-detail">
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
<code>{{ s.literal }}</code> <code>{{ s.use_instead }}</code>
</span>
</span>
</div>
</li>
</ul>
</template>
</template>
</div>
</template>
<style scoped>
.pdt {
padding: var(--fs-space-2) 0;
}
.pdt-note {
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-left: 3px solid var(--fs-warning);
border-radius: var(--fs-radius-sm);
padding: var(--fs-space-3) var(--fs-space-4);
}
.pdt-note p {
margin: var(--fs-space-2) 0 0;
color: var(--fs-text-secondary);
font-size: var(--fs-size-body-sm);
line-height: var(--fs-leading-body);
max-width: 70ch;
}
.pdt-muted,
.pdt-clean,
.pdt-summary {
color: var(--fs-text-tertiary);
font-size: var(--fs-size-body-sm);
margin: 0 0 var(--fs-space-3);
max-width: 70ch;
}
.pdt-clean {
color: var(--fs-status-done);
}
.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>
+5 -5
View File
@@ -51,10 +51,10 @@ function onChange(e: Event) {
<style scoped>
.project-selector {
padding: 0.4rem 0.5rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
width: 100%;
@@ -62,6 +62,6 @@ function onChange(e: Event) {
}
.project-selector:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
</style>
+6 -6
View File
@@ -153,19 +153,19 @@ const calendarDayMax = computed(() =>
}
.rec-label {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
min-width: 2.5rem;
}
.rec-num-input {
width: 4rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border, var(--color-border));
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: inherit;
}
.rec-num-input:focus { outline: none; border-color: var(--fs-accent); }
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
.rec-unit { min-width: 6rem; }
</style>
+5 -5
View File
@@ -28,14 +28,14 @@ defineExpose({ focus: () => inputRef.value?.focus() });
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 1rem;
box-sizing: border-box;
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
background: var(--color-bg-card);
color: var(--color-text);
}
.search-input::placeholder {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
</style>
+26 -26
View File
@@ -206,9 +206,9 @@ onMounted(async () => {
}
.share-dialog {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 480px;
max-width: 95vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
@@ -220,7 +220,7 @@ onMounted(async () => {
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.share-title {
@@ -228,7 +228,7 @@ onMounted(async () => {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
color: var(--fs-text-primary);
color: var(--color-text);
}
@@ -240,17 +240,17 @@ onMounted(async () => {
.share-tab {
background: none;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.3rem 0.8rem;
font-size: 0.82rem;
cursor: pointer;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
transition: all 0.15s;
}
.share-tab.active {
background: var(--fs-accent);
border-color: var(--fs-accent);
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--fs-text-on-action);
}
@@ -269,23 +269,23 @@ onMounted(async () => {
.share-input {
width: 100%;
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
.share-input:focus { border-color: var(--fs-accent); }
.share-input:focus { border-color: var(--color-primary); }
.user-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 6px;
margin-top: 2px;
list-style: none;
@@ -304,24 +304,24 @@ onMounted(async () => {
cursor: pointer;
transition: background 0.1s;
}
.user-result-item:hover { background: var(--fs-surface-raised); }
.user-result-item:hover { background: var(--color-bg-secondary); }
.user-result-name { font-weight: 600; font-size: 0.88rem; }
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
.perm-select {
padding: 0.45rem 0.5rem;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
}
.btn-add-share {
padding: 0.45rem 1rem;
background: var(--fs-gradient-cta);
background: var(--gradient-cta);
color: var(--fs-text-on-action);
border: none;
border-radius: 6px;
@@ -342,7 +342,7 @@ onMounted(async () => {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0 0 0.5rem;
}
@@ -361,7 +361,7 @@ onMounted(async () => {
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
@@ -369,10 +369,10 @@ onMounted(async () => {
.perm-select-inline {
padding: 0.25rem 0.4rem;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
}
@@ -1,212 +0,0 @@
<script setup lang="ts">
/**
* The starter token ROLES offered when a design system is created (#2349).
*
* WHY THIS IS A COMPONENT
* DesignSystemsView has two creation forms — the empty state and the one inside
* the body — because the empty state is a sibling branch, not a parent. Putting
* the checklist inline would make it the third thing in this codebase defined
* twice and free to drift, which is what the whole button migration was about.
*
* WHAT IT OFFERS
* Names and purposes, never values. A role is a question the operator answers
* with their own palette; a default palette would be one install's taste
* shipped as product (rule #115). Every group is individually skippable —
* an operator who wants three tokens should get three.
*
* All groups are checked by default. That default lives HERE rather than in the
* service, because the service must never seed rows into a system whose caller
* did not ask; a UI default is visible and reversible before the click.
*/
import { onMounted, ref } from "vue";
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
// props + emit rather than defineModel, matching TagInput and the rest of
// components/ — being the only file using a different binding idiom costs more
// than the few lines it saves.
const props = defineProps<{ selected: string[]; prefix: string }>();
const emit = defineEmits<{
"update:selected": [value: string[]];
"update:prefix": [value: string];
}>();
const groups = ref<StarterRoleGroup[]>([]);
const defaultPrefix = ref("--ds-");
const loading = ref(false);
const failed = ref(false);
onMounted(async () => {
loading.value = true;
try {
const data = await listStarterRoleGroups();
groups.value = data.groups;
defaultPrefix.value = data.default_prefix;
if (!props.prefix) emit("update:prefix", data.default_prefix);
// Everything on by default — see the note above.
if (!props.selected.length) {
emit("update:selected", data.groups.map((g) => g.group));
}
} catch {
// A creation form must still work when this fails. Roles are an
// accelerator, not a prerequisite: the operator can add tokens by hand.
failed.value = true;
} finally {
loading.value = false;
}
});
function toggle(group: string) {
emit(
"update:selected",
props.selected.includes(group)
? props.selected.filter((g) => g !== group)
: [...props.selected, group],
);
}
const totalTokens = () =>
groups.value
.filter((g) => props.selected.includes(g.group))
.reduce((n, g) => n + g.token_count, 0);
</script>
<template>
<div v-if="loading" class="srp-note">Loading starter roles</div>
<!-- Failure is not fatal and should not read as one. -->
<div v-else-if="failed" class="srp-note">
Starter roles unavailable you can add tokens by hand after creating.
</div>
<fieldset v-else-if="groups.length" class="srp">
<legend class="srp-legend">Start with these token roles</legend>
<p class="srp-intro">
Named now, valued later. A role you haven't filled in shows as
<em>to be decided</em>; a role that doesn't exist is what gets written as a
literal instead. Uncheck anything this system won't have.
</p>
<div class="srp-grid">
<label v-for="g in groups" :key="g.group" class="srp-item">
<input
type="checkbox"
:checked="props.selected.includes(g.group)"
@change="toggle(g.group)"
/>
<span class="srp-name">{{ g.group }}</span>
<span class="srp-count">{{ g.token_count }}</span>
<span class="srp-desc">{{ g.description }}</span>
</label>
</div>
<div class="srp-footer">
<label class="srp-prefix">
<span>Prefix</span>
<input
:value="props.prefix" class="input srp-prefix-input" type="text"
:placeholder="defaultPrefix"
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
/>
</label>
<span class="srp-total">
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
</span>
</div>
</fieldset>
</template>
<style scoped>
.srp {
border: var(--fs-border);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-4);
margin: 0 0 var(--fs-space-4);
min-width: 0;
}
.srp-legend {
font-size: var(--fs-size-label);
font-weight: var(--fs-weight-medium);
color: var(--fs-text-primary);
padding: 0 var(--fs-space-2);
}
.srp-intro,
.srp-note {
margin: 0 0 var(--fs-space-3);
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
line-height: var(--fs-leading-body);
max-width: 62ch;
}
.srp-note {
margin-bottom: var(--fs-space-4);
}
.srp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--fs-space-2);
}
.srp-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: baseline;
gap: var(--fs-space-2);
padding: var(--fs-space-1) var(--fs-space-2);
border-radius: var(--fs-radius-sm);
cursor: pointer;
min-width: 0;
}
.srp-item:hover { background: var(--fs-surface-hover); }
.srp-name {
font-size: var(--fs-size-body-sm);
color: var(--fs-text-primary);
}
.srp-count {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
/* The description is the useful part on a wide card and the first thing worth
dropping on a narrow one — the group name alone still identifies the row. */
.srp-desc {
grid-column: 1 / -1;
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
line-height: var(--fs-leading-body);
}
.srp-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--fs-space-3);
margin-top: var(--fs-space-4);
}
.srp-prefix {
display: flex;
align-items: center;
gap: var(--fs-space-2);
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
}
.srp-prefix-input {
width: 8rem;
font-family: var(--fs-font-mono);
font-size: var(--fs-size-code);
}
.srp-total {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
</style>
+8 -8
View File
@@ -38,20 +38,20 @@ const labels: Record<TaskStatus, string> = {
letter-spacing: 0.025em;
}
.status-todo {
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%);
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
}
.status-in_progress {
background: 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%);
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
}
.status-done {
background: 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%);
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
}
.status-cancelled {
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
color: var(--fs-text-tertiary);
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
color: var(--color-text-muted);
}
.clickable {
cursor: pointer;
@@ -70,9 +70,9 @@ defineExpose({ onKeyDown });
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
@@ -85,6 +85,6 @@ defineExpose({ onKeyDown });
}
.ac-item:hover,
.ac-item.active {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
</style>
+75 -78
View File
@@ -253,7 +253,7 @@ async function confirmDelete() {
<template v-else>
<span
class="system-swatch"
:style="{ background: system.color || 'var(--fs-text-tertiary)' }"
:style="{ background: system.color || 'var(--color-text-muted)' }"
aria-hidden="true"
></span>
<div class="system-body">
@@ -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(--fs-text-tertiary); }
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--fs-radius-sm); text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
.issue-link:hover { background: var(--fs-surface-raised); }
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--fs-accent); }
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
.issue-link:hover { background: var(--color-bg-secondary); }
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--color-primary); }
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
/* ── Toolbar ──────────────────────────────────────────────────── */
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.btn-add-system {
background: none;
border: 1px dashed var(--fs-border-color);
color: var(--fs-text-secondary);
border: 1px dashed var(--color-border);
color: var(--color-text-secondary);
padding: 0.28rem 0.65rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.btn-add-system:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-add-system:focus-visible { outline: none; border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
.archived-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
.archived-checkbox { accent-color: var(--fs-accent); cursor: pointer; }
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
/* ── Create / edit form ───────────────────────────────────────── */
.system-form {
@@ -367,61 +367,26 @@ async function confirmDelete() {
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
.system-input, .system-textarea {
padding: 0.4rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived.
The card WAS a flex row and every child still says so — `.system-swatch`
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
of text rather than centred against the whole card.
The list had no rule at all, so it rendered with browser bullets and
indent — invisible to the dangling-style check, which can only see a class
that is PARTLY styled. A class with no rules anywhere looks exactly like a
semantic-only hook.
Surface values match `.system-form` above, which is the same card shape in
this file and the reason they can be recovered rather than guessed. */
.systems-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.system-card {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.system-card--archived { opacity: 0.6; }
.system-swatch {
@@ -433,13 +398,13 @@ async function confirmDelete() {
}
.system-body { flex: 1; min-width: 0; }
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.system-name { font-weight: 500; color: var(--fs-text-primary); word-break: break-word; }
.system-name { font-weight: 500; color: var(--color-text); word-break: break-word; }
.issue-badge {
font-size: 0.7rem;
font-weight: 500;
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
color: var(--color-primary);
border-radius: 999px;
padding: 0.05rem 0.45rem;
flex-shrink: 0;
@@ -449,15 +414,15 @@ async function confirmDelete() {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.system-description {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
line-height: 1.4;
word-break: break-word;
}
@@ -472,15 +437,15 @@ async function confirmDelete() {
background: none;
border: none;
cursor: pointer;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
width: 26px;
height: 26px;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
transition: background 0.12s, color 0.12s;
}
.action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
.action-btn:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 1px; opacity: 1; }
.action-delete:hover { color: var(--fs-error); }
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
.action-delete:hover { color: var(--color-danger, #e74c3c); }
/* ── Empty ────────────────────────────────────────────────────── */
.systems-empty {
@@ -490,29 +455,61 @@ async function confirmDelete() {
gap: 0.4rem;
padding: 2rem 1rem;
text-align: center;
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-lg);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
}
.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; }
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
/* ── Skeleton ─────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
.skel-row {
height: 3rem;
border-radius: var(--fs-radius-lg);
border-radius: var(--radius-md);
background: linear-gradient(
90deg,
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-row--short { width: 65%; }
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--color-bg); }
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
</style>
+3 -3
View File
@@ -60,7 +60,7 @@ function scrollTo(id: string) {
.toc-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0 0 0.5rem;
letter-spacing: 0.05em;
}
@@ -73,11 +73,11 @@ function scrollTo(id: string) {
margin-bottom: 0.25rem;
}
.toc-link {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-decoration: none;
cursor: pointer;
}
.toc-link:hover {
color: var(--fs-accent);
color: var(--color-primary);
}
</style>
+13 -13
View File
@@ -154,9 +154,9 @@ function focusInput() {
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
cursor: text;
min-height: 2.25rem;
}
@@ -166,9 +166,9 @@ function focusInput() {
gap: 0.2rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border: 1px solid var(--fs-accent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border: 1px solid var(--color-primary);
color: var(--color-primary);
font-size: 0.8rem;
white-space: nowrap;
}
@@ -195,7 +195,7 @@ function focusInput() {
border: none;
outline: none;
background: transparent;
color: var(--fs-text-primary);
color: var(--color-text);
font-size: 0.875rem;
padding: 0;
}
@@ -205,9 +205,9 @@ function focusInput() {
left: 0;
min-width: 160px;
max-width: 280px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
list-style: none;
margin: 0;
@@ -218,11 +218,11 @@ function focusInput() {
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--fs-text-primary);
color: var(--color-text);
}
.tag-autocomplete-item:hover,
.tag-autocomplete-item.selected {
background: var(--fs-surface-hover);
color: var(--fs-accent);
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
color: var(--color-primary);
}
</style>
+5 -5
View File
@@ -29,8 +29,8 @@ defineEmits<{
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--fs-accent-soft);
color: var(--fs-accent);
background: var(--color-tag-bg);
color: var(--color-tag-text);
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
@@ -39,13 +39,13 @@ defineEmits<{
transition: color 0.15s, background 0.15s;
}
.tag-pill:hover {
color: var(--fs-accent);
background: var(--fs-accent-soft);
color: var(--color-primary);
background: var(--color-primary-tint);
}
.dismiss {
background: none;
border: none;
color: var(--fs-accent);
color: var(--color-tag-text);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
+20 -20
View File
@@ -108,15 +108,15 @@ function isOverdue(): boolean {
.task-card {
display: block;
padding: 1rem;
border-radius: var(--fs-radius-lg);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--fs-surface-raised);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
transform: translateY(-2px);
}
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
opacity: 0.8;
}
.dot-todo {
background: var(--fs-status-todo);
border: 2px solid var(--fs-status-todo);
background: var(--color-status-todo, #94a3b8);
border: 2px solid var(--color-status-todo, #94a3b8);
background: transparent;
border: 2px solid var(--fs-text-tertiary);
border: 2px solid var(--color-text-muted);
}
.dot-in-progress {
background: var(--fs-status-in-progress);
background: var(--color-status-in-progress, #3b82f6);
}
.dot-done {
background: var(--fs-status-done);
background: var(--color-status-done, #22c55e);
}
.dot-cancelled {
background: var(--fs-status-cancelled);
background: var(--color-status-cancelled, #6b7280);
}
.task-title-compact {
@@ -170,10 +170,10 @@ function isOverdue(): boolean {
}
.project-crumb {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.1rem 0.4rem;
white-space: nowrap;
flex-shrink: 0;
@@ -185,12 +185,12 @@ function isOverdue(): boolean {
}
.due-compact {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.due-compact.overdue {
color: var(--fs-error);
color: var(--color-danger, #e74c3c);
font-weight: 600;
}
/* Full layout */
@@ -211,7 +211,7 @@ function isOverdue(): boolean {
}
.task-preview {
margin: 0 0 0.5rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
@@ -224,15 +224,15 @@ function isOverdue(): boolean {
}
.due-date {
font-size: 0.8rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.due-date.overdue {
color: var(--fs-overdue);
color: var(--color-overdue);
font-weight: 600;
}
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
</style>
+27 -21
View File
@@ -3,7 +3,6 @@ import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import type { TaskLog } from "@/types/task";
import { fmtStamp } from "@/utils/dateFormat";
const props = defineProps<{ taskId: number }>();
@@ -16,6 +15,13 @@ const editingId = ref<number | null>(null);
const editContent = ref("");
const editDuration = ref("");
function formatDate(iso: string): string {
const d = new Date(iso);
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return `${datePart}, ${timePart}`;
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
@@ -122,7 +128,7 @@ onMounted(loadLogs);
</template>
<template v-else>
<div class="log-entry-meta">
<span class="log-date">{{ fmtStamp(log.created_at) }}</span>
<span class="log-date">{{ formatDate(log.created_at) }}</span>
<span v-if="log.duration_minutes" class="log-duration-badge">
{{ formatDuration(log.duration_minutes) }}
</span>
@@ -169,9 +175,9 @@ onMounted(loadLogs);
<style scoped>
.log-section {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
@@ -181,7 +187,7 @@ onMounted(loadLogs);
.log-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.15rem;
@@ -189,11 +195,11 @@ onMounted(loadLogs);
.log-empty {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.log-entry {
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
padding-top: 0.5rem;
}
@@ -206,11 +212,11 @@ onMounted(loadLogs);
}
.log-date {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.log-duration-badge {
background: var(--fs-accent);
background: var(--color-primary);
color: var(--fs-text-on-action);
border-radius: 99px;
padding: 0.1rem 0.5rem;
@@ -229,10 +235,10 @@ onMounted(loadLogs);
.log-textarea {
width: 100%;
padding: 0.4rem 0.5rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
@@ -241,7 +247,7 @@ onMounted(loadLogs);
.log-textarea:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.log-add-controls,
@@ -253,7 +259,7 @@ onMounted(loadLogs);
.log-duration-label {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 0.25rem;
@@ -262,16 +268,16 @@ onMounted(loadLogs);
.log-duration-input {
width: 5rem;
padding: 0.3rem 0.4rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
}
.log-duration-input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
+1 -1
View File
@@ -156,7 +156,7 @@ defineExpose({ editor });
<style scoped>
.editor-error {
padding: 1rem;
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
}
</style>
@@ -57,13 +57,13 @@ const toastStore = useToastStore();
color: var(--fs-text-on-action);
}
.toast--success {
background: var(--fs-success);
background: var(--color-toast-success);
}
.toast--error {
background: var(--fs-error);
background: var(--color-toast-error);
}
.toast--warning {
background: var(--fs-warning);
background: var(--color-warning);
color: #1a1a1a;
}
.toast--warning .toast-close {
-362
View File
@@ -1,362 +0,0 @@
<script setup lang="ts">
/**
* A design system's tokens, drawn rather than listed (#2431).
*
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
* Every value is resolved on an offscreen probe carrying only this system's
* declarations (`resolveDeclared`), never read from the page. So a token like
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
* accent, not the accent of the app you happen to be looking at. Previewing
* another project's palette from here is the point; a preview that quietly
* borrows the host app's values would be worse than no preview, because it
* would look right.
*
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
* A colour is drawn as a swatch, a length as a rule of that length, a font
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
* naming convention, because the convention is the install's (rule #115) — a
* system that calls its spacing `--gap-N` gets the same treatment.
*
* A token with no value for the chosen mode is shown as undecided rather than
* skipped. A named role awaiting a decision is information; a gap in a grid
* is not.
*/
import { computed, ref, watch } from "vue";
import type { ResolvedToken } from "@/api/designSystems";
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
const props = defineProps<{ tokens: ResolvedToken[] }>();
const modes = computed(() => modesPresent(props.tokens));
const mode = ref(BASE_MODE);
/** Values as the browser would compute them, for the chosen mode. */
const rendered = ref<Map<string, string>>(new Map());
function recompute() {
const declared = new Map<string, string>();
for (const token of props.tokens) {
const value = valueForMode(token.value_by_mode, mode.value);
if (value) declared.set(token.name, value);
}
rendered.value = resolveDeclared(declared);
}
watch(
[() => props.tokens, mode],
() => {
// Keep the selection only while it still exists — switching systems can
// drop a mode, and a stale one would silently render as base.
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
recompute();
},
{ immediate: true, deep: false },
);
type Shape = "colour" | "surface" | "length" | "font" | "plain";
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
const GRADIENT = /gradient\(/;
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
/** A stack of family names: commas, no functions, no digits. */
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
function shapeOf(value: string): Shape {
const v = value.trim();
if (!v) return "plain";
if (COLOUR.test(v)) return "colour";
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
if (LENGTH.test(v)) return "length";
if (FONT_STACK.test(v)) return "font";
return "plain";
}
interface Specimen {
name: string;
declared: string;
rendered: string;
shape: Shape;
purpose: string | null;
/** True when `var()` substitution changed the value — worth showing on hover. */
substituted: boolean;
}
const groups = computed(() => {
const out = new Map<string, Specimen[]>();
for (const token of props.tokens) {
const declared = valueForMode(token.value_by_mode, mode.value);
const value = rendered.value.get(token.name) ?? "";
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
bucket.push({
name: token.name,
declared,
rendered: value,
shape: shapeOf(value),
purpose: token.purpose,
substituted: Boolean(declared) && value !== declared,
});
out.set(token.group_name ?? "ungrouped", bucket);
}
return [...out.entries()];
});
/**
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
* are visibly different — but a stray `100vw` can't stretch the row.
*/
function ruleWidth(value: string): string {
return `min(${value}, 12rem)`;
}
</script>
<template>
<div class="tp">
<div v-if="modes.length > 1" class="tp-modes">
<button
v-for="m in modes"
:key="m"
class="tp-mode"
:class="{ active: m === mode }"
@click="mode = m"
>{{ m }}</button>
<span class="tp-modes-note">
The system's own modes — independent of the theme this app is in.
</span>
</div>
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
<h3 class="tp-group-heading">{{ group }}</h3>
<ul class="tp-grid">
<li v-for="s in specimens" :key="s.name" class="tp-item">
<div
class="tp-specimen"
:class="`is-${s.shape}`"
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
>
<span
v-if="s.shape === 'colour'"
class="tp-swatch"
:style="{ '--tp-fill': s.rendered }"
/>
<span
v-else-if="s.shape === 'surface'"
class="tp-surface"
:style="s.rendered.includes('gradient(')
? { background: s.rendered }
: { boxShadow: s.rendered }"
/>
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
<span class="tp-rule-label">{{ s.rendered }}</span>
</span>
<span
v-else-if="s.shape === 'font'"
class="tp-font"
:style="{ fontFamily: s.rendered }"
>Ag</span>
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
<span v-else class="tp-plain">{{ s.rendered }}</span>
</div>
<code class="tp-name">{{ s.name }}</code>
<span class="tp-value" :title="s.declared">{{ s.declared || "" }}</span>
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.tp-modes {
display: flex;
align-items: center;
gap: var(--fs-space-2);
flex-wrap: wrap;
margin-bottom: var(--fs-space-4);
}
.tp-mode {
padding: 0.2rem 0.6rem;
font: inherit;
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
background: transparent;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
cursor: pointer;
}
.tp-mode:hover { color: var(--fs-text-primary); }
.tp-mode.active {
color: var(--fs-accent);
border-color: var(--fs-accent);
background: var(--fs-accent-faint);
}
.tp-modes-note {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
.tp-group { margin-bottom: var(--fs-space-6); }
.tp-group-heading {
text-transform: uppercase;
letter-spacing: var(--fs-tracking-tiny);
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
margin: 0 0 var(--fs-space-3);
padding-bottom: var(--fs-space-2);
border-bottom: var(--fs-border);
}
.tp-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: var(--fs-space-4) var(--fs-space-3);
}
.tp-item {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
*
* The stage itself is plain. An earlier version put the checkerboard here, so
* every specimen — including opaque colours and plain text — sat inside a
* frame of checks, and the pattern read as the loudest thing on the page. The
* checks belong to the ONE case that needs them: a colour that might be
* translucent. */
.tp-specimen {
height: 2.5rem;
display: flex;
align-items: center;
border-radius: var(--fs-radius-sm);
padding: var(--fs-space-1);
overflow: hidden;
background: var(--fs-surface-raised);
}
/* Text-bearing specimens get no box at all — a border around a value is a
frame around nothing, which is most of what made the grid feel busy. */
.tp-specimen.is-plain,
.tp-specimen.is-length,
.tp-specimen.is-font {
background: none;
padding: 0 var(--fs-space-1);
}
/* Checks UNDER the colour, not around it: an opaque value hides them
completely, and a 15% tint shows exactly as much of them as it should.
Layering the fill as a gradient is what lets one element do both. */
.tp-swatch {
/* Declared here, overridden inline per swatch. Two reasons it is a real
default rather than a formality: a token that resolves to nothing renders
as bare checks instead of an invalid gradient, and a custom property that
exists ONLY as an inline style is invisible to the CI token check — which
reads it as an unresolvable reference, correctly, since nothing in any
stylesheet declares it. */
--tp-fill: transparent;
width: 100%;
height: 100%;
border-radius: calc(var(--fs-radius-sm) - 2px);
background-image:
linear-gradient(var(--tp-fill), var(--tp-fill)),
repeating-conic-gradient(
var(--fs-border-color) 0% 25%,
var(--fs-surface-raised) 0% 50%
);
background-size: auto, 10px 10px;
}
.tp-surface {
width: 100%;
height: 100%;
border-radius: calc(var(--fs-radius-sm) - 2px);
background: var(--fs-surface-raised);
}
.tp-rule-wrap {
width: 100%;
display: flex;
align-items: center;
gap: var(--fs-space-2);
min-width: 0;
}
.tp-rule {
height: 0.4rem;
min-width: 1px;
flex: none;
background: var(--fs-accent);
border-radius: 999px;
}
.tp-rule-label {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
white-space: nowrap;
}
.tp-font {
font-size: 1.4rem;
color: var(--fs-text-primary);
line-height: 1;
}
.tp-plain {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-code);
color: var(--fs-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tp-undecided {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
font-style: italic;
}
/* One line each, with the full text on hover.
*
* These wrapped freely at first, so a card was two lines tall or five depending
* on how long its `color-mix()` happened to be, and the grid lost any rhythm —
* which is most of what "messy" was. A derived value is not something anyone
* reads character by character in a gallery; it is something you check the
* shape of and open if it matters. */
.tp-name,
.tp-value,
.tp-purpose {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tp-name {
font-size: var(--fs-size-body-sm);
color: var(--fs-text-primary);
}
.tp-value {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-tiny);
color: var(--fs-text-secondary);
}
.tp-purpose {
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
</style>
@@ -164,7 +164,7 @@ function restore() {
<style scoped>
.vh-section {
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
}
.vh-header {
@@ -179,19 +179,19 @@ function restore() {
font-family: inherit;
text-align: left;
}
.vh-header:hover { background: var(--fs-surface-raised); }
.vh-header:hover { background: var(--color-bg-secondary); }
.vh-title {
font-size: 0.75rem;
font-weight: 700;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vh-chevron {
font-size: 0.7rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.vh-body {
@@ -201,20 +201,20 @@ function restore() {
.vh-empty {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.vh-item {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
color: var(--fs-text-primary);
color: var(--color-text);
cursor: pointer;
font-family: monospace;
border-left: 2px solid transparent;
}
.vh-item:hover {
background: var(--fs-surface-raised);
border-left-color: var(--fs-accent);
background: var(--color-bg-secondary);
border-left-color: var(--color-primary);
}
.vh-diff-actions {
@@ -225,20 +225,20 @@ function restore() {
.vh-btn-back {
background: none;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
font-family: inherit;
}
.vh-btn-back:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
.vh-btn-restore {
background: var(--fs-action-primary);
background: var(--color-action-primary);
border: none;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: var(--fs-text-on-action);
+2 -2
View File
@@ -50,11 +50,11 @@ const label = computed(() => {
background: none;
border: none;
font-size: 0.72rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
white-space: nowrap;
flex-shrink: 0;
}
.word-count:hover { color: var(--fs-text-primary); }
.word-count:hover { color: var(--color-text); }
</style>
+50 -37
View File
@@ -11,7 +11,6 @@ import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue";
import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{
projectId: number;
@@ -253,6 +252,20 @@ async function confirmDelete(id: number) {
}
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
watch(noteTitle, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
watch(noteTags, () => { dirty.value = true; });
@@ -333,7 +346,7 @@ defineExpose({ reload: loadProjectNotes });
>
<div class="note-row-main">
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
<span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
</div>
<div v-if="note.tags?.length" class="note-row-tags">
<span
@@ -439,8 +452,8 @@ defineExpose({ reload: loadProjectNotes });
flex-direction: row;
height: 100%;
overflow: hidden;
background: var(--fs-surface-hover);
border-left: 1px solid var(--fs-border-color);
background: var(--color-surface);
border-left: 1px solid var(--color-border);
}
/* ── Left rail ── */
@@ -450,7 +463,7 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--fs-surface-raised);
background: var(--color-bg-card, var(--color-bg-secondary));
}
.rail-header {
@@ -458,12 +471,12 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
gap: 0.3rem;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.rail-title {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
@@ -477,7 +490,7 @@ defineExpose({ reload: loadProjectNotes });
background: transparent;
border: none;
font-size: 0.78rem;
color: var(--fs-text-primary);
color: var(--color-text);
min-width: 0;
padding: 0;
}
@@ -490,7 +503,7 @@ defineExpose({ reload: loadProjectNotes });
.rail-state {
padding: 1rem 0.65rem;
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* Note list */
@@ -506,16 +519,16 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
flex-direction: column;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 60%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
cursor: pointer;
gap: 0.15rem;
border-right: 2px solid transparent;
transition: background 0.12s;
}
.note-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.note-row.active {
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
border-right-color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-right-color: var(--color-primary);
}
.note-row-main {
@@ -531,12 +544,12 @@ defineExpose({ reload: loadProjectNotes });
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fs-text-primary);
color: var(--color-text);
}
.note-row-age {
font-size: 0.62rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
@@ -549,8 +562,8 @@ defineExpose({ reload: loadProjectNotes });
.note-tag-pill {
font-size: 0.58rem;
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-radius: 999px;
padding: 0 0.3rem;
white-space: nowrap;
@@ -559,13 +572,13 @@ defineExpose({ reload: loadProjectNotes });
max-width: 5rem;
}
.note-tag-pill.tag-match {
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
outline: 1px solid var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
outline: 1px solid var(--color-primary);
}
.note-tag-more {
font-size: 0.58rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
white-space: nowrap;
}
@@ -593,8 +606,8 @@ defineExpose({ reload: loadProjectNotes });
margin-left: auto;
}
.unsaved { font-size: 0.72rem; color: var(--fs-text-tertiary); }
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
/* Moss action-primary per Hybrid */
@@ -605,14 +618,14 @@ defineExpose({ reload: loadProjectNotes });
font-size: 1.4rem;
font-weight: 500;
line-height: 1.25;
color: var(--fs-text-primary);
color: var(--color-text);
padding: 0;
font-family: 'Fraunces', serif;
letter-spacing: -0.01em;
}
.note-title-input:focus { outline: none; }
.note-title-input::placeholder {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.tag-row {
@@ -632,25 +645,25 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
.tag-suggestions-label { font-size: 0.72rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
.btn-tag-suggestion {
background: none;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
color: var(--fs-text-primary);
color: var(--color-text);
cursor: pointer;
}
.btn-tag-suggestion:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-tag-suggestion:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-tag-suggestion.applied {
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border-color: var(--fs-accent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
@@ -660,12 +673,12 @@ defineExpose({ reload: loadProjectNotes });
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.3rem 0.6rem;
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
.link-suggest-label {
font-size: 0.7rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
flex-shrink: 0;
font-weight: 500;
text-transform: uppercase;
@@ -675,15 +688,15 @@ defineExpose({ reload: loadProjectNotes });
.btn-chip-link {
background: none;
border: 1px solid var(--fs-accent);
border: 1px solid var(--color-primary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
color: var(--fs-accent);
color: var(--color-primary);
cursor: pointer;
font-family: monospace;
white-space: nowrap;
}
.btn-chip-link:hover { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
</style>
+64 -51
View File
@@ -6,7 +6,6 @@ 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 }>();
@@ -199,6 +198,20 @@ function cancelDeleteTask() {
deleteConfirmPending.value = false;
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
onMounted(loadAll);
defineExpose({ reload: loadAll });
</script>
@@ -243,7 +256,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">{{ relativeTimeOrDate(task.updated_at) }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
</li>
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
</ul>
@@ -268,7 +281,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">{{ relativeTimeOrDate(task.updated_at) }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
</li>
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
</ul>
@@ -331,8 +344,8 @@ defineExpose({ reload: loadAll });
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--fs-surface-hover);
border-right: 1px solid var(--fs-border-color);
background: var(--color-surface);
border-right: 1px solid var(--color-border);
}
/* ── List view ── */
@@ -347,17 +360,17 @@ defineExpose({ reload: loadAll });
flex: 0 0 44%;
}
.task-active {
background: color-mix(in srgb, var(--fs-accent) 6%, var(--fs-surface-hover)) !important;
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important;
}
.panel-header {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.panel-title {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
@@ -368,20 +381,20 @@ defineExpose({ reload: loadAll });
display: flex;
gap: 0.4rem;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.task-add-input {
flex: 1;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.28rem 0.5rem;
font-size: 0.83rem;
color: var(--fs-text-primary);
color: var(--color-text);
}
.task-add-input:focus { outline: none; border-color: var(--fs-accent); }
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
@@ -391,7 +404,7 @@ defineExpose({ reload: loadAll });
}
.ms-group {
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.ms-group-header {
@@ -400,18 +413,18 @@ defineExpose({ reload: loadAll });
gap: 0.4rem;
width: 100%;
padding: 0.4rem 0.65rem;
background: var(--fs-surface-raised);
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
border: none;
cursor: pointer;
text-align: left;
font-size: 0.8rem;
color: var(--fs-text-primary);
color: var(--color-text);
}
.ms-group-header:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover)); }
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
.ms-chevron { font-size: 0.6rem; color: var(--fs-text-tertiary); width: 0.8rem; }
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
.ms-count { font-size: 0.72rem; color: var(--fs-text-tertiary); background: var(--fs-surface-page); border-radius: 10px; padding: 0 0.4rem; }
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
.ms-status {
font-size: 0.68rem;
@@ -419,8 +432,8 @@ defineExpose({ reload: loadAll });
border-radius: 10px;
text-transform: capitalize;
}
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
.task-items {
list-style: none;
@@ -434,9 +447,9 @@ defineExpose({ reload: loadAll });
gap: 0.4rem;
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
cursor: pointer;
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 50%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
}
.task-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
.task-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.task-row:last-child { border-bottom: none; }
.status-dot {
@@ -444,7 +457,7 @@ defineExpose({ reload: loadAll });
width: 1.35rem;
height: 1.35rem;
border-radius: 50%;
border: 1.5px solid var(--fs-border-color);
border: 1.5px solid var(--color-border);
background: none;
cursor: pointer;
font-size: 0.62rem;
@@ -452,8 +465,8 @@ defineExpose({ reload: loadAll });
align-items: center;
justify-content: center;
}
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
.task-title {
flex: 1;
@@ -461,20 +474,20 @@ defineExpose({ reload: loadAll });
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fs-text-primary);
color: var(--color-text);
}
.task-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
.task-title.done { text-decoration: line-through; color: var(--color-text-muted); }
.task-age {
font-size: 0.68rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--fs-text-tertiary); }
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--fs-text-tertiary); }
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
/* ── Detail pane (bottom split) ── */
.task-detail {
@@ -483,8 +496,8 @@ defineExpose({ reload: loadAll });
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--fs-surface-hover);
border-top: 2px solid var(--fs-border-color);
background: var(--color-surface);
border-top: 2px solid var(--color-border);
}
.detail-header {
@@ -492,7 +505,7 @@ defineExpose({ reload: loadAll });
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -502,21 +515,21 @@ defineExpose({ reload: loadAll });
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: 1.5px solid var(--fs-border-color);
border: 1.5px solid var(--color-border);
background: none;
text-transform: capitalize;
user-select: none;
margin-left: auto;
}
.status-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); }
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
.btn-edit-task { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; }
.detail-body {
padding: 0.5rem 0.75rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
max-height: 40%;
overflow-y: auto;
@@ -524,17 +537,17 @@ defineExpose({ reload: loadAll });
.body-loading {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.detail-body .prose {
font-size: 0.83rem;
line-height: 1.5;
color: var(--fs-text-primary);
color: var(--color-text);
}
.btn-delete-task { margin-left: 0.25rem; }
.btn-delete-task:hover { color: var(--fs-action-destructive); }
.btn-delete-task:hover { color: var(--color-action-destructive); }
.btn-delete-confirm { margin-left: 0.25rem; }
@@ -550,9 +563,9 @@ defineExpose({ reload: loadAll });
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-tertiary);
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
text-transform: capitalize;
}
@@ -560,20 +573,20 @@ defineExpose({ reload: loadAll });
font-size: 0.72rem;
padding: 0.15rem 0.4rem;
border-radius: 10px;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-tertiary);
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
cursor: pointer;
max-width: 140px;
}
.milestone-select:disabled { opacity: 0.5; cursor: default; }
.milestone-select:focus { outline: none; border-color: var(--fs-accent); }
.milestone-select:focus { outline: none; border-color: var(--color-primary); }
.detail-log {
flex: 1;
overflow-y: auto;
padding: 0 0.6rem 0.6rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
}
/* Detail fade transition */
@@ -596,12 +609,12 @@ defineExpose({ reload: loadAll });
/* Due date on task rows */
.task-due {
font-size: 0.65rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.task-due.overdue {
color: var(--fs-error);
color: var(--color-danger, #e74c3c);
font-weight: 500;
}
@@ -46,19 +46,13 @@ watch(() => props.projectId, load);
<style scoped>
.plan-rules {
margin-top: 1.5rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border, #2a2a2e);
padding-top: 1rem;
}
.plan-rules h3 {
font-size: 0.9em; opacity: 0.7;
text-transform: uppercase; letter-spacing: 0.05em;
}
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
below, and its children carry their own spacing (the h4 keeps the UA
margin-top that separates one rulebook group from the next). Nothing here
assumes a flex or grid parent, which is the tell that distinguishes this
from a base rule someone deleted (#2444). Stated so the next reader doesn't
re-open the question. */
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
.rb h5 {
font-size: 0.8em; opacity: 0.7;
@@ -66,7 +60,7 @@ watch(() => props.projectId, load);
}
.plan-rules ul {
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
border-left: 2px solid var(--fs-accent);
border-left: 2px solid var(--color-primary, #6366f1);
}
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
@@ -2,18 +2,10 @@
import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import {
getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule,
createProjectRule,
deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
includeAlwaysOnRulebook,
getProjectApplicableRules, subscribeProject, unsubscribeProject,
listRulebooks, getRule, createProjectRule, deleteRule,
suppressRuleForProject, unsuppressRuleForProject,
suppressTopicForProject, unsuppressTopicForProject,
} from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
@@ -43,11 +35,6 @@ async function subscribe(rulebookId: number) {
await load();
}
async function includeBack(rulebookId: number) {
await includeAlwaysOnRulebook(props.projectId, rulebookId);
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
@@ -185,17 +172,6 @@ watch(() => props.projectId, load);
</div>
</section>
<section v-if="applicable.excluded_always_on?.length" class="excluded">
<h3>Excluded always-on rulebooks</h3>
<p class="excluded-note">Opted out at inception these do not bind this project.</p>
<div class="chips">
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again"></button>
</span>
</div>
</section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
@@ -345,9 +321,6 @@ 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;
@@ -356,7 +329,7 @@ h3 {
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 0.25rem;
background: var(--fs-accent-soft);
background: var(--color-primary-bg, rgba(99,102,241,0.15));
padding: 0.25rem 0.5rem; border-radius: 999px;
}
.chip a { cursor: pointer; }
@@ -364,29 +337,25 @@ h3 {
.chip-remove:hover { opacity: 1; }
.add {
background: none;
border: 1px dashed var(--fs-border-color);
border: 1px dashed var(--color-border, #2a2a2e);
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
color: inherit;
}
select {
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.applicable { margin-top: 2rem; }
.rb-group { margin-bottom: 1.5rem; }
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
one and the flex row further down), with the h5's own margin-top doing the
separating. Its children assume nothing about it, which is what tells it
apart from a base rule someone deleted (#2444). */
.topic-group h5 {
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
margin-top: 0.75rem;
}
ul { list-style: none; padding: 0; margin: 0; }
.rule {
border-left: 2px solid var(--fs-accent);
border-left: 2px solid var(--color-primary, #6366f1);
padding-left: 0.75rem; margin: 0.5rem 0;
}
.rule-head { cursor: pointer; }
@@ -394,12 +363,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(--fs-surface-page); border-radius: 6px;
background: var(--color-bg, #111113); border-radius: 6px;
}
.rule-detail > div { margin-bottom: 0.5rem; }
.edit-link {
background: none; border: none; cursor: pointer;
color: var(--fs-accent); padding: 0.5rem 0 0 0;
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
}
.empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; }
@@ -408,18 +377,18 @@ ul { list-style: none; padding: 0; margin: 0; }
.new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem;
padding: 0.75rem; margin: 0.5rem 0;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color); border-radius: 6px;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
}
.new-rule-form input, .new-rule-form textarea {
background: var(--fs-surface-hover); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
background: var(--color-surface, #18181b); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.5rem; font: inherit; resize: vertical;
}
.rule-list { margin-top: 0.5rem; }
.delete-link {
background: none; border: none; cursor: pointer;
color: var(--fs-destructive); padding: 0.5rem 0 0 0;
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
}
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
@@ -431,14 +400,14 @@ ul { list-style: none; padding: 0; margin: 0; }
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-text-tertiary); font-size: 0.75rem;
color: var(--color-muted, #888); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--fs-destructive); }
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
@@ -457,13 +426,13 @@ ul { list-style: none; padding: 0; margin: 0; }
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--fs-accent); font-size: 0.85em;
color: var(--color-primary, #6366f1); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style>
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
.slide-over {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(520px, 90vw);
background: var(--fs-surface-hover);
border-left: 2px solid var(--fs-accent);
background: var(--color-surface, #18181b);
border-left: 2px solid var(--color-primary, #6366f1);
padding: 1.5rem;
overflow-y: auto;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
@@ -110,11 +110,11 @@ header h2 {
font-family: Fraunces, serif; font-style: italic;
}
label { display: block; margin-bottom: 1rem; }
.required { color: var(--fs-accent); }
.required { color: var(--color-primary, #6366f1); }
input, textarea {
width: 100%; margin-top: 0.25rem;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.5rem; font: inherit;
font-family: inherit;
}
@@ -22,18 +22,18 @@ const emit = defineEmits<{
</template>
<style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li {
padding: 0.75rem;
cursor: pointer;
border-radius: 6px;
border-left: 2px solid var(--fs-accent);
border-left: 2px solid var(--color-primary, #6366f1);
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.02);
}
li:hover { background: var(--fs-surface-hover); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
.new-rule { cursor: pointer; }
@@ -122,7 +122,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</template>
<style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle {
@@ -133,23 +133,18 @@ 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(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
button-or-form whose children style themselves; the second is a `<ul>`, and
the bare `ul` rule above already gives it list-style, padding and margin —
a base a class-name check cannot see, since it comes from an element
selector. Both namespace descendant rules and assume nothing about layout. */
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
.new-topic input {
width: 100%; margin-bottom: 0.5rem;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border, #2a2a2e);
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
@@ -48,27 +48,27 @@ async function submitNew() {
</template>
<style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
.always-on-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--fs-accent);
color: var(--fs-text-on-action);
background: var(--color-accent, rgba(91,74,138,0.25));
color: var(--color-accent-fg, inherit);
margin-left: auto;
}
.new-rulebook { margin-top: 1rem; }
.new-rulebook input {
width: 100%; margin-bottom: 0.5rem;
background: var(--fs-surface-page); color: inherit;
border: 1px solid var(--fs-border-color); border-radius: 6px;
background: var(--color-bg, #111113); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
@@ -9,15 +9,3 @@ export function relativeTime(iso: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/**
* relativeTime() for the recent past, a short date once it's a week old —
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
* panels used to carry identical copies of this.
*/
export function relativeTimeOrDate(iso: string): string {
const d = new Date(iso);
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
if (days < 7) return relativeTime(iso);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
+9 -5
View File
@@ -110,11 +110,15 @@ const router = createRouter({
component: () => import("@/views/RulesView.vue"),
},
{
// The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the
// running app's own stylesheet out of the browser; it could only ever
// inspect the instance it was served from, which made it a mirror rather
// than a tool (#274).
// Meta-surface, same family as /rules: it describes the app rather than
// holding the operator's records.
path: "/design",
name: "design",
component: () => import("@/views/DesignView.vue"),
},
{
// The editable half of the same surface: /design is what the browser
// renders, /design-systems is the record that ought to decide it.
path: "/design-systems",
name: "design-systems",
component: () => import("@/views/DesignSystemsView.vue"),
+1
View File
@@ -10,6 +10,7 @@ export interface Note {
title: string;
body: string;
description: string | null;
consolidated_at: string | null;
tags: string[];
parent_id: number | null;
parent_title?: string | null;
+57 -24
View File
@@ -1,32 +1,65 @@
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}
/** "9:30 AM" */
export function fmtTime(dt: string): string {
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
export function fmtDateTime(dt: string, allDay: boolean): string {
const d = new Date(dt)
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
if (allDay) return datePart
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
}
/**
* Shared date/time formatting — one rule per display shape. Views import
* 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.
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
*/
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
/** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
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, 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",
});
/**
* 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, 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",
});
/** "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" })
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Drift comparison — what the rulebook claims vs what the stylesheet does.
*
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
* prose into claims) is server-side in `services/design_system.py`, where pytest
* can assert on it. What's left here is set arithmetic over live token values,
* which is the one thing the browser knows and the server doesn't.
*
* SCOPE, and it is a real limit rather than an omission. This compares the
* rulebook against the TOKENS. It cannot see the third category of drift — a
* literal hardcoded in a component where a token should be referenced (#2275,
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
*
* Catching it needs the component sources, which would mean bundling every SFC
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
* matters: a drift report that silently omits a category invites the reader to
* conclude the category is clean.
*/
import type { DesignToken } from "@/utils/designTokens";
export type ExpectationKind = "token" | "color" | "prohibited_color";
export interface Expectation {
kind: ExpectationKind;
value: string;
rule_id: number;
rule_title: string;
context: string;
}
export interface ExpectationResponse {
rulebook_id: number | null;
expectations: Expectation[];
}
export type FindingStatus = "ok" | "missing" | "violated";
export interface Finding {
expectation: Expectation;
status: FindingStatus;
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
matches: string[];
}
/**
* Normalise a colour for comparison — the client-side twin of
* `normalize_hex` in services/design_system.py.
*
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
* spellings of one colour, and a comparison that misses any of them under-reports
* rather than erroring. The rgb() case is browser-specific and therefore has no
* server-side counterpart, which is exactly why it is handled here.
*/
export function normalizeColour(value: string): string | null {
const raw = value.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) {
digits = digits.split("").map((c) => c + c).join("");
}
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
}
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
if (rgb) {
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
if (parts.length < 3) return null;
const channels = parts.slice(0, 3).map((p) => Number(p));
if (channels.some((n) => !Number.isFinite(n))) return null;
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
const base = `#${channels.map(hexOf).join("")}`;
if (parts.length === 3) return base;
const alpha = Number(parts[3]);
if (!Number.isFinite(alpha) || alpha >= 1) return base;
return `${base}${hexOf(alpha * 255)}`;
}
return null;
}
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
const index = new Map<string, string[]>();
for (const token of tokens) {
const colour = normalizeColour(token.value);
if (!colour) continue;
const names = index.get(colour);
if (names) names.push(token.name);
else index.set(colour, [token.name]);
}
return index;
}
/**
* Compare claims against the live tokens.
*
* A `token` claim asks whether a custom property of that name exists.
* A `color` claim asks whether any token resolves to that value.
* A `prohibited_color` claim INVERTS the test — present is the failure.
*/
export function compareToTokens(
expectations: Expectation[],
tokens: DesignToken[],
): Finding[] {
const names = new Set(tokens.map((t) => t.name));
const colours = colourIndex(tokens);
return expectations.map((expectation) => {
if (expectation.kind === "token") {
const present = names.has(expectation.value);
return {
expectation,
status: present ? "ok" : "missing",
matches: present ? [expectation.value] : [],
};
}
const matches = colours.get(expectation.value) ?? [];
if (expectation.kind === "prohibited_color") {
return {
expectation,
status: matches.length ? "violated" : "ok",
matches,
};
}
return {
expectation,
status: matches.length ? "ok" : "missing",
matches,
};
});
}
export interface DriftSummary {
ok: number;
missing: number;
violated: number;
total: number;
}
export function summarise(findings: Finding[]): DriftSummary {
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
for (const finding of findings) summary[finding.status] += 1;
return summary;
}
/**
* Findings worth leading with.
*
* A panel that opens with every row gets closed and never reopened — the same
* principle the auto-inject menu is built on: a short list that gets read beats
* a complete one that doesn't. Violations first (something is actively wrong),
* then missing (something was never built), and `ok` rows are not "findings" at
* all — they belong behind an expansion.
*/
export function rankFindings(findings: Finding[]): Finding[] {
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
return [...findings].sort((a, b) => {
const byStatus = order[a.status] - order[b.status];
if (byStatus !== 0) return byStatus;
return a.expectation.rule_id - b.expectation.rule_id;
});
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Design-token inventory — what tokens exist, and what they actually resolve to.
*
* Foundation for the design explorer (milestone #251): the gallery renders
* against these, and the drift panel compares them to the design rulebook.
*
* DESIGN NOTE — why this parses NAMES but never VALUES.
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
* its VALUE is not: values contain nested parens, commas inside rgba(),
* `var()` references to other tokens, multi-part shadows, and gradients — and
* `theme.css` has all of those today. So we take the names from the source and
* ask the BROWSER for every value.
*
* That is not just easier, it is more correct. getComputedStyle reports what
* actually won the cascade, resolves `var()` chains, and — critically for this
* milestone — reflects live overrides set on a container, which is exactly what
* the preview surface needs (see #2261). Parsing the source would report what
* the file says rather than what the user is looking at.
*
* It also means this module needs no unit tests to be trustworthy: the only
* logic here is a name regex and a group lookup. The frontend has no test
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
* error-prone half in the browser rather than in our code is deliberate.
*/
import themeCss from "@/assets/theme.css?raw";
export type TokenGroup =
| "color"
| "radius"
| "gradient"
| "glow"
| "focus"
| "layout"
| "other";
export type ThemeMode = "light" | "dark";
export interface DesignToken {
/** Full custom-property name, including the leading `--`. */
name: string;
/** Coarse family, derived from the name prefix. */
group: TokenGroup;
/** Resolved value in the requested context, straight from the browser. */
value: string;
/** True when the declaration appears inside the dark block in source. */
overriddenInDark: boolean;
}
/**
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
*
* The discriminator is the COLON, not the preceding character. A declaration is
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
* actively wrong: an earlier version required the match to follow `{` or `;`,
* which silently dropped every declaration that came after a comment —
* including `--color-bg`, the first and most-used token in the file.
*/
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
/** Comments are stripped first so a commented-out declaration isn't counted. */
const COMMENT = /\/\*[\s\S]*?\*\//g;
/** The dark block's selector, as written in theme.css. */
const DARK_SELECTOR = '[data-theme="dark"]';
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
["--color-", "color"],
["--radius-", "radius"],
["--gradient-", "gradient"],
["--glow-", "glow"],
["--focus-", "focus"],
["--page-", "layout"],
["--sidebar-", "layout"],
["--chat-", "layout"],
];
export function groupFor(name: string): TokenGroup {
for (const [prefix, group] of GROUP_PREFIXES) {
if (name.startsWith(prefix)) return group;
}
return "other";
}
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
export function tokenNames(css: string = themeCss): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
const name = match[1];
if (!seen.has(name)) {
seen.add(name);
out.push(name);
}
}
return out;
}
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
export function darkOverriddenNames(css: string = themeCss): Set<string> {
const bare = css.replace(COMMENT, "");
const start = bare.indexOf(DARK_SELECTOR);
if (start === -1) return new Set();
const open = bare.indexOf("{", start);
const close = bare.indexOf("}", open);
if (open === -1 || close === -1) return new Set();
return new Set(tokenNames(bare.slice(open, close)));
}
/**
* Read the resolved value of every token in `host`'s context.
*
* Pass a container to read the tokens as they apply INSIDE it — which is how
* the preview surface reads a scoped override without disturbing the page.
* Defaults to the document root, i.e. the app-wide values.
*/
export function readTokens(host: Element = document.documentElement): DesignToken[] {
const computed = getComputedStyle(host);
const dark = darkOverriddenNames();
return tokenNames().map((name) => ({
name,
group: groupFor(name),
value: computed.getPropertyValue(name).trim(),
overriddenInDark: dark.has(name),
}));
}
/**
* Read tokens as they would resolve in a given mode, without touching the page.
*
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
* mutated to take a reading.
*
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
* function: light is declared on `:root` while dark is declared on
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
* subtree, but there is no `[data-theme="light"]` block to add the light ones
* back. So reading "light" from inside a dark page returns the dark values —
* the probe has nothing to match.
*
* Concretely: dark-inside-light previews work, light-inside-dark previews do
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
* (milestone #251 step 6) is what makes this symmetric, and until then callers
* should treat a cross-mode read as best-effort.
*/
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
const probe = document.createElement("div");
probe.setAttribute("data-theme", mode);
probe.style.display = "none";
document.body.appendChild(probe);
try {
return readTokens(probe);
} finally {
probe.remove();
}
}
/** Tokens grouped by family, preserving source order within each group. */
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
const out = new Map<TokenGroup, DesignToken[]>();
for (const token of tokens) {
const bucket = out.get(token.group);
if (bucket) bucket.push(token);
else out.set(token.group, [token]);
}
return out;
}
/**
* Tokens declared in the stylesheet that nothing references with `var()`.
*
* Dead tokens are drift too: `--chat-reading-width` and
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
* in the MCP-first pivot, and nothing has referenced them since. Takes the
* corpus of source files to search as an argument so the caller decides what
* "used" means — this module has no opinion about the project layout.
*/
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
const haystack = sources.join("\n");
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
}
-88
View File
@@ -1,88 +0,0 @@
/**
* Turning a design system's RECORDED values into ones you can look at.
*
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
* running app's own stylesheet — names out of a bundled `theme.css`, values out
* of `getComputedStyle(document.documentElement)`. That could only ever describe
* the install serving the page, and the design surface is for the projects an
* install TRACKS (#274). What is left here works on any system's record,
* including one for an app this browser has never loaded.
*
* Nothing in this module reads the document's own tokens or mutates the page.
*/
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
export const BASE_MODE = "base";
/**
* Which declared value applies in `mode`.
*
* Falls back to base, which is the storage model rather than a convenience: a
* mode block is an OVERRIDE layer, so a token with no entry for the current
* mode is not missing — it is inheriting, exactly as the generated sheet has it.
*/
export function valueForMode(
valueByMode: Record<string, string>,
mode: string,
): string {
const own = valueByMode[mode];
if (own !== undefined && own !== "") return own;
return valueByMode[BASE_MODE] ?? "";
}
/** Every mode any token in the set declares, base first then the rest by name. */
export function modesPresent(
tokens: { value_by_mode: Record<string, string> }[],
): string[] {
const modes = new Set<string>();
for (const token of tokens) {
for (const [mode, value] of Object.entries(token.value_by_mode)) {
if (value) modes.add(mode);
}
}
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
}
/**
* Resolve declared values the way a browser would, without applying them.
*
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
* as text that is a string; shown as a swatch it needs `var()` substituted and
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
* offscreen probe and read them back — the substitution is done by the
* implementation that would do it for real.
*
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
* sitting in this page would resolve any reference the record leaves undeclared
* against the surrounding app's own tokens. Previewing another project's system
* would then quietly borrow this one's palette wherever that system was
* incomplete, and a token the record already knows is broken (it shows up under
* `unknown_refs`) would render as though it were fine.
*
* So every name referenced but not declared is blanked on the probe first. It
* resolves to nothing, which is what the record says it is.
*/
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
const probe = document.createElement("div");
probe.style.display = "none";
for (const value of declared.values()) {
for (const match of value.matchAll(VAR_REFERENCE)) {
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
}
}
for (const [name, value] of declared) probe.style.setProperty(name, value);
document.body.appendChild(probe);
try {
const computed = getComputedStyle(probe);
const out = new Map<string, string>();
for (const name of declared.keys()) {
out.set(name, computed.getPropertyValue(name).trim());
}
return out;
} finally {
probe.remove();
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
/** Cyclic color palette for milestone progress bars. */
export const MILESTONE_PALETTE = [
'var(--fs-accent)',
'var(--fs-success, #22c55e)',
'var(--color-primary)',
'var(--color-success, #22c55e)',
'#c98a00',
'#8b5cf6',
'var(--fs-error, #ef4444)',
'var(--color-danger, #ef4444)',
'#06b6d4',
]
+38 -41
View File
@@ -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(--fs-accent)' }" />
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
<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(--fs-accent)' }" />
<span class="pstat-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
<span class="pstat-title">{{ p.title }}</span>
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
</router-link>
@@ -176,72 +176,69 @@ 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(--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; }
.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; }
.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(--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-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-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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-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-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(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); 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(--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; }
.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; }
.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(--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); }
.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); }
.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-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-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(--fs-border-color); color: var(--fs-text-tertiary); }
.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); }
.pri-high { color: #c0556b; border-color: #c0556b66; }
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--fs-accent); text-decoration: none; }
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
.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; }
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); 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(--fs-text-tertiary); font-size: 0.78rem; }
.stats-sub { color: var(--color-muted); 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(--fs-text-primary); font-size: 0.85rem; }
.pstat-row:hover { background: var(--fs-surface-hover); }
.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-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(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
.pstat-counts { font-size: 0.74rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.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); }
.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); }
.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(--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-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-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-meta { font-size: 0.72rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
.issue-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
</style>
+67 -121
View File
@@ -1,13 +1,9 @@
<script setup lang="ts">
/**
* Design systems — the stylesheets this install RECORDS, for the projects it
* tracks (milestone #254 step 5).
* Design systems — editing the stylesheet Scribe holds (milestone #254 step 5).
*
* 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.
* 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.
*
* 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
@@ -43,10 +39,9 @@ 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();
@@ -153,10 +148,6 @@ 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();
@@ -167,16 +158,11 @@ 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}`);
@@ -493,28 +479,14 @@ watch(selectedId, () => {
snippetCheck.value = null;
});
/**
* 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(");
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
}
</script>
<template>
<div class="ds-view">
<DesignTabs />
<header class="ds-header">
<h1>Design systems</h1>
@@ -568,10 +540,6 @@ function isSelfContainedColour(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" }}
@@ -633,10 +601,6 @@ function isSelfContainedColour(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>
@@ -786,7 +750,7 @@ function isSelfContainedColour(value: string): boolean {
<ul class="dupe-list">
<li v-for="[value, names] in duplicateEntries" :key="value">
<span
v-if="isSelfContainedColour(value)" class="swatch"
v-if="isColourish(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<code>{{ value }}</code> — {{ names.join(", ") }}
@@ -944,7 +908,7 @@ function isSelfContainedColour(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="isSelfContainedColour(row.value)" class="swatch"
v-if="isColourish(row.value)" class="swatch"
:style="{ background: row.value }" aria-hidden="true"
/>
<button
@@ -982,7 +946,7 @@ function isSelfContainedColour(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="isSelfContainedColour(value)" class="swatch"
v-if="isColourish(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<span class="mode-name">{{ mode }}</span>
@@ -1005,18 +969,6 @@ function isSelfContainedColour(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>
@@ -1050,7 +1002,7 @@ function isSelfContainedColour(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="isSelfContainedColour(origin.value)" class="swatch"
v-if="isColourish(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
@@ -1071,7 +1023,7 @@ function isSelfContainedColour(value: string): boolean {
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
<span class="mode-chip">
<span
v-if="isSelfContainedColour(origin.value)" class="swatch"
v-if="isColourish(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
@@ -1102,12 +1054,6 @@ function isSelfContainedColour(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;
@@ -1115,7 +1061,7 @@ function isSelfContainedColour(value: string): boolean {
}
.lede {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
max-width: 70ch;
line-height: 1.6;
margin: 0 0 1.5rem;
@@ -1137,9 +1083,9 @@ function isSelfContainedColour(value: string): boolean {
/* Sidebar ---------------------------------------------------------------- */
.ds-sidebar {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
}
@@ -1156,7 +1102,7 @@ function isSelfContainedColour(value: string): boolean {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.system-list {
@@ -1173,22 +1119,22 @@ function isSelfContainedColour(value: string): boolean {
text-align: left;
background: none;
border: 1px solid transparent;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
cursor: pointer;
color: var(--fs-text-primary);
color: var(--color-text);
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.system-btn:hover {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.system-btn.active {
border-color: var(--fs-accent);
background: var(--fs-surface-raised);
border-color: var(--color-primary);
background: var(--color-bg-secondary);
}
@@ -1198,7 +1144,7 @@ function isSelfContainedColour(value: string): boolean {
.system-kind {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* Sections --------------------------------------------------------------- */
@@ -1211,9 +1157,9 @@ function isSelfContainedColour(value: string): boolean {
}
.ds-section {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
}
@@ -1232,7 +1178,7 @@ function isSelfContainedColour(value: string): boolean {
.section-note,
.muted {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.85rem;
margin: 0 0 1rem;
}
@@ -1247,17 +1193,17 @@ function isSelfContainedColour(value: string): boolean {
}
.chain-link {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.chain-link.self {
color: var(--fs-text-primary);
color: var(--color-text);
font-weight: 500;
}
.chain-arrow,
.chain-note {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* Fields ----------------------------------------------------------------- */
@@ -1282,24 +1228,24 @@ function isSelfContainedColour(value: string): boolean {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin-bottom: 0.3rem;
}
.field-hint {
margin: 0.3rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
line-height: 1.5;
}
.input {
width: 100%;
padding: 0.45rem 0.6rem;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font: inherit;
}
@@ -1328,15 +1274,15 @@ textarea.input {
.confirm-copy {
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
max-width: 40ch;
}
/* Tokens ----------------------------------------------------------------- */
.token-form {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1rem;
margin-bottom: 1rem;
}
@@ -1371,7 +1317,7 @@ textarea.input {
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.token-name {
@@ -1386,7 +1332,7 @@ textarea.input {
}
.token-meta {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.8rem;
flex: 1;
min-width: 0;
@@ -1405,18 +1351,18 @@ textarea.input {
.finding-list li {
padding: 0.6rem 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.finding-title {
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
}
.finding-line {
margin: 0.3rem 0 0;
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -1428,24 +1374,24 @@ textarea.input {
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.45rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
flex: none;
}
.spec-status.violated {
background: var(--fs-priority-high-bg);
color: var(--fs-priority-high);
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.missing {
background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium);
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.sheet {
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-top: 0.75rem;
overflow-x: auto;
@@ -1472,7 +1418,7 @@ textarea.input {
.supersedes {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-style: italic;
}
@@ -1484,7 +1430,7 @@ textarea.input {
}
.mode-name {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.06em;
@@ -1495,7 +1441,7 @@ textarea.input {
width: 0.9rem;
height: 0.9rem;
border-radius: 3px;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
flex: none;
}
@@ -1510,12 +1456,12 @@ textarea.input {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.resolved-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.resolved-head {
@@ -1551,33 +1497,33 @@ textarea.input {
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.45rem;
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text-muted);
}
.origin-badge.own {
background: var(--fs-accent-soft);
color: var(--fs-accent);
background: var(--color-primary-tint);
color: var(--color-primary);
}
/* Notices ---------------------------------------------------------------- */
.notice {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
max-width: 60ch;
}
.notice-warn {
border-left: 3px solid var(--fs-warning);
border-left: 3px solid var(--color-warning);
}
.notice p {
margin: 0.5rem 0 1rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
line-height: 1.6;
}
</style>
+545
View File
@@ -0,0 +1,545 @@
<script setup lang="ts">
/**
* Design explorer — the gallery (milestone #251 step 3).
*
* Renders the design system against the tokens that are actually live, read at
* runtime rather than parsed from source, so what you see here is what the app
* is using right now.
*
* HONESTY RULE, and the reason parts of this page say "not implemented":
* a gallery of hand-written look-alikes drifts from the app within a month and
* then lies — which is the same failure this whole surface exists to catch. So
* every specimen below is either a REAL component imported from the app, or a
* real token read from the browser, or it is explicitly marked as missing.
*
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
* in five `<style scoped>` blocks, all five drifted, and this page reported it
* as a gap because drawing a look-alike would have made it a sixth copy.
* `assets/components.css` is now the single definition (#2273), so the
* specimens below are the app's real classes — they cannot drift from the app
* without drifting the app itself.
*/
import { computed, onMounted, ref } from "vue";
import { fetchDesignExpectations } from "@/api/design";
import DesignTabs from "@/components/DesignTabs.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import TagPill from "@/components/TagPill.vue";
import {
compareToTokens,
rankFindings,
summarise,
type Expectation,
type Finding,
} from "@/utils/designDrift";
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
const tokens = ref<DesignToken[]>([]);
const expectations = ref<Expectation[]>([]);
const designRulebookId = ref<number | null>(null);
const driftLoaded = ref(false);
const showCleanRows = ref(false);
/**
* Read on mount, not at module scope: the values depend on the live cascade,
* which needs the app's stylesheets applied and the theme attribute set.
*/
onMounted(async () => {
tokens.value = readTokens();
try {
const response = await fetchDesignExpectations();
designRulebookId.value = response.rulebook_id;
expectations.value = response.expectations;
} catch {
// The gallery is useful without the panel, so a failed fetch degrades to
// "no drift data" rather than taking the page down with it.
designRulebookId.value = null;
} finally {
driftLoaded.value = true;
}
});
const findings = computed<Finding[]>(() =>
rankFindings(compareToTokens(expectations.value, tokens.value)),
);
const driftSummary = computed(() => summarise(findings.value));
const visibleFindings = computed(() =>
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
);
const grouped = computed(() => groupTokens(tokens.value));
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
const orderedGroups = computed(() =>
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
);
/** A token whose value reads as a colour is worth showing as a swatch. */
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
}
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
const RULEBOOK_BUTTONS = [
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
];
const TYPE_SPECIMENS = [
{ token: "Display", spec: "40 / 500 / Fraunces" },
{ token: "H1", spec: "32 / 500 / Fraunces" },
{ token: "H2", spec: "24 / 500 / Fraunces" },
{ token: "H3", spec: "18 / 500 / Inter" },
{ token: "Body", spec: "15 / 400 / Inter" },
{ token: "Body small", spec: "13 / 400 / Inter" },
{ token: "Label", spec: "12 / 500 / Inter" },
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
];
</script>
<template>
<div class="design-view">
<DesignTabs />
<header class="design-header">
<h1>Live tokens</h1>
<p class="lede">
The system as it actually is. Token values are read from the browser at
runtime, so this page reflects the live cascade rather than what the
stylesheet says. Components shown are the real ones where a piece of
the system has no shared implementation, it is marked missing rather
than mocked up.
</p>
</header>
<!-- Drift: what the rulebook claims vs what the tokens do. -->
<section class="design-section">
<h2>Rulebook drift</h2>
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook</p>
<div v-else-if="designRulebookId === null" class="gap-notice">
<strong>No design rulebook designated.</strong>
<p>
This install hasn't said which rulebook describes its design system, so
there is nothing to check the tokens against. Designate one in
<router-link to="/settings">Settings</router-link> and this panel will
compare every colour and token the rulebook names against what the
stylesheet actually resolves to.
</p>
</div>
<template v-else>
<p class="section-note">
<strong>{{ driftSummary.violated }}</strong> violated ·
<strong>{{ driftSummary.missing }}</strong> missing ·
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
claims in rulebook #{{ designRulebookId }}.
</p>
<div class="gap-notice">
<strong>This compares the rulebook against the TOKENS only.</strong>
<p>
A value hardcoded in a component — where a token should have been
referenced — is invisible here, because the drift isn't in the tokens
at all. Reading it would mean bundling every component's source into
the app. That check belongs in CI and is tracked separately, so treat
a clean panel as "the tokens agree", not "the app agrees".
</p>
</div>
<p v-if="!findings.length" class="muted">
The rulebook names nothing this panel can check. Rules that state values
— colours, token names — produce claims; rules that state judgement
don't, by design.
</p>
<ul v-else class="spec-list">
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
<span class="spec-name">
<span
v-if="finding.expectation.kind !== 'token'"
class="swatch"
:style="{ background: finding.expectation.value }"
aria-hidden="true"
/>
<code>{{ finding.expectation.value }}</code>
</span>
<span class="spec-detail">
rule #{{ finding.expectation.rule_id }} {{ finding.expectation.rule_title }}
<span v-if="finding.matches.length" class="matches">
· {{ finding.matches.join(", ") }}
</span>
</span>
<span class="spec-status" :class="finding.status">
{{ finding.status === "violated" ? "forbidden, but present"
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
</span>
</li>
</ul>
<button
v-if="findings.length && driftSummary.ok"
class="reveal-toggle"
@click="showCleanRows = !showCleanRows"
>
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
</button>
</template>
</section>
<!-- Real components: these are imported, not recreated. -->
<section class="design-section">
<h2>Components</h2>
<p class="section-note">Imported from the app. What you see is what ships.</p>
<div class="specimen">
<span class="specimen-label">Status badge</span>
<div class="specimen-row">
<StatusBadge status="todo" />
<StatusBadge status="in_progress" />
<StatusBadge status="done" />
<StatusBadge status="cancelled" />
</div>
</div>
<div class="specimen">
<span class="specimen-label">Priority badge</span>
<div class="specimen-row">
<PriorityBadge priority="low" />
<PriorityBadge priority="medium" />
<PriorityBadge priority="high" />
<span class="muted">(<code>none</code> renders nothing, by design)</span>
</div>
</div>
<div class="specimen">
<span class="specimen-label">Tag pill</span>
<div class="specimen-row">
<TagPill tag="design-system" />
<TagPill tag="dismissible" dismissible />
</div>
</div>
</section>
<!-- No longer a gap: these are the app's real classes, from the shared
sheet. Nothing here is a look-alike — change components.css and these
specimens change with it, which is the only way this page stays true. -->
<section class="design-section">
<h2>Buttons</h2>
<div class="button-specimens">
<button class="btn-primary">Save</button>
<button class="btn-secondary">Detect</button>
<button class="btn-ghost">Cancel</button>
<button class="btn-danger">Delete</button>
<button class="btn-primary" disabled>Disabled</button>
</div>
<p class="spec-caption">
Three sizes, because the app has three kinds of button: a page action, a
row action, and an affordance that sits inside a card without disturbing
its rhythm.
</p>
<div class="button-specimens">
<button class="btn-primary">Default — page action</button>
<button class="btn-primary btn-compact">Compact — row action</button>
<button class="btn-primary btn-inline">Inline</button>
</div>
<ul class="spec-list">
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
<span class="spec-name">{{ b.name }}</span>
<span class="spec-detail">{{ b.spec }}</span>
<span class="spec-status ok">shared</span>
</li>
</ul>
</section>
<!-- Typography: the families load, the scale does not exist as tokens. -->
<section class="design-section">
<h2>Type scale</h2>
<div class="gap-notice">
<strong>Families load; the scale has no tokens.</strong>
<p>
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
scale is not expressed as custom properties, so sizes and weights are
set ad hoc per component. Listed here as specification, not as a live
specimen there is nothing to read.
</p>
</div>
<ul class="spec-list">
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
<span class="spec-name">{{ t.token }}</span>
<span class="spec-detail">{{ t.spec }}</span>
<span class="spec-status missing">no token</span>
</li>
</ul>
</section>
<!-- Tokens: entirely real, read live. -->
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
<ul class="token-list">
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
<span
v-if="isColourish(token.value)"
class="swatch"
:style="{ background: token.value }"
aria-hidden="true"
/>
<span v-else class="swatch swatch-none" aria-hidden="true" />
<code class="token-name">{{ token.name }}</code>
<code class="token-value">{{ token.value || "—" }}</code>
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
mode-aware
</span>
</li>
</ul>
</section>
<p v-if="!tokens.length" class="muted">Reading tokens</p>
</div>
</template>
<style scoped>
.design-view {
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem var(--page-padding-x) 4rem;
}
.design-header {
margin-bottom: 2rem;
}
.lede {
color: var(--color-text-secondary);
max-width: 60ch;
line-height: 1.7;
}
.design-section {
margin-bottom: 2.5rem;
}
.design-section h2 {
margin-bottom: 0.25rem;
}
.token-group-heading {
text-transform: capitalize;
}
.count {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 400;
}
.section-note,
.muted {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 1rem;
}
/* Specimens -------------------------------------------------------------- */
.specimen {
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
}
.specimen-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.specimen-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* Gaps ------------------------------------------------------------------- */
.spec-caption {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
line-height: 1.5;
max-width: 60ch;
}
/* Layout only. The buttons inside style themselves from the shared sheet —
adding any appearance rule here would recreate the copy this section
just stopped being. */
.button-specimens {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--fs-space-3);
margin-bottom: 1rem;
}
.gap-notice {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.gap-notice p {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.6;
max-width: 70ch;
}
.spec-list {
list-style: none;
padding: 0;
margin: 0;
}
.spec-list li {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.spec-name {
min-width: 8rem;
font-weight: 500;
}
.spec-detail {
color: var(--color-text-secondary);
font-size: 0.85rem;
flex: 1;
}
.spec-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
}
.spec-status.missing {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.spec-status.violated {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
.spec-status.ok {
background: var(--color-status-done-bg);
color: var(--color-status-done);
}
.spec-name .swatch {
vertical-align: middle;
margin-right: 0.4rem;
}
.matches {
color: var(--color-text-muted);
}
.reveal-toggle {
margin-top: 0.75rem;
padding: 0.35rem 0.75rem;
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
}
.reveal-toggle:hover {
border-color: var(--color-text-muted);
}
/* Tokens ----------------------------------------------------------------- */
.token-list {
list-style: none;
padding: 0;
margin: 0;
}
.token-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.swatch {
width: 1.25rem;
height: 1.25rem;
flex: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.swatch-none {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 3px,
var(--color-border) 3px,
var(--color-border) 4px
);
}
.token-name {
min-width: 16rem;
font-size: 0.8rem;
}
.token-value {
color: var(--color-text-secondary);
font-size: 0.8rem;
flex: 1;
word-break: break-all;
}
.token-flag {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.05rem 0.35rem;
}
@media (max-width: 640px) {
.token-name {
min-width: 0;
}
}
</style>
+88 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { apiPost, apiErrorMessage } from "@/api/client";
import { apiPost } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue";
const email = ref("");
@@ -15,7 +15,12 @@ async function handleSubmit() {
await apiPost("/api/auth/forgot-password", { email: email.value });
submitted.value = true;
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Something went wrong");
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";
}
} finally {
submitting.value = false;
}
@@ -50,7 +55,7 @@ async function handleSubmit() {
</form>
</template>
<div v-else class="auth-note">
<div v-else class="success-msg">
<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>
@@ -62,4 +67,83 @@ async function handleSubmit() {
</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;
}
.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>
+60 -60
View File
@@ -208,7 +208,7 @@ function initGraph() {
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-4L8,0L0,4")
.attr("fill", "var(--fs-accent)")
.attr("fill", "var(--color-primary)")
.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(--fs-accent)" : "var(--fs-text-tertiary)"
d.type === "wikilink" ? "var(--color-primary)" : "var(--color-text-muted)"
)
.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(--fs-accent) 20%, transparent)";
return d.project_color ?? "var(--fs-surface-raised)";
if (d.type === "tag") return "color-mix(in srgb, var(--color-primary) 20%, transparent)";
return d.project_color ?? "var(--color-bg-secondary)";
})
.attr("stroke", (d: GraphNode) =>
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-border-color)"
d.type === "tag" ? "var(--color-primary)" : "var(--color-border)"
)
.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(--fs-accent)" : "var(--fs-text-secondary)"
d.type === "tag" ? "var(--color-primary)" : "var(--color-text-secondary)"
)
.attr("pointer-events", "none");
@@ -600,7 +600,7 @@ onUnmounted(() => {
.graph-page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--fs-layout-header));
height: calc(100vh - var(--header-height, 52px));
overflow: hidden;
}
@@ -609,17 +609,17 @@ onUnmounted(() => {
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.graph-select {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
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(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.graph-toggle input {
cursor: pointer;
accent-color: var(--fs-accent);
accent-color: var(--color-primary);
}
.graph-stats {
margin-left: auto;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.graph-physics-btn {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-secondary);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-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(--fs-accent);
color: var(--fs-text-primary);
border-color: var(--color-primary);
color: var(--color-text);
}
.graph-physics-btn.active {
border-color: var(--fs-accent);
color: var(--fs-accent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.graph-physics-panel {
@@ -672,8 +672,8 @@ onUnmounted(() => {
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
padding: 0.6rem 1rem;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -688,20 +688,20 @@ onUnmounted(() => {
.knob-label {
font-size: 0.75rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
display: flex;
justify-content: space-between;
}
.knob-label em {
font-style: normal;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.physics-knob input[type="range"] {
width: 100%;
accent-color: var(--fs-accent);
accent-color: var(--color-primary);
cursor: pointer;
}
@@ -709,7 +709,7 @@ onUnmounted(() => {
flex: 1;
position: relative;
overflow: hidden;
background: var(--fs-surface-page);
background: var(--color-bg);
}
.graph-svg {
@@ -728,15 +728,15 @@ onUnmounted(() => {
}
.graph-empty p {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.95rem;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--fs-border-color);
border-top-color: var(--fs-accent);
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@@ -747,10 +747,10 @@ onUnmounted(() => {
.graph-tooltip {
position: absolute;
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);
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));
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(--fs-text-primary);
color: var(--color-text);
margin-bottom: 0.25rem;
}
@@ -773,15 +773,15 @@ onUnmounted(() => {
.tag-chip {
font-size: 0.7rem;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
border-radius: 999px;
padding: 0.1rem 0.4rem;
}
.tooltip-meta {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-transform: capitalize;
}
@@ -792,8 +792,8 @@ onUnmounted(() => {
right: 0;
bottom: 0;
width: 340px;
background: var(--fs-surface-raised);
border-left: 1px solid var(--fs-border-color);
background: var(--color-bg-card);
border-left: 1px solid var(--color-border);
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(--fs-border-color);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -826,10 +826,10 @@ onUnmounted(() => {
letter-spacing: 0.05em;
padding: 0.15rem 0.45rem;
border-radius: 10px;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.peek-type-task { border-color: var(--fs-accent); color: var(--fs-accent); }
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
.peek-actions {
display: flex;
@@ -840,7 +840,7 @@ onUnmounted(() => {
.peek-link {
font-size: 0.78rem;
color: var(--fs-accent);
color: var(--color-primary);
text-decoration: none;
}
.peek-link:hover { text-decoration: underline; }
@@ -848,20 +848,20 @@ onUnmounted(() => {
.peek-close {
background: none;
border: none;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.peek-close:hover { color: var(--fs-text-primary); }
.peek-close:hover { color: var(--color-text); }
.peek-title {
margin: 0;
padding: 0.75rem 0.75rem 0.4rem;
font-size: 1rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
flex-shrink: 0;
}
@@ -877,7 +877,7 @@ onUnmounted(() => {
flex: 1;
overflow-y: auto;
padding: 0 0.75rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.peek-prose {
@@ -887,7 +887,7 @@ onUnmounted(() => {
.peek-loading,
.peek-empty {
font-size: 0.82rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding-top: 0.5rem;
}
@@ -901,7 +901,7 @@ onUnmounted(() => {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
@@ -921,14 +921,14 @@ onUnmounted(() => {
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--fs-text-primary);
color: var(--color-text);
cursor: pointer;
padding: 0.25rem 0.4rem;
border-radius: 4px;
}
.peek-linked-item:hover {
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
color: var(--color-primary);
}
.peek-linked-type {
@@ -937,12 +937,12 @@ onUnmounted(() => {
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
background: var(--color-bg);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
</style>
+60 -220
View File
@@ -46,50 +46,6 @@ 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 }
@@ -429,51 +385,6 @@ 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 -->
@@ -579,7 +490,7 @@ onUnmounted(() => {
.knowledge-root {
display: flex;
flex-direction: column;
height: calc(100vh - var(--fs-layout-header));
height: calc(100vh - var(--header-height, 56px));
overflow: hidden;
}
@@ -590,8 +501,8 @@ onUnmounted(() => {
justify-content: space-between;
gap: 12px;
padding: 8px 20px;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
flex-shrink: 0;
font-size: 0.82rem;
flex-wrap: wrap;
@@ -607,7 +518,7 @@ onUnmounted(() => {
font-size: 0.78rem;
}
.today-link {
color: var(--fs-accent);
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
opacity: 0.85;
@@ -625,19 +536,19 @@ onUnmounted(() => {
/* ── Filter panel ────────────────────────────────────────── */
.filter-panel {
width: var(--fs-layout-sidebar);
width: var(--sidebar-width);
flex-shrink: 0;
padding: 16px 12px;
border-right: 1px solid var(--fs-border-color);
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
overflow-y: auto;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: rgba(91, 74, 138, 0.3);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
@@ -645,7 +556,7 @@ onUnmounted(() => {
.filter-label {
font-family: 'Fraunces', Georgia, serif;
font-size: 0.95rem;
color: var(--fs-accent);
color: var(--color-primary);
margin-bottom: 8px;
padding: 0 4px;
}
@@ -662,14 +573,14 @@ onUnmounted(() => {
padding: 8px 12px;
border-radius: 10px;
border: none;
background: var(--fs-gradient-cta);
background: var(--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(--fs-glow-cta-hover); }
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
.btn-new-icon {
font-size: 1.1rem;
line-height: 1;
@@ -680,8 +591,8 @@ onUnmounted(() => {
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
z-index: 50;
@@ -696,15 +607,15 @@ onUnmounted(() => {
padding: 9px 14px;
background: none;
border: none;
color: var(--fs-text-primary);
color: var(--color-text);
cursor: pointer;
font-size: 0.84rem;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.new-note-menu button:hover {
background: var(--fs-accent-soft);
color: var(--fs-accent);
background: var(--color-primary-tint);
color: var(--color-primary);
}
.new-note-menu button svg {
flex-shrink: 0;
@@ -712,7 +623,7 @@ onUnmounted(() => {
}
.new-note-menu button:hover svg {
opacity: 1;
stroke: var(--fs-accent);
stroke: var(--color-primary);
}
.filter-btn {
@@ -725,7 +636,7 @@ onUnmounted(() => {
border-radius: 7px;
border: none;
background: transparent;
color: var(--fs-text-primary);
color: var(--color-text);
cursor: pointer;
font-size: 0.85rem;
margin-bottom: 2px;
@@ -734,8 +645,8 @@ onUnmounted(() => {
}
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
.filter-btn.active {
background: var(--fs-accent-wash);
color: var(--fs-accent);
background: var(--color-primary-wash);
color: var(--color-primary);
opacity: 1;
}
.filter-btn-label { flex: 1; }
@@ -744,15 +655,15 @@ onUnmounted(() => {
padding: 1px 6px;
border-radius: 10px;
background: rgba(255,255,255,0.07);
color: var(--fs-text-tertiary);
color: var(--color-muted);
font-weight: 500;
min-width: 20px;
text-align: center;
flex-shrink: 0;
}
.filter-btn.active .filter-count {
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
color: var(--fs-accent);
background: rgba(91, 74, 138, 0.2);
color: var(--color-primary);
}
.filter-tag { font-size: 0.78rem; }
@@ -772,7 +683,7 @@ onUnmounted(() => {
gap: 10px;
padding: 12px 20px;
flex-shrink: 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
}
.search-wrap {
flex: 1;
@@ -783,27 +694,27 @@ onUnmounted(() => {
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--fs-text-tertiary);
color: var(--color-muted);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 7px 12px 7px 32px;
border-radius: 8px;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
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);
font-size: 0.88rem;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--fs-accent); }
.search-input:focus { border-color: var(--color-primary); }
.sort-select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
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);
font-size: 0.85rem;
cursor: pointer;
outline: none;
@@ -824,9 +735,9 @@ onUnmounted(() => {
.k-card {
position: relative;
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
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);
padding: 14px;
cursor: pointer;
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
@@ -838,12 +749,12 @@ onUnmounted(() => {
}
.k-card:hover {
transform: translateY(-2px);
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);
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);
}
/* Type-specific card DNA */
.k-card--note { border-color: color-mix(in srgb, var(--fs-accent) 20%, transparent); }
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
/* Top gradient bars */
@@ -858,7 +769,7 @@ onUnmounted(() => {
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, var(--fs-accent), #7A6DA8);
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
}
.k-card--task::before {
right: 0;
@@ -877,7 +788,7 @@ onUnmounted(() => {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
@@ -894,7 +805,7 @@ onUnmounted(() => {
}
.k-card-snippet {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-muted);
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
@@ -914,9 +825,9 @@ onUnmounted(() => {
padding: 1px 6px;
border-radius: 8px;
background: rgba(255,255,255,0.05);
color: var(--fs-text-tertiary);
color: var(--color-muted);
}
.k-card-date { font-size: 0.72rem; color: var(--fs-text-secondary); white-space: nowrap; opacity: 0.7; }
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
/* Only rendered for a record another user owns, so an unmarked card is
unambiguously the viewer's own. */
.shared-tag {
@@ -924,8 +835,8 @@ onUnmounted(() => {
padding: 0.08rem 0.35rem;
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
color: var(--fs-text-secondary);
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
color: var(--color-text-secondary);
}
/* ── Task card ──────────────────────────────────────────── */
@@ -945,10 +856,10 @@ onUnmounted(() => {
border-radius: 8px;
font-weight: 500;
}
.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; }
.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; }
.priority-badge {
font-size: 0.7rem;
@@ -956,16 +867,16 @@ onUnmounted(() => {
border-radius: 8px;
font-weight: 500;
}
.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); }
.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); }
.task-due {
font-size: 0.78rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.task-overdue {
color: var(--fs-overdue);
color: var(--color-overdue);
font-weight: 500;
}
@@ -977,7 +888,7 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--fs-text-tertiary);
color: var(--color-muted);
text-align: center;
gap: 6px;
}
@@ -985,7 +896,7 @@ onUnmounted(() => {
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-size: 1rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
opacity: 0.85;
}
@@ -999,17 +910,17 @@ onUnmounted(() => {
}
.sentinel-loading {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-muted);
}
/* ── Graph panel ─────────────────────────────────────────── */
.graph-panel {
width: 500px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
display: flex;
flex-direction: column;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
transition: width 0.2s ease;
}
.graph-panel.expanded {
@@ -1022,83 +933,12 @@ onUnmounted(() => {
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 500;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
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>
+82 -7
View File
@@ -3,7 +3,6 @@ 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();
@@ -31,7 +30,12 @@ async function handleSubmit() {
const redirect = (route.query.redirect as string) || "/";
router.push(redirect);
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Login failed");
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";
}
} finally {
submitting.value = false;
}
@@ -108,21 +112,92 @@ 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(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
border-top: 1px solid var(--fs-border-color);
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);
}
.forgot-link {
text-align: right;
@@ -130,9 +205,9 @@ function loginWithOAuth() {
font-size: 0.85rem;
}
.forgot-link a {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.forgot-link a:hover {
color: var(--fs-accent);
color: var(--color-primary);
}
</style>
+49 -41
View File
@@ -3,7 +3,6 @@ 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();
@@ -99,6 +98,17 @@ 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 {
@@ -200,7 +210,7 @@ function clearFilters() {
:class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)"
>
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td class="cell-time">{{ formatTime(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }}
@@ -253,9 +263,9 @@ function clearFilters() {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
@@ -282,23 +292,23 @@ function clearFilters() {
.stat-count {
font-size: 1.5rem;
font-weight: 700;
color: var(--fs-text-primary);
color: var(--color-text);
}
.stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.stat-audit {
color: var(--fs-accent);
color: var(--color-primary);
}
.stat-usage {
color: var(--fs-success);
color: var(--color-success);
}
.stat-error {
color: var(--fs-error);
color: var(--color-danger);
}
/* Filters */
@@ -311,10 +321,10 @@ function clearFilters() {
.filter-input,
.filter-date {
padding: 0.4rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
}
.filter-select {
@@ -332,7 +342,7 @@ function clearFilters() {
.loading-msg,
.empty-msg {
text-align: center;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
@@ -346,13 +356,13 @@ function clearFilters() {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.logs-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
}
.logs-table tbody tr:last-child td {
@@ -363,18 +373,18 @@ function clearFilters() {
transition: background 0.1s;
}
.log-row:hover {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.row-expanded {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.cell-time {
white-space: nowrap;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.8rem;
}
.cell-user {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.cell-action {
max-width: 280px;
@@ -389,22 +399,22 @@ function clearFilters() {
.cell-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
white-space: nowrap;
}
.cell-duration {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.8rem;
white-space: nowrap;
}
.detail-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.text-error {
color: var(--fs-error);
color: var(--color-danger);
}
/* Category badges */
@@ -415,19 +425,19 @@ function clearFilters() {
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.35rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
}
.cat-audit {
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.cat-usage {
color: var(--fs-success);
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 15%, transparent);
}
.cat-error {
color: var(--fs-error);
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
}
/* Method tag */
@@ -438,24 +448,22 @@ function clearFilters() {
font-family: monospace;
padding: 0.05rem 0.25rem;
border-radius: 3px;
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
background: var(--color-bg-secondary);
color: var(--color-text-muted);
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(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.detail-json {
margin: 0;
padding: 0.75rem;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
+35 -35
View File
@@ -633,13 +633,13 @@ onUnmounted(() => assist.clearSelection());
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.editor-tabs {
display: inline-flex;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
background: var(--color-bg);
border: 1px solid var(--color-border);
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(--fs-text-tertiary);
color: var(--color-text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tab:hover { color: var(--fs-text-primary); }
.tab:hover { color: var(--color-text); }
.tab.active {
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
background: var(--color-surface);
color: var(--color-text);
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(--fs-text-tertiary);
color: var(--color-text-muted);
}
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
background: var(--color-bg-card);
min-height: 200px;
}
@@ -699,7 +699,7 @@ onUnmounted(() => assist.clearSelection());
.note-sidebar {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
border-left: 1px solid var(--color-border);
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(--fs-radius-sm);
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
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);
font-size: 0.82rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.sb-select:focus, .sb-input:focus {
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
/* 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(--fs-accent);
border-radius: var(--fs-radius-sm);
color: var(--fs-accent);
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
cursor: pointer;
font-family: inherit;
}
.btn-link-all:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
.btn-link-all:hover { background: var(--color-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(--fs-accent);
color: var(--color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-suggest-count {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
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(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.btn-apply-link:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* Writing Assistant section */
.assist-section {
@@ -801,7 +801,7 @@ onUnmounted(() => assist.clearSelection());
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-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(--fs-accent);
color: var(--color-primary);
}
.prompt-editor {
width: 100%;
min-height: 60vh;
margin-top: 8px;
padding: 14px 16px;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--fs-surface-hover);
color: var(--fs-text-primary);
background: var(--color-surface);
color: var(--color-text);
/* Prompts are plain markdown — a code-style editor, not rich text. */
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
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(--fs-accent);
border-color: var(--color-primary);
}
/* 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(--fs-border-color);
border-top: 1px solid var(--color-border);
overflow-y: visible;
}
}
+19 -19
View File
@@ -340,7 +340,7 @@ async function convertToTask() {
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.meta-item {
@@ -359,7 +359,7 @@ async function convertToTask() {
}
.backlinks {
margin-top: 2.5rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
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(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
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(--fs-radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--radius-md);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
text-decoration: none;
color: var(--fs-text-primary);
color: var(--color-text);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-card:hover {
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
color: var(--fs-accent);
color: var(--color-primary);
}
.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(--fs-accent) 12%, transparent);
color: var(--fs-accent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
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);
}
.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(--fs-radius-sm);
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 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(--fs-radius-lg); }
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--radius-md); }
.skel-meta { height: 0.85rem; width: 40%; }
.skel-line { height: 0.9rem; }
.skel-line--short { width: 55%; }
+98 -72
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { emptyChoices, type InceptionChoices } from "@/api/inception";
import InceptionCard from "@/components/InceptionCard.vue";
import { apiGet, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { milestoneColor } from "@/utils/palette";
@@ -49,9 +47,6 @@ 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;
@@ -78,8 +73,6 @@ function openNewProjectModal() {
newTitle.value = "";
newDescription.value = "";
newGoal.value = "";
modalStep.value = 1;
newInception.value = emptyChoices();
showNewProjectModal.value = true;
}
@@ -95,15 +88,13 @@ 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 (e: unknown) {
toast.show(apiErrorMessage(e, "Failed to create project"), "error");
} catch {
toast.show("Failed to create project", "error");
} finally {
creating.value = false;
}
@@ -275,9 +266,8 @@ 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">{{ 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">
<h3 class="modal-title">New Project</h3>
<div class="modal-field">
<label>Title <span class="required">*</span></label>
<input
v-model="newTitle"
@@ -285,11 +275,11 @@ function overallPct(project: Project): { total: number; pct: number } {
class="modal-input"
placeholder="Project title"
autofocus
@keydown.enter="modalStep = 2"
@keydown.enter="createProject"
@keydown.escape="closeModal"
/>
</div>
<div v-if="modalStep === 1" class="modal-field">
<div class="modal-field">
<label>Goal</label>
<input
v-model="newGoal"
@@ -299,7 +289,7 @@ function overallPct(project: Project): { total: number; pct: number } {
@keydown.escape="closeModal"
/>
</div>
<div v-if="modalStep === 1" class="modal-field">
<div class="modal-field">
<label>Description</label>
<textarea
v-model="newDescription"
@@ -311,17 +301,7 @@ 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"
@@ -337,9 +317,9 @@ function overallPct(project: Project): { total: number; pct: number } {
<style scoped>
.projects-list {
max-width: var(--fs-layout-page-max);
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
@@ -360,7 +340,7 @@ function overallPct(project: Project): { total: number; pct: number } {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
}
.tab-btn {
@@ -370,40 +350,40 @@ function overallPct(project: Project): { total: number; pct: number } {
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-family: inherit;
margin-bottom: -1px;
}
.tab-btn:hover {
color: var(--fs-accent);
color: var(--color-primary);
}
.tab-btn.active {
color: var(--fs-accent);
border-bottom-color: var(--fs-accent);
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 500;
}
.loading-msg,
.error-msg {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
}
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--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); }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-action-primary); border-radius: var(--radius-sm); color: var(--color-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
.empty-action:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
.skeleton-card {
height: 140px;
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%);
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@@ -424,9 +404,9 @@ function overallPct(project: Project): { total: number; pct: number } {
}
.project-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
@@ -435,7 +415,7 @@ function overallPct(project: Project): { total: number; pct: number } {
gap: 0.5rem;
}
.project-card:hover {
border-color: var(--fs-accent);
border-color: var(--color-primary);
box-shadow: 0 2px 8px var(--color-shadow);
transform: translateY(-2px);
}
@@ -449,7 +429,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.project-title {
font-size: 1rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
min-width: 0;
flex: 1;
word-break: break-word;
@@ -466,32 +446,32 @@ function overallPct(project: Project): { total: number; pct: number } {
white-space: nowrap;
}
.status-active {
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
color: var(--fs-success);
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.status-completed {
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.status-archived {
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
.project-goal {
font-size: 0.875rem;
color: var(--fs-text-primary);
color: var(--color-text);
margin: 0;
line-height: 1.4;
}
.field-label {
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.project-desc {
font-size: 0.82rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.45;
}
@@ -506,18 +486,18 @@ function overallPct(project: Project): { total: number; pct: number } {
.overall-bar-track {
flex: 1;
height: 6px;
background: var(--fs-border-color);
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--fs-accent);
background: var(--color-primary);
transition: width 0.3s ease;
}
.overall-bar-pct {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
@@ -537,7 +517,7 @@ function overallPct(project: Project): { total: number; pct: number } {
font-size: 0.72rem;
}
.milestone-bar-label {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
min-width: 0;
flex: 0 0 30%;
overflow: hidden;
@@ -547,7 +527,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.milestone-bar-track {
flex: 1;
height: 5px;
background: var(--fs-border-color);
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
@@ -557,7 +537,7 @@ function overallPct(project: Project): { total: number; pct: number } {
transition: width 0.3s ease;
}
.milestone-bar-pct {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
@@ -566,7 +546,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(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: var(--fs-size-tiny);
padding-top: 0.15rem;
}
@@ -576,11 +556,27 @@ function overallPct(project: Project): { total: number; pct: number } {
}
.meta-date {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* 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;
@@ -597,18 +593,18 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-field label {
font-size: 0.875rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
}
.required {
color: var(--fs-error);
color: var(--color-danger);
}
.modal-input,
.modal-textarea {
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);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
@@ -617,11 +613,41 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-input:focus,
.modal-textarea:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.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 {
File diff suppressed because it is too large Load Diff
+109 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { apiGet, apiPost } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
@@ -68,7 +68,12 @@ async function handleSubmit() {
await authStore.checkAuth();
router.push("/");
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Registration failed");
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";
}
} finally {
submitting.value = false;
}
@@ -80,9 +85,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="auth-loading">Validating invitation...</div>
<div v-if="validating" class="loading-msg">Validating invitation...</div>
<div v-else-if="!token || !valid" class="auth-note">
<div v-else-if="!token || !valid" class="error-block">
<p>This invitation link is invalid or has expired.</p>
<p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link>
@@ -152,4 +157,103 @@ async function handleSubmit() {
</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;
}
.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>
+104 -5
View File
@@ -3,7 +3,6 @@ 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();
@@ -40,7 +39,12 @@ async function handleSubmit() {
await authStore.register(username.value, password.value, email.value || undefined);
router.push("/");
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Registration failed");
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";
}
} finally {
submitting.value = false;
}
@@ -52,9 +56,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="auth-loading">Checking registration status...</div>
<div v-if="checking" class="loading-msg">Checking registration status...</div>
<div v-else-if="!authStore.registrationOpen" class="auth-note">
<div v-else-if="!authStore.registrationOpen" class="closed-msg">
<p>Registration is currently closed.</p>
<p>Contact an administrator to get an account.</p>
<p class="auth-footer">
@@ -126,4 +130,99 @@ async function handleSubmit() {
</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;
}
.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>
+108 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
import { apiPost, apiErrorMessage } from "@/api/client";
import { apiPost } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue";
const route = useRoute();
@@ -35,7 +35,12 @@ async function handleSubmit() {
});
success.value = true;
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Failed to reset password");
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";
}
} finally {
submitting.value = false;
}
@@ -47,7 +52,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="auth-note">
<div v-if="!token" class="error-block">
<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>
@@ -89,7 +94,7 @@ async function handleSubmit() {
</form>
</template>
<div v-else class="auth-note">
<div v-else class="success-msg">
<p>Your password has been reset successfully.</p>
<p>You can now sign in with your new password.</p>
</div>
@@ -101,4 +106,102 @@ async function handleSubmit() {
</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;
}
.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>
+2 -2
View File
@@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute);
grid-template-columns: 280px 300px 1fr;
height: 100vh;
gap: 1px;
background: var(--fs-border-color);
background: var(--color-border, #2a2a2e);
}
.pane.empty {
background: var(--fs-surface-hover);
background: var(--color-surface, #18181b);
padding: 1rem;
opacity: 0.6;
font-style: italic;
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -106,11 +106,11 @@ onMounted(async () => {
font-size: 1.8rem;
font-weight: 700;
margin: 0;
color: var(--fs-text-primary);
color: var(--color-text);
}
.loading-state {
color: var(--fs-text-tertiary);
color: var(--color-muted);
padding: 2rem;
text-align: center;
}
@@ -124,7 +124,7 @@ onMounted(async () => {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--fs-text-tertiary);
color: var(--color-muted);
margin: 0 0 0.75rem;
}
@@ -136,9 +136,9 @@ onMounted(async () => {
.shared-card {
display: flex;
background: var(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-xl);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
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(--fs-border-color);
background: var(--color-border);
}
.card-body {
@@ -163,7 +163,7 @@ onMounted(async () => {
.card-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--fs-text-primary);
color: var(--color-text);
margin-bottom: 0.3rem;
white-space: nowrap;
overflow: hidden;
@@ -179,12 +179,12 @@ onMounted(async () => {
.card-owner {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-muted);
}
.card-desc {
font-size: 0.82rem;
color: var(--fs-text-tertiary);
color: var(--color-muted);
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(--fs-surface-hover);
border: 1px solid var(--fs-border-color);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
text-decoration: none;
transition: background 0.1s;
}
.shared-row:hover {
background: var(--fs-surface-hover);
background: var(--color-hover);
}
.row-icon {
font-size: 0.9rem;
flex-shrink: 0;
color: var(--fs-text-tertiary);
color: var(--color-muted);
}
.row-title {
flex: 1;
font-size: 0.9rem;
color: var(--fs-text-primary);
color: var(--color-text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -229,7 +229,7 @@ onMounted(async () => {
}
.row-owner {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-muted);
white-space: nowrap;
}
@@ -242,12 +242,12 @@ onMounted(async () => {
border-radius: 4px;
white-space: nowrap;
}
.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); }
.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); }
.empty-msg {
color: var(--fs-text-tertiary);
color: var(--color-muted);
font-size: 0.88rem;
margin: 0;
padding: 1rem 0;
+33 -33
View File
@@ -205,7 +205,7 @@ async function confirmDelete() {
.snippet-detail {
max-width: 820px;
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
@@ -213,20 +213,20 @@ async function confirmDelete() {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--fs-accent);
color: var(--color-primary);
}
.state-msg {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
margin-top: 1rem;
}
@@ -239,7 +239,7 @@ async function confirmDelete() {
}
.snippet-name {
margin: 0;
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
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(--fs-text-secondary);
color: var(--color-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(--fs-text-tertiary);
border-left: 3px solid var(--color-text-muted);
border-radius: 6px;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
font-size: 0.85rem;
line-height: 1.5;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.meta-grid {
@@ -280,23 +280,23 @@ async function confirmDelete() {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding-top: 0.15rem;
}
.meta-grid dd {
margin: 0;
font-size: 0.9rem;
color: var(--fs-text-primary);
color: var(--color-text);
min-width: 0;
}
.meta-grid code,
.tag-row + * code {
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 0.82rem;
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
padding: 0.08rem 0.35rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
word-break: break-all;
}
.location-list {
@@ -317,7 +317,7 @@ async function confirmDelete() {
align-items: baseline;
}
.merged-hint {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
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(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
cursor: pointer;
}
.unmerge-btn:hover:not(:disabled) {
color: var(--fs-text-primary);
border-color: var(--fs-text-tertiary);
color: var(--color-text);
border-color: var(--color-text-muted);
}
.unmerge-btn:disabled {
opacity: 0.6;
@@ -344,30 +344,30 @@ async function confirmDelete() {
}
.unmerge-na {
font-size: 0.72rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
/* Cursor cues that the explanation is in the tooltip. */
cursor: help;
}
.code-block {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--fs-surface-page);
background: var(--color-bg);
}
.code-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.5rem 0.4rem 0.85rem;
border-bottom: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.code-lang {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.btn-copy {
padding: 0.2rem 0.65rem;
@@ -379,10 +379,10 @@ async function confirmDelete() {
overflow-x: auto;
}
.code-block code {
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 0.85rem;
line-height: 1.6;
color: var(--fs-text-primary);
color: var(--color-text);
white-space: pre;
}
@@ -396,9 +396,9 @@ async function confirmDelete() {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
@media (max-width: 600px) {
+37 -37
View File
@@ -365,7 +365,7 @@ function cancel() {
.snippet-editor {
max-width: 760px;
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.snippet-editor h1 {
@@ -376,19 +376,19 @@ function cancel() {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--fs-accent);
color: var(--color-primary);
}
.state-msg {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.9rem;
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
}
@@ -415,27 +415,27 @@ function cancel() {
.location-set legend {
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
}
.required {
color: var(--fs-error);
color: var(--color-danger);
}
.hint {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0.1rem 0 0;
}
.hint-inline {
font-weight: 400;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.input {
padding: 0.5rem 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);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
@@ -443,11 +443,11 @@ function cancel() {
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.mono {
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
}
.code-area {
resize: vertical;
@@ -459,8 +459,8 @@ function cancel() {
}
.location-set {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.85rem 1rem 1rem;
margin: 0;
}
@@ -477,32 +477,32 @@ function cancel() {
.loc-remove {
width: 2rem;
height: 2rem;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
background: transparent;
color: var(--fs-text-tertiary);
border-radius: var(--fs-radius-sm);
color: var(--color-text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.loc-remove:hover {
border-color: var(--fs-error);
color: var(--fs-error);
border-color: var(--color-danger);
color: var(--color-danger);
}
.loc-add {
margin-top: 0.15rem;
padding: 0.35rem 0.7rem;
border: 1px dashed var(--fs-border-color);
border: 1px dashed var(--color-border);
background: transparent;
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
}
.loc-add:hover {
border-color: var(--fs-accent);
color: var(--fs-accent);
border-color: var(--color-primary);
color: var(--color-primary);
}
@media (max-width: 600px) {
@@ -514,7 +514,7 @@ function cancel() {
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
}
.systems {
display: flex;
@@ -528,11 +528,11 @@ function cancel() {
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
color: var(--fs-text-primary);
color: var(--color-text);
cursor: pointer;
}
.system-opt input {
accent-color: var(--fs-accent);
accent-color: var(--color-primary);
cursor: pointer;
}
@@ -541,25 +541,25 @@ function cancel() {
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-left: 3px solid var(--fs-warning);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning, var(--color-primary));
border-radius: 8px;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.duplicate-title {
margin: 0;
font-size: 0.85rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
}
.duplicate-body {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.duplicate-body a {
color: var(--fs-accent);
color: var(--color-primary);
}
.duplicate-actions {
display: flex;
+118 -73
View File
@@ -519,9 +519,9 @@ function usageTitle(s: SnippetListItem): string {
<style scoped>
.snippets-list {
max-width: var(--fs-layout-page-max);
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
@@ -536,7 +536,7 @@ function usageTitle(s: SnippetListItem): string {
}
.page-sub {
margin: 0 0 1.25rem;
color: var(--fs-text-secondary);
color: var(--color-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(--fs-accent);
color: var(--fs-accent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.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(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
box-sizing: border-box;
}
.loc-input-wide {
@@ -583,46 +583,46 @@ function usageTitle(s: SnippetListItem): string {
}
.loc-input::placeholder {
font-family: inherit;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.loc-input:focus {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.loc-clear {
padding: 0.4rem 0.65rem;
border: none;
background: transparent;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
text-decoration: underline;
}
.loc-clear:hover {
color: var(--fs-text-primary);
color: var(--color-text);
}
.search-input {
width: 100%;
max-width: 420px;
padding: 0.5rem 0.8rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
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(--fs-text-tertiary);
color: var(--color-text-muted);
}
.empty-icon {
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
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(--fs-text-secondary);
color: var(--color-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(--fs-action-primary);
border-radius: var(--fs-radius-sm);
color: var(--fs-action-primary);
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(--fs-action-primary);
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
@@ -675,8 +675,8 @@ function usageTitle(s: SnippetListItem): string {
}
.skeleton-card {
height: 96px;
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%);
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 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(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
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(--fs-accent);
border-color: var(--color-primary);
box-shadow: 0 2px 8px var(--color-shadow);
transform: translateY(-2px);
}
.snippet-card:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.card-header {
@@ -716,11 +716,11 @@ function usageTitle(s: SnippetListItem): string {
.snippet-name {
font-size: 0.95rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
min-width: 0;
flex: 1;
word-break: break-word;
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
}
/* 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(--fs-accent) 15%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.snippet-when {
font-size: 0.83rem;
color: var(--fs-text-secondary);
color: var(--color-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(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* 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(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
/* Near-duplicate report */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--fs-surface-hover);
background: var(--color-surface-alt, var(--color-surface));
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.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(--fs-border-color);
border-top: 1px solid var(--color-border);
}
.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(--fs-text-tertiary) 12%, transparent);
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
/* Long snippet names must not push the row into a horizontal scroll. */
overflow-wrap: anywhere;
}
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
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(--fs-error) 15%, transparent);
color: var(--fs-error);
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
color: var(--color-danger, #b91c1c);
}
.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(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
color: var(--fs-warning);
background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
color: var(--color-warning, #b45309);
}
/* 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(--fs-accent);
border-color: var(--color-primary);
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(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
transition: background 0.12s, border-color 0.12s;
}
.select-box.on {
background: var(--fs-accent);
border-color: var(--fs-accent);
background: var(--color-primary);
border-color: var(--color-primary);
}
.select-bar {
@@ -885,26 +885,41 @@ function usageTitle(s: SnippetListItem): string {
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
}
.select-count {
font-weight: 500;
font-size: 0.9rem;
color: var(--fs-text-primary);
color: var(--color-text);
}
.select-hint {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
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;
@@ -916,7 +931,7 @@ function usageTitle(s: SnippetListItem): string {
.modal-desc {
margin: 0;
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
line-height: 1.5;
}
.merge-choices {
@@ -929,18 +944,18 @@ function usageTitle(s: SnippetListItem): string {
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.merge-choice.chosen {
border-color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.merge-choice-name {
flex: 1;
min-width: 0;
font-family: var(--fs-font-mono);
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 0.85rem;
word-break: break-word;
}
@@ -948,9 +963,39 @@ function usageTitle(s: SnippetListItem): string {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
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 {
+60 -40
View File
@@ -41,6 +41,7 @@ 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");
@@ -302,6 +303,7 @@ 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;
@@ -830,7 +832,7 @@ useEditorGuards(dirty, save);
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
/* .task-main is a flex column; without flex-shrink: 0, long body content
@@ -852,7 +854,7 @@ useEditorGuards(dirty, save);
.task-sidebar {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
border-left: 1px solid var(--color-border);
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -866,21 +868,21 @@ useEditorGuards(dirty, save);
background: none;
border: none;
cursor: pointer;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
flex-shrink: 0;
}
.btn-clear-parent:hover { color: var(--fs-error); }
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--color-shadow);
z-index: 50;
max-height: 200px;
@@ -890,21 +892,21 @@ useEditorGuards(dirty, save);
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--fs-text-primary);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parent-dropdown-item:hover { background: var(--fs-surface-raised); }
.parent-empty { color: var(--fs-text-tertiary); cursor: default; }
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
.parent-empty { color: var(--color-text-muted); cursor: default; }
.parent-empty:hover { background: none; }
/* Sub-tasks */
.subtasks-section {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.5rem 0.65rem;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.subtasks-header {
display: flex;
@@ -915,23 +917,23 @@ useEditorGuards(dirty, save);
.subtasks-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
.subtask-title {
font-size: 0.83rem;
color: var(--fs-text-primary);
color: var(--color-text);
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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-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-add-row {
display: flex;
align-items: center;
@@ -941,19 +943,19 @@ useEditorGuards(dirty, save);
.subtask-input {
flex: 1;
padding: 0.25rem 0.4rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.83rem;
font-family: inherit;
}
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
background: var(--color-bg-card);
min-height: 200px;
}
.main-diff {
@@ -963,9 +965,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(--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); }
.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); }
/* Writing Assistant section (in sidebar) */
.assist-section {
@@ -976,7 +978,7 @@ useEditorGuards(dirty, save);
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -1007,11 +1009,11 @@ useEditorGuards(dirty, save);
font-size: 0.75rem;
}
.sb-ts-label {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
flex-shrink: 0;
}
.sb-ts-value {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-align: right;
}
@@ -1022,7 +1024,7 @@ useEditorGuards(dirty, save);
.task-sidebar {
width: 100%;
border-left: none;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
overflow-y: visible;
}
}
@@ -1035,13 +1037,13 @@ useEditorGuards(dirty, save);
margin: 0.5rem 0 0.25rem;
}
.task-goal-label {
font-family: var(--fs-font-display);
font-family: var(--font-display, "Fraunces", serif);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fs-text-tertiary);
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
}
.task-goal-input {
width: 100%;
@@ -1051,14 +1053,32 @@ useEditorGuards(dirty, save);
font: inherit;
font-size: 0.95rem;
line-height: 1.4;
color: var(--fs-text-primary);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
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);
}
.task-goal-input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary, #6366f1);
}
/* ── 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>
+62 -42
View File
@@ -365,6 +365,13 @@ 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"
@@ -475,7 +482,7 @@ const subTaskProgress = computed(() => {
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.meta-item {
@@ -494,10 +501,10 @@ const subTaskProgress = computed(() => {
}
.due-date {
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.due-date.overdue {
color: var(--fs-overdue);
color: var(--color-overdue);
font-weight: 500;
}
.task-meta-row {
@@ -508,10 +515,10 @@ const subTaskProgress = computed(() => {
}
.task-meta-item {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.task-meta-recurrence {
color: var(--fs-accent);
color: var(--color-primary);
font-weight: 500;
}
.tags {
@@ -524,7 +531,7 @@ const subTaskProgress = computed(() => {
/* Sub-tasks */
.subtasks {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.subtasks-header {
@@ -540,21 +547,21 @@ const subTaskProgress = computed(() => {
}
.subtasks-progress {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.subtasks-pct {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.subtasks-track {
height: 4px;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
border-radius: 2px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.subtasks-fill {
height: 100%;
background: var(--fs-status-done);
background: var(--color-status-done, #22c55e);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -571,10 +578,10 @@ const subTaskProgress = computed(() => {
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
}
.subtask-row:hover {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
}
.sub-dot {
flex-shrink: 0;
@@ -592,21 +599,21 @@ const subTaskProgress = computed(() => {
}
.dot-todo {
background: transparent;
border: 2px solid var(--fs-text-tertiary);
border: 2px solid var(--color-text-muted);
}
.dot-in-progress {
background: var(--fs-status-in-progress);
background: var(--color-status-in-progress, #3b82f6);
}
.dot-done {
background: var(--fs-status-done);
background: var(--color-status-done, #22c55e);
}
.dot-cancelled {
background: var(--fs-text-tertiary);
background: var(--color-text-muted, #6b7280);
}
.sub-title {
flex: 1;
font-size: 0.9rem;
color: var(--fs-text-primary);
color: var(--color-text);
text-decoration: none;
min-width: 0;
overflow: hidden;
@@ -614,21 +621,21 @@ const subTaskProgress = computed(() => {
white-space: nowrap;
}
.sub-title:hover {
color: var(--fs-accent);
color: var(--color-primary);
}
.sub-title.sub-done {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-decoration: line-through;
}
.sub-due {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
flex-shrink: 0;
}
.backlinks {
margin-top: 2.5rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
padding-top: 1.25rem;
}
.backlinks-heading {
@@ -639,14 +646,14 @@ const subTaskProgress = computed(() => {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.4;
@@ -661,18 +668,18 @@ const subTaskProgress = computed(() => {
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--radius-md);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
text-decoration: none;
color: var(--fs-text-primary);
color: var(--color-text);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-card:hover {
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: var(--fs-accent);
color: var(--color-primary);
}
.backlink-type-badge {
font-size: 0.68rem;
@@ -684,9 +691,9 @@ const subTaskProgress = computed(() => {
flex-shrink: 0;
}
.badge-note {
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);
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);
}
.badge-task {
background: color-mix(in srgb, #f59e0b 12%, transparent);
@@ -716,12 +723,12 @@ const subTaskProgress = computed(() => {
.skel-meta,
.skel-badges,
.skel-line {
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
@@ -733,7 +740,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(--fs-radius-lg); }
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--radius-md); }
.skel-meta { height: 0.85rem; width: 45%; }
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
.skel-line { height: 0.9rem; }
@@ -742,26 +749,39 @@ const subTaskProgress = computed(() => {
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
.task-goal-display {
border-left: 2px solid var(--fs-border-color);
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
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(--fs-font-display);
font-family: var(--font-display, "Fraunces", serif);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fs-text-tertiary);
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
margin: 0 0 0.25rem;
}
.goal-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.45;
color: var(--fs-text-primary);
color: var(--color-text, inherit);
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>
+3 -3
View File
@@ -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(--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); }
.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); }
</style>
+53 -36
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete } 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;
@@ -70,7 +69,12 @@ async function sendInvite() {
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
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");
}
} finally {
sendingInvite.value = false;
}
@@ -124,11 +128,24 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted");
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
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");
}
} finally {
deleting.value = null;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
@@ -195,8 +212,8 @@ async function deleteUser(userId: number) {
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</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="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button
class="btn-ghost btn-compact"
@@ -238,7 +255,7 @@ async function deleteUser(userId: number) {
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td>
<td class="cell-actions">
<template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span>
@@ -280,9 +297,9 @@ async function deleteUser(userId: number) {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
@@ -300,16 +317,16 @@ async function deleteUser(userId: number) {
.invite-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.invite-input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.invite-list {
margin-top: 1rem;
@@ -317,7 +334,7 @@ async function deleteUser(userId: number) {
.invite-list h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
/* Registration toggle */
@@ -335,33 +352,33 @@ async function deleteUser(userId: number) {
font-size: 0.95rem;
}
.text-success {
color: var(--fs-success);
color: var(--color-success);
}
.text-muted {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* 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(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-toggle-close:hover:not(:disabled) {
border-color: var(--fs-warning);
color: var(--fs-warning);
border-color: var(--color-warning);
color: var(--color-warning);
}
/* Users table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
@@ -375,13 +392,13 @@ async function deleteUser(userId: number) {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.users-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.users-table tbody tr:last-child td {
@@ -391,10 +408,10 @@ async function deleteUser(userId: number) {
font-weight: 600;
}
.cell-email {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.cell-date {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.85rem;
}
.cell-actions {
@@ -409,21 +426,21 @@ async function deleteUser(userId: number) {
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
}
.role-admin {
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.role-user {
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
color: var(--color-text-muted);
background: var(--color-bg-secondary);
}
/* Action buttons */
.you-label {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
@media (max-width: 768px) {

Some files were not shown because too many files have changed in this diff Show More