Compare commits

..
Author SHA1 Message Date
Renovate Bot 5bf55fc488 Add renovate.json 2026-08-04 04:01:43 +00:00
bvandeusen fefae606ed Fix the projects-page pool exhaustion, and cap milestone bars at 10 (#95)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / integration (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 24s
2026-08-02 19:43:00 -04:00
bvandeusen da6bb815bb Buttons: one definition, aligned to the design system — plus local prior-art recall (#94)
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 20s
2026-08-02 18:50:04 -04:00
bvandeusen 8288c6e4a7 Design-system delivery, the contrast fix, recall scoping, and backup coverage (#93)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 23s
CI & Build / Python tests (push) Successful in 57s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 17s
2026-07-31 23:21:25 -04:00
bvandeusen 716f227bc7 theme.css generated from the design system (#92)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 21s
2026-07-31 12:45:06 -04:00
bvandeusen d1d335e293 Formulas — derived tokens that follow their source (#91)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 19s
2026-07-31 09:54:10 -04:00
bvandeusen 7872e7d9ec Drop the rulebook import — a migration, not a product feature (#90)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
2026-07-31 09:49:48 -04:00
bvandeusen 15eae532bd Fix the dead create button on a fresh install, and make Design one surface (#89)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 22s
2026-07-31 08:32:07 -04:00
bvandeusen f00e9747ad Design systems as records — the stylesheet Scribe holds (#88)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 17s
2026-07-30 23:35:26 -04:00
bvandeusen 1adf57739d Cross-language prior-art labelling + close the surfaced→pulled loop on get_task (#87)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 19s
Closes #2244 and #2245. Prior-art hits in a different language than the file
being written are now labelled and explained rather than surfaced bare, and
get_task records a pull so auto-inject's pull-through stops reading near-zero for
the kind it mostly surfaces.

No migration, no plugin manifest bump — server-side only.
2026-07-30 11:03:57 -04:00
bvandeusen a36837c96a Write-path semantic arm: query snippets by concept, not raw code (#86)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 18s
Implements #2242. The semantic arm now queries with what the code says it is FOR
— declarations plus the first docstring / JSDoc / leading comment — instead of the
raw payload, because snippet documents are prose-forward: 0.823 vs 0.743, with
double the separation from the noise floor.

No doc means no rewrite (a bare identifier measured 0.671, worse than the code),
and the 48-char floor still judges the raw payload before the rewrite.

No migration, no plugin manifest bump — server-side only.
2026-07-30 10:21:19 -04:00
bvandeusen 0550bf4687 Write-path prior-art precision: own threshold + payload floor (#85)
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 21s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python tests (push) Successful in 42s
Closes #2223. Gives the write-path semantic arm its own similarity threshold
(kb_writepath_threshold, default 0.68) and a 48-non-whitespace-char payload
floor, so unrelated code no longer reads as prior art. Auto-inject keeps 0.55.

No migration, no plugin manifest bump — server-side only.
2026-07-30 08:07:47 -04:00
bvandeusen 327b8a99a4 ci(plugin): gate the path that ships straight to users, and fix one more line-oriented cap (#84)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 20s
2026-07-29 08:55:58 -04:00
bvandeusen f039a46dae fix(plugin): bump to 0.1.19 so the hook fixes actually reach installs (#83) 2026-07-28 20:19:24 -04:00
bvandeusen 3284ac67dd Drafter hardening (milestone #232) — plugin hook fix, usage signal, drift check, duplicate finder, un-merge (#82)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 56s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 32s
CI & Build / Build & push image (push) Successful in 20s
2026-07-28 20:10:42 -04:00
bvandeusen 6153231f9c fix(ci+docker): install from uv.lock — stop resolving dependencies at build time (#81)
CI & Build / Python lint (push) Successful in 7s
CI & Build / integration (push) Successful in 39s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 21s
CI & Build / Python tests (push) Successful in 1m0s
2026-07-28 13:04:24 -04:00
bvandeusen d5d0b012b1 fix(deps): cap mcp below 2.0 — un-reds main (#80)
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 41s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 19s
2026-07-28 10:18:23 -04:00
bvandeusen ca94c332ee Drafter hardening: reverse lookup by location + the write-path trigger (#79)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 30s
CI & Build / Python tests (push) Failing after 30s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Build & push image (push) Has been skipped
2026-07-28 10:12:48 -04:00
bvandeusen a8f152b7d4 Merge pull request 'Drafter reuse-recall (both halves) + the multi-user ACL work it exposed' (#78) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 17s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 52s
CI & Build / Build & push image (push) Successful in 17s
Drafter reuse-recall layer (milestones #227, #231) plus the sharing/ACL
corrections reviewing it exposed: two visibility scopes, provenance on every
surface that hands over another user's record, and write access aligned with the
share model. CI green on 4b5d900 (run 2901).
2026-07-26 00:25:27 -04:00
bvandeusen 71d65442d3 Merge pull request 'Narrow Scribe to a work system-of-record — remove calendar + person/place/list surfaces' (#77) from dev into main
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 12s
2026-07-19 18:24:40 -04:00
bvandeusen 05f0cc2c4b Merge pull request 'fix(plugin): keep always-on rules alive across compaction (0.1.11)' (#76) from dev into main 2026-06-30 23:01:28 -04:00
bvandeusen 03772ff424 Merge pull request 'fix(plugin): bump to 0.1.10 to ship the auto-inject hook' (#75) from dev into main 2026-06-22 22:08:25 -04:00
bvandeusen 058e8794af Merge pull request 'KB injection tuning: pgvector substrate + retrieval telemetry + title-first auto-inject' (#74) from dev into main
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 13s
2026-06-22 20:56:15 -04:00
bvandeusen f8c58a7f0f Merge pull request 'feat(mcp): S5 — issue-kind guidance across all instruction surfaces' (#73) from dev into main
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 23:32:04 -04:00
bvandeusen 6cdac307af Merge pull request 'DB maintenance + health observability + Postgres integration CI lane' (#72) from dev into main
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 20:54:34 -04:00
bvandeusen d324205450 Merge pull request 'Release: Issues+Systems, milestone-as-plan, plugin reliability/skills/dedup, compaction hygiene' (#71) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 49s
2026-06-14 15:51:44 -04:00
152 changed files with 3885 additions and 8429 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,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)")
+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;
}
+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`);
+17 -47
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,7 +184,7 @@
/* 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
+86 -86
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 {
@@ -320,15 +320,15 @@
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: 1.5rem;
max-width: 400px;
width: 90%;
@@ -340,7 +340,7 @@
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
@@ -350,17 +350,17 @@
}
.modal-btn {
padding: 0.45rem 1rem;
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.9rem;
}
.modal-btn-danger {
background: var(--fs-error);
background: var(--color-danger);
color: var(--fs-text-on-action);
border-color: var(--fs-error);
border-color: var(--color-danger);
}
/* ── Floating inline assist button (teleported to body) ── */
@@ -369,10 +369,10 @@
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);
@@ -384,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;
@@ -408,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;
}
@@ -416,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;
@@ -427,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) {
@@ -452,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;
@@ -480,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,
@@ -497,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,
@@ -505,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. */
@@ -541,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;
}
+26 -26
View File
@@ -309,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 {
@@ -322,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;
@@ -338,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;
}
@@ -346,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;
@@ -365,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;
}
@@ -381,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 {
@@ -390,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);
}
@@ -403,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;
@@ -419,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 {
@@ -430,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;
@@ -442,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);
@@ -463,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 {
+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>
+55 -90
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,25 +455,25 @@ 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;
@@ -518,33 +483,33 @@ async function confirmDelete() {
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--fs-overlay);
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(--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.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-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(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
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(--fs-surface-page); }
.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); }
.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>
+19 -19
View File
@@ -175,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;
@@ -187,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;
@@ -195,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;
}
@@ -212,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;
@@ -235,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;
@@ -247,7 +247,7 @@ onMounted(loadLogs);
.log-textarea:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.log-add-controls,
@@ -259,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;
@@ -268,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>
+35 -35
View File
@@ -452,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 ── */
@@ -463,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 {
@@ -471,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;
@@ -490,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;
}
@@ -503,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 */
@@ -519,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 {
@@ -544,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;
}
@@ -562,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;
@@ -572,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;
}
@@ -606,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 */
@@ -618,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 {
@@ -645,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);
}
@@ -673,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;
@@ -688,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>
+48 -48
View File
@@ -344,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 ── */
@@ -360,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;
@@ -381,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 */
@@ -404,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 {
@@ -413,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;
@@ -432,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;
@@ -447,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 {
@@ -457,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;
@@ -465,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;
@@ -474,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 {
@@ -496,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 {
@@ -505,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;
}
@@ -515,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;
@@ -537,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; }
@@ -563,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;
}
@@ -573,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 */
@@ -609,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; }
@@ -329,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; }
@@ -337,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; }
@@ -367,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; }
@@ -381,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 {
@@ -404,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 {
@@ -430,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 -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;
+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>
+13 -13
View File
@@ -78,9 +78,9 @@ async function handleSubmit() {
.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);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
@@ -97,7 +97,7 @@ async function handleSubmit() {
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.field {
@@ -112,25 +112,25 @@ async function handleSubmit() {
.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-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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
@@ -140,10 +140,10 @@ async function handleSubmit() {
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
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>
+17 -17
View File
@@ -123,9 +123,9 @@ function loginWithOAuth() {
.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);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
@@ -142,11 +142,11 @@ function loginWithOAuth() {
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
color: var(--color-primary);
}
.field {
margin-bottom: 1rem;
@@ -160,19 +160,19 @@ function loginWithOAuth() {
.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-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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
@@ -181,23 +181,23 @@ function loginWithOAuth() {
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(--fs-text-secondary);
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
color: var(--color-primary);
}
.forgot-link {
text-align: right;
@@ -205,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>
+37 -39
View File
@@ -263,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;
}
@@ -292,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 */
@@ -321,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 {
@@ -342,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;
}
@@ -356,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 {
@@ -373,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;
@@ -399,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 */
@@ -425,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 */
@@ -448,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%; }
+55 -55
View File
@@ -317,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;
}
@@ -340,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 {
@@ -350,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;
}
@@ -404,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;
@@ -415,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);
}
@@ -429,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;
@@ -446,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;
}
@@ -486,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;
@@ -517,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;
@@ -527,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;
}
@@ -537,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;
@@ -546,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;
}
@@ -556,23 +556,23 @@ 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(--fs-overlay);
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(--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.5rem;
width: 100%;
max-width: 480px;
@@ -593,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;
@@ -613,7 +613,7 @@ 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;
@@ -625,20 +625,20 @@ function overallPct(project: Project): { total: number; pct: number } {
}
.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);
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(--fs-surface-page);
background: var(--color-bg);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
background: var(--color-action-primary);
border-color: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
+158 -322
View File
@@ -7,7 +7,6 @@ import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import {
@@ -109,12 +108,11 @@ async function confirmStartPlanning() {
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
const tasksLoading = ref(false);
const tasksError = ref<string | null>(null);
const notesLoading = ref(false);
const milestones = ref<Milestone[]>([]);
@@ -171,49 +169,8 @@ function toggleMilestoneCollapse(id: number) {
}
}
// A milestone IS the plan, so its body carries the whole design — Goal,
// Approach, Verification, and often several hundred words of reasoning. Rendered
// in full, one milestone's plan pushes every other milestone off the screen, and
// the board stops being a board.
//
// Length is judged on the SOURCE, not by measuring the rendered box. Measuring
// would be exact, but it means a ref per milestone, a post-render scrollHeight
// read, and a re-measure on every markdown change — a lot of machinery to decide
// whether to show one button. This proxy is wrong only in the narrow band around
// the threshold, where either answer is fine.
const PLAN_CLAMP_CHARS = 400;
const expandedPlans = ref<Set<number>>(new Set());
function isPlanLong(ms: Milestone): boolean {
return (ms.body || "").length > PLAN_CLAMP_CHARS;
}
function isPlanClamped(ms: Milestone): boolean {
return isPlanLong(ms) && !expandedPlans.value.has(ms.id);
}
function togglePlanExpanded(id: number) {
if (expandedPlans.value.has(id)) {
expandedPlans.value.delete(id);
} else {
expandedPlans.value.add(id);
}
}
// Milestones this has already ruled on. Without it, the rule re-applies on every
// reload — and `loadMilestones` runs after a task's status changes. So expanding
// a finished milestone and then ticking anything snapped it shut again, with no
// visible cause. That is half of why the collapse state read as arbitrary: it
// wasn't only deciding at START, it was overriding the reader continuously.
const autoCollapsedOnce = ref<Set<number>>(new Set());
function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) {
if (autoCollapsedOnce.value.has(ms.id)) continue;
autoCollapsedOnce.value.add(ms.id);
// Fully done and non-empty: start collapsed. A finished milestone is
// history, and the board is for what's live.
if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id);
}
@@ -336,45 +293,15 @@ async function confirmDeleteMilestone() {
}
}
// The route's max_limit. Asking for more is clamped server-side, so this is the
// largest page a single request can return.
const TASK_PAGE_SIZE = 500;
async function loadTasks() {
tasksLoading.value = true;
tasksError.value = null;
try {
// PAGE UNTIL COMPLETE. This board groups tasks under their milestone and
// shows each milestone's progress beside them, and that progress is counted
// SERVER-SIDE over every task. A partial fetch therefore doesn't just hide
// rows — it makes the bar disagree with the cards under it, and the
// auto-collapse rule (100% done starts collapsed) read as arbitrary.
//
// The original `limit=100` with no second page shipped the day this view was
// written, when the project had a couple of dozen tasks. At 166 it was
// dropping 66 — the least-recently-updated, so mostly done tasks in
// completed milestones, which is exactly where the mismatch is least
// visible and most confusing.
const url = (offset: number) =>
`/api/projects/${projectId.value}/notes?type=task` +
`&limit=${TASK_PAGE_SIZE}&offset=${offset}`;
const first = await apiGet<{ notes: NoteItem[]; total: number }>(url(0));
const all = [...first.notes];
while (all.length < first.total) {
const next = await apiGet<{ notes: NoteItem[]; total: number }>(url(all.length));
// A page that returns nothing while `total` still says there is more means
// the two disagree. Stop rather than loop forever; showing what we have
// beats hanging the board.
if (!next.notes.length) break;
all.push(...next.notes);
}
tasks.value = all;
const data = await apiGet<{ notes: NoteItem[]; total: number }>(
`/api/projects/${projectId.value}/notes?type=task&limit=100`
);
tasks.value = data.notes;
} catch {
// Say so. This used to swallow the error and leave an empty board, which is
// indistinguishable from a project with no tasks — the same "hidden with no
// indicator" failure as the truncation above, one layer up.
tasksError.value = "Could not load tasks. Refresh to try again.";
// Silently fail — tasks just won't show
} finally {
tasksLoading.value = false;
}
@@ -518,27 +445,27 @@ async function confirmDelete() {
@keyup.enter="confirmStartPlanning"
/>
<button
class="btn-primary btn-compact"
class="btn-workspace"
:disabled="!planTitle.trim() || planningBusy"
@click="confirmStartPlanning"
>
Create plan
</button>
<button class="btn-ghost btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
<button class="btn-secondary btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
</template>
<button
v-else-if="project"
class="btn-ghost btn-compact"
class="btn-workspace"
@click="showStartPlanning = true"
>
Start planning
</button>
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-cta btn-compact">
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-workspace">
<LayoutGrid :size="16" />
Workspace
</router-link>
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
<button v-if="project && !showStartPlanning" class="btn-danger-outline btn-compact" @click="showDeleteConfirm = true">Delete</button>
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
</div>
</div>
@@ -599,15 +526,11 @@ async function confirmDelete() {
<h3 class="panel-heading">Details</h3>
<div class="edit-field">
<label class="edit-label">Goal</label>
<!-- A textarea, not a single-line input. A project goal is a
paragraph in practice this one showed as "Maintain Scribe as
the reliabl" and gave no way to read the rest without arrowing
through it. -->
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
<input v-model="editGoal" type="text" class="edit-input" placeholder="What are you trying to achieve?" />
</div>
<div class="edit-field">
<label class="edit-label">Description</label>
<textarea v-model="editDescription" class="edit-textarea" rows="6" placeholder="Optional description..."></textarea>
<textarea v-model="editDescription" class="edit-textarea" rows="4" placeholder="Optional description..."></textarea>
</div>
<div class="edit-field">
<label class="edit-label">Status</label>
@@ -647,9 +570,6 @@ async function confirmDelete() {
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
Rules
</button>
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
Design
</button>
</div>
<!-- Tasks tab milestone-grouped kanban -->
@@ -659,7 +579,6 @@ async function confirmDelete() {
<div class="skel-row skel-row--short"></div>
<div class="skel-row"></div>
</div>
<p v-else-if="tasksError" class="tasks-error">{{ tasksError }}</p>
<template v-else>
<div class="milestone-actions">
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
@@ -740,21 +659,12 @@ async function confirmDelete() {
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
</div>
</template>
<template v-else>
<div
:class="['ms-plan-rendered', 'markdown-body',
{ 'ms-plan-clamped': isPlanClamped(group.milestone) }]"
@click="startEditPlan(group.milestone.id, group.milestone.body)"
v-html="renderMarkdown(group.milestone.body || '')"
></div>
<button
v-if="isPlanLong(group.milestone)"
class="btn-text ms-plan-toggle"
@click.stop="togglePlanExpanded(group.milestone.id)"
>
{{ expandedPlans.has(group.milestone.id) ? "Show less" : "Show more" }}
</button>
</template>
<div
v-else
class="ms-plan-rendered markdown-body"
@click="startEditPlan(group.milestone.id, group.milestone.body)"
v-html="renderMarkdown(group.milestone.body || '')"
></div>
</div>
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
@@ -874,16 +784,6 @@ async function confirmDelete() {
<!-- Rules tab -->
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
<!-- Design tab: this project's recorded components against its sheet.
Bound to the SAVED pointer rather than the picker's draft value,
so an unsaved change in the sidebar can't make the tab report on
a system this project isn't using. -->
<ProjectDesignTab
v-if="activeTab === 'design'"
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
</div>
</div>
</template>
@@ -932,9 +832,9 @@ async function confirmDelete() {
<style scoped>
/* ── Layout ─────────────────────────────────────────────────── */
.project-view {
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;
}
@@ -947,21 +847,24 @@ async function confirmDelete() {
}
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
.plan-title-input {
background: var(--fs-surface-page);
background: var(--color-bg, #111113);
color: inherit;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border, #2a2a2e);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font: inherit;
min-width: 200px;
}
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); }
/* Share: Bronze action-secondary — alternate path */
.project-title-input {
flex: 1;
font-size: 1.75rem;
font-weight: 500;
font-family: "Fraunces", Georgia, serif;
color: var(--fs-text-primary);
color: var(--color-text);
background: transparent;
border: none;
border-bottom: 1.5px solid transparent;
@@ -970,8 +873,8 @@ async function confirmDelete() {
min-width: 0;
transition: border-color 0.15s;
}
.project-title-input:focus { border-bottom-color: var(--fs-accent); }
.project-title-input::placeholder { color: var(--fs-text-tertiary); font-weight: 400; }
.project-title-input:focus { border-bottom-color: var(--color-primary); }
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; }
.status-badge {
font-size: 0.68rem;
@@ -982,14 +885,14 @@ async function confirmDelete() {
border-radius: 999px;
flex-shrink: 0;
}
.status-active { background: color-mix(in srgb, var(--fs-success) 14%, transparent); color: var(--fs-success); border: 1px solid color-mix(in srgb, var(--fs-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--fs-warning) 14%, transparent); color: var(--fs-warning); border: 1px solid color-mix(in srgb, var(--fs-warning) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--fs-accent) 14%, transparent); color: var(--fs-accent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--fs-text-tertiary) 14%, transparent); color: var(--fs-text-tertiary); border: 1px solid color-mix(in srgb, var(--fs-text-tertiary) 30%, transparent); }
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
.project-goal {
font-size: 1rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin: 0 0 0.3rem;
line-height: 1.45;
}
@@ -998,7 +901,7 @@ async function confirmDelete() {
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin: 0;
}
@@ -1015,7 +918,7 @@ async function confirmDelete() {
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
border-radius: var(--fs-radius-lg);
border-radius: var(--radius-md);
font-size: 0.82rem;
border: 1px solid;
}
@@ -1028,40 +931,28 @@ async function confirmDelete() {
border-radius: 50%;
flex-shrink: 0;
}
.dot-todo { background: transparent; border: 2px solid var(--fs-text-tertiary); }
.dot-inprogress { background: var(--fs-status-in-progress); }
.dot-done { background: var(--fs-status-done); }
.dot-todo { background: transparent; border: 2px solid var(--color-text-muted); }
.dot-inprogress { background: var(--color-status-in-progress, #3b82f6); }
.dot-done { background: var(--color-status-done, #22c55e); }
.stat-todo { background: color-mix(in srgb, var(--fs-text-tertiary) 8%, transparent); color: var(--fs-text-secondary); border-color: var(--fs-border-color); }
.stat-todo { background: color-mix(in srgb, var(--color-text-muted) 8%, transparent); color: var(--color-text-secondary); border-color: var(--color-border); }
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); }
.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); }
.stat-done { background: color-mix(in srgb, var(--color-success) 10%, transparent); color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 28%, transparent); }
.stat-notes { background: color-mix(in srgb, var(--color-primary) 8%, transparent); color: var(--color-primary); border-color: color-mix(in srgb, var(--color-primary) 22%, transparent); }
/* ── Two-column body ─────────────────────────────────────────── */
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
cannot shrink below its content — one wide descendant anywhere in the
content column widens the whole column past the grid, and everything inside
it then overflows the page and gets cut by `.project-view`'s
`overflow-x: clip`.
This is the same property the header nav relies on and wants (neither side
squeezed under its content); here it is exactly wrong, because the column
holds a kanban whose own tracks push outward. `min-width: 0` on the item is
the twin half — a grid item's default `min-width: auto` refuses to shrink
even when its track will. */
.project-body {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
grid-template-columns: 248px 1fr;
gap: 1.25rem;
align-items: start;
}
.content-area { min-width: 0; }
/* ── Edit panel ──────────────────────────────────────────────── */
.edit-panel {
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;
display: flex;
flex-direction: column;
@@ -1075,28 +966,28 @@ async function confirmDelete() {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
.edit-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.edit-input, .edit-textarea, .edit-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;
width: 100%;
}
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--fs-accent); }
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--color-primary); }
.edit-textarea { resize: vertical; }
/* Save panel: Moss action-primary per Hybrid rule */
@@ -1110,27 +1001,27 @@ async function confirmDelete() {
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;
transition: color 0.15s;
}
.tab-btn:hover { color: var(--fs-accent); }
.tab-btn.active { color: var(--fs-accent); border-bottom-color: var(--fs-accent); font-weight: 500; }
.tab-btn:hover { color: var(--color-primary); }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 500; }
.tab-count {
font-size: 0.7rem;
font-weight: 500;
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.5;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.tab-btn.active .tab-count {
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
border-color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--fs-accent);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
color: var(--color-primary);
}
/* ── Tasks view ──────────────────────────────────────────────── */
@@ -1139,98 +1030,50 @@ async function confirmDelete() {
.milestone-actions { display: flex; align-items: center; }
.btn-add-milestone {
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-milestone:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.btn-add-milestone:hover { border-color: var(--color-primary); color: var(--color-primary); }
.new-milestone-row { display: flex; gap: 0.4rem; align-items: center; flex: 1; }
.milestone-title-input {
flex: 1;
padding: 0.3rem 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-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
}
.milestone-title-input:focus { outline: none; border-color: var(--fs-accent); }
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
/* RESTORED. Both of these lost their base rule to a CSS sweep and left only
modifiers behind — `.milestone-header.clickable`, `.milestone-header:hover`.
Every child here (`.ms-chevron`, `.ms-name { flex: 1 }`, the progress track,
`.ms-pct`) is written for a flex ROW, so without the parent they stacked
vertically and each milestone grew to five lines of mostly nothing. That is
the "uses space poorly" the operator saw, and it was a deletion rather than a
design change. A dangling `:hover` is the tell, and it is now checked for. */
.milestone-group {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
overflow: hidden;
margin-bottom: 0.75rem;
}
.milestone-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.85rem;
background: var(--fs-surface-raised);
}
.milestone-header.clickable { cursor: pointer; }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--fs-accent) 4%, var(--fs-surface-raised)); }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
/* Plan body: the milestone's design/intent, shown above its task columns. */
.ms-plan {
padding: 0.6rem 0.85rem;
background: color-mix(in srgb, var(--fs-accent) 3%, var(--fs-surface-raised));
border-bottom: 1px solid var(--fs-border-color);
background: color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card));
border-bottom: 1px solid var(--color-border);
}
.ms-plan-rendered { font-size: 0.85rem; color: var(--fs-text-primary); cursor: text; }
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--fs-accent) 4%, transparent); }
/* max-height rather than -webkit-line-clamp: the body is rendered markdown, so
it holds headings, lists and tables. line-clamp counts lines inside ONE inline
formatting context and behaves unpredictably once block children are involved,
which is most plans. */
.ms-plan-clamped {
max-height: 6.5rem;
overflow: hidden;
position: relative;
}
/* Fades into the plan block's own background, which is a tint over the card —
restate it here rather than approximating, or the fade shows as a grey band. */
.ms-plan-clamped::after {
content: "";
position: absolute;
inset: auto 0 0 0;
height: 2.25rem;
background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--fs-accent) 3%, var(--fs-surface-raised))
);
pointer-events: none; /* the text under the fade stays clickable to edit */
}
.ms-plan-toggle { padding-left: 0; margin-top: 0.15rem; }
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
.ms-plan-editor {
width: 100%;
font-family: var(--fs-font-mono);
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
line-height: 1.5;
padding: 0.5rem;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
background: var(--color-bg);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
@@ -1241,19 +1084,19 @@ async function confirmDelete() {
padding: 0.3rem 0.75rem;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
}
.ms-plan-actions .btn-primary { background: var(--fs-action-primary); color: var(--fs-text-on-action); border-color: var(--fs-action-primary); }
.ms-plan-actions .btn-primary { background: var(--color-action-primary); color: var(--fs-text-on-action); border-color: var(--color-action-primary); }
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
.ms-plan-actions .btn-secondary { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
.ms-chevron { display: flex; align-items: center; color: var(--fs-text-tertiary); flex-shrink: 0; }
.ms-name { font-weight: 500; color: var(--fs-text-primary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-count {
font-size: 0.7rem;
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-card);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.05rem 0.4rem;
flex-shrink: 0;
@@ -1262,18 +1105,18 @@ async function confirmDelete() {
.ms-progress-track {
width: 72px;
height: 6px;
background: var(--fs-border-color);
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
}
.ms-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--fs-accent), color-mix(in srgb, var(--fs-accent) 70%, var(--fs-success)));
background: linear-gradient(90deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 70%, var(--color-success)));
border-radius: 999px;
transition: width 0.4s ease;
}
.ms-pct { font-size: 0.72rem; color: var(--fs-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 500; }
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 500; }
.ms-actions { display: flex; gap: 0.15rem; margin-left: 0.2rem; opacity: 0; transition: opacity 0.15s; }
.milestone-header:hover .ms-actions { opacity: 1; }
@@ -1284,22 +1127,22 @@ async function confirmDelete() {
background: none;
border: none;
cursor: pointer;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
width: 24px;
height: 24px;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
}
.ms-action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
.ms-action-delete:hover { color: var(--fs-error); }
.ms-action-btn:hover { background: var(--color-bg-card); color: var(--color-text); }
.ms-action-delete:hover { color: var(--color-danger, #e74c3c); }
.ms-rename-input {
flex: 1;
min-width: 0;
padding: 0.1rem 0.35rem;
border: 1px solid var(--fs-accent);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: inherit;
font-weight: 500;
@@ -1309,27 +1152,24 @@ async function confirmDelete() {
/* ── Kanban ──────────────────────────────────────────────────── */
.kanban {
display: grid;
/* Same reason as .project-body: three auto-minimum tracks add up to more
than the column when a card title or a column header won't compress, and
the excess pushes the whole milestone card wider than the page. */
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
align-items: start;
padding: 0.75rem;
background: color-mix(in srgb, var(--fs-surface-page) 60%, var(--fs-surface-raised));
background: color-mix(in srgb, var(--color-bg) 60%, var(--color-bg-secondary));
}
.kanban-col {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
padding: 0.65rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
border-top: 3px solid;
}
.col-todo { border-top-color: var(--fs-border-color); }
.col-inprogress { border-top-color: var(--fs-status-in-progress); }
.col-done { border-top-color: var(--fs-status-done); }
.col-todo { border-top-color: var(--color-border); }
.col-inprogress { border-top-color: var(--color-status-in-progress, #3b82f6); }
.col-done { border-top-color: var(--color-status-done, #22c55e); }
.kanban-col-header {
display: flex;
@@ -1339,18 +1179,18 @@ async function confirmDelete() {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin-bottom: 0.3rem;
}
.col-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.col-label { flex: 1; }
.col-count {
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: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.68rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-weight: 500;
}
.col-add-btn {
@@ -1359,40 +1199,40 @@ async function confirmDelete() {
justify-content: center;
width: 18px;
height: 18px;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-decoration: none;
font-size: 1rem;
line-height: 1;
border-radius: 3px;
margin-left: auto;
}
.col-add-btn:hover { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.col-add-btn:hover { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }
.task-card {
display: block;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-left: 3px solid transparent;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
padding: 0.45rem 0.55rem;
text-decoration: none;
color: var(--fs-text-primary);
color: var(--color-text);
font-size: 0.85rem;
transition: border-color 0.12s, box-shadow 0.18s, transform 0.18s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.task-card:hover {
border-color: color-mix(in srgb, var(--fs-accent) 50%, var(--fs-border-color));
border-left-color: var(--fs-accent);
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
border-left-color: var(--color-primary);
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
transform: translateY(-2px);
}
/* Priority left-border colors */
.task-card.pri-high { border-left-color: var(--fs-error); }
.task-card.pri-high { border-left-color: var(--color-danger, #e74c3c); }
.task-card.pri-medium { border-left-color: #f59e0b; }
.task-card.pri-low { border-left-color: var(--fs-success); }
.task-card.pri-low { border-left-color: var(--color-success); }
.task-card-done { opacity: 0.65; }
.task-card-done .task-title { text-decoration: line-through; }
@@ -1404,10 +1244,10 @@ async function confirmDelete() {
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
width: 1.4rem; height: 1.4rem;
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);
font-size: 0.75rem;
cursor: pointer;
opacity: 0;
@@ -1415,8 +1255,8 @@ async function confirmDelete() {
line-height: 1;
}
.task-card:hover .task-advance-btn { opacity: 1; }
.task-advance-btn:hover { background: var(--fs-action-primary); border-color: var(--fs-action-primary); color: var(--fs-text-on-action); }
.task-advance-btn--done:hover { background: var(--fs-success); border-color: var(--fs-success); color: var(--fs-text-on-action); }
.task-advance-btn:hover { background: var(--color-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: var(--fs-text-on-action); }
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
.priority-dot {
@@ -1425,18 +1265,18 @@ async function confirmDelete() {
border-radius: 50%;
flex-shrink: 0;
}
.dot-pri-high { background: var(--fs-error); }
.dot-pri-high { background: var(--color-danger, #e74c3c); }
.dot-pri-medium { background: #f59e0b; }
.dot-pri-low { background: var(--fs-success); }
.dot-pri-low { background: var(--color-success); }
.due-date { font-size: 0.7rem; color: var(--fs-text-tertiary); }
.due-date { font-size: 0.7rem; color: var(--color-text-muted); }
.col-empty {
text-align: center;
padding: 1.25rem 0.5rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.78rem;
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
opacity: 0.7;
}
@@ -1447,62 +1287,58 @@ async function confirmDelete() {
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
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);
text-decoration: none;
color: var(--fs-text-primary);
color: var(--color-text);
font-size: 0.9rem;
transition: border-color 0.12s, box-shadow 0.15s, transform 0.15s;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.note-row:hover {
border-color: color-mix(in srgb, var(--fs-accent) 50%, var(--fs-border-color));
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
transform: translateY(-1px);
}
.note-icon { color: var(--fs-text-tertiary); flex-shrink: 0; }
.note-icon { color: var(--color-text-muted); flex-shrink: 0; }
.note-title { font-weight: 500; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-date { font-size: 0.75rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
.note-date { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; text-align: center; padding: 1rem; }
/* Deliberately NOT styled like .empty-msg: "no tasks" and "the tasks did not
load" look identical to a user, and conflating them is what let a silent
failure read as an empty project. */
.tasks-error { color: var(--fs-error); font-size: 0.875rem; padding: 1rem; text-align: center; }
.empty-msg { color: var(--color-text-muted); font-size: 0.875rem; text-align: center; padding: 1rem; }
/* ── Modal ───────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--fs-overlay);
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(--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.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-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(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
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(--fs-surface-page); }
.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); }
.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); }
/* ── Skeleton ────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
@@ -1512,21 +1348,21 @@ async function confirmDelete() {
gap: 0.65rem;
}
.skel-title, .skel-goal, .skel-stats, .skel-body, .skel-row {
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) 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-title { height: 2.2rem; width: 55%; border-radius: var(--fs-radius-lg); }
.skel-title { height: 2.2rem; width: 55%; border-radius: var(--radius-md); }
.skel-goal { height: 1rem; width: 75%; }
.skel-stats { height: 2rem; width: 50%; border-radius: var(--fs-radius-lg); }
.skel-body { height: 200px; border-radius: var(--fs-radius-lg); }
.skel-row { height: 2.5rem; border-radius: var(--fs-radius-sm); }
.skel-stats { height: 2rem; width: 50%; border-radius: var(--radius-md); }
.skel-body { height: 200px; border-radius: var(--radius-md); }
.skel-row { height: 2.5rem; border-radius: var(--radius-sm); }
.skel-row--short { width: 65%; }
/* ── Responsive ──────────────────────────────────────────────── */
+17 -17
View File
@@ -168,9 +168,9 @@ async function handleSubmit() {
.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);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
@@ -186,13 +186,13 @@ async function handleSubmit() {
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.95rem;
padding: 1rem 0;
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
@@ -211,11 +211,11 @@ async function handleSubmit() {
.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-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;
}
.input:disabled {
@@ -224,36 +224,36 @@ async function handleSubmit() {
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.input-error {
border-color: var(--fs-error);
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--fs-error);
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
color: var(--color-danger);
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
color: var(--color-primary);
}
</style>
+17 -17
View File
@@ -141,9 +141,9 @@ async function handleSubmit() {
.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);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
@@ -159,13 +159,13 @@ async function handleSubmit() {
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
.closed-msg {
text-align: center;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
@@ -184,45 +184,45 @@ async function handleSubmit() {
.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-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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.input-error {
border-color: var(--fs-error);
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--fs-error);
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
color: var(--color-danger);
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
color: var(--color-primary);
}
</style>
+17 -17
View File
@@ -117,9 +117,9 @@ async function handleSubmit() {
.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);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
@@ -135,7 +135,7 @@ async function handleSubmit() {
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
@@ -144,7 +144,7 @@ async function handleSubmit() {
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
@@ -163,45 +163,45 @@ async function handleSubmit() {
.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-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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.input-error {
border-color: var(--fs-error);
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--fs-error);
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
color: var(--color-danger);
}
.error-msg {
color: var(--fs-error);
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
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;
+85 -85
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,19 +885,19 @@ 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;
}
@@ -906,16 +906,16 @@ function usageTitle(s: SnippetListItem): string {
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
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(--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.5rem;
width: 100%;
max-width: 460px;
@@ -931,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 {
@@ -944,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;
}
@@ -963,7 +963,7 @@ 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 {
@@ -973,24 +973,24 @@ function usageTitle(s: SnippetListItem): string {
}
.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);
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(--fs-surface-page);
background: var(--color-bg);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-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(--fs-action-primary-hover);
background: var(--color-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
+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>
+29 -29
View File
@@ -297,9 +297,9 @@ function formatDate(iso: string): string {
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;
}
@@ -317,16 +317,16 @@ function formatDate(iso: string): string {
.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;
@@ -334,7 +334,7 @@ function formatDate(iso: string): string {
.invite-list h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
/* Registration toggle */
@@ -352,33 +352,33 @@ function formatDate(iso: string): string {
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;
}
@@ -392,13 +392,13 @@ function formatDate(iso: string): string {
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 {
@@ -408,10 +408,10 @@ function formatDate(iso: string): string {
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 {
@@ -426,21 +426,21 @@ function formatDate(iso: string): string {
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) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.29",
"version": "0.1.22",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
-15
View File
@@ -196,21 +196,6 @@ if [ -n "$body" ]; then
fi
fi
# ARM 1½ — the RECORD nudge (#2664). The local arm just proved the thing being
# written already exists elsewhere in this repo, and Scribe returned no record
# of anything for it. That is the one moment "record it" is earned rather than
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
# an ordinary new helper (no other copies) and an already-recorded one (the
# server spoke) stay nudge-free — a reflex that fires on everything is one
# that gets skipped. An unreachable server counts as "nothing recorded": the
# local finding needed no server, and the nudge fails open with it.
if [ -n "$local_lines" ]; then
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy."
fi
fi
# Local first. It answers "this already EXISTS", which is a stronger claim than
# "this resembles something recorded" — and it is the one the recorded arms are
# structurally unable to make.
-25
View File
@@ -61,31 +61,6 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
# --- Which version is actually RUNNING (keyless, networkless) ---
#
# An install has two halves and only one self-updates:
#
# marketplaces/…/scribe-plugin/ git clone — pulls on its own
# cache/…/scribe/<version>/ what EXECUTES — refreshed only when the
# manifest version changes
#
# So inspecting the clone shows a fix present while the broken copy keeps
# running, and the obvious debugging move actively misleads (#2209). Twice, the
# only detector was the operator saying "I don't think it updated".
#
# Naming the running version in every session makes that answerable from the
# transcript instead of by archaeology in the cache directory. Deliberately NOT
# a server round-trip or a stored per-user record: the state most needing
# diagnosis is the one where credentials never arrive, and this line still
# appears there.
manifest="$here/../.claude-plugin/plugin.json"
if [ -f "$manifest" ]; then
plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
if [ -n "$plugin_version" ]; then
append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
fi
fi
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
+5 -45
View File
@@ -37,31 +37,11 @@ for the operator's work, and as your own working memory across sessions.
moment it's complete. When you **fix** something — even in passing — record it
as its own issue (`create_task(kind="issue")`), not as a work-log line on an
unrelated open task.
- **Tag to Systems as you write** — `enter_project` lists the project's
Systems (its named subsystems/areas). When you create or meaningfully update
a record, ask which areas it is about and pass `system_ids`; if an area has
no System yet, create it with `create_system` (name + a one-paragraph
charter) rather than leaving it unmodelled. Cross-cutting records — audits,
sweeps, reviews — take SEVERAL tags, and are the best moment to DISCOVER
missing Systems: a pass that walks the subsystems has just enumerated the
vocabulary, so mint what it names. Create liberally; the duplicate gate on
`create_system` (and reviewing the existing list) is the guardrail against
sprawl, not restraint. Every read and write of a project record shows its
`systems` — that is the "am I in a System's territory?" signal, and
`list_system_records` reads that territory's whole pile before you work in
it. An untagged project record carries the `systems_hint` question instead,
on creates, updates, and work-logs alike — treat it as the tagging question
asked at the moment of work, not as noise to skip past.
- **Reuse before rebuilding — and record what you build** — before writing a
new helper/utility/component, search recorded **snippets** (reusable code
recorded once for recall) and reuse the prior art instead of re-solving it.
The recording half has NAMED TRIGGERS, not a vibe: the moment you extract a
shared component, hoist a helper into a common module, or notice you are
writing the second copy of anything, record it with `create_snippet` (name,
code, when-to-reach-for-it, location) in the same breath as the commit.
Work that "refactors X into a shared Y" is not finished until Y is recorded
— an unrecorded shared component is invisible to every later session, which
is how a codebase grows four `.btn-primary` definitions.
- **Reuse before rebuilding** — before writing a new helper/utility/component,
search recorded **snippets** (reusable code recorded once for recall) and
reuse the prior art instead of re-solving it; when you build something
reusable, record it with `create_snippet` (name, code, when-to-reach-for-it,
location) so a later session is offered it, not left to write it again.
- Do **not** keep the operator's rules, plans, or project notes in local
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
- **Compact at clean seams** — because you record as you go, a context
@@ -71,25 +51,5 @@ for the operator's work, and as your own working memory across sessions.
`/compact` (name what you logged). You can't run it yourself — surface the
recommendation and let them decide. Suggest it at seams, not every turn.
**How the instruction surfaces divide the work:** this file carries the
session-level reflexes (WHEN to reach for Scribe); each tool's own description
carries its full contract (HOW to call it — read it when you load the tool);
the bundled skills carry process arcs (planning, debugging, verification). The
MCP server's instruction block is deliberately only a map — the client injects
roughly its first 2,000 characters and silently cuts the rest, so nothing
load-bearing lives below that fold.
**If two Scribe instruction surfaces disagree** — this file, the MCP server's
tool instructions, the `using-scribe` skill — **follow the one that assumes
least about its own delivery.** This file is the floor: it ships with the
plugin and needs no API key and no network, so it still applies in exactly the
session where the others never arrived. The others may elaborate on what is
written here; they must not contradict it. Weigh a disagreement by which way it
fails, not by which surface said more: doing something a push would also have
covered costs one redundant call, while skipping it because you expected a push
that never came means working without the operator's rules and not knowing.
A contradiction between surfaces is a defect in the product — say so, so it
gets recorded and fixed rather than silently arbitrated again next session.
If the Scribe tools are unavailable, say so rather than silently falling back
to local notes.
-25
View File
@@ -81,31 +81,6 @@ Two constraints on *how* that's achieved:
(`arose_from_id`) and the subsystem it touches (`system_ids`). Don't bury a
fix as a work-log line on whatever task happened to be open.
7. **Tag records to Systems.** `enter_project` lists the project's Systems —
its named subsystems/areas. When you create or meaningfully update a record,
ask which areas it is *about* and pass `system_ids`. The test: would someone
investigating that subsystem want this record in the pile
`list_system_records` returns? If the area has no System yet, create one
(`create_system`: name + a one-paragraph charter) — an area that plainly
exists deserves naming the moment two records would share it; don't wait to
be asked. Cross-cutting records — audits, sweeps, reviews — take *several*
tags and are the prime discovery moment: a pass that walks the subsystems
has just enumerated the vocabulary, so mint the Systems it names as it
names them. Create liberally — `create_system` is duplicate-gated, and that
gate (plus reviewing the existing list) is the guardrail against sprawl,
not restraint. Only a record genuinely about no particular area goes
untagged.
8. **State updates in place; chronicles don't.** A dev-log records what
*happened* — write it once, never rewrite it. A durable finding (how a
subsystem works, a measured number) lives in that System's **reference
note** ("«System» — reference"), which you UPDATE as facts change — safe,
because every meaningful edit is snapshotted and the version history is the
changelog. The dev-log then `[[links]]` the reference note instead of
restating state. When a new record outright *corrects* an older one (a
re-measurement, a reversed decision), pass the old id in `supersedes` so the
stale record is demoted and labelled rather than left competing.
## Stay inside the active project's scope
Once a project is in scope — you called `enter_project`, or the working repo is
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
-185
View File
@@ -1,185 +0,0 @@
#!/usr/bin/env python3
"""Find elements whose classes have no base rule — the dangling-selector bug.
THE FAILURE THIS CATCHES
Deleting a CSS rule from a scoped stylesheet is not the local edit it looks
like. Three ways it goes wrong, all of them silent:
1. A rule is deleted and its `:hover` / modifier survives. The selector still
exists, so nothing reads as unused, but the element renders with no base
styling at all. `.btn-workspace:hover` outlived `.btn-workspace` and a
router-link rendered as raw browser blue for days.
2. The parent's layout rule is deleted while the children keep theirs.
`.milestone-header` was a flex row; its children still declare `flex: 1`
and `flex-shrink: 0`. Without the parent they stack vertically, and a
milestone that was one line becomes five. Nothing errors — the page just
wastes space, which reads as a design decision.
3. A rule is removed from a comma-separated group, leaving `.a,` dangling in
front of the next rule and swallowing it. That one at least has a
brace-balance tell; these two do not.
None of it is visible to `vue-tsc`, which is the frontend's entire check. A
dead style typechecks perfectly.
WHAT IT REPORTS
An element whose every static class is styled NOWHERE as a base rule, while at
least one of them appears in the file's CSS. That conjunction is the signal: a
class nobody styles is ordinary (a hook for a test, a semantic label), and a
class with only modifier rules is a deletion that went half-way.
Descendant selectors count as a base — `.panel .row {}` styles `.row` — because
from the element's side there is no difference. Only the LAST compound of a
selector is what it styles, and a compound's whole class SET is what it
requires: `.pane.empty` and `td.num` are base rules for the element carrying
those classes, not modifiers. Reading them as modifiers cost this check four
false reports on its first run, and a check with false reports is one that gets
skimmed.
REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate,
so this cannot be a gate without a suppression mechanism nobody would maintain.
A count that grows is the signal to look. Where a wrapper is bare on purpose,
say so in a comment beside its descendant rules — the remaining reports here
all carry one, so a new entry means something changed.
KNOWN BLIND SPOT: a class with NO rule anywhere is invisible to this, because
it cannot be told from a semantic-only hook. `.systems-list` had lost its
entire rule and was rendering with browser bullets; it was found by reading the
file next to a class that WAS half-styled, not by this check.
INSTANCE-AGNOSTIC (rule #115). Nothing here knows a class name, a component, or
a convention; point it at any Vue tree.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
# `class="a b"` only — never `:class="[...]"`, whose value is an expression.
# The negative lookbehind is the whole point: a bound class list mentions names
# that a static parse would misread as the element's only classes.
STATIC_CLASS = re.compile(r'(?<![:\w-])class="([^"{}\[\]]*)"')
SELECTOR = re.compile(r"([^{}]+)\{")
CLASS_TOKEN = re.compile(r"\.([A-Za-z][\w-]*)")
def shared_classes(sheets: list[pathlib.Path]) -> set[str]:
"""Class names any global stylesheet defines — a base rule from elsewhere."""
names: set[str] = set()
for sheet in sheets:
if sheet.exists():
names |= set(CLASS_TOKEN.findall(sheet.read_text()))
return names
def base_class_sets(css: str) -> list[frozenset[str]]:
"""Class combinations this stylesheet gives a base rule to.
The last compound of a selector is what the rule styles: in
`.panel .row:hover` that is `.row:hover`, a state — but in `.panel .row` it
is `.row`, a base.
A compound may carry more than one class, and a type selector alongside
them. `.pane.empty` and `td.num` are both base rules for the element that
matches, so each is recorded as the SET of classes it requires; an element
is styled when it carries all of them. Recording the classes individually
instead would clear `.pane` everywhere on the strength of a rule that only
ever applies with `.empty` — precision matters more here than reach, since
a missed base is a false report and a wrong one is a defect gone quiet.
A compound with no class at all (`ul`, `li`) is skipped: it styles by tag,
which this cannot verify without parsing the template's elements, and an
empty set would clear every element in the file.
"""
out: list[frozenset[str]] = []
for selector in SELECTOR.findall(css):
for part in selector.split(","):
part = part.strip()
if not part or part.startswith("@"):
continue
last = re.split(r"[\s>+~]+", part)[-1]
# A pseudo-class, pseudo-element or attribute selector makes it a
# state or a variant, not the element's base appearance.
if ":" in last or "[" in last:
continue
names = CLASS_TOKEN.findall(last)
# Everything outside the class tokens must be a bare type selector.
if names and re.fullmatch(r"[A-Za-z][\w-]*|\*|", CLASS_TOKEN.sub("", last)):
out.append(frozenset(names))
return out
def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]:
source = path.read_text()
template = source.split("<style")[0]
css = "\n".join(CSS_COMMENT.sub("", block) for block in STYLE_BLOCK.findall(source))
if not css.strip():
return []
base_sets = base_class_sets(css)
mentioned = set(CLASS_TOKEN.findall(css))
findings: list[tuple[str, list[str]]] = []
for attr in sorted(set(STATIC_CLASS.findall(template))):
names = [n for n in attr.split() if re.fullmatch(r"[A-Za-z][\w-]*", n)]
if not names:
continue
carried = set(names)
if any(carried >= required for required in base_sets):
continue
if any(n in shared for n in names):
continue
dangling = [n for n in names if n in mentioned]
if dangling:
findings.append((attr, dangling))
return findings
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default="frontend/src", help="tree of .vue files")
parser.add_argument(
"--shared",
action="append",
default=None,
help="global stylesheet whose classes count as a base rule (repeatable)",
)
args = parser.parse_args()
root = pathlib.Path(args.root)
if not root.exists():
print(f"{root}: no such directory", file=sys.stderr)
return 2
sheets = [pathlib.Path(s) for s in (args.shared or [])]
if not sheets:
sheets = sorted(root.glob("assets/*.css"))
shared = shared_classes(sheets)
total = 0
for path in sorted(root.rglob("*.vue")):
for attr, dangling in scan(path, shared):
total += 1
print(f'{path}: class="{attr}" — styled but never based: {", ".join(dangling)}')
print()
if total:
print(
f"REPORT: {total} element(s) whose classes carry modifier rules but no base "
f"rule. Each is either a deleted rule that left its :hover behind, or a "
f"deliberately bare wrapper."
)
else:
print("OK — every styled class has a base rule.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-39
View File
@@ -307,44 +307,6 @@ def check_local_prior_art_needs_no_instance() -> None:
ok("prior-art local arm: answers with no instance configured")
def check_session_context_reports_its_version() -> None:
"""The SessionStart context must name the plugin version it is running.
An install has two halves and only one self-updates: the marketplace clone
pulls on its own, while the CACHE is what executes and refreshes only when
the manifest version changes. So a shipped fix can sit unreached while
inspecting the clone shows it present — the obvious debugging move
misleads, and twice the only detector was a human saying "I don't think it
updated" (#2209, #2220).
Asserted WITHOUT credentials on purpose. The state most needing diagnosis
is the one where the token never arrives, and a marker that vanished there
would be missing exactly when it is wanted.
"""
script = HOOKS_DIR / "scribe_session_context.sh"
if not script.is_file() or not shutil.which("jq"):
skip("version marker: hook or jq missing")
return
manifest_v = manifest_version()
if manifest_v is None:
fail("version marker: could not read the manifest version")
return
try:
proc = _run_hook(script, json.dumps({"source": "startup"}), {})
except subprocess.TimeoutExpired:
fail("version marker: hook hung")
return
if proc.returncode != 0:
fail(f"version marker: hook exited {proc.returncode}")
elif manifest_v not in proc.stdout:
fail(f"version marker: session context never names v{manifest_v}"
f"a stale install would be undetectable from the transcript")
else:
ok(f"version marker: session context reports v{manifest_v}, no credentials needed")
def _git(*args: str) -> tuple[int, str]:
proc = subprocess.run(
["git", *args], capture_output=True, text=True, cwd=ROOT
@@ -437,7 +399,6 @@ def main() -> int:
check_shellcheck()
check_fail_open()
check_local_prior_art_needs_no_instance()
check_session_context_reports_its_version()
if not args.no_version:
check_version_bump(args.base)
+2
View File
@@ -26,6 +26,7 @@ from scribe.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
from scribe.routes.design import design_bp
from scribe.routes.design_systems import design_systems_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
@@ -90,6 +91,7 @@ def create_app() -> Quart:
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
app.register_blueprint(design_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
+255 -93
View File
@@ -5,84 +5,268 @@ from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from quart import Quart
## The delivery budget — read before editing this block
#
# Claude Code injects only the FIRST ~2,048 CHARACTERS of an MCP server's
# instructions into the system prompt; the rest is silently cut mid-word
# (#2562 — the cut was observed live at exactly offset 2,048, and ~90% of the
# previous 20k-char version of this block never reached any session). So this
# block is deliberately a MAP, not a manual, and a test pins it under the
# fold (test_instruction_surfaces_agree.py::test_instructions_fit_the_fold).
#
# Where the detail lives instead — each surface has one job:
# - Tool docstrings: the per-tool HOW. Delivered with the tool schema, at
# reach-for time when the client defers tools. Guidance about one tool
# belongs there, not here.
# - Plugin static context (plugin/hooks/scribe_static_context.md): the
# session-level reflexes (recall-first, record-as-you-go, tag-to-Systems,
# compaction). Always delivered in full; needs no key and no network.
# - Plugin skills: process arcs (planning, debugging, verification…).
# Their listing line is the always-visible trigger; the body loads on
# match. Stored Processes become skills via /scribe:sync.
# - The server itself: behaviors prose can't be trusted to fire (the
# duplicate gate, the untagged-record systems_hint) act in-band in tool
# responses, at the moment they apply.
# Grow one of those, not this block.
_INSTRUCTIONS = """
Scribe is the operator's self-hosted second brain and system of record — and
yours: recall from it before acting, record as you go. Keep no parallel copy
in local files (CLAUDE.md, auto-memory); Scribe holds the single copy.
Scribe is the user's self-hosted second-brain and project-management data
store, and your own system of record for their work. You (Claude) are the
assistant: record what you do here — tasks, work-logs, decisions, notes — and
recall from here before acting. Do not keep the user's project work in local
files (CLAUDE.md, scratch/auto memory) in parallel; Scribe holds the single copy.
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
notes, Systems and design system in one call.
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
fix), never a work-log line on an unrelated task. Log with add_task_log;
keep status honest — in_progress on start, done on finish.
- PLAN work with an arc: start_planning. The plan IS a milestone; each step is
a child task, not a checkbox. No local plan .md files.
- CAPTURE: create_note. RECALL: search first, before answering about the
operator's work or opening a task — assume prior art exists, and pass the
active project_id to stay in scope.
- WHERE work happens: Systems. Tag records with system_ids as you write;
create_system when the area is unmodelled.
- HOW to work: rules are pull-only and binding — call list_always_on_rules()
yourself at session start; a push that also delivered them was an
optimisation, not the bridge.
- UI: the project's design system is binding — resolve_design_system /
get_design_system_stylesheet before hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with
create_snippet. Saved procedures are Processes (follow verbatim). Deletes
are trash-recoverable.
Hierarchy: Project -> Milestone -> Task/Note.
A task is a note with status; *_note tools for notes, *_task for tasks.
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
id — update it, don't force. shared:true records are another user's — a
suggestion, not the operator's settled practice.
What each part is for, and when to reach for it:
- Project: the top-level container for a body of work.
- Milestone: groups related tasks within a project toward a goal (status
active/done). A milestone is ALSO the home of a plan — its `body` holds the
design/intent (Goal/Approach/Verification) and its child tasks are the steps.
Use one when a chunk of work needs its own arc.
- Task: a unit of actionable work with a lifecycle (status
todo/in_progress/done/cancelled, optional priority). A task is a note with a
status — reach for one when there is something to DO. Record progress over
time with work-logs (add_task_log) rather than rewriting the body.
- Issue: a task whose kind is corrective — a problem you fixed or are fixing, as
opposed to productive `work`. Create it with create_task(kind="issue"); the
body carries symptom → root cause → fix. It has the full task lifecycle, and
can link the originating task it arose from (arose_from_id) and the System(s)
it touches (system_ids). Reach for one whenever you fix something — even in
passing — instead of burying the fix in another task's work-log.
- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of
work. The design/intent lives in the milestone `body`; each step is its own
child task (create_task(milestone_id=...)), tracked with status + work-logs —
NOT a checkbox buried in the body. Create one with start_planning when the
work has an arc (same test as a milestone, above) and you want the approach
reviewable before you start; read it back with get_milestone (body + steps).
Work without an arc is a task, not a plan. (The old kind=plan task is retired
— some historical plan-tasks still exist and remain readable, but don't
create new ones.)
- Note: durable free-form knowledge — reference material, decisions, logs of
what happened.
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
- Design system: the visual standards a project's UI is built from — design
tokens (name + value per mode) plus the prose a token table cannot hold
(aesthetic, voice, what is out of scope). Systems INHERIT: a child holds only
what it changes and the chain supplies the rest, so a family's house style and
one app's departures from it are the same structure at two depths. A project
points at one with set_project_design_system, and enter_project then hands it
back with the guidance chain-merged. Treat it as binding for UI work: reach
for a token (resolve_design_system / get_design_system_stylesheet) before
writing a colour, size, radius or duration by hand. Do NOT record a design
system as a rulebook — rules are for behaviour, and tokens kept as prose
cannot be resolved, inherited, rendered to a stylesheet, or checked against
code.
- System: a per-project, reusable, self-describing subsystem/area. Associate any
record (note, task, issue) with it via system_ids so research, build-work, and
fixes for the same area line up, and recurring problem-spots surface. Manage
with create_system / list_systems / get_system.
This is only a map — the client injects ~2k chars and cuts the rest. Each
tool's description carries its full contract: read it when you load the
tool, and trust it over habit.
Mechanics:
- Notes and Tasks share a model; tasks are notes with is_task=True.
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
unchanged on updates.
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
removes the task from its milestone); 0 leaves it unchanged.
Reach for Scribe to RECALL, not just to record. Scribe is a second brain —
its value is mostly in what it already holds, so make searching it a reflex,
not something you wait to be asked for:
- Before you answer a question about the user's work, or start a task, search
Scribe first (search / list_tasks / list_notes). Assume relevant prior work
already exists — a related task, an earlier decision, a prior note — and look
before you re-derive it or open a duplicate.
- Before creating a task, search for an existing one (search content_type=
'task') — don't open a second task for work already tracked.
- create_note / create_task enforce this: if a title- or meaning-similar record
already exists in the same project, the call is BLOCKED and returns
{"duplicate": true, "existing_id": ...} instead of creating. UPDATE that
record (update_note / update_task / add_task_log) rather than duplicating.
Only pass force=true when it's genuinely a distinct record — a duplicate both
bloats the store and surfaces as a stale competing copy in later searches.
- Scope to the project in scope. When a project is active (you called
enter_project), pass its project_id to search / list_tasks / list_notes so
results stay inside that project. Querying with no project_id pulls in every
project and bleeds unrelated work into the session — only do it for a
deliberate cross-project sweep. get_recent takes no project filter and spans
every project; when one is active, prefer the scoped list_* tools over it.
And this is not only about reads: once a project is in scope, only reference
or offer work on THAT project — don't surface or propose work from other
projects unless the operator widens scope. If something clearly belongs to a
different project, say so and ask before switching; never silently operate
cross-project. The active project does not stick on the server (each call is
self-contained); carrying its id forward is on you.
Keep task state honest — this is what makes the project a trustworthy record:
- When you begin working a task, set it to in_progress (update_task
status=in_progress).
- Log progress as you go with add_task_log — at meaningful steps, not saved up
for the end.
- The moment a task's work is complete, set it done. Never leave finished work
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
left to do.
- At a meaningful point — finishing a task, or hitting or discovering a problem
that changes direction — write a short dated note on the project (create_note)
capturing what happened (the pivots, not just the wins), and set the finished
task to done.
- When you fix a problem — even one solved in passing — record it as its own
issue (create_task(kind="issue")) with symptom → root cause → fix in the body,
NOT as a work-log line on whatever task happened to be open. An issue is
corrective work with its own lifecycle; recording it discretely (optionally
linked via arose_from_id to the task it came from, and system_ids to the
subsystem it touches) is what makes it findable so it isn't diagnosed from
scratch next time.
Compaction hygiene — recommend compacting at clean seams. Because you record
progress as you go, a context compaction is SAFE: the durable state lives in
Scribe (task status, work-logs, decision notes), not the transcript, so it
survives the summary. Use this rather than letting auto-compaction fire mid-task:
- At the end of a coherent block of work (a task closed, a plan phase finished)
in a long session, first make sure in-flight state is actually in Scribe —
update task status, add a work-log, capture any decision as a note. Surface
the few things worth logging before suggesting the compact.
- Then tell the operator it's a good, safe moment to /compact, naming what you
logged ("logged to #X/#Y — safe to /compact, nothing will be lost"). You
cannot run /compact yourself; surface the recommendation and let them decide.
- Recommend it at genuine seams, not every turn. The next session's start will
prompt you to reload your bearings from Scribe — so a clean-seam compact plus
that reload loses nothing.
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
an actionable statement plus optional Why and How-to-apply context. At the
start of any session that touches Scribe, call list_always_on_rules() to
load the standing rules — treat them as binding. When you also have a project
in scope, get_project(id) returns applicable_rules (rules from rulebooks the
project subscribes to) and subscribed_rulebooks; consult those too. Full text
(Why / How-to-apply) is available via get_rule(id).
Workflow and standards rules live in Scribe. When you notice a pattern
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
— those stores are reserved for facts about the user (preferences, role,
communication style) and codebase onboarding pointers, respectively. Before
creating a rule, call list_always_on_rules and list_rules(project_id=...) to
avoid duplicates.
Choose a rule's home by WHO it should bind, and keep each home's rules at the
right altitude:
- Always-on rulebook (a rulebook flagged always_on) — universal norms that
bind EVERY one of your projects. Reserve for cross-project standards.
- Subscribed rulebook (always_on off; projects opt in via
subscribe_project_to_rulebook) — a reusable, THEMED module of general
rules that binds only the projects which subscribe. Its rules must make
sense for every project that could subscribe, never one specific project
(e.g. a code-review checklist, or a compliance regime a category of
projects shares — no rule names a single app).
- Project rule (create_project_rule) — anything specific to ONE project.
Both rulebook tiers are SHARED, so their rules stay general; the difference
between them is REACH (all projects vs opt-in by theme), not generality. Rule
of thumb: names a specific project's files/paths/quirks -> project rule; a
standard a CATEGORY of projects shares -> subscribed rulebook; a universal
norm -> always-on rulebook. Coordinate with the operator on which home fits.
Before writing a rule, check whether another entity already models the thing.
A rule is prose an agent must remember and apply; the other entities are
structure a tool can resolve, render and check. Visual standards are a DESIGN
SYSTEM, not a rulebook — a token can be inherited, resolved per mode, rendered
to a stylesheet and diffed against code, and none of that survives being
written as a rule. A repeatable procedure is a PROCESS. Reusable code is a
SNIPPET. Reach for a rule when the thing genuinely is a standing instruction
about how to work, and nothing else can hold it.
One thing NOT to do: don't bridge Scribe into a session by writing to the
host's native memory. Rules are pull-only, so a fresh session won't reach for
them unless its always-loaded context says to — but the bridge for that is the
Scribe plugin's SessionStart hook, which pushes the always-on rules +
active-project context into each session directly. So do NOT create or refresh
a "rules live in Scribe" pointer in CLAUDE.md / AGENTS.md / ~/.claude memory,
and do NOT keep rules, recall, or plans in those stores in parallel with Scribe
— Scribe holds the single copy. Native auto-memory stays for facts about the
user; CLAUDE.md for codebase onboarding. Never make Scribe's correctness depend
on the operator disabling a native function (e.g. autoMemoryEnabled): the
plugin must work with auto-memory at its default. If the plugin is ever removed
the session loses this push and rebuilds context over time — an acceptable cost,
and far better than a silent settings change the operator may not know about.
When you are working on a specific project, call enter_project(project_id)
ONCE at session start (or whenever the active project changes). It returns the
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
summary, open tasks, and recent notes — everything you need to know the lay of
the land before mutating. Don't call get_project + get_applicable_rules + a
search separately when enter_project already composes them.
Don't wait to be told which project you're in. At the start of a session that
touches Scribe — or the moment work clearly belongs to a project but none is in
scope — bootstrap project context proactively: search for a related existing
project (search / list_projects, matching on the work's subject, the repo or
directory name, and recent activity). If you find a confident match, propose it
and call enter_project once the operator confirms. If nothing matches, offer to
create a project, confirming its name and goal first. Always confirm before
adopting or creating — never do either silently, and never guess a project into
existence. Once a project is in scope, the enter_project handshake and the
host-memory pointer step above both apply.
When work DOES get a plan, Scribe is the plan's canonical home: it is a
milestone (see the Plan entry above), created with start_planning and written
into with update_milestone + child tasks. If a habit tells you to save a plan or
spec to a local `.md` file, that's superseded here — the milestone is the
record, not a file on disk. Whether a given piece of work wants a plan at all is
a separate question, answered by the arc test above and by the writing-plans
skill; these instructions do not mandate one.
Deletes are recoverable: every delete_* tool moves the entity (and its
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
auto-purges after the operator's retention window.
Scribe stores reusable Processes — saved prompts/workflows (note_type
"process"), e.g. a drift audit or a DRY pass. When the operator says "run the
X process" or otherwise references a saved process, call list_processes() /
get_process(name) and follow the returned prompt verbatim, including any
"clarify first" steps it contains. Author a new one with create_process(title,
body); edit with update_process.
Scribe also stores Snippets — reusable functions/components recorded once for
recall (note_type "snippet"): a name, language, signature, canonical location
(repo · path · symbol), a one-line "when to reach for it", and the code. They
are ordinary embedded notes, so a recorded snippet also surfaces through the
same search + proactive recall as everything else. Two reflexes: (1) before you
write a new helper/utility/component, search first (list_snippets(q=...) or
search) — reuse the prior art with get_snippet(id) instead of re-deriving a
one-off; (2) the moment you build or notice something reusable, record it with
create_snippet(name, code, when_to_use, language, signature, repo, path, symbol,
project_id, system_ids) so a later session is offered it. Make when_to_use sharp
— it becomes the title, which is what a recall menu shows. Edit an existing one
with update_snippet rather than recording a second copy; when the same reusable
thing already exists as several one-offs, unify them into one canonical record
with merge_snippets (it folds every call site in as a location and trashes the
duplicates). Keep the record honest: a snippet whose details have gone stale can
be corrected with update_snippet (an empty string clears a field), and one that
is wrong or obsolete should be retired with delete_snippet — a bad snippet keeps
being offered as prior art, which costs more than none at all.
Scribe is multi-user, so some records belong to other people. Anything another
user owns comes back marked `shared: true` with an `owner`. Treat a shared
record as THAT PERSON'S SUGGESTION, never as the operator's settled practice:
weigh it on its merits, attribute it when you reference it, and ask before
adopting it or acting on it. This matters most for a shared Process — do not run
one as written; describe what it would do and get the operator's go-ahead.
Records shared directly with the operator are also deliberately search-only:
they surface when you look for them (pass a query), not in plain lists, so
nobody else's material arrives unasked. Editing another user's record needs an
editor or admin share from them; a read-only share is refused, and the right
answer is usually to record the operator's own version rather than to push.
When developing Scribe itself, honor its multi-user sharing ACL: scope every
read and mutation of user data by owner + shares — never assume a single
operator. "Works for one user" is not done.
"""
# Tools a read-only API key may call. Anything not listed is treated as a
# write for read keys (default-deny), so a newly-added tool is locked down
# until explicitly classified here.
#
# The list stays EXPLICIT rather than being derived from the name. A read key is
# what you hand to something you don't fully trust — a dashboard, a CI job, a
# shared integration — and a boundary inferred from a naming convention grants
# access to whatever a future author happens to call `get_*`. Enumerating it is
# the point; staleness is the cost, and test_mcp_auth covers that (a read-shaped
# tool must appear here or in _DELIBERATELY_WRITE_SCOPED below, so adding one
# forces a decision instead of silently denying it).
#
# Membership means "reads the operator's data and mutates none of it". Several
# getters record a retrieval event via record_pulled; that is telemetry about
# the read itself, not a change to what was read, and it must keep working for a
# read key or the corpus's surfaced:pulled ratio silently under-counts whichever
# consumers hold one.
_READ_ONLY_TOOLS = frozenset({
"get_note", "get_project", "get_rule", "get_rulebook",
"get_task", "get_milestone", "get_recent", "enter_project",
@@ -90,33 +274,11 @@ _READ_ONLY_TOOLS = frozenset({
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
"list_always_on_rules", "search",
"get_system", "list_systems", "list_system_records",
# Reports on the corpus. Reads only — the merge or supersession each
# suggests is a separate, explicitly-called write.
"find_duplicate_snippets", "find_duplicate_records",
# Snippets and processes are notes with a kind. A key that may read a note
# but not a snippet inverts the sensitivity ordering: it exposes the
# free-text records and withholds the structured ones (#2496).
"get_snippet", "list_snippets",
"get_process", "list_processes",
# Design systems: read, resolve (inheritance + mode), render, and compare
# against recorded snippets. All four compute from stored records and write
# nothing — the drift report is a report, and applying it is a separate
# explicit call.
"get_design_system", "list_design_systems", "resolve_design_system",
"get_design_system_stylesheet", "list_design_tokens",
"check_snippets_against_design_system", "list_starter_role_groups",
# Which repos map to which project. Read-only by nature; bind_repo /
# unbind_repo are the writes.
"list_repo_bindings",
# Reports on the snippet corpus. Reads only — the merge it suggests is a
# separate, explicitly-called write.
"find_duplicate_snippets",
})
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
# creates on miss, a list that has a side effect. Empty today, and deliberately
# kept as a declared escape hatch rather than left implicit: without it, the
# completeness test would push a future `get_or_create_*` into the allow-list
# above, which is exactly the wrong way to make a test pass.
_DELIBERATELY_WRITE_SCOPED: frozenset[str] = frozenset()
async def _buffer_request_body(receive):
"""Drain the ASGI request body and return (body_bytes, replay_receive).
-41
View File
@@ -22,11 +22,6 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
from scribe.services.design_starter_roles import (
ALL_GROUPS,
DEFAULT_TOKEN_PREFIX,
describe_groups,
)
async def create_design_system(
@@ -34,17 +29,9 @@ async def create_design_system(
description: str = "",
guidance: str = "",
parent_id: int = 0,
starter_role_groups: list[str] | None = None,
token_prefix: str = "",
) -> dict:
"""Create a design system, optionally inheriting from another.
This — not a rulebook — is where visual standards live: a token can be
inherited, resolved per mode, rendered to a stylesheet and checked against
code, and none of that survives being written as rule prose. Once a
project points at one (set_project_design_system), treat it as binding for
that project's UI work.
Args:
title: What this system is — a house style, or one app within it
(required).
@@ -54,47 +41,20 @@ async def create_design_system(
parent_id: Inherit from this system — it holds the defaults this one
overrides. Omit (0) for a top-level "family" system, which is what
a first design system usually is.
starter_role_groups: Seed the system with named but VALUELESS token
roles, so there is something to reach for before a literal gets
written instead. Call list_starter_role_groups() for the catalogue.
Pass ["all"] for every group. Omit for none — a system with three
hand-written tokens is a legitimate design system.
token_prefix: Naming convention for the seeded roles, e.g. "--fs-".
Defaults to a neutral "--ds-"; pass the install's own if it has one.
Ignored when no starter groups are requested.
"""
uid = current_user_id()
groups = starter_role_groups
if groups and len(groups) == 1 and groups[0] == "all":
groups = list(ALL_GROUPS)
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
guidance=guidance or None,
parent_id=parent_id or None,
starter_role_groups=groups,
token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
async def list_starter_role_groups() -> dict:
"""The starter token ROLES offered at design-system creation.
Roles, not values. Every group is a set of named questions — "page
background, the deepest surface" — that the operator answers with their own
palette. Nothing here carries a colour, because a default palette would be
one install's taste shipped as product.
Reach for this before create_design_system so the choice is informed, and
pass the group names you want as `starter_role_groups`.
"""
return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX}
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
@@ -390,7 +350,6 @@ async def set_project_design_system(project_id: int, design_system_id: int = 0)
def register(mcp) -> None:
for fn in (
create_design_system,
list_starter_role_groups,
list_design_systems,
get_design_system,
resolve_design_system,
+13 -116
View File
@@ -14,11 +14,9 @@ Sentinel conventions (inherited from existing fable-mcp tools):
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import notes as notes_svc
from scribe.services import supersession as supersession_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
@@ -33,15 +31,10 @@ async def list_notes(
) -> dict:
"""List notes (non-task documents) stored in Scribe.
Optionally filter by a single tag (plain string, no # prefix) or by
`search_text`, which matches on MEANING — the same semantic match every
search surface uses (#2462; keyword ILIKE is only the fallback when the
embedder is down). With `search_text`, results order by relevance; without
it, by last-updated descending. Reaches your records plus shared-project
records, like every other list.
Optionally filter by a single tag (plain string, no # prefix) or a keyword
search against title and body. Results are ordered by last-updated descending.
Prefer `search` for a pure lookup — it returns scores and reaches
everything readable; this adds the lifecycle filters on top.
Use search for semantic/meaning-based lookup instead of exact keyword search.
Args:
project_id: Scope to one project. PASS THE ACTIVE PROJECT'S ID whenever a
@@ -62,44 +55,12 @@ async def list_notes(
return {"notes": [n.to_dict() for n in rows], "total": total}
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Add both directions of the supersession relation to a note payload.
Both, because they answer different questions and only one of them is
obvious. `supersedes` is what the author claimed. `superseded_by` is what a
READER needs and what the note itself cannot know — a stale record handed
over without that marker gets acted on confidently, which is worse than
never surfacing it.
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
permanently-empty lists. A field that always says nothing trains readers to
skip fields, which is the lesson `consolidated_at` cost us (#2483).
"""
rel = await supersession_svc.get_relations(uid, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
data["superseded_note"] = (
"A later note claims to bring this up to date — see superseded_by. "
"Read this as what was true when written, and check the newer one "
"before acting on it."
)
async def get_note(note_id: int) -> dict:
"""Fetch the full content of a single Scribe note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at,
updated_at — plus `systems` (the areas this note is filed under) or, for
an untagged project note, the `systems_hint` question.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
A note another user shared with you also carries `shared`, `owner` and
`permission` — read it as their suggestion, not as settled practice you set.
IF THE RESULT CARRIES `superseded_by`, a later note claims to have brought
this one up to date. It is still here and still readable — supersession
demotes, it never hides — but read it as what was true when written, and
open the newer note before acting on it.
"""
uid = current_user_id()
loaded = await notes_svc.get_note_for_user(uid, note_id)
@@ -113,10 +74,6 @@ async def get_note(note_id: int) -> dict:
# snippets would leave those permanently at zero pulls and make them look
# like dead weight next to snippets that merely had a counter (#2085).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
await _attach_supersession(uid, note_id, out)
await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id
)
return out
@@ -126,7 +83,6 @@ async def create_note(
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
supersedes: list[int] | None = None,
force: bool = False,
) -> dict:
"""Create a new note in Scribe.
@@ -138,15 +94,6 @@ async def create_note(
project_id: Associate with a project (use 0 for no project / orphan note).
system_ids: Ids of the project's Systems to associate this note with
(e.g. research about a subsystem). See list_systems / create_system.
supersedes: Ids of EARLIER notes this one replaces or brings up to date.
Reach for it whenever you write something that overtakes what an
older note recorded — a re-measurement, a decision that reverses an
earlier one, a dev-log covering ground a previous one covered.
The older note stays readable and keeps its place in search; it
simply stops competing with this one for the same question, and
arrives labelled when it does surface. This records a CLAIM, not a
verdict: it never says the older note was wrong, only that it is no
longer the current answer.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar note already exists in the same project, creation is
BLOCKED and the existing note's id is returned so you update it
@@ -155,10 +102,7 @@ async def create_note(
Returns the created note object including its assigned id, OR — when a
near-duplicate is found and force is false — {"duplicate": true,
"existing_id": ..., "message": ...} and nothing is created. A tagged
record shows its `systems`; created untagged in a project, the response
carries the `systems_hint` question instead — answer it: tag the record,
create the missing System, or deliberately leave it untagged.
"existing_id": ..., "message": ...} and nothing is created.
"""
uid = current_user_id()
if not force:
@@ -177,16 +121,11 @@ async def create_note(
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
if supersedes:
try:
await supersession_svc.set_supersedes(uid, note.id, supersedes)
except PermissionError as exc:
# The note WAS created — surface the real reason rather than a
# not-found, and leave the note rather than silently rolling it back.
raise ValueError(str(exc)) from exc
data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await _attach_supersession(uid, note.id, data)
if system_ids:
data["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
return data
@@ -197,7 +136,6 @@ async def update_note(
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
supersedes: list[int] | None = None,
) -> dict:
"""Update an existing Scribe note. Only explicitly provided fields are changed.
@@ -209,9 +147,6 @@ async def update_note(
project_id: New project association. Omit (or pass 0) to leave unchanged.
system_ids: Replace this note's System associations with these ids
(set-semantics). None = leave unchanged; [] = clear all.
supersedes: Replace the ids of earlier notes this one replaces
(set-semantics). None = leave unchanged; [] = clear all. See
create_note for when to reach for it.
"""
uid = current_user_id()
fields: dict = {}
@@ -228,51 +163,14 @@ async def update_note(
raise ValueError(f"note {note_id} not found")
if system_ids is not None:
await systems_svc.set_record_systems(uid, note_id, system_ids)
if supersedes is not None:
try:
await supersession_svc.set_supersedes(uid, note_id, supersedes)
except PermissionError as exc:
raise ValueError(str(exc)) from exc
data = note.to_dict()
await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
)
await _attach_supersession(uid, note_id, data)
if system_ids is not None:
data["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note_id)
]
return data
async def find_duplicate_records(kind: str = "note", threshold: float = 0.0) -> dict:
"""Notes or tasks already recorded that closely resemble each other.
The create gate PREVENTS a duplicate arriving through an agent; the web UI
deliberately has no gate (a human mid-thought must not be blocked by a 409),
which makes YOU the corrections system — and a corrector has to be able to
SEE what needs correcting. This is the finder. Run it when tidying a
project's records, or when you suspect the same ground was covered twice.
Args:
kind: "note" (documents) or "task". Snippets have their own report,
find_duplicate_snippets, whose groups propose a lossless merge.
threshold: Similarity floor, 0-1. 0 uses the configured setting.
READ THE `suggestion` FIELD BEFORE ACTING, because the right fix differs by
what the records ARE, not how alike they score. For notes: a correction
pair → declare `supersedes` on the newer; state smeared across dated
records → extract it into the System's reference note and leave these as
history; genuinely parallel records → leave them alone. NEVER merge notes.
Each group carries `members` with dates and any `existing_supersessions`
already declared inside it — a pair someone has ruled on is not an open
question.
"""
uid = current_user_id()
if kind not in ("note", "task"):
raise ValueError('kind must be "note" or "task" — snippets have their '
"own report, find_duplicate_snippets")
return await dedup_svc.find_duplicate_records(
uid, kind=kind, threshold=threshold if threshold > 0 else None,
)
async def delete_note(note_id: int) -> dict:
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
uid = current_user_id()
@@ -289,7 +187,6 @@ def register(mcp) -> None:
get_note,
create_note,
update_note,
find_duplicate_records,
delete_note,
):
mcp.tool(name=fn.__name__)(fn)
+2 -89
View File
@@ -8,11 +8,8 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
@@ -44,47 +41,17 @@ async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
return {"processes": procs, "total": total}
async def create_process(
title: str, body: str, tags: list[str] | None = None, force: bool = False,
) -> dict:
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
"""Create a stored process (a reusable saved prompt).
AUTHOR IT AS A SHAPE, NOT A SCRIPT. A process's value is the accumulated
procedure — the steps, the taxonomy, the quality bar, the failure modes
worth guarding. It must not force anything the invoking conversation
didn't choose: no embedded approach mandates, no pre-granted approvals
(a fan-out opt-in, a permission to act), no assumptions that overwrite
the live request's intent. The conversation that invokes it supplies the
parameters and always wins where they disagree; write clarify steps to
seed from what the operator already said, not to re-ask it.
Args:
title: Process name, e.g. "Drift Audit" (required).
body: The full prompt to run later (markdown). Required.
tags: Plain-string tags, no # prefix.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar process already exists, creation is BLOCKED and the
existing one's id is returned so you update it instead. Set true
only for a genuinely distinct procedure.
Returns the created process, OR — when a near-duplicate is found and force
is false — {"duplicate": true, "existing_id": ..., "message": ...} (nothing
created).
The gate matters more here than for other kinds: every process becomes a
skill file that auto-surfaces on the operator's machine, so two near-identical
procedures don't merely bloat the corpus — they compete to be followed, and
which one wins is decided by a slug.
"""
if not (title or "").strip() or not (body or "").strip():
raise ValueError("create_process requires a non-empty title and body")
uid = current_user_id()
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, is_task=False, note_type="process",
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "process")
note = await notes_svc.create_note(
uid, title=title.strip(), body=body, note_type="process", tags=tags,
)
@@ -96,16 +63,6 @@ async def get_process(name_or_id: str) -> dict:
fire mechanism. The operator says "run the <name> process"; call this and
follow the returned body (including any 'clarify first' steps it contains).
COMPOSITION CONTRACT — a process is the PROCEDURE, not the whole prompt.
The conversation that invoked it supplies the PARAMETERS: fold the
operator's live constraints, scope, and focus areas into the procedure,
and wherever the two disagree, the live instructions override the
process's defaults. A 'clarify first' step asks only what the
conversation has NOT already answered — confirm your interpretation of
what was said rather than re-asking it, and turn stated concerns (a
posture, a hardware budget, a subsystem under suspicion) into lenses the
procedure applies, not text it discards.
Resolution: numeric id → exact (case-insensitive) title → substring. On an
ambiguous substring match, the best (most-recent) match is returned with an
`other_matches` list so you can disambiguate with the operator.
@@ -125,12 +82,6 @@ async def get_process(name_or_id: str) -> dict:
if candidates:
out["other_matches"] = candidates
out.update(await access_svc.describe_provenance(uid, note))
# A process is embedded like any other note, so auto-inject can surface one —
# and its menu header names THIS tool as the way to open that kind. Without
# this, the getter the product points at is the one getter that records
# nothing, and every process sits permanently at zero pulls looking like dead
# weight beside kinds that merely had a counter (#2476, the repeat of #2245).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_process")
return out
@@ -168,44 +119,6 @@ async def update_process(process_id: int, title: str = "", body: str = "",
return out
async def delete_process(process_id: int) -> dict:
"""Retire a stored process — it moves to the trash and is recoverable.
Reach for this when a procedure is wrong, superseded, or was never worth
keeping. A stored process is installed as a skill file on the operator's
machine and auto-surfaces there, so a bad one is followed rather than merely
ignored — it costs more than a missing one.
Deletion was always possible through `delete_note` (a process is a note, and
the trash is kind-agnostic), but nothing said so, and a kind whose own tools
offer create/read/update reads as one you cannot retire (#2250).
"""
uid = current_user_id()
loaded = await notes_svc.get_note_for_user(uid, process_id)
note = loaded[0] if loaded else None
# Check the KIND before deleting: this tool is reached for by name, and
# letting it trash an ordinary note because the id happened to resolve would
# be a destructive action taken on a mistyped argument.
if note is None or note.note_type != "process" or note.deleted_at is not None:
raise ValueError(f"process {process_id} not found")
batch = await trash_svc.delete(uid, "note", process_id)
if batch is None:
raise ValueError(f"process {process_id} not found")
return {
"deleted_batch_id": batch,
"message": (
f"Process {process_id} moved to trash. Restore with restore('{batch}'). "
f"Its skill stub disappears on the operator's next process sync."
),
}
def register(mcp) -> None:
for fn in (
list_processes,
create_process,
get_process,
update_process,
delete_process,
):
for fn in (list_processes, create_process, get_process, update_process):
mcp.tool(name=fn.__name__)(fn)
+1 -49
View File
@@ -22,9 +22,7 @@ from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_surfaced
async def list_projects() -> dict:
@@ -56,33 +54,13 @@ async def enter_project(project_id: int) -> dict:
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes, design_system, systems.
`systems` is the project's vocabulary of named subsystems/areas. It is
returned here so you can TAG as you write: when creating or meaningfully
updating a record, ask which of these areas it is about and pass their ids
as `system_ids`. If the area a record describes is missing from this list,
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records.
open_tasks, recent_notes, design_system.
`design_system` is null unless the project points at one. When present it
carries the chain-merged guidance (the house style AND this project's
departures from it) plus a summary of the token set — treat it as binding
for any UI you write, and pull the values with resolve_design_system or
get_design_system_stylesheet before reaching for a literal.
Entering a project also SCOPES the session: reference and offer work on
this project only, and pass its id to search / list_* so results stay
inside it. If something clearly belongs to a different project, say so and
ask before switching — never silently operate cross-project. The active
project does not stick on the server (each call is self-contained);
carrying its id forward is on you.
Don't wait to be told which project is in scope. When work clearly belongs
to a project but none is entered, look for a match yourself (list_projects
/ search on the repo or subject), propose it, and enter it once the
operator confirms; if nothing matches, offer to create one — confirming
name and goal first, never guessing a project into existence.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
@@ -103,23 +81,6 @@ async def enter_project(project_id: int) -> dict:
uid, is_task=False, project_id=project_id,
sort="updated_at", limit=5,
)
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
# records to Systems" is only executable if the list is in front of the
# agent when it writes — which it never was, and tagging stopped within
# three days of the feature landing (#2546's audit).
systems = await systems_svc.list_systems(uid, project_id)
# Probably the largest surfacing by volume, and it emitted nothing — so
# the pulls it caused floated unattributed and the surfaced:pulled ratio
# ran against a denominator missing its biggest contributor (#2477). An
# AMBIENT source: these are top-N-by-recency, not a ranked choice, and the
# readout counts them apart so dead-weight detection isn't poisoned by
# "recently updated in a project you opened".
record_surfaced(
user_id=uid,
note_ids=[int(t.id) for t in open_tasks] + [int(n.id) for n in recent_notes],
source="enter_project",
)
# A project need not have one, and most installs won't — null is ordinary
# here, not a missing prerequisite.
design_system = None
@@ -130,15 +91,6 @@ async def enter_project(project_id: int) -> dict:
return {
"project": project.to_dict(),
# Trimmed to what tagging needs. The full charter is get_system's job —
# this list rides along on every session start, so it stays lean.
"systems": [
{
"id": s.id, "name": s.name,
"description": (s.description or "").split("\n")[0][:200],
}
for s in systems
],
"design_system": design_system,
"milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"],
-8
View File
@@ -271,14 +271,6 @@ async def create_rule(
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares,
put it in a themed subscribed rulebook, not the always-on one.
Before writing a rule at all, check whether another entity already models
the thing. A rule is prose an agent must remember and apply; the others
are structure a tool can resolve, render and check. Visual standards are a
DESIGN SYSTEM (tokens inherit, resolve per mode, render to a stylesheet —
none of that survives being prose). A repeatable procedure is a PROCESS.
Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely
is a standing instruction about how to work and nothing else can hold it.
Args:
topic_id: The topic to attach the rule to.
title: A short imperative title (e.g. "dev is home").
-7
View File
@@ -20,7 +20,6 @@ async def search(
content_type: str = "all",
limit: int = 10,
project_id: int = 0,
system_id: int = 0,
) -> dict:
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
@@ -40,11 +39,6 @@ async def search(
enter_project) — otherwise this searches across ALL projects and
bleeds unrelated work into the result set. 0 = search everything
(use only when you genuinely want a cross-project sweep).
system_id: Narrow to records tagged to one System (a named
subsystem/area — enter_project lists them). Use when investigating
a specific subsystem: it cuts the candidates to records someone
deliberately filed under that area. 0 = no system filter.
list_system_records gives the same slice unranked.
Returns:
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
@@ -61,7 +55,6 @@ async def search(
raw = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task,
project_id=project_id or None,
system_id=system_id or None,
# An explicit search reaches everything the operator may read, including
# records shared with them one-to-one.
scope="read",
+11 -24
View File
@@ -11,7 +11,6 @@ on create, System association passthrough).
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import snippets as snippets_svc
@@ -128,22 +127,12 @@ async def create_snippet(
force: Bypass the near-duplicate gate (see below).
Returns the created snippet (including a parsed `snippet` field), OR — when a
duplicate already exists and force is false — {"duplicate": true,
near-duplicate snippet already exists and force is false — {"duplicate": true,
"existing_id": ..., "message": ...} and nothing is created. When that happens
and it really is the same reusable thing found in another place, prefer
merge_snippets(existing_id, [new...]) — or record then merge — to unify them
into ONE canonical record (which then carries every call site as a location),
rather than forcing a second copy with force=true. A tagged record shows
its `systems`; created untagged in a project, the response carries the
`systems_hint` question instead — answer it: tag, create the missing
System, or deliberately skip.
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
same repo · path · symbol, or holding byte-identical code. Those are certain,
and force is almost never the right answer to them. Only then a semantic
check, held to a high bar so that VARIANTS of one component are not refused:
`.btn-primary` and `.btn-secondary` read alike and are two different things,
so record both (#2518).
rather than forcing a second copy with force=true.
"""
if not (name or "").strip() or not (code or "").strip():
raise ValueError("create_snippet requires a non-empty name and code")
@@ -159,10 +148,6 @@ async def create_snippet(
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
# The artefact itself, not just its description — the gate compares
# location and code before it compares prose (#2518).
code=code,
locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "snippet")
@@ -175,7 +160,10 @@ async def create_snippet(
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
data = snippets_svc.snippet_to_dict(note)
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
if system_ids:
data["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
return data
@@ -199,9 +187,6 @@ async def get_snippet(snippet_id: int) -> dict:
# paths, and counting those would inflate exactly the number that is
# supposed to mean "someone chose to look at this" (#2085).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_snippet")
await systems_tools.attach_systems(
uid, note.user_id, data, note.id, note.project_id
)
return data
@@ -394,9 +379,11 @@ async def update_snippet(
await systems_svc.set_record_systems(note.user_id, snippet_id, system_ids)
data = snippets_svc.snippet_to_dict(note)
data.update(await access_svc.describe_provenance(uid, note))
await systems_tools.attach_systems(
uid, note.user_id, data, snippet_id, note.project_id
)
if system_ids is not None:
data["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
]
return data

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