Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf55fc488 | ||
|
|
fefae606ed | ||
|
|
5795fa908a | ||
|
|
be3a0ffaf9 | ||
|
|
da6bb815bb | ||
|
|
5f8b824523 | ||
|
|
3fc693443e | ||
|
|
3d6931b838 | ||
|
|
c7cf07824a | ||
|
|
67fdf7c55b | ||
|
|
37616682f0 | ||
|
|
97b93bcaea | ||
|
|
f491b6d7b9 | ||
|
|
1a959b1db0 | ||
|
|
6d01788326 | ||
|
|
17d59fa3e0 | ||
|
|
6a6a388ecd | ||
|
|
8288c6e4a7 | ||
|
|
2cc9e1380e | ||
|
|
84541f392b | ||
|
|
da2383b079 | ||
|
|
f8522fb28f | ||
|
|
5c51e29f26 | ||
|
|
4c9a637507 | ||
|
|
731ca284c3 | ||
|
|
1e139d0d18 | ||
|
|
6eedb0f6b9 | ||
|
|
378a4b8f99 | ||
|
|
716f227bc7 | ||
|
|
67a529a38e | ||
|
|
473280e690 | ||
|
|
d1d335e293 | ||
|
|
1fde646c60 | ||
|
|
7872e7d9ec | ||
|
|
23a385e2db | ||
|
|
15eae532bd | ||
|
|
8eef9e7845 | ||
|
|
f00e9747ad | ||
|
|
15d2e0c682 | ||
|
|
0f80b790c7 | ||
|
|
46d88f9e7e | ||
|
|
b0a7d9e89b | ||
|
|
4dc57f8ab2 | ||
|
|
3da40abcb8 | ||
|
|
78489308b8 | ||
|
|
0937b1761e | ||
|
|
143b968c5d | ||
|
|
839d6902ad | ||
|
|
03b3998585 | ||
|
|
4ca3ab02c4 | ||
|
|
d3ee24f239 | ||
|
|
3c0192d749 | ||
|
|
61e6e38419 | ||
|
|
293a14361a | ||
|
|
1adf57739d | ||
|
|
6ca215d2b6 | ||
|
|
390846a3d5 | ||
|
|
a36837c96a | ||
|
|
57781770c3 | ||
|
|
0550bf4687 | ||
|
|
1fb883b72f | ||
|
|
05b64ccacb |
@@ -165,7 +165,18 @@ jobs:
|
||||
# ruff is pre-installed in the ci-python image — no install
|
||||
# step needed, lint runs in ~2s.
|
||||
- name: Lint
|
||||
run: ruff check src/
|
||||
run: ruff check src/ scripts/
|
||||
|
||||
# Design tokens: does the frontend's CSS agree with the stylesheet the
|
||||
# design system generates? Fails only on an unresolvable var() reference —
|
||||
# that count is at zero, so this is a ratchet rather than a backlog. The
|
||||
# literal findings are printed, not gated; hundreds exist and a
|
||||
# permanently-red job is one nobody reads.
|
||||
#
|
||||
# Stdlib only, no install, no network: the source of truth is theme.css,
|
||||
# which is generated from the design system and committed.
|
||||
- name: Design token check
|
||||
run: python3 scripts/check_design_tokens.py --report-literals
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""design systems + tokens, and the project pointer
|
||||
|
||||
Revision ID: 0072
|
||||
Revises: 0071
|
||||
Create Date: 2026-07-30
|
||||
|
||||
Makes the design system a first-class record instead of prose in a rulebook: a
|
||||
named set of tokens with an optional parent, so a family system holds the house
|
||||
style and an app system holds only what it changes.
|
||||
|
||||
`design_systems.parent_id` is the whole model. It replaces both an `always_on`
|
||||
flag (a family system is one with no parent) and a subscription join table (a
|
||||
project points at ONE system, and the chain supplies the rest), which is less
|
||||
schema than the rulebook shape it mirrors.
|
||||
|
||||
Two deliberate choices worth stating here rather than leaving to be re-derived:
|
||||
|
||||
- **`design_tokens.value_by_mode` is JSONB keyed by mode**, not `value_light` +
|
||||
`value_dark` columns. In a child system an unset mode means "inherit"; in a
|
||||
root it would mean "not mode-dependent", and as columns both are NULL and
|
||||
indistinguishable. As a map, resolution is a dict merge at every level with
|
||||
no special case for roots — and a third mode (high-contrast, print) is data
|
||||
rather than a schema change. The cost is that a typo'd mode key is not
|
||||
rejected by the database. Nothing filters tokens by value in SQL, so the
|
||||
queryability the columns would have bought is for a query no caller makes.
|
||||
(Named `value_by_mode` rather than `values`, which is reserved in SQL.)
|
||||
- **`group_name` is free text, not a CHECK enum.** Groupings are each design
|
||||
system's own vocabulary; a whitelist would bake one install's kit into the
|
||||
schema. No CHECK is introduced anywhere in this migration.
|
||||
|
||||
`parent_id` and `projects.design_system_id` are both ON DELETE SET NULL. Deleting
|
||||
a family system must orphan its children into roots that still hold their own
|
||||
overrides, not cascade away every app system that inherited from it; deleting a
|
||||
system a project points at must unstyle that project, not delete it.
|
||||
|
||||
Downgrade drops both tables and the column. Any design system defined this way
|
||||
is lost — this is the migration that introduces the concept, so there is no
|
||||
earlier representation to fall back to.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0072"
|
||||
down_revision = "0071"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"design_systems",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"owner_user_id", sa.BigInteger(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"parent_id", sa.BigInteger(),
|
||||
sa.ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_design_systems_owner_user_id", "design_systems", ["owner_user_id"]
|
||||
)
|
||||
op.create_index("ix_design_systems_parent_id", "design_systems", ["parent_id"])
|
||||
|
||||
op.create_table(
|
||||
"design_tokens",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"design_system_id", sa.BigInteger(),
|
||||
sa.ForeignKey("design_systems.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
# NOT NULL with a '{}' default: a nullable JSONB column has two empty
|
||||
# states (SQL NULL and JSON null) and every reader has to test for both.
|
||||
sa.Column(
|
||||
"value_by_mode", JSONB,
|
||||
nullable=False, server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column("group_name", sa.Text(), nullable=True),
|
||||
sa.Column("purpose", sa.Text(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_design_tokens_design_system_id", "design_tokens", ["design_system_id"]
|
||||
)
|
||||
# Partial unique: a name is unique among LIVE tokens in a system. Two live
|
||||
# rows with the same name are a duplicate definition and the cascade would
|
||||
# pick between them arbitrarily; a trashed row must not block reusing its
|
||||
# name.
|
||||
op.create_index(
|
||||
"uq_token_per_design_system", "design_tokens", ["design_system_id", "name"],
|
||||
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"projects", sa.Column("design_system_id", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_projects_design_system_id", "projects", "design_systems",
|
||||
["design_system_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_projects_design_system_id", "projects", type_="foreignkey")
|
||||
op.drop_column("projects", "design_system_id")
|
||||
op.drop_index("uq_token_per_design_system", table_name="design_tokens")
|
||||
op.drop_index("ix_design_tokens_design_system_id", table_name="design_tokens")
|
||||
op.drop_table("design_tokens")
|
||||
op.drop_index("ix_design_systems_parent_id", table_name="design_systems")
|
||||
op.drop_index("ix_design_systems_owner_user_id", table_name="design_systems")
|
||||
op.drop_table("design_systems")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""design_tokens.supersedes — the literals a token should be used instead of
|
||||
|
||||
Revision ID: 0073
|
||||
Revises: 0072
|
||||
Create Date: 2026-07-30
|
||||
|
||||
Records what a prohibition was actually trying to say.
|
||||
|
||||
A design rulebook writes "pure white #FFFFFF is NEVER used as text color". That
|
||||
sentence has no row in a table of tokens, because a design system stores what
|
||||
things ARE — which looked like a gap in the model and was really a sentence
|
||||
written backwards. The positive fact is "text is Parchment", and the useful
|
||||
record is the mapping from the literal someone would otherwise write to the
|
||||
token they should write instead.
|
||||
|
||||
So `supersedes` is a JSONB array of literal values, e.g. `["#fff", "#ffffff"]`
|
||||
on a text-on-action token. A finding built from it can say what to write, not
|
||||
merely what not to.
|
||||
|
||||
It must be DECLARED rather than derived. `#fff` and Parchment `#E8E4D8` are
|
||||
different colours, so no value-matching rule could ever have connected them —
|
||||
which is exactly why the prohibition felt unrepresentable until it was turned
|
||||
around.
|
||||
|
||||
Consumed by the source lint that reads component CSS, not by the drift panel:
|
||||
these literals are in the components, which the panel cannot see.
|
||||
|
||||
NOT NULL with a `'[]'` default, matching `value_by_mode` — a nullable JSONB
|
||||
column has two empty states and every reader has to test for both.
|
||||
|
||||
Downgrade drops the column; the declarations are lost, which costs the lint its
|
||||
input and nothing else.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0073"
|
||||
down_revision = "0072"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"design_tokens",
|
||||
sa.Column(
|
||||
"supersedes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("design_tokens", "supersedes")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""central prose on a design system, and rationale on a token
|
||||
|
||||
Revision ID: 0074
|
||||
Revises: 0073
|
||||
Create Date: 2026-07-31
|
||||
|
||||
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
|
||||
on the system. Two fields rather than one per category:
|
||||
|
||||
design_systems.guidance the narrative a token table cannot hold — aesthetic,
|
||||
voice and tone, what is deliberately out of scope.
|
||||
Markdown, free-form.
|
||||
design_tokens.rationale WHY this token is this value, which is a different
|
||||
question from `purpose` (what it is FOR). "Success
|
||||
equals Moss, aligned by design" is a rationale;
|
||||
"page bg, deepest surface" is a purpose.
|
||||
|
||||
Free-form rather than a column per category on purpose. A schema with `voice`,
|
||||
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
|
||||
every install (rule #115), and the next install's design system would have three
|
||||
empty columns and nowhere to put what it actually cares about.
|
||||
|
||||
Both nullable: a design system with no prose at all is complete, not a draft.
|
||||
|
||||
Downgrade drops both columns and the prose with them.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0074"
|
||||
down_revision = "0073"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("design_systems", sa.Column("guidance", sa.Text(), nullable=True))
|
||||
op.add_column("design_tokens", sa.Column("rationale", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("design_tokens", "rationale")
|
||||
op.drop_column("design_systems", "guidance")
|
||||
+1
-1
@@ -22,7 +22,7 @@ real Postgres), build (docker buildx).
|
||||
- uv (test + integration jobs run `uv sync --locked`; installed in the
|
||||
image since the ci-python Dockerfile started pip-installing it)
|
||||
- docker CLI + buildx (build job pushes the production image to the
|
||||
Forgejo registry)
|
||||
Fabled-Git registry)
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ onUnmounted(() => {
|
||||
z-index: 9999;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 0 0 4px 4px;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Design systems — the stylesheet held as records (milestone #254).
|
||||
*
|
||||
* A design system is a named set of tokens with an optional parent. A system
|
||||
* with no parent is a "family"; one with a parent holds ONLY what it changes,
|
||||
* so "what does this app alter?" is a plain list rather than a diff.
|
||||
*/
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
|
||||
|
||||
export interface DesignSystem {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
|
||||
guidance: string;
|
||||
parent_id: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** A token as STORED — one system's own row for it. */
|
||||
export interface DesignToken {
|
||||
id: number;
|
||||
design_system_id: number;
|
||||
name: string;
|
||||
/** Values keyed by mode. `base` applies when no mode is more specific. */
|
||||
value_by_mode: Record<string, string>;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
/** WHY it is this value — distinct from `purpose`, which is what it is FOR. */
|
||||
rationale: string | null;
|
||||
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
|
||||
*
|
||||
* How a design system records what a prohibition was trying to say: not
|
||||
* "white is banned" but "write this token instead". Declared rather than
|
||||
* inferred, because a superseded literal and the token's own value are
|
||||
* usually different values and nothing could connect them by matching. */
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface Contribution {
|
||||
system_id: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A token after the cascade.
|
||||
*
|
||||
* `contributions` is every system that offered a value, per mode, DEEPEST
|
||||
* FIRST — entry 0 won and the rest were shadowed. `value_by_mode` and
|
||||
* `origin_by_mode` are the winners, provided so the client never has to derive
|
||||
* them (and so it cannot derive them differently).
|
||||
*
|
||||
* Provenance is per MODE because overriding is: a system can own `base` and
|
||||
* inherit `dark` at the same time.
|
||||
*/
|
||||
export interface ResolvedToken {
|
||||
name: string;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
rationale: string | null;
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
value_by_mode: Record<string, string>;
|
||||
origin_by_mode: Record<string, number>;
|
||||
contributions: Record<string, Contribution[]>;
|
||||
}
|
||||
|
||||
export const fetchDesignSystems = () =>
|
||||
apiGet<{ design_systems: DesignSystem[] }>("/api/design-systems");
|
||||
|
||||
export const fetchDesignSystem = (id: number) =>
|
||||
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
||||
|
||||
export const createDesignSystem = (body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||
|
||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||
export const updateDesignSystem = (
|
||||
id: number,
|
||||
body: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
},
|
||||
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
|
||||
|
||||
export const deleteDesignSystem = (id: number) =>
|
||||
apiDelete(`/api/design-systems/${id}`);
|
||||
|
||||
/** The EFFECTIVE set: everything inherited, with this system's on top. */
|
||||
export const fetchResolvedTokens = (id: number) =>
|
||||
apiGet<{ design_system_id: number; tokens: ResolvedToken[] }>(
|
||||
`/api/design-systems/${id}/resolved`,
|
||||
);
|
||||
|
||||
/** This system's OWN tokens — its override set. */
|
||||
export const fetchDesignTokens = (id: number) =>
|
||||
apiGet<{ tokens: DesignToken[] }>(`/api/design-systems/${id}/tokens`);
|
||||
|
||||
export const createDesignToken = (
|
||||
designSystemId: number,
|
||||
body: {
|
||||
name: string;
|
||||
value_by_mode?: Record<string, string>;
|
||||
group_name?: string | null;
|
||||
purpose?: string | null;
|
||||
rationale?: string | null;
|
||||
supersedes?: string[];
|
||||
order_index?: number;
|
||||
},
|
||||
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
|
||||
|
||||
export const updateDesignToken = (
|
||||
tokenId: number,
|
||||
body: Partial<Omit<DesignToken, "id" | "design_system_id">>,
|
||||
) => apiPatch<DesignToken>(`/api/design-tokens/${tokenId}`, body);
|
||||
|
||||
export const deleteDesignToken = (tokenId: number) =>
|
||||
apiDelete(`/api/design-tokens/${tokenId}`);
|
||||
|
||||
/** Point a project at a design system. `null` clears it. */
|
||||
export const setProjectDesignSystem = (
|
||||
projectId: number,
|
||||
designSystemId: number | null,
|
||||
) =>
|
||||
apiPut<{ project_id: number; design_system_id: number | null }>(
|
||||
`/api/projects/${projectId}/design-system`,
|
||||
{ design_system_id: designSystemId },
|
||||
);
|
||||
|
||||
export interface StylesheetResult {
|
||||
design_system_id: number;
|
||||
/** The master sheet: purpose tokens only, no element or class rules. */
|
||||
css: string;
|
||||
token_count: number;
|
||||
/** Tokens the system names but has no value for yet. */
|
||||
valueless: string[];
|
||||
/** Values declared under more than one name — alias, or one idea twice. */
|
||||
duplicates: Record<string, string[]>;
|
||||
derivation: {
|
||||
/** Tokens computed from others, mapped to what they're computed from. */
|
||||
derived: Record<string, string[]>;
|
||||
/** Formulas pointing at tokens that don't exist — the browser drops these. */
|
||||
unknown_refs: Record<string, string[]>;
|
||||
/** Derivation loops, which resolve to nothing for the same reason. */
|
||||
cycles: string[][];
|
||||
};
|
||||
}
|
||||
|
||||
/** The master CSS sheet a design system generates.
|
||||
*
|
||||
* Purpose tokens only. Components (buttons, tables, input schemes) are
|
||||
* snippets that reference these names, so a value is stated once and reused
|
||||
* rather than restated per element. */
|
||||
export const fetchStylesheet = (id: number) =>
|
||||
apiGet<StylesheetResult>(`/api/design-systems/${id}/stylesheet`);
|
||||
|
||||
export interface SnippetFinding {
|
||||
snippet_id: number;
|
||||
title: string;
|
||||
/** References that resolve against the sheet. */
|
||||
used: string[];
|
||||
/** `var(--x)` where the system has no `--x` — renders as nothing at all. */
|
||||
unknown: string[];
|
||||
/** Literals the sheet says to stop writing, paired with what to write. */
|
||||
superseded_literals: { literal: string; use_instead: string }[];
|
||||
/** Custom properties the snippet mints for itself instead of reusing. */
|
||||
local_definitions: string[];
|
||||
}
|
||||
|
||||
export interface SnippetCheck {
|
||||
design_system_id: number;
|
||||
checked: number;
|
||||
/** Only snippets with something to act on; clean ones are omitted. */
|
||||
findings: SnippetFinding[];
|
||||
}
|
||||
|
||||
/** Which recorded snippets disagree with this design system's sheet. */
|
||||
export const checkSnippets = (id: number) =>
|
||||
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
||||
@@ -88,6 +88,11 @@ export interface SnippetListItem {
|
||||
* them, not one of your own. Absent means it's yours. */
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
/** The recorded language, when one was given. Projected from the `data`
|
||||
* mirror so lists can show it without parsing the body — and so a prior-art
|
||||
* hit can be flagged as being in a DIFFERENT language than the file being
|
||||
* written, which is a shape to adapt rather than code to paste. */
|
||||
language?: string;
|
||||
/** Always present from the backend, zero-filled for records with no events. */
|
||||
usage?: SnippetUsage;
|
||||
/** Present on the detail record; the list feed carries it when a check has
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Shared component styles — the house recipes, in one place.
|
||||
* ===========================================================================
|
||||
*
|
||||
* WHY THIS FILE EXISTS
|
||||
*
|
||||
* `.btn-primary` was defined five times, in five scoped stylesheets, and all
|
||||
* five had drifted: three paddings, three font sizes, three disabled opacities,
|
||||
* and one view with no disabled style at all (#2273). Nothing detected that,
|
||||
* because a scoped duplicate is invisible to every tool — it isn't a rule
|
||||
* violation, isn't a broken reference, and isn't a recorded snippet.
|
||||
*
|
||||
* GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or
|
||||
* scale change moves the buttons rather than stranding a copy that no longer
|
||||
* matches.
|
||||
*
|
||||
* A button is `variant + size`, composed in the template:
|
||||
* btn-primary a page action
|
||||
* btn-primary btn-compact a row action
|
||||
* btn-ghost btn-inline an affordance inside a card
|
||||
* btn-primary btn-block a form's single submitting action
|
||||
* Semantic per-view names (.btn-save, .btn-delete-task, …) were the thing that
|
||||
* drifted, because a name says what a button is FOR and nothing about what it
|
||||
* should look like — so two buttons doing the same job in two views had no
|
||||
* reason to match, and didn't.
|
||||
*
|
||||
* MIGRATION NOTE — this file is deliberately safe to land ahead of the removals.
|
||||
* These are plain selectors (specificity 0,1,0); a Vue `<style scoped>` rule
|
||||
* compiles to `.btn-primary[data-v-…]` (0,2,0) and therefore WINS. So a view
|
||||
* still carrying its own copy is unaffected until that copy is deleted, and
|
||||
* every intermediate state of the migration is coherent.
|
||||
*/
|
||||
|
||||
/* --- the shared shape ---------------------------------------------------- */
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger,
|
||||
.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 */
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-label); /* 12px */
|
||||
font-weight: var(--fs-weight-medium); /* 500 — the heaviest the system goes */
|
||||
line-height: var(--fs-leading-body);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
||||
|
||||
/* One rule, so a disabled button can never look enabled in one view and
|
||||
* disabled in another — which is exactly what ProjectListView shipped, having
|
||||
* no disabled style at all. */
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-danger-outline:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary:focus-visible,
|
||||
.btn-secondary:focus-visible,
|
||||
.btn-ghost:focus-visible,
|
||||
.btn-danger:focus-visible,
|
||||
.btn-danger-outline:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* --- variants ------------------------------------------------------------ */
|
||||
|
||||
/* The accent is deliberately ABSENT from every filled variant. Action colours
|
||||
* are universal across the family so a Save button looks identical in every
|
||||
* app — the accent is identity, not action. */
|
||||
.btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-action-secondary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-secondary:not(:disabled):hover {
|
||||
background: var(--color-action-secondary-hover);
|
||||
}
|
||||
|
||||
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
|
||||
* are the same token rather than two values that happen to match. Hover moves
|
||||
* the BORDER, not the text to the accent — SnippetDetailView tinted the label
|
||||
* with the accent on hover, which the house style reserves for identity and
|
||||
* active state, not for general chrome. */
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-ghost:not(:disabled):hover {
|
||||
border: var(--fs-border-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(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-danger:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* A bare text button: no fill, no border. The most common shape in the dense
|
||||
* surfaces — a dismiss, a cancel next to a confirm, a clear-search — where a
|
||||
* border would draw a box around something that should read as an action on
|
||||
* the text beside it. Distinct from ghost, which IS a box. */
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
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);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* Destructive, outlined — fills on hover. Already existed independently in
|
||||
* three views before this sheet, which is what makes it a variant rather than
|
||||
* a one-off: it is what a delete looks like when it must not shout. */
|
||||
.btn-danger-outline {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
}
|
||||
.btn-danger-outline:not(:disabled):hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
/* --- size modifiers ------------------------------------------------------
|
||||
*
|
||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
||||
* bespoke button rules before this scale existed, vertical padding fell into
|
||||
* clusters rather than a spread: ~27 at 0.4–0.45rem, ~28 at 0.25–0.3rem, ~23
|
||||
* at 0.1–0.15rem. Those are three different components — a page action, a row
|
||||
* action, and an affordance living inside a card — that happen to share a name
|
||||
* prefix. Collapsing them to one size would visibly break the card layouts.
|
||||
*
|
||||
* A button carries its size modifier; the DEFAULT (no modifier) is the page
|
||||
* action, which is the one the house style specifies.
|
||||
*/
|
||||
|
||||
/* Row actions: a toolbar, a table row, a list item's controls. */
|
||||
.btn-compact,
|
||||
.btn-small, /* pre-existing spellings, kept so no template churns */
|
||||
.btn-sm {
|
||||
padding: var(--fs-space-1) var(--fs-space-3); /* 4px 12px */
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
|
||||
/* Inline affordances: a dismiss ×, a confirm tick, an add-chip — things that
|
||||
* sit INSIDE a line of text or a card and must not disturb its rhythm. Below
|
||||
* the spacing scale's first step on the vertical axis by necessity: 4px of
|
||||
* padding on a 11px label already exceeds the line box these live in. */
|
||||
.btn-inline {
|
||||
padding: 2px var(--fs-space-1); /* 2px 4px */
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Full width, for a form's single submitting action — the auth screens. Width
|
||||
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
||||
because a full-width button
|
||||
is the page's main action */
|
||||
font-size: var(--fs-size-body-sm);
|
||||
}
|
||||
@@ -34,86 +34,11 @@
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||
the software" — not a brand moment. Accent gradient is reserved for
|
||||
Send / empty-state CTAs. */
|
||||
.btn-save {
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.btn-delete {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-delete:hover { background: var(--color-action-destructive-hover); }
|
||||
.btn-assist-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-assist-toggle.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.title-input {
|
||||
padding: 0.4rem 0;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--color-primary);
|
||||
@@ -155,23 +80,6 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn-suggest-tags {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-suggest-tags:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -187,29 +95,17 @@
|
||||
}
|
||||
.tag-pill:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.tag-pill.applied {
|
||||
background: var(--color-success, #2ecc71);
|
||||
border-color: var(--color-success, #2ecc71);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: default;
|
||||
}
|
||||
.tag-check {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.btn-dismiss-tags {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-dismiss-tags:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Assist panel ── */
|
||||
.assist-panel {
|
||||
@@ -237,32 +133,6 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.btn-proofread {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-proofread:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-proofread:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-close-assist {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.assist-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -342,28 +212,6 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-generate {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-generate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Streaming */
|
||||
.assist-streaming-label {
|
||||
@@ -419,14 +267,6 @@
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-toggle-view {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.diff-view {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -475,24 +315,6 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-accept {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-reject {
|
||||
padding: 0.4rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
@@ -537,7 +359,7 @@
|
||||
}
|
||||
.modal-btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -547,8 +369,8 @@
|
||||
z-index: 100;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
@@ -640,3 +462,96 @@
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editor button aliases.
|
||||
*
|
||||
* These names are used across six views, so they alias onto the shared variants
|
||||
* rather than every call site being rewritten — the class stays the app's, the
|
||||
* appearance comes from components.css. Same reasoning as .btn-small: a name
|
||||
* already in the templates is cheaper to point somewhere than to replace.
|
||||
*
|
||||
* They are @extend-shaped, which CSS lacks, so each carries the variant's own
|
||||
* declarations. That is the one duplication this migration cannot remove — but
|
||||
* it is duplication of a REFERENCE (a var()), not of a value, so a palette
|
||||
* change still moves everything at once.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
.btn-accept,
|
||||
.btn-generate,
|
||||
.btn-save {
|
||||
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(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
.btn-clear,
|
||||
.btn-reject,
|
||||
.btn-proofread,
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-back:not(:disabled):hover,
|
||||
.btn-clear:not(:disabled):hover,
|
||||
.btn-reject:not(:disabled):hover,
|
||||
.btn-proofread:not(:disabled):hover,
|
||||
.btn-suggest-tags:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-delete:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* Shared geometry for every alias above. */
|
||||
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
|
||||
.btn-delete, .btn-dismiss-tags, .btn-proofread, .btn-suggest-tags {
|
||||
border-radius: var(--fs-radius-md);
|
||||
font-family: var(--fs-font-body);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
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);
|
||||
}
|
||||
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
|
||||
.btn-delete {
|
||||
padding: var(--fs-space-2) var(--fs-space-4);
|
||||
font-size: var(--fs-size-label);
|
||||
}
|
||||
.btn-proofread, .btn-suggest-tags {
|
||||
padding: var(--fs-space-1) var(--fs-space-3);
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
.btn-dismiss-tags {
|
||||
background: none;
|
||||
border: none;
|
||||
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(--color-text); }
|
||||
|
||||
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
|
||||
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
|
||||
.btn-delete:disabled, .btn-proofread:disabled, .btn-suggest-tags:disabled,
|
||||
.btn-dismiss-tags:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
+331
-161
@@ -1,149 +1,319 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
||||
|
||||
/* ==========================================================================
|
||||
GENERATED FROM THE DESIGN SYSTEM — Scribe (design system 2), which inherits
|
||||
the FabledSword house style (design system 1).
|
||||
|
||||
Do not hand-edit the --fs-* block below. Edit the design system in the app
|
||||
and regenerate: /design-systems -> Master stylesheet -> Copy.
|
||||
|
||||
DARK IS THE BASE LAYER. The kit is dark-mode-first, so :root carries the dark
|
||||
palette and [data-theme="light"] overrides it. That is the inverse of how this
|
||||
file used to read, and it is deliberate: the light palette was never specified
|
||||
by any rule, so it is recorded as a departure rather than as the default.
|
||||
|
||||
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
|
||||
radius, and every derived colour — is stated once, because a value built with
|
||||
var() resolves where it is USED, not where it is written.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Light mode — warm parchment palette */
|
||||
--color-bg: #F5F1E8;
|
||||
--color-bg-secondary: #FBF8F0;
|
||||
--color-bg-card: #FBF8F0;
|
||||
--color-surface: #EFEAE0;
|
||||
--color-text: #14171A;
|
||||
--color-text-secondary: #5A5852;
|
||||
--color-text-muted: #9A9890;
|
||||
--color-border: #D9D6CE;
|
||||
--color-input-border: #D9D6CE;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.12);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #EBEDF0;
|
||||
--color-code-inline-bg: #EBEDF0;
|
||||
--color-table-stripe: rgba(20, 23, 26, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #EFEAE0;
|
||||
--color-input-bar-text: #14171A;
|
||||
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #D9D6CE;
|
||||
--color-bubble-user-text: #5A5852;
|
||||
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.08);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.12);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.20);
|
||||
/* accent */
|
||||
--fs-accent: #5B4A8A; /* Scribe's signature colour */
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent); /* Pill, tag and badge backgrounds */
|
||||
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
|
||||
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
|
||||
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
|
||||
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
|
||||
|
||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
/* action */
|
||||
--fs-action-primary: #4A5D3F; /* Save, Submit, Confirm */
|
||||
--fs-action-secondary: #8B7355; /* Non-destructive alternates */
|
||||
--fs-action-tertiary: var(--fs-border-color); /* Ghost / outline actions */
|
||||
--fs-action-destructive: var(--fs-destructive);
|
||||
--fs-action-primary-hover: color-mix(in srgb, var(--fs-action-primary) 88%, white);
|
||||
--fs-action-secondary-hover: color-mix(in srgb, var(--fs-action-secondary) 88%, white);
|
||||
--fs-action-destructive-hover: color-mix(in srgb, var(--fs-action-destructive) 88%, white);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
/* border */
|
||||
--fs-border-color: #3F4651; /* Borders, dividers and ghost outlines */
|
||||
--fs-border: 0.5px solid var(--fs-border-color);
|
||||
--fs-border-hover: 0.5px solid color-mix(in srgb, var(--fs-text-secondary) 30%, transparent);
|
||||
--fs-border-active: 2px solid var(--fs-accent); /* Selected card or active tab only */
|
||||
|
||||
/* editor */
|
||||
--fs-wikilink: var(--fs-accent);
|
||||
|
||||
/* elevation */
|
||||
--fs-shadow-1: 0 1px 0 rgba(0,0,0,0.4); /* Hairline lift */
|
||||
--fs-shadow-2: 0 4px 12px rgba(0,0,0,0.35); /* Dropdowns, popovers */
|
||||
--fs-shadow-3: 0 16px 40px rgba(0,0,0,0.5); /* Modals */
|
||||
|
||||
/* focus */
|
||||
--fs-focus-ring: 0 0 0 2px var(--fs-accent);
|
||||
|
||||
/* font */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif;
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif;
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
/* icon */
|
||||
--fs-icon-stroke: 1.5px; /* at 24px */
|
||||
--fs-icon-stroke-sm: 1px; /* at 16px */
|
||||
|
||||
/* layout */
|
||||
--fs-layout-page-max: 1200px;
|
||||
--fs-layout-page-pad: 1rem;
|
||||
--fs-layout-sidebar: 260px;
|
||||
--fs-layout-header: 52px;
|
||||
|
||||
/* motion */
|
||||
--fs-ease: cubic-bezier(0.2, 0.6, 0.2, 1); /* the one curve */
|
||||
--fs-dur-fast: 120ms;
|
||||
--fs-dur-base: 180ms;
|
||||
--fs-dur-slow: 280ms;
|
||||
|
||||
/* priority */
|
||||
--fs-priority-low: var(--fs-info);
|
||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
||||
--fs-priority-medium: var(--fs-warning);
|
||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
||||
--fs-priority-high: var(--fs-error);
|
||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
||||
|
||||
/* radius */
|
||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||
--fs-radius-md: 8px; /* buttons, inputs, small cards */
|
||||
--fs-radius-lg: 12px; /* cards, panels, modals */
|
||||
--fs-radius-xl: 16px; /* hero containers */
|
||||
--fs-radius-pill: 9999px;
|
||||
|
||||
/* semantic */
|
||||
--fs-success: var(--fs-action-primary);
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-error: #C04A1F;
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
||||
|
||||
/* space */
|
||||
--fs-space-1: 4px;
|
||||
--fs-space-2: 8px;
|
||||
--fs-space-3: 12px;
|
||||
--fs-space-4: 16px;
|
||||
--fs-space-5: 20px;
|
||||
--fs-space-6: 24px;
|
||||
--fs-space-7: 32px;
|
||||
--fs-space-8: 48px;
|
||||
--fs-space-9: 64px;
|
||||
--fs-space-10: 96px;
|
||||
|
||||
/* state */
|
||||
--fs-disabled-opacity: 0.5;
|
||||
--fs-overlay: rgba(0, 0, 0, 0.65);
|
||||
|
||||
/* status */
|
||||
--fs-status-todo: var(--fs-border-color);
|
||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
||||
--fs-status-in-progress: var(--fs-accent);
|
||||
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
|
||||
--fs-status-done: var(--fs-success);
|
||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
||||
--fs-overdue: var(--fs-error);
|
||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||
--fs-surface-raised: #1E2228; /* cards, raised elements */
|
||||
--fs-surface-hover: #2C313A; /* hovered surfaces */
|
||||
--fs-surface-code: var(--fs-surface-page);
|
||||
--fs-surface-code-inline: var(--fs-surface-raised);
|
||||
--fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent);
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
||||
--fs-text-secondary: #C2BFB4;
|
||||
--fs-text-tertiary: #9C9A92;
|
||||
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
|
||||
|
||||
/* type */
|
||||
--fs-size-display: 40px;
|
||||
--fs-size-h1: 32px;
|
||||
--fs-size-h2: 24px;
|
||||
--fs-size-h3: 18px;
|
||||
--fs-size-body: 15px;
|
||||
--fs-size-body-sm: 13px;
|
||||
--fs-size-label: 12px;
|
||||
--fs-size-code: 13px;
|
||||
--fs-size-tiny: 11px; /* the only ALL CAPS, the only non-default tracking */
|
||||
--fs-weight-regular: 400;
|
||||
--fs-weight-medium: 500; /* the heaviest the system goes */
|
||||
--fs-leading-heading: 1.3;
|
||||
--fs-leading-body: 1.5;
|
||||
--fs-leading-longform: 1.7;
|
||||
--fs-tracking-tiny: 0.08em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode — Obsidian / Iron / Pewter */
|
||||
--color-bg: #14171A;
|
||||
--color-bg-secondary: #1E2228;
|
||||
--color-bg-card: #1E2228;
|
||||
--color-surface: #2C313A;
|
||||
--color-text: #E8E4D8;
|
||||
--color-text-secondary: #C2BFB4;
|
||||
--color-text-muted: #9C9A92;
|
||||
--color-border: #3F4651;
|
||||
--color-input-border: #3F4651;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
||||
--color-tag-text: #5B4A8A;
|
||||
[data-theme="light"] {
|
||||
/* accent */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 20px color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||
|
||||
/* border */
|
||||
--fs-border-color: #D9D6CE;
|
||||
|
||||
/* state */
|
||||
--fs-overlay: rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #F5F1E8;
|
||||
--fs-surface-raised: #FBF8F0;
|
||||
--fs-surface-hover: #EFEAE0;
|
||||
--fs-surface-code: #EBEDF0;
|
||||
--fs-surface-code-inline: #EBEDF0;
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #14171A;
|
||||
--fs-text-secondary: #5A5852;
|
||||
--fs-text-tertiary: #9A9890;
|
||||
}
|
||||
|
||||
/* SUPERSEDES — write the token, not the literal.
|
||||
* #fff -> --fs-text-on-action
|
||||
* #ffffff -> --fs-text-on-action
|
||||
* white -> --fs-text-on-action
|
||||
* bold -> --fs-weight-medium
|
||||
* bolder -> --fs-weight-medium
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
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 {
|
||||
/* 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);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.18);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #14171A;
|
||||
--color-code-inline-bg: #1E2228;
|
||||
--color-table-stripe: rgba(255, 255, 255, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #1E2228;
|
||||
--color-input-bar-text: #E8E4D8;
|
||||
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #3F4651;
|
||||
--color-bubble-user-text: #C2BFB4;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.10);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.14);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.22);
|
||||
|
||||
/* Action color set — identical across themes */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Base element styles
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -152,36 +322,36 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-family: var(--fs-font-body);
|
||||
font-feature-settings: "cv11";
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
line-height: var(--fs-leading-body);
|
||||
transition: background-color var(--fs-dur-base) var(--fs-ease),
|
||||
color var(--fs-dur-base) var(--fs-ease);
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-family: var(--fs-font-display);
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
line-height: var(--fs-leading-heading);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
font-family: var(--fs-font-body);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
line-height: var(--fs-leading-heading);
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-feature-settings: "liga", "calt";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 74, 138, 0.3);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-accent-wash);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -190,15 +360,15 @@ select:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
|
||||
button:not(:disabled):active,
|
||||
.btn:not(:disabled):active,
|
||||
[role="button"]:not(:disabled):active {
|
||||
transform: scale(0.97);
|
||||
transition: transform 0.08s ease;
|
||||
transition: transform 0.08s var(--fs-ease);
|
||||
}
|
||||
|
||||
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
|
||||
@@ -228,11 +398,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
background: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
@@ -240,14 +410,14 @@ button:not(:disabled):active,
|
||||
position: fixed;
|
||||
z-index: 150;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-primary);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--fs-size-body-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
box-shadow: var(--fs-shadow-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inline-assist-btn:hover {
|
||||
|
||||
@@ -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();
|
||||
@@ -64,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" />
|
||||
@@ -98,6 +108,7 @@ router.afterEach(() => {
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -281,7 +281,7 @@ onMounted(loadVersions);
|
||||
|
||||
<div class="history-footer">
|
||||
<button
|
||||
class="btn-restore"
|
||||
class="btn-primary"
|
||||
:disabled="!selectedVersion?.body"
|
||||
@click="restore"
|
||||
>Restore this version</button>
|
||||
@@ -393,19 +393,6 @@ onMounted(loadVersions);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-restore {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-restore:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
||||
.pin-badge {
|
||||
|
||||
@@ -188,11 +188,11 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
}
|
||||
.iap-btn-accept {
|
||||
background: var(--color-success, #22c55e);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.iap-btn-accept:hover { opacity: 0.85; }
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ onUnmounted(() => {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
|
||||
@@ -50,7 +50,7 @@ onMounted(() => store.fetchAll())
|
||||
<span class="notif-panel-title">Notifications</span>
|
||||
<button
|
||||
v-if="store.count > 0"
|
||||
class="btn-mark-all"
|
||||
class="btn-text"
|
||||
@click="store.markAll()"
|
||||
>Mark all read</button>
|
||||
</header>
|
||||
@@ -70,7 +70,7 @@ onMounted(() => store.fetchAll())
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
<button class="btn-text" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notif-empty">No unread notifications</div>
|
||||
@@ -108,21 +108,6 @@ onMounted(() => store.fetchAll())
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-mark-all:hover { text-decoration: underline; }
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notif-item {
|
||||
display: flex;
|
||||
@@ -148,22 +133,4 @@ onMounted(() => store.fetchAll())
|
||||
}
|
||||
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
|
||||
|
||||
.btn-notif-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.btn-notif-close:hover { color: var(--color-text); }
|
||||
|
||||
.notif-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,7 +90,7 @@ function goToPage(page: number) {
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ellipsis {
|
||||
|
||||
@@ -117,7 +117,7 @@ onMounted(async () => {
|
||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||
<header class="share-header">
|
||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
<button class="btn-text" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
</header>
|
||||
|
||||
<!-- Add share form -->
|
||||
@@ -184,7 +184,7 @@ onMounted(async () => {
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
<button class="btn-text" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
</li>
|
||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||
</ul>
|
||||
@@ -231,23 +231,6 @@ onMounted(async () => {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-close:hover { color: var(--color-text); }
|
||||
|
||||
.share-add {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.share-tabs {
|
||||
display: flex;
|
||||
@@ -268,7 +251,7 @@ onMounted(async () => {
|
||||
.share-tab.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
.share-target-form {
|
||||
@@ -339,11 +322,11 @@ onMounted(async () => {
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
@@ -394,23 +377,4 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-remove-share {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-remove-share:hover { color: var(--color-danger, #ef4444); }
|
||||
|
||||
.shares-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -163,7 +163,7 @@ async function confirmDelete() {
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="systems-toolbar">
|
||||
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
|
||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||
+ System
|
||||
</button>
|
||||
<label v-if="archivedSystems.length" class="archived-toggle">
|
||||
@@ -190,10 +190,10 @@ async function confirmDelete() {
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-confirm" :disabled="!newName.trim() || creating">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
</button>
|
||||
<button type="button" class="btn-cancel" @click="cancelCreate">Cancel</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="cancelCreate">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -211,7 +211,7 @@ async function confirmDelete() {
|
||||
<div v-else-if="!visibleSystems.length" class="systems-empty">
|
||||
<p class="empty-title">No systems yet</p>
|
||||
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
|
||||
<button v-if="!showCreate" class="btn-confirm" @click="openCreate">+ Create a system</button>
|
||||
<button v-if="!showCreate" class="btn-primary btn-compact" @click="openCreate">+ Create a system</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
@@ -241,10 +241,10 @@ async function confirmDelete() {
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-confirm" :disabled="!editName.trim() || savingEdit">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||
{{ savingEdit ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button type="button" class="btn-cancel" @click="cancelEdit">Cancel</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -387,51 +387,6 @@ async function confirmDelete() {
|
||||
.system-textarea { resize: vertical; }
|
||||
|
||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||
.btn-confirm {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-confirm:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||
.btn-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-cancel {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-cancel:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||
|
||||
/* ── List ─────────────────────────────────────────────────────── */
|
||||
.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.65rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
transition: border-color 0.12s, box-shadow 0.15s;
|
||||
}
|
||||
.system-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
|
||||
}
|
||||
.system-card--archived { opacity: 0.6; }
|
||||
|
||||
.system-swatch {
|
||||
@@ -555,6 +510,6 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
||||
.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>
|
||||
|
||||
@@ -122,8 +122,8 @@ onMounted(loadLogs);
|
||||
class="log-duration-input"
|
||||
placeholder="min"
|
||||
/>
|
||||
<button class="btn-log-save" @click="saveEdit(log)">Save</button>
|
||||
<button class="btn-log-cancel" @click="cancelEdit">Cancel</button>
|
||||
<button class="btn-primary btn-compact" @click="saveEdit(log)">Save</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -133,8 +133,8 @@ onMounted(loadLogs);
|
||||
{{ formatDuration(log.duration_minutes) }}
|
||||
</span>
|
||||
<div class="log-entry-actions">
|
||||
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
|
||||
<button class="btn-log-delete" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||
<button class="btn-text" @click="startEdit(log)" title="Edit">Edit</button>
|
||||
<button class="btn-text" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
|
||||
@@ -162,7 +162,7 @@ onMounted(loadLogs);
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn-log-submit"
|
||||
class="btn-primary btn-compact"
|
||||
@click="submitLog"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
>
|
||||
@@ -217,7 +217,7 @@ onMounted(loadLogs);
|
||||
|
||||
.log-duration-badge {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 99px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
@@ -230,34 +230,7 @@ onMounted(loadLogs);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-log-edit,
|
||||
.btn-log-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
padding: 0.1rem 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-log-edit:hover { color: var(--color-primary); }
|
||||
.btn-log-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
.log-content {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.log-content :deep(p) { margin: 0; }
|
||||
|
||||
.log-add {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.log-textarea {
|
||||
width: 100%;
|
||||
@@ -307,32 +280,6 @@ onMounted(loadLogs);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-log-submit,
|
||||
.btn-log-save {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-log-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-log-cancel {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,7 +36,7 @@ const toastStore = useToastStore();
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
min-width: 200px;
|
||||
@@ -54,7 +54,7 @@ const toastStore = useToastStore();
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.toast-close:hover {
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.toast--success {
|
||||
background: var(--color-toast-success);
|
||||
|
||||
@@ -236,12 +236,12 @@ function restore() {
|
||||
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.vh-btn-restore {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -314,14 +314,14 @@ defineExpose({ reload: loadProjectNotes });
|
||||
@keydown.enter="createNote"
|
||||
@keydown.escape="cancelNewNote"
|
||||
/>
|
||||
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
<button class="btn-primary btn-inline" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '…' : '+' }}
|
||||
</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rail-title">Notes</span>
|
||||
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
|
||||
<button class="btn-ghost btn-inline" @click="startNewNote" title="New note">+ New</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
type="search"
|
||||
aria-label="Search notes"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
<button v-if="searchQuery" class="btn-text btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||
@@ -358,12 +358,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
</div>
|
||||
<div class="note-row-actions" @click.stop>
|
||||
<template v-if="deletingId === note.id">
|
||||
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
<button class="btn-danger-outline btn-inline" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<button v-else class="btn-text" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -382,7 +382,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<WordCount :body="noteBody" />
|
||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||
<span v-if="saving" class="saving-txt">Saving…</span>
|
||||
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
<button class="btn-primary btn-compact" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -400,7 +400,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<div class="tag-row">
|
||||
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
|
||||
<button
|
||||
class="btn-suggest-tags"
|
||||
class="btn-ghost btn-compact"
|
||||
:disabled="tagSuggestions.suggestingTags.value"
|
||||
title="Auto-suggest tags from title and body"
|
||||
@click="tagSuggestions.fetchTagSuggestions()"
|
||||
@@ -417,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
@@ -429,8 +429,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
<button class="btn-ghost btn-inline" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-text" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
@@ -484,26 +484,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-new-note {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.rail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-search-input {
|
||||
flex: 1;
|
||||
@@ -517,17 +497,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.rail-search-input:focus { outline: none; }
|
||||
.rail-search-input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.btn-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-search-clear:hover { color: var(--color-text); }
|
||||
/* Sits inside the search field: no padding, and must not flex. */
|
||||
.btn-search-clear { padding: 0; flex-shrink: 0; }
|
||||
|
||||
.rail-state {
|
||||
padding: 1rem 0.65rem;
|
||||
@@ -617,99 +588,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
}
|
||||
.btn-cancel-delete:hover { color: var(--color-text); }
|
||||
|
||||
/* Inline new note */
|
||||
.new-note-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.new-note-input:focus { outline: none; }
|
||||
|
||||
.btn-confirm {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-confirm:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-cancel:hover { color: var(--color-text); }
|
||||
|
||||
/* ── Right editor pane ── */
|
||||
.note-editor-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Editor UI */
|
||||
.panel-header {
|
||||
@@ -731,23 +610,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
padding: 0.9rem 1.1rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-title-input {
|
||||
width: 100%;
|
||||
@@ -775,20 +637,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-suggest-tags:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
||||
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
@@ -817,21 +666,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-dismiss-suggestions {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.btn-dismiss-suggestions:hover { color: var(--color-text); }
|
||||
|
||||
.toolbar-row {
|
||||
padding: 0.3rem 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-suggest-strip {
|
||||
display: flex;
|
||||
@@ -865,21 +699,4 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
||||
|
||||
.btn-link-all {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
.btn-link-all:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.editor-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -232,7 +232,7 @@ defineExpose({ reload: loadAll });
|
||||
placeholder="New task..."
|
||||
@keydown.enter="addTask"
|
||||
/>
|
||||
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
<button class="btn-primary btn-inline btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state-msg">Loading...</div>
|
||||
@@ -293,18 +293,18 @@ defineExpose({ reload: loadAll });
|
||||
<Transition name="detail-fade">
|
||||
<div v-if="activeTask" class="task-detail">
|
||||
<div class="detail-header">
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
<button class="btn-danger-outline btn-inline btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<button v-else class="btn-text btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
<button class="btn-text btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||
@@ -396,17 +396,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.btn-add {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.28rem 0.55rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-add:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
||||
|
||||
.groups-scroll {
|
||||
flex: 1;
|
||||
@@ -534,17 +524,7 @@ defineExpose({ reload: loadAll });
|
||||
.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 {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-edit-task { margin-left: 0.25rem; }
|
||||
.btn-edit-task:hover { text-decoration: underline; }
|
||||
|
||||
.detail-body {
|
||||
@@ -566,51 +546,11 @@ defineExpose({ reload: loadAll });
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-delete-task {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.btn-delete-task { margin-left: 0.25rem; }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-delete-confirm {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-delete-confirm { margin-left: 0.25rem; }
|
||||
|
||||
.btn-delete-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.btn-delete-cancel:hover { color: var(--color-text); }
|
||||
|
||||
.detail-title {
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
@@ -679,15 +619,5 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
|
||||
/* Close detail button */
|
||||
.btn-close-detail {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.btn-close-detail:hover { color: var(--color-text); }
|
||||
.btn-close-detail { margin-left: 0.2rem; }
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./assets/theme.css";
|
||||
// After theme.css — it consumes the tokens declared there.
|
||||
import "./assets/components.css";
|
||||
import "./assets/prose.css";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -109,6 +109,20 @@ const router = createRouter({
|
||||
name: "rules",
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// Meta-surface, same family as /rules: it describes the app rather than
|
||||
// holding the operator's records.
|
||||
path: "/design",
|
||||
name: "design",
|
||||
component: () => import("@/views/DesignView.vue"),
|
||||
},
|
||||
{
|
||||
// The editable half of the same surface: /design is what the browser
|
||||
// renders, /design-systems is the record that ought to decide it.
|
||||
path: "/design-systems",
|
||||
name: "design-systems",
|
||||
component: () => import("@/views/DesignSystemsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
redirect: "/",
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
||||
*
|
||||
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
||||
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
||||
* can assert on it. What's left here is set arithmetic over live token values,
|
||||
* which is the one thing the browser knows and the server doesn't.
|
||||
*
|
||||
* SCOPE, and it is a real limit rather than an omission. This compares the
|
||||
* rulebook against the TOKENS. It cannot see the third category of drift — a
|
||||
* literal hardcoded in a component where a token should be referenced (#2275,
|
||||
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
||||
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
||||
*
|
||||
* Catching it needs the component sources, which would mean bundling every SFC
|
||||
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
|
||||
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
|
||||
* matters: a drift report that silently omits a category invites the reader to
|
||||
* conclude the category is clean.
|
||||
*/
|
||||
import type { DesignToken } from "@/utils/designTokens";
|
||||
|
||||
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
||||
|
||||
export interface Expectation {
|
||||
kind: ExpectationKind;
|
||||
value: string;
|
||||
rule_id: number;
|
||||
rule_title: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface ExpectationResponse {
|
||||
rulebook_id: number | null;
|
||||
expectations: Expectation[];
|
||||
}
|
||||
|
||||
export type FindingStatus = "ok" | "missing" | "violated";
|
||||
|
||||
export interface Finding {
|
||||
expectation: Expectation;
|
||||
status: FindingStatus;
|
||||
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a colour for comparison — the client-side twin of
|
||||
* `normalize_hex` in services/design_system.py.
|
||||
*
|
||||
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
||||
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
||||
* spellings of one colour, and a comparison that misses any of them under-reports
|
||||
* rather than erroring. The rgb() case is browser-specific and therefore has no
|
||||
* server-side counterpart, which is exactly why it is handled here.
|
||||
*/
|
||||
export function normalizeColour(value: string): string | null {
|
||||
const raw = value.trim().toLowerCase();
|
||||
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
||||
if (hex) {
|
||||
let digits = hex[1];
|
||||
if (digits.length === 3 || digits.length === 4) {
|
||||
digits = digits.split("").map((c) => c + c).join("");
|
||||
}
|
||||
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
||||
}
|
||||
|
||||
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
|
||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||
if (rgb) {
|
||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const channels = parts.slice(0, 3).map((p) => Number(p));
|
||||
if (channels.some((n) => !Number.isFinite(n))) return null;
|
||||
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
||||
const base = `#${channels.map(hexOf).join("")}`;
|
||||
if (parts.length === 3) return base;
|
||||
const alpha = Number(parts[3]);
|
||||
if (!Number.isFinite(alpha) || alpha >= 1) return base;
|
||||
return `${base}${hexOf(alpha * 255)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
||||
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
||||
const index = new Map<string, string[]>();
|
||||
for (const token of tokens) {
|
||||
const colour = normalizeColour(token.value);
|
||||
if (!colour) continue;
|
||||
const names = index.get(colour);
|
||||
if (names) names.push(token.name);
|
||||
else index.set(colour, [token.name]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare claims against the live tokens.
|
||||
*
|
||||
* A `token` claim asks whether a custom property of that name exists.
|
||||
* A `color` claim asks whether any token resolves to that value.
|
||||
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
||||
*/
|
||||
export function compareToTokens(
|
||||
expectations: Expectation[],
|
||||
tokens: DesignToken[],
|
||||
): Finding[] {
|
||||
const names = new Set(tokens.map((t) => t.name));
|
||||
const colours = colourIndex(tokens);
|
||||
|
||||
return expectations.map((expectation) => {
|
||||
if (expectation.kind === "token") {
|
||||
const present = names.has(expectation.value);
|
||||
return {
|
||||
expectation,
|
||||
status: present ? "ok" : "missing",
|
||||
matches: present ? [expectation.value] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const matches = colours.get(expectation.value) ?? [];
|
||||
if (expectation.kind === "prohibited_color") {
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "violated" : "ok",
|
||||
matches,
|
||||
};
|
||||
}
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "ok" : "missing",
|
||||
matches,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface DriftSummary {
|
||||
ok: number;
|
||||
missing: number;
|
||||
violated: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarise(findings: Finding[]): DriftSummary {
|
||||
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
||||
for (const finding of findings) summary[finding.status] += 1;
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings worth leading with.
|
||||
*
|
||||
* A panel that opens with every row gets closed and never reopened — the same
|
||||
* principle the auto-inject menu is built on: a short list that gets read beats
|
||||
* a complete one that doesn't. Violations first (something is actively wrong),
|
||||
* then missing (something was never built), and `ok` rows are not "findings" at
|
||||
* all — they belong behind an expansion.
|
||||
*/
|
||||
export function rankFindings(findings: Finding[]): Finding[] {
|
||||
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
||||
return [...findings].sort((a, b) => {
|
||||
const byStatus = order[a.status] - order[b.status];
|
||||
if (byStatus !== 0) return byStatus;
|
||||
return a.expectation.rule_id - b.expectation.rule_id;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||
*
|
||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
||||
* against these, and the drift panel compares them to the design rulebook.
|
||||
*
|
||||
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||
* its VALUE is not: values contain nested parens, commas inside rgba(),
|
||||
* `var()` references to other tokens, multi-part shadows, and gradients — and
|
||||
* `theme.css` has all of those today. So we take the names from the source and
|
||||
* ask the BROWSER for every value.
|
||||
*
|
||||
* That is not just easier, it is more correct. getComputedStyle reports what
|
||||
* actually won the cascade, resolves `var()` chains, and — critically for this
|
||||
* milestone — reflects live overrides set on a container, which is exactly what
|
||||
* the preview surface needs (see #2261). Parsing the source would report what
|
||||
* the file says rather than what the user is looking at.
|
||||
*
|
||||
* It also means this module needs no unit tests to be trustworthy: the only
|
||||
* logic here is a name regex and a group lookup. The frontend has no test
|
||||
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
|
||||
* error-prone half in the browser rather than in our code is deliberate.
|
||||
*/
|
||||
import themeCss from "@/assets/theme.css?raw";
|
||||
|
||||
export type TokenGroup =
|
||||
| "color"
|
||||
| "radius"
|
||||
| "gradient"
|
||||
| "glow"
|
||||
| "focus"
|
||||
| "layout"
|
||||
| "other";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export interface DesignToken {
|
||||
/** Full custom-property name, including the leading `--`. */
|
||||
name: string;
|
||||
/** Coarse family, derived from the name prefix. */
|
||||
group: TokenGroup;
|
||||
/** Resolved value in the requested context, straight from the browser. */
|
||||
value: string;
|
||||
/** True when the declaration appears inside the dark block in source. */
|
||||
overriddenInDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
|
||||
*
|
||||
* The discriminator is the COLON, not the preceding character. A declaration is
|
||||
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
|
||||
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
|
||||
* actively wrong: an earlier version required the match to follow `{` or `;`,
|
||||
* which silently dropped every declaration that came after a comment —
|
||||
* including `--color-bg`, the first and most-used token in the file.
|
||||
*/
|
||||
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
||||
|
||||
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||
|
||||
/** The dark block's selector, as written in theme.css. */
|
||||
const DARK_SELECTOR = '[data-theme="dark"]';
|
||||
|
||||
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||
["--color-", "color"],
|
||||
["--radius-", "radius"],
|
||||
["--gradient-", "gradient"],
|
||||
["--glow-", "glow"],
|
||||
["--focus-", "focus"],
|
||||
["--page-", "layout"],
|
||||
["--sidebar-", "layout"],
|
||||
["--chat-", "layout"],
|
||||
];
|
||||
|
||||
export function groupFor(name: string): TokenGroup {
|
||||
for (const [prefix, group] of GROUP_PREFIXES) {
|
||||
if (name.startsWith(prefix)) return group;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
|
||||
export function tokenNames(css: string = themeCss): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
|
||||
const name = match[1];
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
||||
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
||||
const bare = css.replace(COMMENT, "");
|
||||
const start = bare.indexOf(DARK_SELECTOR);
|
||||
if (start === -1) return new Set();
|
||||
const open = bare.indexOf("{", start);
|
||||
const close = bare.indexOf("}", open);
|
||||
if (open === -1 || close === -1) return new Set();
|
||||
return new Set(tokenNames(bare.slice(open, close)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved value of every token in `host`'s context.
|
||||
*
|
||||
* Pass a container to read the tokens as they apply INSIDE it — which is how
|
||||
* the preview surface reads a scoped override without disturbing the page.
|
||||
* Defaults to the document root, i.e. the app-wide values.
|
||||
*/
|
||||
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||
const computed = getComputedStyle(host);
|
||||
const dark = darkOverriddenNames();
|
||||
return tokenNames().map((name) => ({
|
||||
name,
|
||||
group: groupFor(name),
|
||||
value: computed.getPropertyValue(name).trim(),
|
||||
overriddenInDark: dark.has(name),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tokens as they would resolve in a given mode, without touching the page.
|
||||
*
|
||||
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
|
||||
* mutated to take a reading.
|
||||
*
|
||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
||||
* function: light is declared on `:root` while dark is declared on
|
||||
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
||||
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
||||
* back. So reading "light" from inside a dark page returns the dark values —
|
||||
* the probe has nothing to match.
|
||||
*
|
||||
* Concretely: dark-inside-light previews work, light-inside-dark previews do
|
||||
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
||||
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
||||
* should treat a cross-mode read as best-effort.
|
||||
*/
|
||||
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||
const probe = document.createElement("div");
|
||||
probe.setAttribute("data-theme", mode);
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
try {
|
||||
return readTokens(probe);
|
||||
} finally {
|
||||
probe.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens grouped by family, preserving source order within each group. */
|
||||
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
|
||||
const out = new Map<TokenGroup, DesignToken[]>();
|
||||
for (const token of tokens) {
|
||||
const bucket = out.get(token.group);
|
||||
if (bucket) bucket.push(token);
|
||||
else out.set(token.group, [token]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens declared in the stylesheet that nothing references with `var()`.
|
||||
*
|
||||
* Dead tokens are drift too: `--chat-reading-width` and
|
||||
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
|
||||
* in the MCP-first pivot, and nothing has referenced them since. Takes the
|
||||
* corpus of source files to search as an argument so the caller decides what
|
||||
* "used" means — this module has no opinion about the project layout.
|
||||
*/
|
||||
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
|
||||
const haystack = sources.join("\n");
|
||||
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -49,7 +49,7 @@ async function handleSubmit() {
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
|
||||
{{ submitting ? "Sending..." : "Send Reset Link" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -137,24 +137,6 @@ async function handleSubmit() {
|
||||
.success-msg p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -381,7 +381,7 @@ onUnmounted(() => {
|
||||
<option value="alpha">Alphabetical</option>
|
||||
<option value="type">By type</option>
|
||||
</select>
|
||||
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<button class="btn-ghost btn-compact" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
@@ -463,14 +463,14 @@ onUnmounted(() => {
|
||||
<span>Graph</span>
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
class="btn-text"
|
||||
@click="toggleGraphExpand"
|
||||
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
||||
>
|
||||
<ChevronLeft v-if="graphExpanded" :size="16" />
|
||||
<ChevronRight v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
|
||||
<button class="btn-text" @click="toggleGraph" title="Close graph">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -574,7 +574,7 @@ onUnmounted(() => {
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
@@ -719,26 +719,6 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.btn-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: transparent;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Card grid ───────────────────────────────────────────── */
|
||||
.card-grid {
|
||||
@@ -956,22 +936,6 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-icon-sm:hover { color: var(--color-text); }
|
||||
.graph-embed {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||
.graph-embed :deep(.graph-page) {
|
||||
height: 100%;
|
||||
|
||||
@@ -84,7 +84,7 @@ function loginWithOAuth() {
|
||||
<p class="forgot-link">
|
||||
<router-link to="/forgot-password">Forgot your password?</router-link>
|
||||
</p>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
|
||||
{{ submitting ? "Signing in..." : "Sign In" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -98,7 +98,7 @@ function loginWithOAuth() {
|
||||
|
||||
<button
|
||||
v-if="authStore.oauthEnabled"
|
||||
class="btn-oauth"
|
||||
class="btn-ghost btn-block"
|
||||
@click="loginWithOAuth"
|
||||
>
|
||||
Login with Authentik
|
||||
@@ -176,24 +176,6 @@ function loginWithOAuth() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -208,20 +190,6 @@ function loginWithOAuth() {
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-oauth {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-oauth:hover {
|
||||
background: var(--color-bg-hover, var(--color-border));
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -177,7 +177,7 @@ function clearFilters() {
|
||||
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
|
||||
<button
|
||||
v-if="category || search || dateFrom || dateTo"
|
||||
class="btn-clear"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear
|
||||
@@ -337,19 +337,6 @@ function clearFilters() {
|
||||
.filter-date {
|
||||
width: 140px;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-clear:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.loading-msg,
|
||||
|
||||
@@ -748,7 +748,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
|
||||
.btn-link-all:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
.link-suggest-list {
|
||||
display: flex;
|
||||
|
||||
@@ -194,22 +194,22 @@ async function convertToTask() {
|
||||
</div>
|
||||
<template v-else-if="store.currentNote">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">← Notes</router-link>
|
||||
<router-link to="/notes" class="btn-ghost">← Notes</router-link>
|
||||
<router-link
|
||||
:to="`/notes/${store.currentNote.id}/edit`"
|
||||
class="btn-edit"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="!store.currentNote.is_task"
|
||||
class="btn-convert"
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToTask"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Task" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent → project → milestone -->
|
||||
@@ -329,81 +329,10 @@ async function convertToTask() {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit: Moss action-primary — switching from view to edit is operating
|
||||
the software, not a brand moment. */
|
||||
.btn-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.note-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
|
||||
@@ -113,6 +113,45 @@ function truncate(text: string | null, max = 120): string {
|
||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
||||
}
|
||||
|
||||
// A card is a glance, not a report. Roundtable had ~35 milestones and its tile
|
||||
// ran several viewport-heights tall, which made the grid unreadable (#2391).
|
||||
const MAX_MILESTONE_BARS = 10;
|
||||
|
||||
interface MilestoneBar extends MilestoneSummary {
|
||||
/** Position in the FULL list, so a bar keeps its colour when another
|
||||
* milestone is added or finishes. Tying the palette to the visible index
|
||||
* would recolour the card every time work closed. */
|
||||
paletteIndex: number;
|
||||
}
|
||||
|
||||
/** Bars to draw per project, plus how many were withheld.
|
||||
*
|
||||
* Ordered OPEN WORK FIRST, newest first. Recency alone would be wrong here: a
|
||||
* long-running project's oldest milestones are usually its finished ones, so
|
||||
* showing 10 completed bars while hiding the 3 in flight is worse than showing
|
||||
* nothing. What the card is for is "what is happening", not "what happened".
|
||||
*
|
||||
* Computed once per load rather than called from the template — a helper in a
|
||||
* v-for is re-run on every render, and this one sorts.
|
||||
*/
|
||||
const milestoneBars = computed(() => {
|
||||
const byProject = new Map<number, { bars: MilestoneBar[]; hidden: number }>();
|
||||
for (const project of projects.value) {
|
||||
const all = project.summary?.milestone_summary ?? [];
|
||||
const indexed: MilestoneBar[] = all.map((ms, i) => ({ ...ms, paletteIndex: i }));
|
||||
const newestFirst = (a: MilestoneBar, b: MilestoneBar) => b.id - a.id;
|
||||
const ordered = [
|
||||
...indexed.filter((m) => m.pct < 100).sort(newestFirst),
|
||||
...indexed.filter((m) => m.pct >= 100).sort(newestFirst),
|
||||
];
|
||||
byProject.set(project.id, {
|
||||
bars: ordered.slice(0, MAX_MILESTONE_BARS),
|
||||
hidden: Math.max(0, ordered.length - MAX_MILESTONE_BARS),
|
||||
});
|
||||
}
|
||||
return byProject;
|
||||
});
|
||||
|
||||
function overallPct(project: Project): { total: number; pct: number } {
|
||||
const counts = project.summary?.task_counts;
|
||||
if (!counts) return { total: 0, pct: 0 };
|
||||
@@ -185,11 +224,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
<!-- Milestone progress bars -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
v-if="milestoneBars.get(project.id)?.bars.length"
|
||||
class="milestone-bars"
|
||||
>
|
||||
<div
|
||||
v-for="(ms, i) in project.summary.milestone_summary"
|
||||
v-for="ms in milestoneBars.get(project.id)!.bars"
|
||||
:key="ms.id"
|
||||
class="milestone-bar-row"
|
||||
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
|
||||
@@ -198,11 +237,23 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
<div class="milestone-bar-track">
|
||||
<div
|
||||
class="milestone-bar-fill"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(ms.paletteIndex) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
||||
</div>
|
||||
<!-- Say what is withheld. A list that simply stops reads as a
|
||||
rendering bug; a count reads as a summary. Plain text, not a
|
||||
link: the whole card already navigates to this project, and a
|
||||
link nested inside a clickable region is a trap for keyboard
|
||||
and screen-reader users. -->
|
||||
<span
|
||||
v-if="milestoneBars.get(project.id)!.hidden"
|
||||
class="milestone-more"
|
||||
>
|
||||
+{{ milestoneBars.get(project.id)!.hidden }}
|
||||
{{ milestoneBars.get(project.id)!.hidden === 1 ? 'more milestone' : 'more milestones' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
@@ -284,20 +335,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
/* Moss action-primary per Hybrid — list-view utility action,
|
||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
@@ -340,8 +377,8 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-primary); color: #fff; }
|
||||
.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;
|
||||
@@ -506,6 +543,14 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Deliberately quiet — it is a footnote about what is not shown, not another
|
||||
row competing with the bars above it. */
|
||||
.milestone-more {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-size-tiny);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -592,9 +637,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
|
||||
@@ -9,6 +9,11 @@ import { renderMarkdown } from "@/utils/markdown";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||
import SystemsSection from "@/components/SystemsSection.vue";
|
||||
import {
|
||||
fetchDesignSystems,
|
||||
setProjectDesignSystem,
|
||||
type DesignSystem,
|
||||
} from "@/api/designSystems";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Clock,
|
||||
@@ -41,6 +46,7 @@ interface Project {
|
||||
goal: string | null;
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
design_system_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -69,6 +75,12 @@ const toast = useToastStore();
|
||||
const tasksStore = useTasksStore();
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
|
||||
// Design system the project is styled from. Loaded separately because an
|
||||
// install with none is the ordinary case (rule #115) and the picker simply
|
||||
// doesn't render — a failed fetch must not take the project page with it.
|
||||
const designSystems = ref<DesignSystem[]>([]);
|
||||
const editDesignSystemId = ref<number | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const showStartPlanning = ref(false);
|
||||
@@ -175,6 +187,7 @@ async function loadProject() {
|
||||
editDescription.value = data.description ?? "";
|
||||
editGoal.value = data.goal ?? "";
|
||||
editStatus.value = data.status;
|
||||
editDesignSystemId.value = data.design_system_id ?? null;
|
||||
editDirty.value = false;
|
||||
milestones.value = data.summary?.milestone_summary ?? [];
|
||||
autoCollapseCompleted(milestones.value);
|
||||
@@ -336,8 +349,20 @@ onMounted(async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadDesignSystems();
|
||||
});
|
||||
|
||||
/** Populate the design-system picker. Swallows failure on purpose: with no
|
||||
* design systems the picker doesn't render at all, which is the ordinary state
|
||||
* for most installs — so this must never be able to break the project page. */
|
||||
async function loadDesignSystems() {
|
||||
try {
|
||||
designSystems.value = (await fetchDesignSystems()).design_systems;
|
||||
} catch {
|
||||
designSystems.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
@@ -345,28 +370,39 @@ watch(projectId, async () => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
|
||||
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
|
||||
() => {
|
||||
if (!project.value) return;
|
||||
editDirty.value =
|
||||
editTitle.value !== project.value.title ||
|
||||
editDescription.value !== (project.value.description ?? "") ||
|
||||
editGoal.value !== (project.value.goal ?? "") ||
|
||||
editStatus.value !== project.value.status;
|
||||
editStatus.value !== project.value.status ||
|
||||
editDesignSystemId.value !== (project.value.design_system_id ?? null);
|
||||
}
|
||||
);
|
||||
|
||||
async function saveProject() {
|
||||
if (!project.value || saving.value) return;
|
||||
// Bound once rather than re-read: the checks below straddle two awaits, and
|
||||
// `project.value` is a ref whose narrowing doesn't survive them.
|
||||
const current = project.value;
|
||||
if (!current || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
|
||||
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
|
||||
title: editTitle.value.trim(),
|
||||
description: editDescription.value.trim() || null,
|
||||
goal: editGoal.value.trim() || null,
|
||||
status: editStatus.value,
|
||||
});
|
||||
project.value = { ...project.value, ...updated };
|
||||
// The design-system pointer is its own endpoint (PUT, because clearing it
|
||||
// is a real outcome rather than an omission), so it saves separately —
|
||||
// only when it actually changed, to keep the common save at one request.
|
||||
if (editDesignSystemId.value !== (current.design_system_id ?? null)) {
|
||||
await setProjectDesignSystem(current.id, editDesignSystemId.value);
|
||||
updated.design_system_id = editDesignSystemId.value;
|
||||
}
|
||||
project.value = { ...current, ...updated };
|
||||
editDirty.value = false;
|
||||
toast.show("Project saved");
|
||||
} catch {
|
||||
@@ -399,7 +435,7 @@ async function confirmDelete() {
|
||||
|
||||
<!-- Nav bar -->
|
||||
<div class="page-header">
|
||||
<router-link to="/projects" class="btn-back">← Projects</router-link>
|
||||
<router-link to="/projects" class="btn-ghost">← Projects</router-link>
|
||||
<div class="page-header-actions">
|
||||
<template v-if="showStartPlanning">
|
||||
<input
|
||||
@@ -415,7 +451,7 @@ async function confirmDelete() {
|
||||
>
|
||||
Create plan
|
||||
</button>
|
||||
<button class="btn-share" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
||||
<button class="btn-secondary btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="project"
|
||||
@@ -428,7 +464,7 @@ async function confirmDelete() {
|
||||
<LayoutGrid :size="16" />
|
||||
Workspace
|
||||
</router-link>
|
||||
<button v-if="project && !showStartPlanning" class="btn-share" @click="showShare = true">Share</button>
|
||||
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -505,7 +541,14 @@ async function confirmDelete() {
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
|
||||
<div v-if="designSystems.length" class="edit-field">
|
||||
<label class="edit-label" for="project-design-system">Design system</label>
|
||||
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" @click="saveProject" :disabled="!editDirty || saving">
|
||||
{{ saving ? "Saving..." : "Save Changes" }}
|
||||
</button>
|
||||
</aside>
|
||||
@@ -538,7 +581,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="milestone-actions">
|
||||
<button v-if="!showNewMilestone" class="btn-add-milestone" @click="showNewMilestone = true">
|
||||
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
|
||||
+ Milestone
|
||||
</button>
|
||||
<div v-else class="new-milestone-row">
|
||||
@@ -550,10 +593,10 @@ async function confirmDelete() {
|
||||
@keydown.enter="createMilestone"
|
||||
@keydown.escape="showNewMilestone = false; newMilestoneTitle = ''"
|
||||
/>
|
||||
<button class="btn-ms-confirm" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
|
||||
<button class="btn-primary btn-compact" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
|
||||
{{ creatingMilestone ? "..." : "Add" }}
|
||||
</button>
|
||||
<button class="btn-ms-cancel" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
|
||||
<button class="btn-secondary btn-compact" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -813,80 +856,9 @@ async function confirmDelete() {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
|
||||
the project's "central feature moment" — entering the focused workspace
|
||||
is a Scribe-flavored action, not a plain operation. */
|
||||
.btn-workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); }
|
||||
|
||||
/* Share: Bronze action-secondary — alternate path */
|
||||
.btn-share {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Delete project: Oxblood action-destructive ghost — outline form since
|
||||
the actual confirm modal carries the filled destructive treatment */
|
||||
.btn-danger-outline {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
|
||||
/* ── Project identity header ─────────────────────────────────── */
|
||||
.project-header { margin-bottom: 1rem; }
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.project-title-input {
|
||||
flex: 1;
|
||||
font-size: 1.75rem;
|
||||
@@ -1019,30 +991,6 @@ async function confirmDelete() {
|
||||
.edit-textarea { resize: vertical; }
|
||||
|
||||
/* Save panel: Moss action-primary per Hybrid rule */
|
||||
.btn-save-panel {
|
||||
padding: 0.45rem 0.9rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* ── Content area ────────────────────────────────────────────── */
|
||||
.content-area { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tab-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1105,49 +1053,6 @@ async function confirmDelete() {
|
||||
}
|
||||
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
||||
.btn-ms-confirm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-ms-cancel {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* ── Milestone group ─────────────────────────────────────────── */
|
||||
.milestone-group {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
.milestone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.milestone-header.clickable { cursor: pointer; }
|
||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||
|
||||
@@ -1181,7 +1086,7 @@ async function confirmDelete() {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-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(--color-bg-card); color: var(--color-text); }
|
||||
|
||||
@@ -1350,8 +1255,8 @@ async function confirmDelete() {
|
||||
line-height: 1;
|
||||
}
|
||||
.task-card:hover .task-advance-btn { opacity: 1; }
|
||||
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
|
||||
.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 {
|
||||
@@ -1432,7 +1337,7 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
||||
.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 ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -144,7 +144,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Creating Account..." : "Create Account" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -247,24 +247,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -117,7 +117,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Creating account..." : "Create Account" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -216,24 +216,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -88,7 +88,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Resetting..." : "Reset Password" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -195,24 +195,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
+101
-245
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
|
||||
import { listRulebooks } from "@/api/rulebooks";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
@@ -23,10 +24,19 @@ const kbInjectEnabled = ref(true);
|
||||
const kbInjectThreshold = ref("0.55");
|
||||
const kbInjectTopK = ref("3");
|
||||
const kbWritePathEnabled = ref(true);
|
||||
// The write-path arm's OWN threshold, stricter than auto-inject's 0.55 above:
|
||||
// code embeddings sit on a much higher similarity floor than prose, so 0.55 let
|
||||
// unrelated code through (#2223). Shares top-k, not the threshold.
|
||||
const kbWritePathThreshold = ref("0.68");
|
||||
// Near-duplicate report floor. Deliberately looser than the 0.90 write-time
|
||||
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
||||
// suggests a merge the operator reviews (services/dedup.py).
|
||||
const kbDuplicateThreshold = ref("0.82");
|
||||
// Which rulebook describes this install's design system, for the /design drift
|
||||
// panel. Empty = none designated, which is the normal state for a fresh install
|
||||
// rather than a misconfiguration — the panel explains itself when unset.
|
||||
const designRulebookId = ref("");
|
||||
const designRulebooks = ref<{ id: number; title: string }[]>([]);
|
||||
const savingKbInject = ref(false);
|
||||
const kbInjectSaved = ref(false);
|
||||
|
||||
@@ -76,9 +86,13 @@ async function saveKbInject() {
|
||||
// default, not to 0 — a 0 floor would report every snippet as a duplicate of
|
||||
// every other one.
|
||||
const dupT = Math.min(1, Math.max(0, Number(kbDuplicateThreshold.value) || 0.82));
|
||||
// Same `|| default` reasoning as dupT: falling back to 0 would surface every
|
||||
// snippet in the corpus on every edit, which is the failure this knob fixes.
|
||||
const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68));
|
||||
kbInjectThreshold.value = String(t);
|
||||
kbInjectTopK.value = String(k);
|
||||
kbDuplicateThreshold.value = String(dupT);
|
||||
kbWritePathThreshold.value = String(wpT);
|
||||
savingKbInject.value = true;
|
||||
kbInjectSaved.value = false;
|
||||
try {
|
||||
@@ -86,10 +100,15 @@ async function saveKbInject() {
|
||||
kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false',
|
||||
kb_autoinject_threshold: String(t),
|
||||
kb_autoinject_top_k: String(k),
|
||||
// Its own switch, but deliberately the same threshold/ceiling — see
|
||||
// WRITEPATH_ENABLED_KEY in services/plugin_context.py.
|
||||
// Its own switch AND its own threshold (shares only the ceiling) — see
|
||||
// WRITEPATH_DEFAULT_THRESHOLD in services/plugin_context.py for the
|
||||
// measurements that split them.
|
||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||
kb_writepath_threshold: String(wpT),
|
||||
kb_duplicate_threshold: String(dupT),
|
||||
// Empty string DELETES the setting (see routes/settings.py), which is
|
||||
// exactly right for "no design rulebook" — absent rather than zero.
|
||||
design_rulebook_id: designRulebookId.value,
|
||||
});
|
||||
kbInjectSaved.value = true;
|
||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||
@@ -474,9 +493,20 @@ onMounted(async () => {
|
||||
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||||
}
|
||||
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
|
||||
if (allSettings.kb_writepath_threshold !== undefined) {
|
||||
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
|
||||
}
|
||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
||||
}
|
||||
designRulebookId.value = allSettings.design_rulebook_id ?? "";
|
||||
// Best-effort: the picker degrades to "none available" rather than blocking
|
||||
// the whole settings page if rulebooks can't be listed.
|
||||
try {
|
||||
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
|
||||
} catch {
|
||||
designRulebooks.value = [];
|
||||
}
|
||||
if (allSettings.notify_task_reminders !== undefined) {
|
||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||
}
|
||||
@@ -1135,7 +1165,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
||||
<button class="btn-primary" @click="saveTimezone" :disabled="savingTimezone">
|
||||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1160,7 +1190,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveRetention" :disabled="savingRetention">
|
||||
<button class="btn-primary" @click="saveRetention" :disabled="savingRetention">
|
||||
{{ savingRetention ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="retentionSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1220,8 +1250,47 @@ function formatUserDate(iso: string): string {
|
||||
Checks the file Claude is about to write or edit against your recorded
|
||||
snippets — what's already kept at that path, and what resembles the code
|
||||
being written — so a helper you already have is offered before it's
|
||||
rewritten. Uses the same threshold and ceiling above, and never blocks the
|
||||
edit. Off = prior art surfaces only on your own prompts.
|
||||
rewritten. Uses the ceiling above with its own threshold below, and never
|
||||
blocks the edit. Off = prior art surfaces only on your own prompts.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-writepath-threshold">Prior-art confidence threshold (0–1)</label>
|
||||
<input
|
||||
id="kb-writepath-threshold"
|
||||
v-model="kbWritePathThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="input"
|
||||
style="max-width: 8rem"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Stricter than the prompt threshold above on purpose. Any two pieces of
|
||||
code look somewhat alike — shared keywords, indentation, structure — so
|
||||
resemblance scores start higher for code than for prose, and a bar tuned
|
||||
for prompts flags unrelated code as prior art. Lower this if genuine
|
||||
duplicates go unnoticed; raise it if you're being offered snippets that
|
||||
have nothing to do with what's being written. Snippets recorded at the
|
||||
exact file are always shown regardless — those are prior art by
|
||||
location, not by resemblance.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="design-rulebook">Design-system rulebook</label>
|
||||
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
|
||||
<option value="">None — don't check for design drift</option>
|
||||
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
|
||||
{{ rb.title }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Which rulebook describes how this app should look. Once set, the
|
||||
<router-link to="/design">Design</router-link> page compares every colour
|
||||
and token your rules name against what the stylesheet actually resolves
|
||||
to, and reports where they disagree. Leave it as None if your rules
|
||||
don't describe a design system — nothing else depends on this.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -1244,7 +1313,7 @@ function formatUserDate(iso: string): string {
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveKbInject" :disabled="savingKbInject">
|
||||
<button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject">
|
||||
{{ savingKbInject ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1292,7 +1361,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
class="btn-primary"
|
||||
@click="changeEmail"
|
||||
:disabled="changingEmail || !emailPassword"
|
||||
>
|
||||
@@ -1340,7 +1409,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn-save"
|
||||
class="btn-primary"
|
||||
@click="changePassword"
|
||||
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
||||
>
|
||||
@@ -1396,7 +1465,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1422,7 +1491,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1432,7 +1501,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
|
||||
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1464,7 +1533,7 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1493,7 +1562,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveNotifications" :disabled="savingNotifications">
|
||||
<button class="btn-primary" @click="saveNotifications" :disabled="savingNotifications">
|
||||
{{ savingNotifications ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1520,7 +1589,7 @@ function formatUserDate(iso: string): string {
|
||||
placeholder="Enter a search query..."
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
||||
<button class="btn-primary" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
||||
{{ searchLoading ? "Searching..." : "Search" }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1822,7 +1891,7 @@ function formatUserDate(iso: string): string {
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
|
||||
<button class="btn-primary" @click="saveBaseUrl" :disabled="savingBaseUrl">
|
||||
{{ savingBaseUrl ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1847,7 +1916,7 @@ function formatUserDate(iso: string): string {
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
|
||||
<button class="btn-primary" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
|
||||
{{ savingMarketplaceUrl ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1877,10 +1946,10 @@ function formatUserDate(iso: string): string {
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveDbMaintenance" :disabled="savingDbMaint">
|
||||
<button class="btn-primary" @click="saveDbMaintenance" :disabled="savingDbMaint">
|
||||
{{ savingDbMaint ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button class="btn-save btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
|
||||
<button class="btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
|
||||
{{ runningDbMaint ? "Running..." : "Run now" }}
|
||||
</button>
|
||||
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1971,7 +2040,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom: 1.25rem;">
|
||||
<button class="btn-save" @click="saveSmtp" :disabled="savingSmtp">
|
||||
<button class="btn-primary" @click="saveSmtp" :disabled="savingSmtp">
|
||||
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
|
||||
</button>
|
||||
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
|
||||
@@ -1985,7 +2054,7 @@ function formatUserDate(iso: string): string {
|
||||
placeholder="test@example.com"
|
||||
class="input"
|
||||
/>
|
||||
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
||||
<button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
||||
{{ sendingTest ? "Sending..." : "Send Test" }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -2010,7 +2079,7 @@ function formatUserDate(iso: string): string {
|
||||
<p class="field-hint">When closed, new users can only be added by an administrator.</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
class="btn-primary btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
@@ -2031,7 +2100,7 @@ function formatUserDate(iso: string): string {
|
||||
required
|
||||
:disabled="sendingInvite"
|
||||
/>
|
||||
<button type="submit" class="btn-save" :disabled="sendingInvite || !inviteEmail.trim()">
|
||||
<button type="submit" class="btn-primary" :disabled="sendingInvite || !inviteEmail.trim()">
|
||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -2053,7 +2122,7 @@ function formatUserDate(iso: string): string {
|
||||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button class="btn-delete" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
|
||||
<button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
|
||||
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
||||
</button>
|
||||
</td>
|
||||
@@ -2092,13 +2161,13 @@ function formatUserDate(iso: string): string {
|
||||
<span class="you-label">You</span>
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button class="btn-confirm-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">
|
||||
<button class="btn-danger btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="btn-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
|
||||
<button class="btn-ghost btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2236,10 +2305,10 @@ function formatUserDate(iso: string): string {
|
||||
<span v-if="g.description" class="group-desc">{{ g.description }}</span>
|
||||
</div>
|
||||
<div class="group-card-actions">
|
||||
<button class="btn-sm" @click="toggleGroupExpand(g)">
|
||||
<button class="btn-ghost btn-compact" @click="toggleGroupExpand(g)">
|
||||
{{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
|
||||
</button>
|
||||
<button class="btn-sm btn-danger-sm" @click="deleteGroupConfirm(g)">Delete</button>
|
||||
<button class="btn-danger-outline btn-compact" @click="deleteGroupConfirm(g)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2269,7 +2338,7 @@ function formatUserDate(iso: string): string {
|
||||
<li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
|
||||
<span class="member-name">{{ m.username }}</span>
|
||||
<span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
|
||||
<button class="btn-sm btn-danger-sm" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
|
||||
<button class="btn-danger-outline btn-compact" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
|
||||
</li>
|
||||
<li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
|
||||
</ul>
|
||||
@@ -2495,82 +2564,6 @@ function formatUserDate(iso: string): string {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Save: Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
|
||||
/* Danger outline (Invalidate sessions, Clear observations, etc.):
|
||||
Oxblood action-destructive ghost — fills on hover */
|
||||
.btn-danger-outline {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: none;
|
||||
color: var(--color-action-destructive);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover:not(:disabled) {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Filled destructive: Oxblood action-destructive */
|
||||
.btn-danger {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Secondary: Bronze action-secondary — alternate paths (Detect, Test,
|
||||
Refresh, Add slot, etc.). Outline form for visual lightness. */
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* DB maintenance last-run summary */
|
||||
.db-maint-last { margin-top: 1rem; }
|
||||
.db-maint-last-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.db-maint-table-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -2616,7 +2609,7 @@ function formatUserDate(iso: string): string {
|
||||
.db-health-table tr.dh-warn td:first-child code { color: var(--color-warning); }
|
||||
.btn-warn:hover:not(:disabled) {
|
||||
background: var(--color-warning);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
.saved-msg {
|
||||
@@ -2916,68 +2909,6 @@ function formatUserDate(iso: string): string {
|
||||
}
|
||||
.you-label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||
.btn-delete:disabled { opacity: 0.4; cursor: default; }
|
||||
/* Two-stage destructive: Confirm = Oxblood filled, Cancel = Bronze ghost */
|
||||
.btn-confirm-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-right: 0.25rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-cancel-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
|
||||
/* Toggle (Open/Close registration, etc.): Open = Moss, Close = Pewter ghost */
|
||||
.btn-toggle {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-toggle-open { background: var(--color-action-primary); color: #fff; }
|
||||
.btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-toggle-close {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); }
|
||||
.loading-msg, .empty-msg {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* Logs panel */
|
||||
.stats-section { padding: 1rem 1.25rem; }
|
||||
@@ -3079,20 +3010,6 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
/* ── Groups tab ──────────────────────────────────────────────── */
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-primary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
@@ -3175,32 +3092,6 @@ function formatUserDate(iso: string): string {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-danger-sm:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||
|
||||
.group-members-panel {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.members-search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.member-search-wrap {
|
||||
flex: 1;
|
||||
@@ -3322,7 +3213,7 @@ function formatUserDate(iso: string): string {
|
||||
}
|
||||
.unit-btn.active {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.unit-btn:hover:not(.active) {
|
||||
color: var(--color-text);
|
||||
@@ -3713,21 +3604,6 @@ function formatUserDate(iso: string): string {
|
||||
text-align: right;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-remove-slot {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-remove-slot:hover {
|
||||
color: var(--color-danger, #e05555);
|
||||
border-color: var(--color-danger, #e05555);
|
||||
}
|
||||
.blend-actions {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@@ -3781,24 +3657,4 @@ function formatUserDate(iso: string): string {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-danger-outline {
|
||||
padding: 0.45rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover:not(:disabled) {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
|
||||
@keyframes va-dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -249,34 +249,6 @@ async function confirmDelete() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-danger {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: none;
|
||||
background: var(--color-action-destructive, #6B2118);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.when-to-use {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
|
||||
@@ -572,37 +572,6 @@ function cancel() {
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.field-row,
|
||||
|
||||
@@ -543,21 +543,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-bottom: 1.25rem;
|
||||
@@ -669,17 +654,17 @@ function usageTitle(s: SnippetListItem): string {
|
||||
.empty-action {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border: 1px solid var(--color-action-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
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-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
.skeleton-grid,
|
||||
@@ -870,21 +855,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-ghost {
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Selected card = 2px accent border per the design system (featured/active). */
|
||||
.snippet-card.selected {
|
||||
@@ -931,10 +901,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.select-bar .btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Merge modal */
|
||||
.modal-overlay {
|
||||
@@ -1021,7 +987,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
.modal-btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
|
||||
@@ -645,7 +645,7 @@ useEditorGuards(dirty, save);
|
||||
@focus="onParentFocus"
|
||||
@blur="hideParentDropdown"
|
||||
/>
|
||||
<button v-if="parentId" class="btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
||||
<button v-if="parentId" class="btn-text btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
||||
</div>
|
||||
<div v-if="showParentDropdown" class="parent-dropdown">
|
||||
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
||||
@@ -666,7 +666,7 @@ useEditorGuards(dirty, save);
|
||||
<div v-if="isEditing" class="subtasks-section">
|
||||
<div class="subtasks-header">
|
||||
<span class="subtasks-label">Sub-tasks</span>
|
||||
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
|
||||
<button class="btn-text" @click="addingSubTask = !addingSubTask">+ Add</button>
|
||||
</div>
|
||||
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
|
||||
<template v-else>
|
||||
@@ -686,8 +686,8 @@ useEditorGuards(dirty, save);
|
||||
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
|
||||
autofocus
|
||||
/>
|
||||
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||||
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
||||
<button class="btn-primary btn-compact" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||||
<button class="btn-ghost btn-compact" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -921,23 +921,6 @@ useEditorGuards(dirty, save);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.btn-add-subtask {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
padding: 0.1rem 0.2rem;
|
||||
}
|
||||
.btn-add-subtask:hover { opacity: 0.8; }
|
||||
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
.subtask-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
||||
.subtask-title {
|
||||
font-size: 0.83rem;
|
||||
@@ -968,33 +951,6 @@ useEditorGuards(dirty, save);
|
||||
font-family: inherit;
|
||||
}
|
||||
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.btn-subtask-confirm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-subtask-cancel {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Streaming preview */
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -1125,24 +1081,4 @@ useEditorGuards(dirty, save);
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: normal;
|
||||
}
|
||||
.btn-reconsolidate {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: normal;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.btn-reconsolidate:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-reconsolidate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -263,29 +263,29 @@ const subTaskProgress = computed(() => {
|
||||
<div class="toolbar">
|
||||
<router-link
|
||||
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
||||
class="btn-back"
|
||||
class="btn-ghost"
|
||||
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
||||
<router-link
|
||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||
class="btn-edit"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="advanceLabel"
|
||||
class="btn-advance"
|
||||
class="btn-primary"
|
||||
@click="advanceStatus"
|
||||
>
|
||||
{{ advanceLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-convert"
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToNote"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent task → project → milestone -->
|
||||
@@ -475,83 +475,6 @@ const subTaskProgress = computed(() => {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit + Advance: Moss action-primary — both are "operating the software"
|
||||
workflow actions, not brand moments. */
|
||||
.btn-edit,
|
||||
.btn-advance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover,
|
||||
.btn-advance:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.task-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
|
||||
@@ -25,7 +25,7 @@ onMounted(() => store.fetchTrash());
|
||||
<h1>Trash</h1>
|
||||
<button
|
||||
v-if="store.batches.length"
|
||||
class="btn-empty"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="empty"
|
||||
>Empty trash</button>
|
||||
</header>
|
||||
@@ -52,8 +52,8 @@ onMounted(() => store.fetchTrash());
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-actions">
|
||||
<button class="btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
||||
<button class="btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
||||
<button class="btn-ghost btn-compact btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
||||
<button class="btn-ghost btn-compact btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -64,25 +64,11 @@ onMounted(() => store.fetchTrash());
|
||||
.trash-page { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
|
||||
.trash-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.trash-header h1 { font-family: Fraunces, serif; font-style: italic; margin: 0; }
|
||||
.btn-empty {
|
||||
background: none; border: 1px solid var(--color-border, #2a2a2e);
|
||||
color: inherit; border-radius: 6px; padding: 0.4rem 0.8rem; cursor: pointer;
|
||||
}
|
||||
.btn-empty:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
||||
.trash-note { opacity: 0.7; font-size: 0.9em; margin: 0.5rem 0 1.5rem; }
|
||||
.trash-loading, .trash-empty { opacity: 0.6; font-style: italic; padding: 2rem 0; }
|
||||
.batch-list { list-style: none; padding: 0; margin: 0; }
|
||||
.batch {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 1rem; padding: 0.85rem 1rem; margin-bottom: 0.5rem;
|
||||
background: var(--color-surface, #18181b); border-radius: 8px;
|
||||
border-left: 2px solid var(--color-border, #2a2a2e);
|
||||
}
|
||||
.batch-summary { font-weight: 500; }
|
||||
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
|
||||
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
|
||||
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
|
||||
.btn-restore:hover { border-color: var(--color-primary, #6366f1); color: var(--color-primary, #6366f1); }
|
||||
.btn-purge:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
||||
.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>
|
||||
|
||||
@@ -167,7 +167,7 @@ function formatDate(iso: string): string {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
class="btn-primary btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
@@ -190,7 +190,7 @@ function formatDate(iso: string): string {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-invite"
|
||||
class="btn-primary"
|
||||
:disabled="sendingInvite || !inviteEmail.trim()"
|
||||
>
|
||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||
@@ -216,7 +216,7 @@ function formatDate(iso: string): string {
|
||||
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
class="btn-delete"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="revokeInvitation(inv.id)"
|
||||
:disabled="revokingId !== null"
|
||||
>
|
||||
@@ -262,17 +262,17 @@ function formatDate(iso: string): string {
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button
|
||||
class="btn-confirm-delete"
|
||||
class="btn-danger btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="btn-delete"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
@@ -328,24 +328,6 @@ function formatDate(iso: string): string {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-invite {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-invite:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-invite:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.invite-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -380,26 +362,8 @@ function formatDate(iso: string): string {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-toggle {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-toggle-open {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-toggle-open:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* The one genuine override: 'close registration' must NOT read as the
|
||||
primary action it sits on. Scoped, so it beats the shared variant. */
|
||||
.btn-toggle-close {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
@@ -478,54 +442,6 @@ function formatDate(iso: string): string {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-confirm-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.btn-confirm-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-cancel-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-cancel-delete:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.registration-row {
|
||||
|
||||
@@ -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.20",
|
||||
"version": "0.1.22",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
@@ -48,16 +48,9 @@ case "$file_path" in
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → silent. Prior-art recall is pure enrichment.
|
||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||
|
||||
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
||||
# an absolute one would simply match nothing.
|
||||
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
||||
# because the local arm below needs the repo root and needs no server at all.
|
||||
lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true)
|
||||
[ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
@@ -68,6 +61,84 @@ if [ -n "$repo_root" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
|
||||
# of the codebase, so a helper nobody thought to record is invisible to them —
|
||||
# which is how `.btn-primary` came to be defined four times, in four scoped
|
||||
# stylesheets, already diverged. It was never a snippet, so no threshold and no
|
||||
# query rewrite could ever have surfaced it.
|
||||
#
|
||||
# This arm closes that by asking the only question the record cannot answer,
|
||||
# in the only place that can: the hook already runs on the developer's machine,
|
||||
# inside the repo, holding the code about to be written. No index, no storage,
|
||||
# no staleness, and no server — it deliberately runs even on an install that
|
||||
# has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none.
|
||||
# ---------------------------------------------------------------------------
|
||||
local_lines=""
|
||||
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
||||
# kind<TAB>name for each thing this payload DEFINES.
|
||||
names=$(printf '%s' "$code" | awk '
|
||||
match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t);
|
||||
if (t != "") print "css\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) {
|
||||
t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) {
|
||||
t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t);
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
' 2>/dev/null | sort -u | head -12) || names=""
|
||||
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written, which would
|
||||
# otherwise always match itself on an Edit.
|
||||
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
|
||||
done <<< "$names"
|
||||
fi
|
||||
|
||||
local_context=""
|
||||
if [ -n "$local_lines" ]; then
|
||||
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
|
||||
fi
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
|
||||
# arm above already ran and may have something to say.
|
||||
if [ -z "$url" ] || [ -z "$token" ]; then
|
||||
if [ -n "$local_context" ]; then
|
||||
jq -n --arg c "$local_context" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cap the code sent as the semantic query. The embedder truncates at its own
|
||||
# token limit well before this, so a bigger slice buys no extra signal — and the
|
||||
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
|
||||
@@ -110,20 +181,32 @@ if [ -n "$session_id" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
||||
# finding that needed no instance to produce.
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
||||
[ -n "$body" ] || exit 0
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body=""
|
||||
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
||||
[ -n "$context" ] || exit 0
|
||||
|
||||
# Remember what was surfaced so it isn't shown again this session.
|
||||
if [ -n "$idfile" ]; then
|
||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||
context=""
|
||||
if [ -n "$body" ]; then
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
||||
# Remember what was surfaced so it isn't shown again this session.
|
||||
if [ -n "$idfile" ] && [ -n "$context" ]; then
|
||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||
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.
|
||||
combined="$local_context"
|
||||
if [ -n "$context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${context}"
|
||||
fi
|
||||
[ -n "$combined" ] || exit 0
|
||||
|
||||
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
||||
jq -n --arg c "$context" \
|
||||
jq -n --arg c "$combined" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
exit 0
|
||||
|
||||
@@ -64,11 +64,13 @@ Two constraints on *how* that's achieved:
|
||||
3. **Update over duplicate.** When recording, prefer updating an existing
|
||||
note/rule/task over creating a new one. Search first; revise what's there.
|
||||
|
||||
4. **Plans live in Scribe.** For non-trivial work call `start_planning(project_id,
|
||||
title)` FIRST — it creates a milestone whose `body` holds the design; each
|
||||
step is its own task under that milestone (`create_task(milestone_id=...)`),
|
||||
progress goes in work-logs (`add_task_log`). Read it back with `get_milestone`.
|
||||
Do not write plans/specs to local `.md` files.
|
||||
4. **When you plan, plan in Scribe.** Work with an *arc* — several steps toward
|
||||
one goal — gets a plan, and a plan is a milestone: `start_planning(project_id,
|
||||
title)` creates one whose `body` holds the design, each step is its own task
|
||||
under it (`create_task(milestone_id=...)`), progress goes in work-logs
|
||||
(`add_task_log`). Work without an arc (a fix, a one-file change, a question)
|
||||
is just a task — don't wrap it in a milestone. Either way, do not write
|
||||
plans/specs to local `.md` files. See the **writing-plans** skill.
|
||||
|
||||
5. **Keep state honest.** Set a task `in_progress` when you start it, `done` the
|
||||
moment it's complete; log progress as you go.
|
||||
@@ -102,7 +104,7 @@ shared homes general:
|
||||
norms that bind *every* project. Cross-project standards only.
|
||||
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
|
||||
reusable, *themed* module of general rules that binds only projects that opt
|
||||
in (e.g. a design system → visual apps). Themed, but still project-agnostic.
|
||||
in (e.g. a review checklist → every service). Themed, but project-agnostic.
|
||||
- **Project rule** (`create_project_rule`) — anything specific to one project
|
||||
(its files, paths, quirks).
|
||||
|
||||
@@ -112,6 +114,26 @@ project rule; a standard a category shares → subscribed rulebook; a universal
|
||||
norm → always-on rulebook. Never put project-specific detail in a shared
|
||||
rulebook — it leaks to every other project that gets it.
|
||||
|
||||
**First ask whether it's a rule at all.** A rule is prose you have to remember
|
||||
and apply; Scribe's other entities are structure a tool can resolve and check.
|
||||
Visual standards belong in 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
|
||||
really is a standing instruction about how to work.
|
||||
|
||||
## Building UI: the project's design system binds
|
||||
|
||||
`enter_project` returns a `design_system` when the project has one, with the
|
||||
guidance **chain-merged** — the house style it inherits plus its own departures
|
||||
from it. Treat it the way you treat a rule.
|
||||
|
||||
Before writing a colour, size, radius, weight or duration by hand, reach for a
|
||||
token: `resolve_design_system(id)` for the values, or
|
||||
`get_design_system_stylesheet(id)` for the rendered sheet. A literal is a value
|
||||
stated outside the system, so it can never follow a palette change — and
|
||||
nothing will tell you it drifted.
|
||||
|
||||
## Other Scribe process-skills
|
||||
|
||||
This plugin also ships focused process-skills — writing-plans, systematic
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: writing-plans
|
||||
description: Use before starting any non-trivial or multi-step piece of work — produce a clear plan BEFORE diving in. Triggers when the user asks you to plan, design an approach, scope an effort, or tackle work big enough to need ordered steps. The plan lives in a Scribe milestone (via start_planning), not a local file.
|
||||
description: Use when a piece of work has an arc — several steps toward one goal, worth tracking as a unit — and you want the approach reviewable before you start. Triggers when the user asks you to plan, design an approach, or scope an effort, or when work is about to sprawl across several steps. Not for single-step work. The plan lives in a Scribe milestone (via start_planning), not a local file.
|
||||
---
|
||||
|
||||
# Writing plans
|
||||
@@ -9,12 +9,30 @@ A plan is **how** you'll execute a chunk of work — the design plus an ordered
|
||||
set of steps — written *before* you start, so the approach is reviewable and the
|
||||
work stays trackable.
|
||||
|
||||
## Start the plan in Scribe, not a file
|
||||
## First decide whether this work wants a plan
|
||||
|
||||
For non-trivial work, call **`start_planning(project_id, title)` FIRST** —
|
||||
before any design or implementation. It creates a **milestone** (the plan
|
||||
container) seeded with a design template and returns the milestone id plus the
|
||||
project's applicable rules. The plan lives in that milestone:
|
||||
A plan lives in a milestone, and **a milestone earns its place when the work has
|
||||
an arc**: several steps, one shared goal, a beginning and an end worth tracking
|
||||
as a unit. That is the whole test, and it is a judgment about the *shape* of the
|
||||
work — not about its size, difficulty, or importance.
|
||||
|
||||
Plenty of real work has no arc. A bug fix, a one-file change, a question
|
||||
answered, a setting changed. For those, a milestone is a container with one
|
||||
thing in it: the ceremony costs more than it records, and it leaves the project
|
||||
with milestones that never meant anything. **Use a task instead** — set it
|
||||
`in_progress`, record what you find with `add_task_log`, set it `done`. That is
|
||||
a complete, honest record of work that didn't need a plan.
|
||||
|
||||
Some projects are milestone-shaped and some are a flat task list. Read the
|
||||
project you are in rather than imposing a shape on it.
|
||||
|
||||
## When it does: start the plan in Scribe, not a file
|
||||
|
||||
Call **`start_planning(project_id, title)`** before designing or implementing —
|
||||
so the milestone exists to write into, rather than being backfilled from work
|
||||
already done. It creates a **milestone** (the plan container) seeded with a
|
||||
design template and returns the milestone id plus the project's applicable
|
||||
rules. The plan lives in that milestone:
|
||||
|
||||
- The **design/intent** goes in the milestone `body` — edit it with
|
||||
`update_milestone(milestone_id, body=...)`.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the frontend's CSS against the tokens its stylesheet declares.
|
||||
|
||||
The gap this closes: `services/design_stylesheet.check_code_against_tokens` has
|
||||
always been able to answer "does this code use the sheet correctly?", but the
|
||||
only thing ever fed to it was recorded SNIPPETS. The app's own components — where
|
||||
sixteen unresolvable references were found living quietly (#2319) — were checked
|
||||
by nothing at all.
|
||||
|
||||
That was structural rather than an oversight. The drift panel runs in the browser
|
||||
and cannot read source files, and the server has no repo access. CI is the only
|
||||
place holding both the component sources and the ability to run the check, and it
|
||||
only became cheap once `theme.css` became a generated artifact — so the source of
|
||||
truth is a local file, with no network and no credentials.
|
||||
|
||||
INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should
|
||||
be called or which literals are discouraged. Both come from the stylesheet: the
|
||||
declarations, and the `SUPERSEDES` block the generator emits. Point it at a
|
||||
different install's sheet and it checks that install's rules.
|
||||
|
||||
Two severities, and the split is deliberate:
|
||||
|
||||
FAIL an unresolvable `var()` reference. Currently zero, so this is a ratchet
|
||||
that holds a line already reached rather than a backlog that keeps CI
|
||||
red. It also cannot false-positive: either the name is declared or it
|
||||
is not.
|
||||
REPORT superseded literals and raw colour literals. Hundreds today, so gating
|
||||
on them would mean a permanently failing job that everyone learns to
|
||||
ignore — which is worse than no check.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
# A declaration is `--name:`; a reference is `var(--name)` or `var(--name, …)`.
|
||||
DECLARATION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
|
||||
REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
|
||||
SUPERSEDES_LINE = re.compile(r"^\s*\*\s*(\S+)\s*->\s*(--[A-Za-z0-9_-]+)\s*$")
|
||||
HEX_LITERAL = re.compile(r"#[0-9a-fA-F]{3,8}\b")
|
||||
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
|
||||
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
||||
|
||||
|
||||
def declared_tokens(sheet: str) -> set[str]:
|
||||
"""Every custom property the stylesheet declares.
|
||||
|
||||
Anchored on the colon alone. Anchoring on `{` or `;` instead silently drops
|
||||
every declaration that follows a comment — a mistake made once already, which
|
||||
lost `--color-bg` and 2 others without erroring.
|
||||
"""
|
||||
return set(DECLARATION.findall(sheet))
|
||||
|
||||
|
||||
def superseded_literals(sheet: str) -> dict[str, str]:
|
||||
"""`{literal: token}` from the generator's SUPERSEDES block, lowercased."""
|
||||
out: dict[str, str] = {}
|
||||
for line in sheet.splitlines():
|
||||
match = SUPERSEDES_LINE.match(line)
|
||||
if match:
|
||||
out[match.group(1).lower()] = match.group(2)
|
||||
return out
|
||||
|
||||
|
||||
def _literal_pattern(literal: str) -> re.Pattern:
|
||||
"""Match a literal without matching a longer one containing it.
|
||||
|
||||
`#fff` must not fire inside `#ffffff`: different colours, and a finding on
|
||||
the wrong one sends someone to change correct code.
|
||||
"""
|
||||
return re.compile(
|
||||
r"(?<![0-9A-Za-z_#-])" + re.escape(literal) + r"(?![0-9A-Za-z_-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def style_source(path: pathlib.Path) -> str:
|
||||
"""The CSS in a file — `<style>` blocks for an SFC, the whole of a .css.
|
||||
|
||||
Comments are stripped, and that is load-bearing rather than tidy. A comment
|
||||
EXPLAINING a rule mentions the very literal the rule forbids: this file's own
|
||||
stylesheet documents why it avoids `#fff`, and the first run of this checker
|
||||
reported that explanation as a violation. A checker that flags the
|
||||
documentation of a rule teaches people to stop documenting rules.
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
css = "\n".join(STYLE_BLOCK.findall(text)) if path.suffix == ".vue" else text
|
||||
return CSS_COMMENT.sub(" ", css)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
|
||||
parser.add_argument("--root", default="frontend/src")
|
||||
parser.add_argument(
|
||||
"--report-literals", action="store_true",
|
||||
help="also list raw colour literals (advisory, never fails)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
sheet_path = pathlib.Path(args.sheet)
|
||||
if not sheet_path.is_file():
|
||||
print(f"error: stylesheet not found: {sheet_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
sheet = sheet_path.read_text()
|
||||
declared = declared_tokens(sheet)
|
||||
supersedes = superseded_literals(sheet)
|
||||
print(f"{sheet_path}: {len(declared)} tokens declared, "
|
||||
f"{len(supersedes)} superseded literals recorded\n")
|
||||
|
||||
root = pathlib.Path(args.root)
|
||||
sources = sorted(
|
||||
[p for p in root.rglob("*.vue")] + [p for p in root.rglob("*.css")]
|
||||
)
|
||||
|
||||
unresolved: list[tuple[pathlib.Path, str]] = []
|
||||
superseded_hits: list[tuple[pathlib.Path, str, str]] = []
|
||||
literal_count = 0
|
||||
|
||||
for path in sources:
|
||||
if path == sheet_path:
|
||||
continue
|
||||
css = style_source(path)
|
||||
if not css.strip():
|
||||
continue
|
||||
|
||||
# A component may legitimately declare a local custom property; a
|
||||
# reference to it is not unresolved.
|
||||
local = set(DECLARATION.findall(css))
|
||||
for name in sorted(set(REFERENCE.findall(css))):
|
||||
if name not in declared and name not in local:
|
||||
unresolved.append((path, name))
|
||||
|
||||
for literal, token in supersedes.items():
|
||||
if _literal_pattern(literal).search(css):
|
||||
superseded_hits.append((path, literal, token))
|
||||
|
||||
literal_count += len(HEX_LITERAL.findall(css))
|
||||
|
||||
if unresolved:
|
||||
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
|
||||
print(" These render as the fallback if given one, or as nothing at all.")
|
||||
print(" Either way nothing errors, which is why they survive.\n")
|
||||
for path, name in unresolved:
|
||||
print(f" {path}: {name}")
|
||||
print()
|
||||
else:
|
||||
print("OK — every var() reference resolves to a declared token.\n")
|
||||
|
||||
if superseded_hits:
|
||||
print(f"REPORT — {len(superseded_hits)} superseded literal(s). "
|
||||
"The sheet says what to write instead:")
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for path, literal, token in superseded_hits:
|
||||
key = (str(path), literal)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
print(f" {path}: {literal} -> {token}")
|
||||
print()
|
||||
|
||||
if args.report_literals:
|
||||
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
|
||||
print(" Advisory: a literal is a value stated outside the system, so it "
|
||||
"cannot follow a palette change.\n")
|
||||
|
||||
return 1 if unresolved else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+56
-1
@@ -181,13 +181,25 @@ def check_shellcheck() -> None:
|
||||
# pinning: the bug and the healthy no-results case look identical from outside.
|
||||
# Pinning it does NOT make the failure visible; it makes sure the fail-open
|
||||
# behaviour is deliberate rather than accidental.
|
||||
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
|
||||
# The prior-art hook's local arm (#2280) fires with no credentials, so the
|
||||
# silence assertion below needs a name the repo genuinely lacks. Two traps,
|
||||
# both hit while writing this:
|
||||
# - `def f` matched real code, so the hook spoke and "silent" was asserting
|
||||
# the wrong thing;
|
||||
# - spelling the replacement out in full put `def <name>(` INTO this file,
|
||||
# so the smoke event defined the very symbol it claimed was absent.
|
||||
# Concatenating keeps the contiguous string out of the source.
|
||||
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
|
||||
|
||||
SMOKE_EVENTS: dict[str, str] = {
|
||||
"scribe_autoinject.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
|
||||
),
|
||||
"scribe_prior_art.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
|
||||
"tool_input": {"file_path": "src/x.py", "new_string": "def f():\n pass\n"}}
|
||||
"tool_input": {"file_path": "src/x.py",
|
||||
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
|
||||
),
|
||||
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
||||
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
||||
@@ -253,6 +265,48 @@ def check_fail_open() -> None:
|
||||
ok(f"{rel} [{label}]: exit 0, silent")
|
||||
|
||||
|
||||
def check_local_prior_art_needs_no_instance() -> None:
|
||||
"""The prior-art hook's local arm must answer with no credentials (#2280).
|
||||
|
||||
The other arms ask Scribe what was RECORDED. This one asks the repo what
|
||||
EXISTS, which needs no instance — and that is the whole reason it catches
|
||||
the case the recorded arms structurally cannot: a helper nobody thought to
|
||||
record. If it ever silently starts depending on configuration, it stops
|
||||
covering that case and nothing else would notice.
|
||||
|
||||
Paired with the silence assertion in check_fail_open, which uses a symbol
|
||||
that cannot exist. Together they pin both halves: silent when there is
|
||||
nothing to say, and speaking when there is — both with no instance at all.
|
||||
"""
|
||||
script = HOOKS_DIR / "scribe_prior_art.sh"
|
||||
if not script.is_file() or not shutil.which("jq"):
|
||||
skip("prior-art local arm: hook or jq missing")
|
||||
return
|
||||
|
||||
# A definition this repo really does contain, written into a DIFFERENT file
|
||||
# so the self-match exclusion doesn't suppress it.
|
||||
event = json.dumps({
|
||||
"session_id": "smoke", "cwd": ".", "tool_name": "Write",
|
||||
"tool_input": {
|
||||
"file_path": "scripts/_probe_not_real.py",
|
||||
"content": "def check_local_prior_art_needs_no_instance():\n pass\n",
|
||||
},
|
||||
})
|
||||
try:
|
||||
proc = _run_hook(script, event, {}) # NO credentials, on purpose
|
||||
except subprocess.TimeoutExpired:
|
||||
fail("prior-art local arm: hung")
|
||||
return
|
||||
if proc.returncode != 0:
|
||||
fail(f"prior-art local arm: exited {proc.returncode}, must be 0")
|
||||
elif "already defined" not in proc.stdout:
|
||||
fail("prior-art local arm: found nothing for a symbol this repo "
|
||||
"defines, with no credentials — the arm that needs no instance "
|
||||
"has stopped working, and the recorded arms cannot cover for it")
|
||||
else:
|
||||
ok("prior-art local arm: answers with no instance configured")
|
||||
|
||||
|
||||
def _git(*args: str) -> tuple[int, str]:
|
||||
proc = subprocess.run(
|
||||
["git", *args], capture_output=True, text=True, cwd=ROOT
|
||||
@@ -344,6 +398,7 @@ def main() -> int:
|
||||
check_patterns()
|
||||
check_shellcheck()
|
||||
check_fail_open()
|
||||
check_local_prior_art_needs_no_instance()
|
||||
if not args.no_version:
|
||||
check_version_bump(args.base)
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ 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
|
||||
from scribe.routes.systems import systems_bp
|
||||
@@ -89,6 +91,8 @@ 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)
|
||||
app.register_blueprint(systems_bp)
|
||||
|
||||
+36
-18
@@ -33,14 +33,27 @@ What each part is for, and when to reach for it:
|
||||
- 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. Start one with start_planning when
|
||||
beginning non-trivial work, before you dive in; read it back with
|
||||
get_milestone (body + steps). (The old kind=plan task is retired — some
|
||||
historical plan-tasks still exist and remain readable, but don't create new
|
||||
ones.)
|
||||
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
|
||||
@@ -143,8 +156,8 @@ right altitude:
|
||||
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 design-system rulebook: design-specific but project-agnostic — no
|
||||
rule names a single app).
|
||||
(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
|
||||
@@ -152,6 +165,15 @@ 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
|
||||
@@ -184,17 +206,13 @@ adopting or creating — never do either silently, and never guess a project int
|
||||
existence. Once a project is in scope, the enter_project handshake and the
|
||||
host-memory pointer step above both apply.
|
||||
|
||||
A plan is a MILESTONE, and Scribe is the canonical home for it. When you begin
|
||||
non-trivial work, call start_planning(project_id, title) FIRST — before any
|
||||
brainstorming, design, or plan-writing skill runs. start_planning creates the
|
||||
milestone, seeds its `body` with the design template, returns the project's
|
||||
applicable_rules, and gives you the milestone id you'll write into. Put the
|
||||
design/intent in the milestone body via update_milestone(milestone_id, body=...);
|
||||
create each step as a child task with create_task(milestone_id=...) and track it
|
||||
with status + add_task_log — do NOT list steps as checkboxes in the body. Read
|
||||
the plan back with get_milestone (body + steps). 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 local file.
|
||||
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
|
||||
|
||||
@@ -5,7 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets, systems, tags, tasks, trash,
|
||||
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
|
||||
systems, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +18,7 @@ def register_all(mcp) -> None:
|
||||
projects.register(mcp)
|
||||
milestones.register(mcp)
|
||||
systems.register(mcp)
|
||||
design_systems.register(mcp)
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
repos.register(mcp)
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Design system + token MCP tools — wrappers over services/design_systems.py.
|
||||
|
||||
A design system is a stylesheet held as records: a named set of tokens with an
|
||||
optional parent, so a family system carries the house style and an app system
|
||||
carries only what it changes. Precedence by name along the parent chain IS the
|
||||
CSS cascade, which is why "what does this app alter?" is a plain list rather
|
||||
than a diff.
|
||||
|
||||
Parity with the REST surface is a rule, not a nicety (see
|
||||
`tests/test_routes_design_systems.py`): an agent and a browser are two callers
|
||||
of one service.
|
||||
|
||||
Sentinels, matching the milestone/task tool conventions:
|
||||
- title="" / description="" / etc. -> "leave unchanged" on update
|
||||
- parent_id / design_system_id: 0 = leave unchanged, -1 = clear, positive = set
|
||||
(three states, because clearing a parent is a real operation and not the
|
||||
same as omitting the argument)
|
||||
- order_index=-1 -> "leave unchanged" (0 is a valid order_index)
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
async def create_design_system(
|
||||
title: str,
|
||||
description: str = "",
|
||||
guidance: str = "",
|
||||
parent_id: int = 0,
|
||||
) -> dict:
|
||||
"""Create a design system, optionally inheriting from another.
|
||||
|
||||
Args:
|
||||
title: What this system is — a house style, or one app within it
|
||||
(required).
|
||||
description: What it covers and when it applies.
|
||||
guidance: The narrative a token table cannot hold — aesthetic, voice and
|
||||
tone, what is deliberately out of scope. Markdown, free-form.
|
||||
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.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
system = await ds_svc.create_design_system(
|
||||
uid,
|
||||
title=title,
|
||||
description=description or None,
|
||||
guidance=guidance or None,
|
||||
parent_id=parent_id or None,
|
||||
)
|
||||
if system is None:
|
||||
raise ValueError(f"parent design system {parent_id} not found or not writable")
|
||||
return system.to_dict()
|
||||
|
||||
|
||||
async def list_design_systems() -> dict:
|
||||
"""List your design systems. An empty list is normal — most installs have none."""
|
||||
uid = current_user_id()
|
||||
rows = await ds_svc.list_design_systems(uid)
|
||||
return {"design_systems": [s.to_dict() for s in rows]}
|
||||
|
||||
|
||||
async def get_design_system(design_system_id: int) -> dict:
|
||||
"""Fetch a design system plus its OWN tokens — i.e. what it changes.
|
||||
|
||||
For what it actually resolves to once inheritance is applied, use
|
||||
`resolve_design_system`. The two answer different questions and a system
|
||||
that overrides nothing has an empty token list but a full resolved set.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
system = await ds_svc.get_design_system(uid, design_system_id)
|
||||
if system is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
tokens = await ds_svc.list_tokens(uid, design_system_id)
|
||||
return {
|
||||
"design_system": system.to_dict(),
|
||||
"tokens": [t.to_dict() for t in tokens],
|
||||
}
|
||||
|
||||
|
||||
async def resolve_design_system(design_system_id: int) -> dict:
|
||||
"""The EFFECTIVE token set — everything inherited, with this system's on top.
|
||||
|
||||
Each token carries `origin_by_mode` (which system supplied each mode's
|
||||
value) and `contributions` (every system that offered one, deepest first —
|
||||
so entry 0 won and the rest were shadowed). Reach for this when you need to
|
||||
know what a value actually IS; reach for `get_design_system` when you need
|
||||
to know what this system CHANGES.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
resolved = await ds_svc.resolve_design_system(uid, design_system_id)
|
||||
if resolved is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
return {
|
||||
"design_system_id": design_system_id,
|
||||
"tokens": [t.to_dict() for t in resolved],
|
||||
}
|
||||
|
||||
|
||||
async def update_design_system(
|
||||
design_system_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
guidance: str = "",
|
||||
parent_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update a design system.
|
||||
|
||||
Args:
|
||||
design_system_id: The system to update.
|
||||
title: New title, or "" to leave unchanged.
|
||||
description: New description, or "" to leave unchanged.
|
||||
guidance: New guidance prose, or "" to leave unchanged.
|
||||
parent_id: 0 = leave unchanged, -1 = clear (make this a top-level
|
||||
family system), positive = inherit from that system. A parent that
|
||||
already inherits from this system is refused — that would be a loop.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
if title:
|
||||
fields["title"] = title
|
||||
if description:
|
||||
fields["description"] = description
|
||||
if guidance:
|
||||
fields["guidance"] = guidance
|
||||
if parent_id:
|
||||
fields["parent_id"] = None if parent_id == -1 else parent_id
|
||||
try:
|
||||
system = await ds_svc.update_design_system(uid, design_system_id, **fields)
|
||||
except DesignSystemCycle as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if system is None:
|
||||
raise ValueError(f"design system {design_system_id} not found or not writable")
|
||||
return system.to_dict()
|
||||
|
||||
|
||||
async def delete_design_system(design_system_id: int) -> dict:
|
||||
"""Soft-delete a design system (recoverable).
|
||||
|
||||
Systems that inherited from it become top-level systems keeping their own
|
||||
tokens — deleting a family does not delete the apps under it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not await ds_svc.delete_design_system(uid, design_system_id):
|
||||
raise ValueError(f"design system {design_system_id} not found or not writable")
|
||||
return {"message": f"Design system {design_system_id} deleted."}
|
||||
|
||||
|
||||
async def get_design_system_stylesheet(
|
||||
design_system_id: int,
|
||||
root_selector: str = ":root",
|
||||
) -> dict:
|
||||
"""The master CSS sheet a design system generates.
|
||||
|
||||
Purpose tokens only — this sheet declares what values MEAN and styles no
|
||||
elements. Components (buttons, tables, input schemes) are SNIPPETS that
|
||||
reference these names, so a value is stated once and reused rather than
|
||||
restated per element. Reach for this when you need the tokens a snippet is
|
||||
allowed to use.
|
||||
|
||||
Also returns `valueless` (tokens the system names but has no value for) and
|
||||
`duplicates` (values declared under more than one name — a deliberate alias,
|
||||
or one idea recorded twice).
|
||||
|
||||
Args:
|
||||
design_system_id: The system to render.
|
||||
root_selector: Selector for the base layer. Defaults to `:root`; pass a
|
||||
container selector to scope the sheet to a preview region.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
result = await ds_svc.stylesheet_for_system(uid, design_system_id, root_selector)
|
||||
if result is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
return result
|
||||
|
||||
|
||||
async def check_snippets_against_design_system(
|
||||
design_system_id: int,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Which recorded snippets disagree with a design system's sheet.
|
||||
|
||||
Snippets are the component layer — buttons, tables, input schemes — and they
|
||||
are supposed to use the tags the sheet declares. Three findings per snippet,
|
||||
each currently silent in the codebase:
|
||||
|
||||
unknown `var(--x)` where the system has no `--x`. Renders as
|
||||
NOTHING: no error, no failing test, just an element
|
||||
that quietly isn't styled.
|
||||
superseded_literals a literal the sheet says to stop writing, paired with
|
||||
the token to write instead.
|
||||
local_definitions custom properties the snippet mints for itself rather
|
||||
than using shared ones — the bloat a shared sheet
|
||||
exists to prevent.
|
||||
|
||||
Snippets with nothing to report are omitted. Reach for this before writing
|
||||
or reviewing component CSS.
|
||||
|
||||
Args:
|
||||
design_system_id: The system whose sheet is authoritative.
|
||||
project_id: Narrow to one project, or 0 for every project.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
result = await ds_svc.check_snippets_against_system(
|
||||
uid, design_system_id, project_id
|
||||
)
|
||||
if result is None:
|
||||
raise ValueError(f"design system {design_system_id} not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_design_token(
|
||||
design_system_id: int,
|
||||
name: str,
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
rationale: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> dict:
|
||||
"""Add a token to a design system.
|
||||
|
||||
Args:
|
||||
design_system_id: The system that owns this token.
|
||||
name: The custom-property name, e.g. "--surface-page" (required).
|
||||
Name it for its PURPOSE, not its value: a name like "--obsidian"
|
||||
or "--button-bg" stops being true the moment the value or the
|
||||
element changes.
|
||||
value_by_mode: Values keyed by mode, e.g.
|
||||
{"base": "#14171a", "light": "#f7f5ef"}. Use "base" for the value
|
||||
that applies when no mode is more specific; a token that is not
|
||||
mode-dependent needs only "base". In a system WITH a parent, an
|
||||
omitted mode is inherited rather than blanked.
|
||||
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
||||
this system's own vocabulary is.
|
||||
purpose: What the token is for, e.g. "page background, deepest
|
||||
surface".
|
||||
rationale: WHY it is this value — a different question from purpose.
|
||||
"Deliberately the same value as the primary action colour" is a
|
||||
rationale; "page background, deepest surface" is a purpose.
|
||||
supersedes: Literal values this token should be used INSTEAD OF, e.g.
|
||||
["#fff", "#ffffff"]. This is how a design system records what a
|
||||
prohibition was trying to say — not "white is banned" but "write
|
||||
this token instead". Declare it rather than expecting it to be
|
||||
inferred: a superseded literal and the token's own value are
|
||||
usually different values, so nothing can connect them by matching.
|
||||
order_index: Display position within its group.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
token = await ds_svc.create_token(
|
||||
uid,
|
||||
design_system_id=design_system_id,
|
||||
name=name,
|
||||
value_by_mode=value_by_mode,
|
||||
group_name=group_name or None,
|
||||
purpose=purpose or None,
|
||||
rationale=rationale or None,
|
||||
supersedes=supersedes,
|
||||
order_index=order_index,
|
||||
)
|
||||
if token is None:
|
||||
raise ValueError(f"design system {design_system_id} not found or not writable")
|
||||
return token.to_dict()
|
||||
|
||||
|
||||
async def list_design_tokens(design_system_id: int) -> dict:
|
||||
"""A design system's OWN tokens — its override set, not its effective set."""
|
||||
uid = current_user_id()
|
||||
rows = await ds_svc.list_tokens(uid, design_system_id)
|
||||
return {"tokens": [t.to_dict() for t in rows]}
|
||||
|
||||
|
||||
async def update_design_token(
|
||||
token_id: int,
|
||||
name: str = "",
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
rationale: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = -1,
|
||||
) -> dict:
|
||||
"""Update a token. Empty/None args leave a field unchanged.
|
||||
|
||||
`value_by_mode` and `supersedes` REPLACE their whole value rather than
|
||||
merging into it, so send every entry you want the token to keep. Pass `[]`
|
||||
to clear `supersedes` entirely.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
if name:
|
||||
fields["name"] = name
|
||||
if value_by_mode is not None:
|
||||
fields["value_by_mode"] = value_by_mode
|
||||
if group_name:
|
||||
fields["group_name"] = group_name
|
||||
if purpose:
|
||||
fields["purpose"] = purpose
|
||||
if rationale:
|
||||
fields["rationale"] = rationale
|
||||
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
|
||||
# superseded literal) and would otherwise be unreachable.
|
||||
if supersedes is not None:
|
||||
fields["supersedes"] = supersedes
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
token = await ds_svc.update_token(uid, token_id, **fields)
|
||||
if token is None:
|
||||
raise ValueError(f"design token {token_id} not found or not writable")
|
||||
return token.to_dict()
|
||||
|
||||
|
||||
async def delete_design_token(token_id: int) -> dict:
|
||||
"""Soft-delete a token (recoverable).
|
||||
|
||||
In a system with a parent this restores inheritance: the token stops being
|
||||
overridden here and resolves to the parent's value again.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not await ds_svc.delete_token(uid, token_id):
|
||||
raise ValueError(f"design token {token_id} not found or not writable")
|
||||
return {"message": f"Design token {token_id} deleted."}
|
||||
|
||||
|
||||
async def set_project_design_system(project_id: int, design_system_id: int = 0) -> dict:
|
||||
"""Point a project at a design system.
|
||||
|
||||
Args:
|
||||
project_id: The project to style.
|
||||
design_system_id: The system it uses, or -1 to clear it. Pointing at a
|
||||
system only requires READ access to it — consuming a design system
|
||||
is not changing it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
target = None if design_system_id == -1 else design_system_id
|
||||
ok = await ds_svc.set_project_design_system(uid, project_id, target)
|
||||
if not ok:
|
||||
raise ValueError(
|
||||
f"project {project_id} not writable, or design system "
|
||||
f"{design_system_id} not found"
|
||||
)
|
||||
return {"project_id": project_id, "design_system_id": target}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
create_design_system,
|
||||
list_design_systems,
|
||||
get_design_system,
|
||||
resolve_design_system,
|
||||
update_design_system,
|
||||
delete_design_system,
|
||||
get_design_system_stylesheet,
|
||||
check_snippets_against_design_system,
|
||||
create_design_token,
|
||||
list_design_tokens,
|
||||
update_design_token,
|
||||
delete_design_token,
|
||||
set_project_design_system,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -17,6 +17,7 @@ keeps working.
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import design_systems as design_systems_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
@@ -53,7 +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.
|
||||
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.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
project = await projects_svc.get_project(uid, project_id)
|
||||
@@ -74,9 +81,17 @@ async def enter_project(project_id: int) -> dict:
|
||||
uid, is_task=False, project_id=project_id,
|
||||
sort="updated_at", limit=5,
|
||||
)
|
||||
# A project need not have one, and most installs won't — null is ordinary
|
||||
# here, not a missing prerequisite.
|
||||
design_system = None
|
||||
if project.design_system_id:
|
||||
design_system = await design_systems_svc.design_context(
|
||||
uid, project.design_system_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"project": project.to_dict(),
|
||||
"design_system": design_system,
|
||||
"milestone_summary": milestone_summary,
|
||||
"applicable_rules": applicable["rules"],
|
||||
"project_rules": applicable.get("project_rules", []),
|
||||
|
||||
@@ -45,7 +45,8 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
Two ways a rulebook reaches projects, set by its always_on flag (toggle via
|
||||
update_rulebook):
|
||||
- always_on = true -> binds EVERY one of your projects automatically.
|
||||
Use for universal cross-project norms (e.g. "FabledSword family").
|
||||
Use for universal cross-project norms that apply across every
|
||||
project, not just one.
|
||||
- always_on = false -> binds only projects that subscribe
|
||||
(subscribe_project_to_rulebook). Use for a THEMED body of rules a
|
||||
category of projects shares (e.g. a design system that visual apps
|
||||
@@ -54,7 +55,7 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
to any single project. Project-specific rules go in create_project_rule.
|
||||
|
||||
Args:
|
||||
title: Rulebook name (e.g. "FabledSword family").
|
||||
title: Rulebook name.
|
||||
description: Optional short description of what this rulebook covers.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
|
||||
@@ -27,6 +27,7 @@ from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services import task_logs as task_logs_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.note_usage import record_pulled
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
@@ -99,6 +100,14 @@ async def get_task(task_id: int) -> dict:
|
||||
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
# Same reasoning as get_note's record_pulled, and this is the tool where it
|
||||
# matters MOST: auto-inject ranks kind-blind over a corpus that is
|
||||
# overwhelmingly tasks and issues, so tasks dominate what it surfaces. Without
|
||||
# this the surfaced→pulled loop was open exactly where the volume is — every
|
||||
# surfaced task counted as never-pulled because the tool that opens one didn't
|
||||
# say so, driving auto-inject's measured pull-through toward zero for its own
|
||||
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_task")
|
||||
return data
|
||||
|
||||
|
||||
@@ -267,6 +276,12 @@ async def add_task_log(task_id: int, content: str) -> dict:
|
||||
async def start_planning(project_id: int, title: str) -> dict:
|
||||
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
|
||||
|
||||
Reach for this when the work has an ARC — several steps toward one goal,
|
||||
worth tracking as a unit. Work without one (a fix, a one-file change, a
|
||||
question answered) is a task, not a plan: create_task, drive its status, and
|
||||
record progress with add_task_log. A milestone holding a single step is
|
||||
ceremony, and it leaves the project with a plan that never meant anything.
|
||||
|
||||
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
|
||||
template (Goal/Approach/Verification) under the given project, and the call
|
||||
returns it together with the project's applicable Rulebook rules and brief
|
||||
|
||||
@@ -43,3 +43,4 @@ from scribe.models.rulebook import ( # noqa: E402, F401
|
||||
)
|
||||
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
||||
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
||||
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Design systems — a stylesheet held as records, inherited family -> app.
|
||||
|
||||
A DesignSystem is a named set of design tokens with an OPTIONAL parent, and that
|
||||
single self-FK is the whole model. A system with no parent is a family system; a
|
||||
system WITH one holds only what it changes. "What does this app alter?" is
|
||||
therefore `list its tokens` — nothing to compute, nothing to diff — which is why
|
||||
inheritance won over a flat family-plus-loose-overrides shape.
|
||||
|
||||
Resolution walks the chain and lets the deepest system win by token name. That
|
||||
is the CSS cascade rather than an analogy to it, which is why the storage model
|
||||
and the stylesheet model come out the same shape.
|
||||
|
||||
`parent_id` also replaces two things the rulebook model needs to express the same
|
||||
idea: an `always_on` flag (a family system is simply one with no parent) and a
|
||||
subscription join table (a project points at ONE system, and the chain supplies
|
||||
the rest). Less schema for more structure.
|
||||
"""
|
||||
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, Text, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "design_systems"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
owner_user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The narrative a token table cannot hold: aesthetic, voice and tone, what
|
||||
# is deliberately out of scope. Free-form markdown rather than a column per
|
||||
# category — a schema with `voice`/`aesthetic`/`scope` columns would bake one
|
||||
# rulebook's table of contents into every install.
|
||||
guidance: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# SET NULL, not CASCADE: deleting a family system must not delete every app
|
||||
# system that inherited from it. Orphaning turns each child into a root that
|
||||
# still holds its own overrides — recoverable. A cascade would destroy data
|
||||
# the operator never asked to touch.
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("design_systems.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"owner_user_id": self.owner_user_id,
|
||||
"title": self.title,
|
||||
"description": self.description or "",
|
||||
"guidance": self.guidance or "",
|
||||
"parent_id": self.parent_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""One custom property in one system: its name, and its value per mode.
|
||||
|
||||
`value_by_mode` is a JSONB map of mode -> value, e.g.
|
||||
`{"base": "#f7f5ef", "dark": "#14171a"}`. Three reasons it beats a pair of
|
||||
`value_light` / `value_dark` columns here:
|
||||
|
||||
- **Absence means one thing.** In a child system an unset mode means
|
||||
"inherit"; in a root it would have to mean "not mode-dependent". With
|
||||
columns those are both NULL and the resolver cannot tell them apart. With
|
||||
a map, resolution is `{**parent_map, **child_map}` at every level —
|
||||
one rule, no special case for roots.
|
||||
- **Per-mode overrides are already real.** A palette rule in this operator's
|
||||
own kit deepens one accent on light backgrounds for contrast while
|
||||
leaving the dark value alone. Mode is a second override axis, not a
|
||||
second column.
|
||||
- **Nothing filters tokens by value in SQL.** Drift comparison resolves the
|
||||
set first and compares in the client; the importer diffs in Python. The
|
||||
queryability columns would buy is for a query no caller makes.
|
||||
|
||||
The cost is real — a third mode is data rather than schema, so the DB will
|
||||
not reject a typo'd mode key. That is the trade taken.
|
||||
|
||||
NOT NULL with a `{}` default deliberately: a JSONB column otherwise has two
|
||||
empty states (SQL NULL and JSON null) and code has to test for both.
|
||||
"""
|
||||
__tablename__ = "design_tokens"
|
||||
__table_args__ = (
|
||||
# Partial unique: a name is unique among LIVE tokens in a system, so a
|
||||
# trashed token doesn't block recreating the same name. Two live rows
|
||||
# named `--fs-obsidian` in one system is a duplicate definition, and the
|
||||
# cascade would pick between them arbitrarily.
|
||||
Index(
|
||||
"uq_token_per_design_system", "design_system_id", "name",
|
||||
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
design_system_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("design_systems.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
# Named for what it holds rather than the bare word `values`, which is
|
||||
# reserved in SQL — the same reason `group_name` is not `group`.
|
||||
value_by_mode: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
|
||||
)
|
||||
# `group` is a reserved word in SQL; `group_name` throughout — model, column
|
||||
# and payload — so no layer has to remember which spelling it is on.
|
||||
# Free text, not a CHECK enum: groupings are the design system's own
|
||||
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
||||
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# WHY this token is this value — a different question from `purpose`, which
|
||||
# is what it is FOR. "Deliberately the same value as the primary action
|
||||
# colour" is a rationale; "page background, deepest surface" is a purpose.
|
||||
# Design guidance carries the first routinely and a token row had nowhere to
|
||||
# put it.
|
||||
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
|
||||
# "#ffffff"] on a text-on-action token.
|
||||
#
|
||||
# This is how a design system records the thing a prohibition was trying to
|
||||
# say. "Pure white is never text" is the shadow of a positive fact — some
|
||||
# other colour IS the text colour — and a system that stores what things ARE
|
||||
# has no row for a ban.
|
||||
# Recording the replacement keeps the check and makes it actionable: a
|
||||
# finding can name what to write instead of merely objecting.
|
||||
#
|
||||
# It has to be DECLARED rather than inferred, because the superseded literal
|
||||
# and the token's own value are usually different colours entirely. No
|
||||
# value-matching rule could ever connect them.
|
||||
#
|
||||
# Consumed by the source lint (#2277), not by the drift panel: these
|
||||
# literals live in component CSS, which the panel cannot see and says so.
|
||||
supersedes: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"design_system_id": self.design_system_id,
|
||||
"name": self.name,
|
||||
"value_by_mode": self.value_by_mode or {},
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"rationale": self.rationale,
|
||||
"supersedes": self.supersedes or [],
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import enum
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import TimestampMixin, SoftDeleteMixin
|
||||
@@ -21,6 +21,12 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
goal: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(Text, default="active")
|
||||
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
||||
# The design system this project's UI is built from, or NULL. NULL is the
|
||||
# ordinary state, not a degraded one — most installs have no design system
|
||||
# at all and nothing may assume one exists.
|
||||
design_system_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -31,6 +37,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"goal": self.goal,
|
||||
"status": self.status,
|
||||
"color": self.color,
|
||||
"design_system_id": self.design_system_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Design-system surface — what the rulebook expects of the stylesheet.
|
||||
|
||||
The client owns the other half of the comparison: it reads live token values from
|
||||
the browser (see `utils/designTokens.ts`), which is the only place they exist
|
||||
resolved. This endpoint supplies the claims to check them against.
|
||||
"""
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
from scribe.services import design_rulebook_import as design_svc
|
||||
|
||||
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
|
||||
|
||||
|
||||
@design_bp.get("/expectations")
|
||||
@login_required
|
||||
async def get_expectations():
|
||||
"""Checkable claims from the rulebook this install designated as its design system.
|
||||
|
||||
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
|
||||
|
||||
`rulebook_id: null` is the NORMAL case, not an error — an install that has
|
||||
not designated a design rulebook has nothing to compare against, and the
|
||||
client shows an explanatory empty state (rule #115). Distinguishing it from
|
||||
"designated but empty" is why the id is returned alongside the list.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
result = await design_svc.design_expectations(uid)
|
||||
return jsonify(result.as_dict())
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Design system + token REST endpoints (milestone #254 step 4).
|
||||
|
||||
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
|
||||
Design systems are owner-scoped top-level records rather than project-scoped
|
||||
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
|
||||
closer shape.
|
||||
|
||||
Two failures this layer has to keep apart, which is why the service raises for
|
||||
one and returns None for the other:
|
||||
|
||||
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
|
||||
from this system" is a correctable mistake and the caller needs to be told
|
||||
which one they made.
|
||||
- None -> 404, covering both "no such system" and "not yours". Conflating
|
||||
those two IS the intent: distinguishing them would confirm the existence of
|
||||
records the caller may not see.
|
||||
"""
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import login_required
|
||||
from scribe.services import design_systems as ds_svc
|
||||
from scribe.services.design_systems import DesignSystemCycle
|
||||
|
||||
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
def _not_found(what: str = "design system"):
|
||||
return jsonify({"error": f"{what} not found"}), 404
|
||||
|
||||
|
||||
# ── Design systems ──────────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.get("/design-systems")
|
||||
@login_required
|
||||
async def list_design_systems():
|
||||
"""The caller's design systems. An empty list is the ordinary state for an
|
||||
install that has never made one, not an error."""
|
||||
rows = await ds_svc.list_design_systems(_uid())
|
||||
return jsonify({"design_systems": [s.to_dict() for s in rows]})
|
||||
|
||||
|
||||
@design_systems_bp.post("/design-systems")
|
||||
@login_required
|
||||
async def create_design_system():
|
||||
data = await request.get_json() or {}
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
system = await ds_svc.create_design_system(
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
description=data.get("description") or None,
|
||||
guidance=data.get("guidance") or None,
|
||||
parent_id=data.get("parent_id"),
|
||||
)
|
||||
if system is None:
|
||||
return jsonify({"error": "parent design system not found"}), 404
|
||||
return jsonify(system.to_dict()), 201
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def get_design_system(design_system_id: int):
|
||||
system = await ds_svc.get_design_system(_uid(), design_system_id)
|
||||
if system is None:
|
||||
return _not_found()
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def update_design_system(design_system_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items() if k in ("title", "description", "guidance")
|
||||
}
|
||||
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
|
||||
# which a `if data.get("parent_id")` filter would silently drop.
|
||||
if "parent_id" in data:
|
||||
fields["parent_id"] = data["parent_id"]
|
||||
try:
|
||||
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
|
||||
except DesignSystemCycle as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if system is None:
|
||||
return _not_found()
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def delete_design_system(design_system_id: int):
|
||||
if not await ds_svc.delete_design_system(_uid(), design_system_id):
|
||||
return _not_found()
|
||||
return "", 204
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
|
||||
@login_required
|
||||
async def resolve_design_system(design_system_id: int):
|
||||
"""The EFFECTIVE token set — everything inherited, with this system's on top.
|
||||
|
||||
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
|
||||
this returns what it ends up being. Both are real questions and answering
|
||||
only one would make the other a client-side computation.
|
||||
"""
|
||||
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
|
||||
if resolved is None:
|
||||
return _not_found()
|
||||
return jsonify({
|
||||
"design_system_id": design_system_id,
|
||||
"tokens": [t.to_dict() for t in resolved],
|
||||
})
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/stylesheet")
|
||||
@login_required
|
||||
async def get_design_system_stylesheet(design_system_id: int):
|
||||
"""The master CSS sheet this design system generates.
|
||||
|
||||
JSON by default (the UI wants the reuse report alongside the CSS); add
|
||||
`?format=css` for the raw stylesheet as `text/css`, which is what a build
|
||||
step or a `curl` wants.
|
||||
|
||||
`?root=` overrides the base selector — a container-scoped preview cannot use
|
||||
`:root`, so the generator takes it as a parameter.
|
||||
"""
|
||||
root = (request.args.get("root") or ":root").strip() or ":root"
|
||||
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root)
|
||||
if result is None:
|
||||
return _not_found()
|
||||
if request.args.get("format") == "css":
|
||||
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/snippet-check")
|
||||
@login_required
|
||||
async def check_snippets_against_system(design_system_id: int):
|
||||
"""Which recorded snippets disagree with this system's sheet.
|
||||
|
||||
`?project_id=` narrows to one project; omit it to check every project, which
|
||||
is usually right — a component recorded elsewhere still has to use the same
|
||||
tags.
|
||||
"""
|
||||
project_id = request.args.get("project_id", type=int) or 0
|
||||
result = await ds_svc.check_snippets_against_system(
|
||||
_uid(), design_system_id, project_id
|
||||
)
|
||||
if result is None:
|
||||
return _not_found()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
|
||||
@login_required
|
||||
async def list_design_tokens(design_system_id: int):
|
||||
"""This system's OWN tokens — its override set, not its effective set."""
|
||||
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
|
||||
return _not_found()
|
||||
rows = await ds_svc.list_tokens(_uid(), design_system_id)
|
||||
return jsonify({"tokens": [t.to_dict() for t in rows]})
|
||||
|
||||
|
||||
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
|
||||
@login_required
|
||||
async def create_design_token(design_system_id: int):
|
||||
data = await request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
token = await ds_svc.create_token(
|
||||
user_id=_uid(),
|
||||
design_system_id=design_system_id,
|
||||
name=name,
|
||||
value_by_mode=data.get("value_by_mode"),
|
||||
group_name=data.get("group_name") or None,
|
||||
purpose=data.get("purpose") or None,
|
||||
rationale=data.get("rationale") or None,
|
||||
supersedes=data.get("supersedes"),
|
||||
order_index=data.get("order_index") or 0,
|
||||
)
|
||||
if token is None:
|
||||
return _not_found()
|
||||
return jsonify(token.to_dict()), 201
|
||||
|
||||
|
||||
@design_systems_bp.patch("/design-tokens/<int:token_id>")
|
||||
@login_required
|
||||
async def update_design_token(token_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in (
|
||||
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||
"supersedes", "order_index",
|
||||
)
|
||||
}
|
||||
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
||||
if token is None:
|
||||
return _not_found("design token")
|
||||
return jsonify(token.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-tokens/<int:token_id>")
|
||||
@login_required
|
||||
async def delete_design_token(token_id: int):
|
||||
if not await ds_svc.delete_token(_uid(), token_id):
|
||||
return _not_found("design token")
|
||||
return "", 204
|
||||
|
||||
|
||||
# ── The project pointer ─────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.put("/projects/<int:project_id>/design-system")
|
||||
@login_required
|
||||
async def set_project_design_system(project_id: int):
|
||||
"""Point a project at a design system. `{"design_system_id": null}` clears it.
|
||||
|
||||
PUT rather than PATCH: this sets one field to exactly what is sent, and
|
||||
clearing it is a first-class outcome rather than an omission.
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
ok = await ds_svc.set_project_design_system(
|
||||
_uid(), project_id, data.get("design_system_id")
|
||||
)
|
||||
if not ok:
|
||||
return _not_found("project or design system")
|
||||
return jsonify({"project_id": project_id,
|
||||
"design_system_id": data.get("design_system_id")})
|
||||
@@ -1,8 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
@@ -113,9 +111,6 @@ async def create_note_route():
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
||||
return jsonify(note.to_dict()), 201
|
||||
|
||||
|
||||
@@ -221,9 +216,6 @@ async def update_note_route(note_id: int):
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@@ -259,9 +251,6 @@ async def patch_note_route(note_id: int):
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Project management routes."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -13,6 +12,7 @@ from scribe.services.projects import (
|
||||
delete_project,
|
||||
get_project,
|
||||
get_project_for_user,
|
||||
get_project_summaries,
|
||||
get_project_summary,
|
||||
list_projects_for_user,
|
||||
update_project,
|
||||
@@ -31,16 +31,28 @@ async def list_projects_route():
|
||||
include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
|
||||
projects = await list_projects_for_user(uid, status=status)
|
||||
if include_summary:
|
||||
# Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls
|
||||
async def _attach(project_dict: dict) -> dict:
|
||||
# Batched: four queries plus two, in two sessions, for ALL projects.
|
||||
# This replaced an asyncio.gather over a per-project summary that opened
|
||||
# its own session and then one more per milestone — ~250 concurrent
|
||||
# checkouts against a pool of 15, all waiting out the 30s timeout and
|
||||
# starving every other route on the instance (#2384).
|
||||
#
|
||||
# Grouped by OWNER because a shared project's counts belong to its
|
||||
# owner's records, matching what the per-project path passed.
|
||||
by_owner: dict[int, list[dict]] = {}
|
||||
for p in projects:
|
||||
by_owner.setdefault(p.get("user_id") or uid, []).append(p)
|
||||
for owner_uid, owned in by_owner.items():
|
||||
try:
|
||||
owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict()
|
||||
summary = await get_project_summary(owner_uid, project_dict["id"])
|
||||
project_dict["summary"] = summary
|
||||
summaries = await get_project_summaries(
|
||||
owner_uid, [p["id"] for p in owned]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return project_dict
|
||||
projects = list(await asyncio.gather(*[_attach(p) for p in projects]))
|
||||
logger.warning("Project summaries failed", exc_info=True)
|
||||
continue
|
||||
for p in owned:
|
||||
if p["id"] in summaries:
|
||||
p["summary"] = summaries[p["id"]]
|
||||
return jsonify({"projects": projects})
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -8,7 +7,6 @@ from scribe.models.note import TaskPriority, TaskStatus
|
||||
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from scribe.services.access import can_write_note
|
||||
from scribe.services import systems as systems_svc
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
from scribe.services.notes import (
|
||||
create_note,
|
||||
get_note_for_user,
|
||||
@@ -149,9 +147,6 @@ async def create_task_route():
|
||||
)
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, task.id, data["system_ids"])
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
|
||||
out = task.to_dict()
|
||||
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task.id)]
|
||||
return jsonify(out), 201
|
||||
@@ -255,9 +250,6 @@ async def update_task_route(task_id: int):
|
||||
return not_found("Task")
|
||||
if data.get("system_ids") is not None:
|
||||
await systems_svc.set_record_systems(uid, task_id, data["system_ids"])
|
||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
|
||||
out = task.to_dict()
|
||||
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
|
||||
return jsonify(out)
|
||||
|
||||
@@ -12,11 +12,13 @@ import logging
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.design_system import DesignSystem
|
||||
from scribe.models.group import GroupMembership
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.share import NoteShare, ProjectShare
|
||||
from scribe.models.user import User
|
||||
from scribe.services.design_cascade import ancestry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,6 +166,88 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Design-system permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_design_system_permission(
|
||||
user_id: int, design_system_id: int
|
||||
) -> str | None:
|
||||
"""Effective permission on a design system, or None.
|
||||
|
||||
Two ways in, and the asymmetry between them is the point:
|
||||
|
||||
- **Owning it** grants "owner" — full read and write.
|
||||
- **Reaching it through a project you can see** grants "viewer", and only
|
||||
ever "viewer". Being an editor on a shared project must NOT confer the
|
||||
right to rewrite the family system that project inherits from: one
|
||||
project's collaborator would be editing tokens every other project in
|
||||
the family resolves through. Editing a design system stays the owner's
|
||||
act, and it is the same reasoning that keeps a rulebook owner-scoped.
|
||||
|
||||
Reachability follows the parent chain UPWARD. Rendering a project's UI means
|
||||
resolving its whole chain, so read access to a system implies read access to
|
||||
its ancestors — otherwise a shared project would resolve to a truncated
|
||||
cascade and silently render with the wrong values.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
if system.owner_user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
shared_project_ids = select(ProjectShare.project_id).where(
|
||||
or_(
|
||||
ProjectShare.shared_with_user_id == user_id,
|
||||
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
|
||||
)
|
||||
)
|
||||
entry_points = set(
|
||||
(
|
||||
await session.execute(
|
||||
select(Project.design_system_id).where(
|
||||
Project.design_system_id.is_not(None),
|
||||
Project.deleted_at.is_(None),
|
||||
or_(
|
||||
Project.user_id == user_id,
|
||||
Project.id.in_(shared_project_ids),
|
||||
),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
if not entry_points:
|
||||
return None
|
||||
|
||||
# One narrow query for the whole forest's shape. Design systems are a
|
||||
# handful of rows per install — a family and one per app — so walking
|
||||
# from each entry point in memory beats a recursive CTE per check.
|
||||
parents = dict(
|
||||
(
|
||||
await session.execute(
|
||||
select(DesignSystem.id, DesignSystem.parent_id).where(
|
||||
DesignSystem.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
for entry in entry_points:
|
||||
if design_system_id in ancestry(entry, parents):
|
||||
return "viewer"
|
||||
return None
|
||||
|
||||
|
||||
async def can_read_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
return (await get_design_system_permission(user_id, design_system_id)) is not None
|
||||
|
||||
|
||||
async def can_write_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
perm = await get_design_system_permission(user_id, design_system_id)
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set-based visibility (for LIST queries)
|
||||
#
|
||||
|
||||
@@ -8,7 +8,10 @@ from scribe.models.milestone import Milestone
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.note_draft import NoteDraft
|
||||
from scribe.models.note_version import NoteVersion
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.note_usage import NoteUsageEvent
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.repo_binding import RepoBinding
|
||||
from scribe.models.rulebook import (
|
||||
Rule,
|
||||
Rulebook,
|
||||
@@ -18,6 +21,7 @@ from scribe.models.rulebook import (
|
||||
project_topic_suppressions,
|
||||
)
|
||||
from scribe.models.setting import Setting
|
||||
from scribe.models.system import RecordSystem, System
|
||||
from scribe.models.task_log import TaskLog
|
||||
from scribe.models.user import User
|
||||
|
||||
@@ -26,17 +30,44 @@ logger = logging.getLogger(__name__)
|
||||
# Backup format version. v3 (2026-06) added rulebooks/topics/rules + their
|
||||
# project subscription/suppression join tables. v4 (2026-07) dropped events
|
||||
# when the calendar surface was retired — old v3 events are skipped on restore.
|
||||
# v5 (2026-08) added the six tables that had accumulated outside the backup
|
||||
# entirely (#2293), and the coverage guard that stops the seventh.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 4
|
||||
BACKUP_VERSION = 5
|
||||
|
||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||
# below, these two lists must together account for the entire schema — which is
|
||||
# what tests/test_services_backup.py asserts against Base.metadata.
|
||||
#
|
||||
# The point is the ABSENCE case. A new table gets a model and a migration, both
|
||||
# of which fail loudly if wrong, and then silently never gets a backup section:
|
||||
# no error, no warning, and a restore that reports success. Naming the coverage
|
||||
# explicitly turns "someone forgot" into a failing test (#2293).
|
||||
_BACKED_UP = [
|
||||
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
||||
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
||||
"project_rulebook_subscriptions", "project_rule_suppressions",
|
||||
"project_topic_suppressions",
|
||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"note_usage_events", "repo_bindings",
|
||||
]
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
|
||||
# embeddings are derived (regenerated from note bodies); api_keys are sensitive
|
||||
# credentials; the rest are transient/operational.
|
||||
# note_embeddings are derived (regenerated from note bodies); api_keys are
|
||||
# sensitive credentials; retrieval_logs is observational telemetry that nothing
|
||||
# reads for correctness and that grows per query; the rest are
|
||||
# transient/operational.
|
||||
#
|
||||
# REAL table names, deliberately. This list used to read "embeddings",
|
||||
# "invitations", "password_resets" — none of which are tables — so it looked
|
||||
# like coverage while naming nothing the schema could confirm.
|
||||
_NOT_INCLUDED = [
|
||||
"groups", "group_memberships", "project_shares", "note_shares",
|
||||
"api_keys", "embeddings", "app_logs", "notifications", "invitations",
|
||||
"password_resets", "user_profiles",
|
||||
"api_keys", "note_embeddings", "app_logs", "notifications",
|
||||
"invitation_tokens", "password_reset_tokens", "user_profiles",
|
||||
"retrieval_logs",
|
||||
]
|
||||
|
||||
|
||||
@@ -60,12 +91,73 @@ def _topic_suppression_rows(rows) -> list[dict]:
|
||||
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
||||
|
||||
|
||||
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
||||
# same reason: CI has no database, so a serialiser that is a plain function is
|
||||
# one that can actually be tested.
|
||||
|
||||
def _system_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
|
||||
"name": r.name, "description": r.description, "color": r.color,
|
||||
"status": r.status, "order_index": r.order_index,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _record_system_rows(rows) -> list[dict]:
|
||||
return [{"note_id": r.note_id, "system_id": r.system_id} for r in rows]
|
||||
|
||||
|
||||
def _design_system_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": r.id, "owner_user_id": r.owner_user_id, "title": r.title,
|
||||
"description": r.description, "guidance": r.guidance,
|
||||
"parent_id": r.parent_id,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _design_token_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": r.id, "design_system_id": r.design_system_id, "name": r.name,
|
||||
"value_by_mode": r.value_by_mode or {},
|
||||
"group_name": r.group_name, "purpose": r.purpose,
|
||||
"rationale": r.rationale, "supersedes": r.supersedes or [],
|
||||
"order_index": r.order_index,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _usage_event_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"user_id": r.user_id, "note_id": r.note_id, "event": r.event,
|
||||
"source": r.source,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _repo_binding_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def export_full_backup() -> dict:
|
||||
"""Export all data as a version-3 JSON backup."""
|
||||
"""Export all data as a version-5 JSON backup."""
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
projects = (await session.execute(select(Project))).scalars().all()
|
||||
@@ -77,6 +169,18 @@ async def export_full_backup() -> dict:
|
||||
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
systems = (await session.execute(select(System))).scalars().all()
|
||||
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
||||
# Parent-first, so a restore can resolve parent_id as it goes rather
|
||||
# than needing a second pass — the self-FK is the only ordering
|
||||
# constraint in this payload.
|
||||
design_systems = (await session.execute(
|
||||
select(DesignSystem).order_by(DesignSystem.parent_id.nullsfirst(),
|
||||
DesignSystem.id)
|
||||
)).scalars().all()
|
||||
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
||||
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
||||
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
|
||||
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||
rules = (await session.execute(select(Rule))).scalars().all()
|
||||
@@ -244,11 +348,17 @@ async def export_full_backup() -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"systems": _system_rows(systems),
|
||||
"record_systems": _record_system_rows(record_systems),
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
}
|
||||
|
||||
|
||||
async def export_user_backup(user_id: int) -> dict:
|
||||
"""Export a single user's data as a version-3 JSON backup."""
|
||||
"""Export a single user's data as a version-5 JSON backup."""
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
projects = (await session.execute(
|
||||
@@ -274,6 +384,32 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
settings = (await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id)
|
||||
)).scalars().all()
|
||||
systems = (await session.execute(
|
||||
select(System).where(System.user_id == user_id)
|
||||
)).scalars().all()
|
||||
system_ids = [sy.id for sy in systems]
|
||||
note_ids = [n.id for n in notes]
|
||||
# Scoped by the user's SYSTEMS, not their notes: a shared note carrying
|
||||
# this user's system tag belongs in their backup, and a note of theirs
|
||||
# tagged with someone else's system does not — that row is the other
|
||||
# user's to keep.
|
||||
record_systems = (await session.execute(
|
||||
select(RecordSystem).where(RecordSystem.system_id.in_(system_ids))
|
||||
)).scalars().all() if system_ids else []
|
||||
design_systems = (await session.execute(
|
||||
select(DesignSystem).where(DesignSystem.owner_user_id == user_id)
|
||||
.order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id)
|
||||
)).scalars().all()
|
||||
ds_ids = [d.id for d in design_systems]
|
||||
design_tokens = (await session.execute(
|
||||
select(DesignToken).where(DesignToken.design_system_id.in_(ds_ids))
|
||||
)).scalars().all() if ds_ids else []
|
||||
usage_events = (await session.execute(
|
||||
select(NoteUsageEvent).where(NoteUsageEvent.note_id.in_(note_ids))
|
||||
)).scalars().all() if note_ids else []
|
||||
repo_bindings = (await session.execute(
|
||||
select(RepoBinding).where(RepoBinding.user_id == user_id)
|
||||
)).scalars().all()
|
||||
rulebooks = (await session.execute(
|
||||
select(Rulebook).where(Rulebook.owner_user_id == user_id)
|
||||
)).scalars().all()
|
||||
@@ -455,6 +591,12 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"systems": _system_rows(systems),
|
||||
"record_systems": _record_system_rows(record_systems),
|
||||
"design_systems": _design_system_rows(design_systems),
|
||||
"design_tokens": _design_token_rows(design_tokens),
|
||||
"note_usage_events": _usage_event_rows(usage_events),
|
||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||
}
|
||||
|
||||
|
||||
@@ -556,6 +698,8 @@ async def _restore_v2(data: dict) -> dict:
|
||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -814,6 +958,106 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
stats["topic_suppressions"] += 1
|
||||
|
||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||
# payload restores without them rather than failing on an absent key.
|
||||
|
||||
# 15. Systems
|
||||
system_id_map: dict[int, int] = {}
|
||||
for sy_data in data.get("systems", []):
|
||||
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(sy_data.get("project_id", 0))
|
||||
if mapped_uid is None or mapped_pid is None:
|
||||
continue
|
||||
system = System(
|
||||
user_id=mapped_uid, project_id=mapped_pid,
|
||||
name=sy_data.get("name", ""),
|
||||
description=sy_data.get("description"),
|
||||
color=sy_data.get("color"),
|
||||
status=sy_data.get("status", "active"),
|
||||
order_index=sy_data.get("order_index", 0),
|
||||
)
|
||||
session.add(system)
|
||||
await session.flush()
|
||||
system_id_map[sy_data["id"]] = system.id
|
||||
stats["systems"] += 1
|
||||
|
||||
# 16. Record↔system links
|
||||
for rs in data.get("record_systems", []):
|
||||
mapped_nid = note_id_map.get(rs.get("note_id", 0))
|
||||
mapped_sid = system_id_map.get(rs.get("system_id", 0))
|
||||
if mapped_nid is None or mapped_sid is None:
|
||||
continue
|
||||
session.add(RecordSystem(note_id=mapped_nid, system_id=mapped_sid))
|
||||
stats["record_systems"] += 1
|
||||
|
||||
# 17. Design systems. The export orders these parent-first, so a
|
||||
# parent's new id is always in the map by the time a child needs it —
|
||||
# no second pass, and a child whose parent is missing lands as a root
|
||||
# rather than failing the whole restore.
|
||||
design_system_id_map: dict[int, int] = {}
|
||||
for ds_data in data.get("design_systems", []):
|
||||
mapped_uid = user_id_map.get(ds_data.get("owner_user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
design = DesignSystem(
|
||||
owner_user_id=mapped_uid,
|
||||
title=ds_data.get("title", ""),
|
||||
description=ds_data.get("description"),
|
||||
guidance=ds_data.get("guidance"),
|
||||
parent_id=design_system_id_map.get(ds_data.get("parent_id") or 0),
|
||||
)
|
||||
session.add(design)
|
||||
await session.flush()
|
||||
design_system_id_map[ds_data["id"]] = design.id
|
||||
stats["design_systems"] += 1
|
||||
|
||||
# 18. Design tokens
|
||||
for t_data in data.get("design_tokens", []):
|
||||
mapped_dsid = design_system_id_map.get(t_data.get("design_system_id", 0))
|
||||
if mapped_dsid is None:
|
||||
continue
|
||||
session.add(DesignToken(
|
||||
design_system_id=mapped_dsid,
|
||||
name=t_data.get("name", ""),
|
||||
value_by_mode=t_data.get("value_by_mode") or {},
|
||||
group_name=t_data.get("group_name"),
|
||||
purpose=t_data.get("purpose"),
|
||||
rationale=t_data.get("rationale"),
|
||||
supersedes=t_data.get("supersedes") or [],
|
||||
order_index=t_data.get("order_index", 0),
|
||||
))
|
||||
stats["design_tokens"] += 1
|
||||
|
||||
# 19. Usage events. Kept because pull-through is the evidence base for
|
||||
# whether recall works at all, and it is only ever accumulated — a
|
||||
# restore that dropped it would silently reset that measurement to zero
|
||||
# while everything still looked fine.
|
||||
for ev in data.get("note_usage_events", []):
|
||||
mapped_nid = note_id_map.get(ev.get("note_id", 0))
|
||||
if mapped_nid is None:
|
||||
continue
|
||||
session.add(NoteUsageEvent(
|
||||
user_id=user_id_map.get(ev.get("user_id") or 0),
|
||||
note_id=mapped_nid,
|
||||
event=ev.get("event", ""),
|
||||
source=ev.get("source", ""),
|
||||
created_at=_dt(ev.get("created_at")),
|
||||
))
|
||||
stats["note_usage_events"] += 1
|
||||
|
||||
# 20. Repo bindings — small, but losing them means every bound repo
|
||||
# quietly stops loading its project at session start.
|
||||
for rb_data in data.get("repo_bindings", []):
|
||||
mapped_uid = user_id_map.get(rb_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(rb_data.get("project_id", 0))
|
||||
if mapped_uid is None or mapped_pid is None:
|
||||
continue
|
||||
session.add(RepoBinding(
|
||||
user_id=mapped_uid, project_id=mapped_pid,
|
||||
repo_key=rb_data.get("repo_key", ""),
|
||||
))
|
||||
stats["repo_bindings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2/v3 backup: %s", stats)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""The design-system cascade, as pure functions over already-loaded rows.
|
||||
|
||||
Deliberately free of every database and service import — including
|
||||
`services/access.py`, which needs `ancestry` to answer "can this caller read
|
||||
this system?" and would otherwise form an import cycle with the service that
|
||||
needs `access` back. A module that imports nothing can be imported by both.
|
||||
|
||||
Being pure is also what makes the cascade rule testable without a database:
|
||||
these take a plain `{id: parent_id}` map, so a test states the shape of a
|
||||
hierarchy in one literal instead of building one.
|
||||
"""
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The mode key that applies when no more specific one does. Emitted CSS puts it
|
||||
# on the base selector and every other key on a mode selector, mirroring how a
|
||||
# stylesheet is actually written: light on `:root`, dark layered over it.
|
||||
BASE_MODE = "base"
|
||||
|
||||
|
||||
def ancestry(system_id: int, parents: Mapping[int, int | None]) -> list[int]:
|
||||
"""The inheritance chain from `system_id` up to its root, nearest first.
|
||||
|
||||
Includes `system_id` itself at index 0, because resolution wants
|
||||
deepest-to-shallowest and the system being resolved is the deepest link.
|
||||
|
||||
A system missing from `parents` terminates the chain rather than raising: a
|
||||
parent whose row was soft-deleted or filtered out is a truncated chain, not
|
||||
a failed request.
|
||||
|
||||
The visited-set is defensive, not the primary guard — writes already refuse
|
||||
to create a cycle (`would_cycle`). It is here because a loop introduced by a
|
||||
direct DB edit or a future bug must degrade to a truncated chain instead of
|
||||
spinning forever. Truncation shows up in the result; a hang shows up as an
|
||||
outage.
|
||||
"""
|
||||
chain: list[int] = []
|
||||
seen: set[int] = set()
|
||||
current: int | None = system_id
|
||||
while current is not None and current not in seen:
|
||||
seen.add(current)
|
||||
chain.append(current)
|
||||
current = parents.get(current)
|
||||
return chain
|
||||
|
||||
|
||||
def would_cycle(
|
||||
system_id: int,
|
||||
proposed_parent_id: int | None,
|
||||
parents: Mapping[int, int | None],
|
||||
) -> bool:
|
||||
"""Would making `proposed_parent_id` the parent of `system_id` close a loop?
|
||||
|
||||
True when the proposed parent IS the system, or already inherits from it.
|
||||
|
||||
The walk goes UP from the proposed parent, which is the cheap direction —
|
||||
each system has at most one parent, so the chain is a line. Asking the
|
||||
equivalent downward question ("is the proposed parent among my
|
||||
descendants?") would mean searching a whole forest for the same answer.
|
||||
|
||||
`proposed_parent_id=None` clears the parent and can never cycle.
|
||||
"""
|
||||
if proposed_parent_id is None:
|
||||
return False
|
||||
if proposed_parent_id == system_id:
|
||||
return True
|
||||
return system_id in ancestry(proposed_parent_id, parents)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution — flattening a chain into an effective token set
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Contribution:
|
||||
"""One system's offer for one token in one mode."""
|
||||
system_id: int
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedToken:
|
||||
"""A token after the cascade, carrying the whole argument rather than the verdict.
|
||||
|
||||
`contributions` holds every system that supplied a value, per mode, DEEPEST
|
||||
FIRST — so `[0]` is the winner and `[1:]` are what it shadowed. Storing the
|
||||
contest rather than a winner plus a separate provenance field means there is
|
||||
nothing to keep in sync: "which system supplied this?" and "what did it
|
||||
override?" are both reads of the same list, and they cannot disagree.
|
||||
|
||||
Provenance is per MODE, not per token, because overriding is. A system that
|
||||
deepens one accent for light backgrounds while leaving dark alone owns the
|
||||
base value and inherits the dark one, and a token-level "overridden here"
|
||||
flag would have to lie about one of them.
|
||||
"""
|
||||
name: str
|
||||
contributions: dict[str, tuple[Contribution, ...]]
|
||||
group_name: str | None
|
||||
purpose: str | None
|
||||
rationale: str | None
|
||||
supersedes: tuple[str, ...]
|
||||
order_index: int
|
||||
|
||||
@property
|
||||
def value_by_mode(self) -> dict[str, str]:
|
||||
"""The effective value for each mode — the winner of each contest."""
|
||||
return {mode: entries[0].value for mode, entries in self.contributions.items()}
|
||||
|
||||
@property
|
||||
def origin_by_mode(self) -> dict[str, int]:
|
||||
"""Which system supplied each mode's effective value."""
|
||||
return {mode: entries[0].system_id for mode, entries in self.contributions.items()}
|
||||
|
||||
def value_for(self, mode: str) -> str | None:
|
||||
"""The value to render in `mode`, falling back to the base mode.
|
||||
|
||||
This is the read rule the storage shape implies: a token that is not
|
||||
mode-dependent carries only `base`, and asking it for "dark" must yield
|
||||
the base value rather than nothing.
|
||||
"""
|
||||
entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE)
|
||||
return entries[0].value if entries else None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Payload shape for both surfaces — and it carries the SHADOWED entries.
|
||||
|
||||
Serialising only the winner would throw away the provenance at the last
|
||||
step, which is the one thing this type exists to preserve. `contributions`
|
||||
is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so
|
||||
a client renders without re-deriving anything, and cannot derive it
|
||||
differently.
|
||||
"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"rationale": self.rationale,
|
||||
"supersedes": list(self.supersedes),
|
||||
"order_index": self.order_index,
|
||||
"value_by_mode": self.value_by_mode,
|
||||
"origin_by_mode": self.origin_by_mode,
|
||||
"contributions": {
|
||||
mode: [
|
||||
{"system_id": c.system_id, "value": c.value} for c in entries
|
||||
]
|
||||
for mode, entries in self.contributions.items()
|
||||
},
|
||||
}
|
||||
|
||||
def is_overridden_in(self, system_id: int) -> bool:
|
||||
"""Does `system_id` win any mode of this token AND shadow something?
|
||||
|
||||
The distinction the UI needs: a token this system introduced is not an
|
||||
override, and a token it merely inherits is not either.
|
||||
"""
|
||||
return any(
|
||||
len(entries) > 1 and entries[0].system_id == system_id
|
||||
for entries in self.contributions.values()
|
||||
)
|
||||
|
||||
|
||||
def _sort_key(token: ResolvedToken) -> tuple:
|
||||
# Ungrouped tokens sort last rather than first: a design system that has
|
||||
# started grouping should read as its groups, with the not-yet-filed
|
||||
# remainder at the end.
|
||||
return (token.group_name is None, token.group_name or "", token.order_index, token.name)
|
||||
|
||||
|
||||
def resolve_tokens(
|
||||
system_id: int,
|
||||
parents: Mapping[int, int | None],
|
||||
tokens_by_system: Mapping[int, Sequence],
|
||||
) -> list[ResolvedToken]:
|
||||
"""Flatten a system's inheritance chain into its effective token set.
|
||||
|
||||
Walks from `system_id` up to the root and applies tokens by name, deepest
|
||||
winning. That is the CSS cascade — precedence by name along a parent chain —
|
||||
rather than an analogy to it, which is why the storage model and the
|
||||
stylesheet model came out the same shape.
|
||||
|
||||
`tokens_by_system` maps a system id to its own token rows. Any object with
|
||||
`.name`, `.value_by_mode`, `.group_name`, `.purpose` and `.order_index` will
|
||||
do, so a test can state a hierarchy in literals and the service can pass ORM
|
||||
rows to the same function.
|
||||
|
||||
The result includes tokens the system never mentions — inheriting one is
|
||||
what puts it in the effective set. A system with no tokens of its own
|
||||
resolves to its parent's set entire, which is the correct answer for an app
|
||||
that has not departed from the family yet.
|
||||
|
||||
Merging is per (name, MODE): a child that supplies only a dark value
|
||||
overrides only dark and keeps inheriting base. Metadata (`group_name`,
|
||||
`purpose`, `order_index`) cascades separately by the same deepest-wins rule,
|
||||
since a child overriding a value routinely leaves the family's description
|
||||
of what the token is FOR untouched — and inheriting it beats blanking it.
|
||||
"""
|
||||
chain = ancestry(system_id, parents)
|
||||
|
||||
contributions: dict[str, dict[str, list[Contribution]]] = {}
|
||||
metadata: dict[str, dict[str, object]] = {}
|
||||
|
||||
# Deepest first, so the first contribution seen for a (name, mode) wins and
|
||||
# every later one is a shadowed ancestor appended behind it.
|
||||
for depth_system_id in chain:
|
||||
for token in tokens_by_system.get(depth_system_id) or ():
|
||||
per_mode = contributions.setdefault(token.name, {})
|
||||
for mode, value in (token.value_by_mode or {}).items():
|
||||
per_mode.setdefault(mode, []).append(
|
||||
Contribution(system_id=depth_system_id, value=value)
|
||||
)
|
||||
|
||||
meta = metadata.setdefault(
|
||||
token.name,
|
||||
{
|
||||
"group_name": None, "purpose": None, "rationale": None,
|
||||
"supersedes": None, "order_index": None,
|
||||
},
|
||||
)
|
||||
for field in ("group_name", "purpose", "rationale"):
|
||||
if meta[field] is None:
|
||||
meta[field] = getattr(token, field, None)
|
||||
# order_index alone treats 0 as UNSTATED rather than "first",
|
||||
# because 0 is the column default. Reading it as a real value would
|
||||
# let any child override drag its token to the top of the group and
|
||||
# lose the family's ordering — a visible reshuffle in return for a
|
||||
# change that only touched a colour.
|
||||
if not meta["order_index"]:
|
||||
meta["order_index"] = getattr(token, "order_index", 0) or None
|
||||
# `supersedes` cascades on EMPTINESS, not on None: a child that
|
||||
# overrides a colour and says nothing about which literals it
|
||||
# replaces should keep the family's declaration, and an empty list
|
||||
# is what "said nothing" looks like once the column is NOT NULL.
|
||||
# A child that states its own list replaces the whole thing.
|
||||
if not meta["supersedes"]:
|
||||
meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None
|
||||
|
||||
resolved = [
|
||||
ResolvedToken(
|
||||
name=name,
|
||||
contributions={
|
||||
mode: tuple(entries) for mode, entries in per_mode.items()
|
||||
},
|
||||
group_name=metadata[name]["group_name"],
|
||||
purpose=metadata[name]["purpose"],
|
||||
rationale=metadata[name]["rationale"],
|
||||
supersedes=metadata[name]["supersedes"] or (),
|
||||
order_index=metadata[name]["order_index"] or 0,
|
||||
)
|
||||
for name, per_mode in contributions.items()
|
||||
]
|
||||
return sorted(resolved, key=_sort_key)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Design-system expectations — turning rulebook prose into checkable claims.
|
||||
|
||||
Milestone #251 step 2. The drift panel compares what the design rulebook SAYS
|
||||
against what the stylesheet and components actually DO. This module owns the
|
||||
first half: reading a rulebook's rules and extracting the claims that can be
|
||||
mechanically checked.
|
||||
|
||||
WHY THIS LIVES SERVER-SIDE. The frontend has no test runner — `vue-tsc --noEmit`
|
||||
is the entire check — and this is the one genuinely fiddly piece of the feature.
|
||||
Extraction happens here where pytest can assert on it; the comparison itself is
|
||||
set arithmetic and stays in the browser, where the live token values are.
|
||||
|
||||
WHY NOT NLP. Rule statements are prose written for humans, and they should stay
|
||||
that way — they are read by people far more often than they are parsed. So this
|
||||
extracts only what is unambiguous in ANY prose: the hex colours and CSS custom
|
||||
property names a rule mentions. Everything subtler (padding scales, type ramps)
|
||||
needs a rule author to opt into a structured form, which is deliberately left for
|
||||
when someone wants it rather than invented up front.
|
||||
|
||||
RULE #115. Nothing here assumes a design rulebook exists, or that it is this
|
||||
operator's. An install designates one; an install that hasn't gets an empty
|
||||
result and a panel that explains itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Which rulebook describes this install's design system. A plain setting rather
|
||||
# than a column: no migration, discoverable in the Settings UI (rule #25), and
|
||||
# honest about being a per-install choice rather than a property of the rulebook.
|
||||
DESIGN_RULEBOOK_SETTING = "design_rulebook_id"
|
||||
|
||||
# `#abc` and `#aabbcc`, plus the 4/8-digit alpha forms.
|
||||
_HEX = re.compile(r"#([0-9a-fA-F]{3,8})\b")
|
||||
|
||||
# A custom-property name as written in prose, including the slash shorthand the
|
||||
# rulebook uses: `--fs-radius-sm/md/lg/xl`, `--fs-obsidian/iron/slate/pewter`.
|
||||
_TOKEN = re.compile(r"(--[a-zA-Z][\w-]*(?:/[\w-]+)*)")
|
||||
|
||||
# Sentence-ish split. Rules use semicolons as hard breaks as often as periods.
|
||||
_SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+|\n+")
|
||||
|
||||
# Negation markers. Checked PER SENTENCE, which is the whole trick — see
|
||||
# _extract_from_sentence.
|
||||
_NEGATIONS = ("never", "not ", "no ", "avoid", "don't", "must not", "excluded")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Expectation:
|
||||
"""One mechanically-checkable claim a rule makes."""
|
||||
|
||||
kind: str # "token" | "color" | "prohibited_color"
|
||||
value: str # "--fs-obsidian" | "#14171a"
|
||||
rule_id: int
|
||||
rule_title: str
|
||||
context: str # the sentence it came from, for showing your work
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"value": self.value,
|
||||
"rule_id": self.rule_id,
|
||||
"rule_title": self.rule_title,
|
||||
"context": self.context,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpectationSet:
|
||||
rulebook_id: int | None = None
|
||||
expectations: list[Expectation] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"rulebook_id": self.rulebook_id,
|
||||
"expectations": [e.as_dict() for e in self.expectations],
|
||||
}
|
||||
|
||||
|
||||
def normalize_hex(value: str) -> str | None:
|
||||
"""Fold a hex colour to a comparable form, or None if it isn't one.
|
||||
|
||||
Load-bearing for the whole comparison: the rulebook writes `#FFFFFF` and the
|
||||
code writes `#fff`, and those must compare equal or the single largest drift
|
||||
finding (#2275) reads as zero. Expands 3-digit shorthand and lowercases.
|
||||
|
||||
Alpha forms (4 and 8 digit) keep their alpha — `#fff` and `#ffff` are not the
|
||||
same colour, and silently dropping the alpha would invent equality.
|
||||
"""
|
||||
match = _HEX.fullmatch(value.strip()) or _HEX.match(value.strip())
|
||||
if not match:
|
||||
return None
|
||||
digits = match.group(1).lower()
|
||||
if len(digits) in (3, 4):
|
||||
digits = "".join(c * 2 for c in digits)
|
||||
if len(digits) not in (6, 8):
|
||||
return None
|
||||
return f"#{digits}"
|
||||
|
||||
|
||||
def expand_token_shorthand(raw: str) -> list[str]:
|
||||
"""`--fs-radius-sm/md/lg/xl` -> the four names it stands for.
|
||||
|
||||
The rulebook writes token families in a slash shorthand, and both forms it
|
||||
uses expand correctly under one rule: take everything up to and including the
|
||||
LAST hyphen of the first segment as the prefix, then append each alternative.
|
||||
|
||||
--fs-radius-sm/md/lg/xl prefix `--fs-radius-` -> sm, md, lg, xl
|
||||
--fs-obsidian/iron/slate prefix `--fs-` -> obsidian, iron, slate
|
||||
--fs-dur-fast/base/slow prefix `--fs-dur-` -> fast, base, slow
|
||||
|
||||
A name with no slash is returned as-is.
|
||||
"""
|
||||
if "/" not in raw:
|
||||
return [raw]
|
||||
head, *rest = raw.split("/")
|
||||
cut = head.rfind("-")
|
||||
if cut <= 1: # no hyphen beyond the leading `--`
|
||||
return [head, *rest]
|
||||
prefix = head[: cut + 1]
|
||||
return [head, *[f"{prefix}{part}" for part in rest if part]]
|
||||
|
||||
|
||||
def _is_negated(sentence: str) -> bool:
|
||||
return any(marker in sentence.lower() for marker in _NEGATIONS)
|
||||
|
||||
|
||||
def _extract_from_sentence(sentence: str, rule: Rule) -> list[Expectation]:
|
||||
"""Claims in ONE sentence, with negation scoped to that sentence.
|
||||
|
||||
Sentence scope is what makes the prohibition detection usable. Rule 52 reads:
|
||||
|
||||
"Text tokens: Parchment #E8E4D8 …, Vellum #C2BFB4 …, Ash #9C9A92 ….
|
||||
Pure white #FFFFFF is NEVER used as text color."
|
||||
|
||||
Three colours the palette REQUIRES and one it FORBIDS, in one statement.
|
||||
Detecting negation across the whole statement would mark all four as
|
||||
forbidden; detecting it per sentence gets all four right.
|
||||
"""
|
||||
out: list[Expectation] = []
|
||||
negated = _is_negated(sentence)
|
||||
|
||||
for match in _HEX.finditer(sentence):
|
||||
value = normalize_hex(match.group(0))
|
||||
if not value:
|
||||
continue
|
||||
out.append(Expectation(
|
||||
kind="prohibited_color" if negated else "color",
|
||||
value=value,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
# Token names are not negated in practice — a rule says which tokens should
|
||||
# exist, never which must not — so they are recorded as expectations
|
||||
# regardless. If that ever changes, it needs its own kind rather than
|
||||
# borrowing the colour one.
|
||||
for match in _TOKEN.finditer(sentence):
|
||||
for name in expand_token_shorthand(match.group(1)):
|
||||
out.append(Expectation(
|
||||
kind="token",
|
||||
value=name,
|
||||
rule_id=int(rule.id),
|
||||
rule_title=rule.title,
|
||||
context=sentence.strip(),
|
||||
))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def extract_expectations(rules: list[Rule]) -> list[Expectation]:
|
||||
"""Every checkable claim across a set of rules, deduped on (kind, value).
|
||||
|
||||
First occurrence wins so the reported rule is the one that introduced the
|
||||
claim, which is usually the most specific place to send a reader.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[Expectation] = []
|
||||
for rule in rules:
|
||||
text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""]))
|
||||
for sentence in _SENTENCE_SPLIT.split(text):
|
||||
if not sentence.strip():
|
||||
continue
|
||||
for expectation in _extract_from_sentence(sentence, rule):
|
||||
key = (expectation.kind, expectation.value)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(expectation)
|
||||
return out
|
||||
|
||||
|
||||
async def get_design_rulebook_id(user_id: int) -> int | None:
|
||||
"""The rulebook this install designated as its design system, if any."""
|
||||
raw = (await get_setting(user_id, DESIGN_RULEBOOK_SETTING, "")).strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
async def design_expectations(user_id: int) -> ExpectationSet:
|
||||
"""Checkable claims from the designated design rulebook.
|
||||
|
||||
Returns an empty set when no rulebook is designated — the normal case for
|
||||
any install but the one that set it up (rule #115). The caller shows an
|
||||
explanatory empty state rather than treating this as an error.
|
||||
"""
|
||||
rulebook_id = await get_design_rulebook_id(user_id)
|
||||
if rulebook_id is None:
|
||||
return ExpectationSet()
|
||||
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
|
||||
try:
|
||||
rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id)
|
||||
except Exception:
|
||||
logger.warning("Design rulebook %s could not be read", rulebook_id, exc_info=True)
|
||||
return ExpectationSet(rulebook_id=rulebook_id)
|
||||
|
||||
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Render a design system's resolved tokens as its master CSS sheet.
|
||||
|
||||
Pure, like `design_cascade` and for the same reasons: no database import, so the
|
||||
rendering rule is testable without one and the preview surface can reuse it.
|
||||
|
||||
WHAT THIS SHEET IS, AND DELIBERATELY IS NOT
|
||||
-------------------------------------------
|
||||
It declares **purpose tokens only** — custom properties, grouped by what they
|
||||
mean. It contains no rules for elements or classes: no `.btn-primary`, no
|
||||
`table`, no `input`.
|
||||
|
||||
That is the point rather than a limitation. A sheet that styled every element
|
||||
would restate the same handful of values once per element and grow with the UI;
|
||||
a sheet of purpose-named values states each once and is reused. Components —
|
||||
buttons, tables, input schemes — live as SNIPPETS that reference these names and
|
||||
carry prose about the idea, which is a surface that already exists and already
|
||||
has recall, locations, drift checks and merge.
|
||||
|
||||
So the division is: this sheet says what the values MEAN; snippets say what
|
||||
things LOOK LIKE, in terms of those values. A token named after an element
|
||||
(`--button-bg`) is the smell that the two have been mixed — it multiplies
|
||||
with every new element, where a purpose name (`--action-primary`) is reused.
|
||||
|
||||
SAFETY
|
||||
------
|
||||
Values are interpolated into a stylesheet, and design systems are shareable
|
||||
records (rule #47). A value of `red; } body { display: none` in a system someone
|
||||
shared with you would otherwise inject CSS into your page. Everything rendered
|
||||
here is validated or dropped — never escaped-and-hoped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
BASE_MODE = "base"
|
||||
|
||||
# A custom property name, strictly. Anything else is dropped rather than
|
||||
# sanitised: a name is an identifier, and a "cleaned up" identifier is a
|
||||
# different token than the one the operator recorded.
|
||||
_VALID_NAME = re.compile(r"^--[A-Za-z0-9_-]+$")
|
||||
|
||||
# Characters that would end the declaration, open a block, start an at-rule, or
|
||||
# begin a tag. A value containing any of them is not a value.
|
||||
_UNSAFE_VALUE = re.compile(r"[{};@<>]|\*/|/\*|\n|\r")
|
||||
|
||||
|
||||
def is_valid_token_name(name: str) -> bool:
|
||||
return bool(_VALID_NAME.match((name or "").strip()))
|
||||
|
||||
|
||||
def safe_value(value: str) -> str | None:
|
||||
"""A CSS value that cannot escape its declaration, or None.
|
||||
|
||||
Rejects rather than strips. A partially-sanitised value is a value the
|
||||
operator did not write, and silently rendering a different colour than the
|
||||
record holds is worse than rendering none — the sheet's whole claim is that
|
||||
it IS the record.
|
||||
"""
|
||||
candidate = (value or "").strip()
|
||||
if not candidate or _UNSAFE_VALUE.search(candidate):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def safe_comment(text: str) -> str:
|
||||
"""Comment text that cannot close its comment or break the line."""
|
||||
return re.sub(r"\*/|/\*|[\n\r]", " ", (text or "")).strip()
|
||||
|
||||
|
||||
def selector_for_mode(mode: str, root_selector: str = ":root") -> str:
|
||||
"""Which selector a mode's declarations belong under.
|
||||
|
||||
`base` gets the caller's root selector; every other mode gets a bare
|
||||
attribute selector, matching the convention already in the codebase.
|
||||
|
||||
The root selector is a PARAMETER because a container-scoped preview cannot
|
||||
use `:root` — #251 recorded that mode scoping is one-way (light lives on
|
||||
`:root`, dark layers over it), so a generator that hardcoded `:root` could
|
||||
not serve a preview at all.
|
||||
"""
|
||||
if mode == BASE_MODE:
|
||||
return root_selector
|
||||
safe_mode = re.sub(r"[^A-Za-z0-9_-]", "", mode)
|
||||
return f'[data-theme="{safe_mode}"]' if safe_mode else root_selector
|
||||
|
||||
|
||||
def _grouped(tokens: Sequence) -> list[tuple[str | None, list]]:
|
||||
"""Tokens by group, preserving the order they arrive in (already sorted)."""
|
||||
groups: dict[str | None, list] = {}
|
||||
for token in tokens:
|
||||
groups.setdefault(getattr(token, "group_name", None), []).append(token)
|
||||
return list(groups.items())
|
||||
|
||||
|
||||
def _modes_present(tokens: Sequence) -> list[str]:
|
||||
"""Every mode any token declares, base first then the rest alphabetically."""
|
||||
modes = {
|
||||
mode
|
||||
for token in tokens
|
||||
for mode in (getattr(token, "value_by_mode", None) or {})
|
||||
}
|
||||
rest = sorted(modes - {BASE_MODE})
|
||||
return ([BASE_MODE] if BASE_MODE in modes else []) + rest
|
||||
|
||||
|
||||
def render_stylesheet(
|
||||
tokens: Sequence,
|
||||
*,
|
||||
root_selector: str = ":root",
|
||||
title: str = "",
|
||||
design_system_id: int | None = None,
|
||||
) -> str:
|
||||
"""The master sheet for a resolved token set.
|
||||
|
||||
One block per mode. Within a block, only the tokens that declare a value for
|
||||
that mode — so a mode block is an override layer, exactly as the storage
|
||||
model has it.
|
||||
|
||||
Tokens with no value at all are emitted as COMMENTED-OUT declarations in
|
||||
their group, not dropped. The rulebook named them, so their absence is a
|
||||
finding, and a commented line puts that finding where the reader is already
|
||||
looking.
|
||||
"""
|
||||
header = [
|
||||
"/*",
|
||||
f" * {safe_comment(title) or 'Design system'} — generated stylesheet",
|
||||
]
|
||||
if design_system_id is not None:
|
||||
header.append(f" * Source: design system {int(design_system_id)}.")
|
||||
header += [
|
||||
" *",
|
||||
" * Purpose tokens only. This sheet declares what values MEAN; it styles",
|
||||
" * no elements. Buttons, tables and input schemes live as snippets that",
|
||||
" * reference these names, so each value is stated once and reused rather",
|
||||
" * than restated per element.",
|
||||
" *",
|
||||
" * Generated — edit the design system, not this file.",
|
||||
" */",
|
||||
"",
|
||||
]
|
||||
|
||||
lines = list(header)
|
||||
valueless = [
|
||||
t for t in tokens
|
||||
if is_valid_token_name(getattr(t, "name", ""))
|
||||
and not (getattr(t, "value_by_mode", None) or {})
|
||||
]
|
||||
|
||||
for mode in _modes_present(tokens):
|
||||
block: list[str] = []
|
||||
for group, group_tokens in _grouped(tokens):
|
||||
entries: list[str] = []
|
||||
for token in group_tokens:
|
||||
name = (getattr(token, "name", "") or "").strip()
|
||||
if not is_valid_token_name(name):
|
||||
continue
|
||||
raw = (getattr(token, "value_by_mode", None) or {}).get(mode)
|
||||
if raw is None:
|
||||
continue
|
||||
value = safe_value(str(raw))
|
||||
if value is None:
|
||||
entries.append(
|
||||
f" /* {name}: value rejected — not a safe CSS value */"
|
||||
)
|
||||
continue
|
||||
# Purpose first — what the token is FOR is what a reader of the
|
||||
# stylesheet needs. Rationale is the fallback so a token that
|
||||
# only carries the why still says something.
|
||||
purpose = safe_comment(
|
||||
getattr(token, "purpose", "")
|
||||
or getattr(token, "rationale", "")
|
||||
or ""
|
||||
)
|
||||
comment = f" /* {purpose} */" if purpose and mode == BASE_MODE else ""
|
||||
entries.append(f" {name}: {value};{comment}")
|
||||
if entries:
|
||||
if group:
|
||||
block.append(f" /* {safe_comment(group)} */")
|
||||
block.extend(entries)
|
||||
block.append("")
|
||||
|
||||
# Declared-but-valueless tokens belong to the base layer: they have no
|
||||
# mode to sit under, and repeating them per mode would triple the noise.
|
||||
if mode == BASE_MODE and valueless:
|
||||
block.append(" /* Declared by the design system, no value set yet: */")
|
||||
block.extend(f" /* {t.name}: ; */" for t in valueless)
|
||||
block.append("")
|
||||
|
||||
if not block:
|
||||
continue
|
||||
lines.append(f"{selector_for_mode(mode, root_selector)} {{")
|
||||
lines.extend(block[:-1] if block[-1] == "" else block)
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
|
||||
# A trailing, machine-readable record of what this system says to write
|
||||
# INSTEAD of a given literal.
|
||||
#
|
||||
# The sheet carries its own supersedes declarations so that any consumer has
|
||||
# them — notably a CI check, which has the component sources but no database.
|
||||
# Hardcoding the mapping in a checker would bake one install's palette into
|
||||
# the tool; reading it from the sheet keeps the checker instance-agnostic and
|
||||
# keeps this file the single source.
|
||||
replacements = [
|
||||
(literal, getattr(token, "name", ""))
|
||||
for token in tokens
|
||||
for literal in (getattr(token, "supersedes", None) or ())
|
||||
if is_valid_token_name(getattr(token, "name", ""))
|
||||
]
|
||||
if replacements:
|
||||
lines.append("/* SUPERSEDES — write the token, not the literal.")
|
||||
for literal, name in replacements:
|
||||
lines.append(f" * {safe_comment(str(literal))} -> {name}")
|
||||
lines.append(" */")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def duplicate_values(tokens: Sequence) -> dict[str, list[str]]:
|
||||
"""Values declared by more than one token, mapped to the names declaring them.
|
||||
|
||||
Two names for one value are either a deliberate alias or the same idea
|
||||
recorded twice — the token-level form of the duplicated-definition shape.
|
||||
Reported rather than refused: a design system legitimately aligns colours on
|
||||
purpose (one palette entry defined as equal to another), and a generator
|
||||
that rejected that
|
||||
would be wrong about the operator's intent.
|
||||
|
||||
Compares the BASE value only. Two tokens agreeing in one mode and diverging
|
||||
in another are not duplicates of each other — they are a near-miss, which is
|
||||
a different and less interesting finding.
|
||||
"""
|
||||
by_value: dict[str, list[str]] = {}
|
||||
for token in tokens:
|
||||
name = getattr(token, "name", "")
|
||||
base = (getattr(token, "value_by_mode", None) or {}).get(BASE_MODE)
|
||||
if not name or not base:
|
||||
continue
|
||||
by_value.setdefault(str(base).strip().lower(), []).append(name)
|
||||
return {value: names for value, names in by_value.items() if len(names) > 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading a sheet from the other side: does this code use it correctly?
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# "The snippets use the tags from the sheet" is a verifiable relation, and
|
||||
# nothing checked it before. Three questions, each a different failure:
|
||||
#
|
||||
# var(--x) where no --x exists -> renders as NOTHING. No error, no test
|
||||
# failure, no visual clue beyond the thing
|
||||
# silently not being styled.
|
||||
# a superseded literal in code -> the value the sheet said to stop writing,
|
||||
# and the sheet knows what to write instead.
|
||||
# --x: declared inside a snippet -> a component minting its own token is the
|
||||
# bloat a shared sheet exists to prevent.
|
||||
#
|
||||
# The first is not hypothetical: `--color-accent` was used throughout a new view
|
||||
# in this codebase and does not exist.
|
||||
|
||||
_VAR_REFERENCE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)")
|
||||
_LOCAL_DEFINITION = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
|
||||
|
||||
|
||||
def referenced_tokens(code: str) -> set[str]:
|
||||
"""Every custom property the code reads through `var()`."""
|
||||
return set(_VAR_REFERENCE.findall(code or ""))
|
||||
|
||||
|
||||
def defined_tokens(code: str) -> set[str]:
|
||||
"""Every custom property the code declares itself.
|
||||
|
||||
Excludes names it also reads: `--x: var(--x, fallback)` is a redeclaration
|
||||
of something the sheet owns, which the unknown-reference check already
|
||||
covers more precisely.
|
||||
"""
|
||||
return set(_LOCAL_DEFINITION.findall(code or "")) - referenced_tokens(code or "")
|
||||
|
||||
|
||||
def _literal_pattern(literal: str) -> re.Pattern:
|
||||
"""Match a literal value without matching a longer one that contains it.
|
||||
|
||||
`#fff` must not match inside `#ffffff` — they are different colours, and a
|
||||
finding that fired on the wrong one would send someone to change code that
|
||||
was already correct.
|
||||
"""
|
||||
escaped = re.escape(literal)
|
||||
lead = r"(?<![0-9A-Za-z_#-])"
|
||||
trail = r"(?![0-9A-Za-z_-])" if literal.startswith("#") else r"(?![0-9A-Za-z_-])"
|
||||
return re.compile(lead + escaped + trail, re.IGNORECASE)
|
||||
|
||||
|
||||
def check_code_against_tokens(code: str, tokens) -> dict:
|
||||
"""What this code gets wrong about that token set.
|
||||
|
||||
`tokens` is any sequence with `.name`, `.value_by_mode` and `.supersedes` —
|
||||
resolved tokens, or stored ones.
|
||||
|
||||
Reports rather than scores. Every finding here has a legitimate exception:
|
||||
a snippet may target a system it isn't being checked against, and a literal
|
||||
may be deliberate in a context the token doesn't cover. What it removes is
|
||||
the SILENCE — all three currently fail with no signal at all.
|
||||
"""
|
||||
known = {getattr(t, "name", "") for t in tokens}
|
||||
referenced = referenced_tokens(code)
|
||||
|
||||
superseded: list[dict] = []
|
||||
for token in tokens:
|
||||
for literal in getattr(token, "supersedes", None) or ():
|
||||
if _literal_pattern(str(literal)).search(code or ""):
|
||||
superseded.append({
|
||||
"literal": literal,
|
||||
"use_instead": getattr(token, "name", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"used": sorted(referenced & known),
|
||||
"unknown": sorted(referenced - known),
|
||||
"superseded_literals": superseded,
|
||||
"local_definitions": sorted(defined_tokens(code)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derivation — tokens whose value is a formula over other tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent)` needs
|
||||
# NO special storage: it is a value like any other, and the browser resolves the
|
||||
# `var()` at use time. Change `--fs-accent` and every derived token shifts with
|
||||
# it, in every mode, from one declaration.
|
||||
#
|
||||
# That last part is the real win. A derived token declared once in the base layer
|
||||
# follows its source through dark mode automatically, because `var()` resolves in
|
||||
# whatever context it is used rather than where it is written. Storing a computed
|
||||
# literal instead would need one row per mode AND would silently stop tracking
|
||||
# the source the moment the source changed.
|
||||
#
|
||||
# What derivation DOES need is the check below. A formula pointing at a token
|
||||
# that does not exist is invalid-at-computed-value-time: the browser drops the
|
||||
# declaration and the element falls back to inheritance or nothing. Silent, like
|
||||
# everything else in this family.
|
||||
|
||||
|
||||
def token_dependencies(tokens) -> dict[str, set[str]]:
|
||||
"""Each token name mapped to the token names its own values reference."""
|
||||
deps: dict[str, set[str]] = {}
|
||||
for token in tokens:
|
||||
name = getattr(token, "name", "")
|
||||
if not name:
|
||||
continue
|
||||
refs: set[str] = set()
|
||||
for value in (getattr(token, "value_by_mode", None) or {}).values():
|
||||
refs |= referenced_tokens(str(value))
|
||||
deps[name] = refs - {name}
|
||||
return deps
|
||||
|
||||
|
||||
def _find_cycles(deps: dict[str, set[str]]) -> list[list[str]]:
|
||||
"""Derivation loops, each reported once as the names involved.
|
||||
|
||||
CSS degrades a loop to invalid-at-computed-value-time rather than hanging, so
|
||||
this is about telling the operator, not about protecting the renderer. A
|
||||
token that quietly resolves to nothing is the failure worth naming.
|
||||
"""
|
||||
cycles: list[list[str]] = []
|
||||
seen_cycles: set[frozenset] = set()
|
||||
|
||||
def walk(node: str, path: list[str], visiting: set[str]) -> None:
|
||||
for dep in sorted(deps.get(node, ())):
|
||||
if dep in visiting:
|
||||
loop = path[path.index(dep):]
|
||||
key = frozenset(loop)
|
||||
if loop and key not in seen_cycles:
|
||||
seen_cycles.add(key)
|
||||
cycles.append(loop)
|
||||
continue
|
||||
if dep in deps:
|
||||
walk(dep, path + [dep], visiting | {dep})
|
||||
|
||||
for name in sorted(deps):
|
||||
walk(name, [name], {name})
|
||||
return cycles
|
||||
|
||||
|
||||
def derivation_report(tokens) -> dict:
|
||||
"""Which tokens are formulas, and which of those are broken.
|
||||
|
||||
`derived` name -> the tokens it is computed from
|
||||
`unknown_refs` name -> references that resolve to no token in this system.
|
||||
The browser drops such a declaration entirely; nothing errors.
|
||||
`cycles` derivation loops, which resolve to nothing for the same reason
|
||||
"""
|
||||
deps = token_dependencies(tokens)
|
||||
known = set(deps)
|
||||
|
||||
derived = {name: sorted(refs) for name, refs in deps.items() if refs}
|
||||
unknown = {
|
||||
name: sorted(refs - known)
|
||||
for name, refs in deps.items()
|
||||
if refs - known
|
||||
}
|
||||
return {
|
||||
"derived": derived,
|
||||
"unknown_refs": unknown,
|
||||
"cycles": _find_cycles(deps),
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
"""Design system + token persistence, and the guard on the parent chain.
|
||||
|
||||
Access goes through `services/access.py` (rule #78), where reaching a system via
|
||||
a project you can see grants READ but never write — see
|
||||
`get_design_system_permission` for why that asymmetry exists.
|
||||
|
||||
The cascade itself lives in `services/design_cascade.py` as pure functions. This
|
||||
module is the part that needs a database: loading the shape of the hierarchy,
|
||||
refusing writes that would break it, and storing tokens.
|
||||
|
||||
Nothing here seeds or implies a default system. An install with no design
|
||||
systems is an ordinary install, and every caller must handle an empty list as
|
||||
the normal case rather than a missing prerequisite.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.design_system import DesignSystem, DesignToken
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import access
|
||||
from scribe.services.design_stylesheet import (
|
||||
check_code_against_tokens,
|
||||
derivation_report,
|
||||
duplicate_values,
|
||||
render_stylesheet,
|
||||
)
|
||||
from scribe.services.design_cascade import (
|
||||
ResolvedToken,
|
||||
ancestry,
|
||||
resolve_tokens,
|
||||
would_cycle,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DesignSystemCycle(ValueError):
|
||||
"""A parent assignment that would close an inheritance loop.
|
||||
|
||||
Raised rather than returned as None because the two outcomes need different
|
||||
answers: None already means "not found, or not yours", and a caller that
|
||||
conflated them would show "no such design system" for what is really "that
|
||||
parent is one of its own descendants". Routes map this to a 400.
|
||||
"""
|
||||
|
||||
|
||||
async def _parent_map(session, owner_user_id: int) -> dict[int, int | None]:
|
||||
"""`{id: parent_id}` for one owner's live systems — the hierarchy's shape.
|
||||
|
||||
Scoped to the OWNER of the systems, not the caller, and the distinction is
|
||||
load-bearing on the read path: a caller reading through a shared project
|
||||
does not own any link in the chain, and a caller-scoped map would hand them
|
||||
an empty forest and a cascade truncated to one system. They would get a page
|
||||
that renders with plausible wrong values and no error anywhere.
|
||||
|
||||
Owner-scoping is also the constraint on parenting: a chain may only be built
|
||||
from systems its owner controls, or someone else's delete or re-parent would
|
||||
silently restyle your app.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DesignSystem.id, DesignSystem.parent_id).where(
|
||||
DesignSystem.owner_user_id == owner_user_id,
|
||||
DesignSystem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
# --- design systems ---------------------------------------------------------
|
||||
|
||||
async def create_design_system(
|
||||
user_id: int,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
guidance: str | None = None,
|
||||
parent_id: int | None = None,
|
||||
) -> DesignSystem | None:
|
||||
"""Create a system, with or without a parent.
|
||||
|
||||
Returns None when `parent_id` names a system the caller may not write —
|
||||
which, per the ACL, means one they do not own.
|
||||
"""
|
||||
if parent_id is not None and not await access.can_write_design_system(
|
||||
user_id, parent_id
|
||||
):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
system = DesignSystem(
|
||||
owner_user_id=user_id,
|
||||
title=title.strip(),
|
||||
description=description,
|
||||
guidance=guidance,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
session.add(system)
|
||||
await session.commit()
|
||||
await session.refresh(system)
|
||||
return system
|
||||
|
||||
|
||||
async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem | None:
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
if not await access.can_read_design_system(user_id, design_system_id):
|
||||
return None
|
||||
return system
|
||||
|
||||
|
||||
async def list_design_systems(user_id: int) -> list[DesignSystem]:
|
||||
"""The caller's own systems, ordered by title. Empty is normal."""
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
select(DesignSystem)
|
||||
.where(
|
||||
DesignSystem.owner_user_id == user_id,
|
||||
DesignSystem.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(DesignSystem.title)
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def update_design_system(
|
||||
user_id: int, design_system_id: int, **fields: object
|
||||
) -> DesignSystem | None:
|
||||
"""Partial update. Raises DesignSystemCycle if `parent_id` would loop.
|
||||
|
||||
`parent_id` is handled apart from the other fields because None is a
|
||||
meaningful value for it — "make this a root" — where for every other field
|
||||
None means "leave alone". Callers signal it by passing the key at all.
|
||||
"""
|
||||
if not await access.can_write_design_system(user_id, design_system_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
|
||||
if "parent_id" in fields:
|
||||
parent_id = fields.pop("parent_id")
|
||||
if parent_id is not None:
|
||||
parent_id = int(parent_id)
|
||||
if not await access.can_write_design_system(user_id, parent_id):
|
||||
return None
|
||||
if would_cycle(
|
||||
design_system_id,
|
||||
parent_id,
|
||||
await _parent_map(session, system.owner_user_id),
|
||||
):
|
||||
raise DesignSystemCycle(
|
||||
f"Design system {design_system_id} cannot inherit from "
|
||||
f"{parent_id}: that system already inherits from it."
|
||||
)
|
||||
system.parent_id = parent_id
|
||||
|
||||
for key, value in fields.items():
|
||||
if key in ("title", "description", "guidance") and value is not None:
|
||||
setattr(system, key, value)
|
||||
system.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(system)
|
||||
return system
|
||||
|
||||
|
||||
async def delete_design_system(user_id: int, design_system_id: int) -> bool:
|
||||
"""Soft-delete a system. Children survive as roots (the FK is SET NULL)."""
|
||||
if not await access.can_write_design_system(user_id, design_system_id):
|
||||
return False
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return False
|
||||
system.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# --- tokens -----------------------------------------------------------------
|
||||
|
||||
async def create_token(
|
||||
user_id: int,
|
||||
design_system_id: int,
|
||||
name: str,
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str | None = None,
|
||||
purpose: str | None = None,
|
||||
rationale: str | None = None,
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> DesignToken | None:
|
||||
if not await access.can_write_design_system(user_id, design_system_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
token = DesignToken(
|
||||
design_system_id=design_system_id,
|
||||
name=name.strip(),
|
||||
# `or {}` and not the argument as given: the column is NOT NULL so
|
||||
# that absence has exactly one spelling. Passing None here would
|
||||
# otherwise store JSON null and reintroduce the second empty state.
|
||||
value_by_mode=value_by_mode or {},
|
||||
group_name=group_name,
|
||||
purpose=purpose,
|
||||
rationale=rationale,
|
||||
# `or []` for the same reason as value_by_mode above: the column is
|
||||
# NOT NULL so absence has one spelling, and None would store JSON
|
||||
# null instead of an empty array.
|
||||
supersedes=supersedes or [],
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
await session.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def list_tokens(user_id: int, design_system_id: int) -> list[DesignToken]:
|
||||
"""One system's OWN tokens — its override set, not its effective set.
|
||||
|
||||
Resolving the chain is step 2's job; this deliberately answers the narrower
|
||||
question ("what does this system change?") that the model exists to make
|
||||
free.
|
||||
"""
|
||||
if not await access.can_read_design_system(user_id, design_system_id):
|
||||
return []
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
select(DesignToken)
|
||||
.where(
|
||||
DesignToken.design_system_id == design_system_id,
|
||||
DesignToken.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def resolve_design_system(
|
||||
user_id: int, design_system_id: int
|
||||
) -> list[ResolvedToken] | None:
|
||||
"""A system's EFFECTIVE token set — everything it inherits, with its own on top.
|
||||
|
||||
None when the caller may not read the system; an empty list when the chain
|
||||
genuinely holds no tokens, which is an ordinary state for a system that has
|
||||
just been created.
|
||||
|
||||
Two queries regardless of how deep the chain runs: one for the hierarchy's
|
||||
shape, one for every token in it. The flattening itself is
|
||||
`design_cascade.resolve_tokens` — pure, so the cascade rule is tested
|
||||
without a database and this function is only the loading.
|
||||
"""
|
||||
if not await access.can_read_design_system(user_id, design_system_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
|
||||
parents = await _parent_map(session, system.owner_user_id)
|
||||
chain = ancestry(design_system_id, parents)
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DesignToken)
|
||||
.where(
|
||||
DesignToken.design_system_id.in_(chain),
|
||||
DesignToken.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(DesignToken.order_index.asc(), DesignToken.name.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
tokens_by_system: dict[int, list[DesignToken]] = {}
|
||||
for token in rows:
|
||||
tokens_by_system.setdefault(token.design_system_id, []).append(token)
|
||||
return resolve_tokens(design_system_id, parents, tokens_by_system)
|
||||
|
||||
|
||||
async def design_context(user_id: int, design_system_id: int) -> dict | None:
|
||||
"""What a session needs to know about a design system, before it writes UI.
|
||||
|
||||
This is the DELIVERY side of a design system, and it exists because storing
|
||||
one does not make a session aware of it. Rules get pushed into every session
|
||||
by the plugin's SessionStart hook; a design system had no such channel, so
|
||||
the standards were reachable only by an agent that already knew to go
|
||||
looking — which is the same silent failure as a token nobody declares.
|
||||
|
||||
Guidance is chain-merged, ANCESTOR-FIRST, and that is the point rather than
|
||||
a convenience. A child system holds only what it CHANGES, so its own
|
||||
guidance describes a departure from a house style it never restates. Hand an
|
||||
agent the leaf alone and it builds against a fragment, with no signal that
|
||||
the rest exists.
|
||||
|
||||
Tokens are summarised, not listed: the count and the group names are enough
|
||||
to know what the system covers, and the full set is one call away. Sending
|
||||
a hundred token values into every session start would crowd out the context
|
||||
it is meant to inform.
|
||||
|
||||
None when the caller may not read the system.
|
||||
"""
|
||||
tokens = await resolve_design_system(user_id, design_system_id)
|
||||
if tokens is None:
|
||||
return None
|
||||
|
||||
async with async_session() as session:
|
||||
system = await session.get(DesignSystem, design_system_id)
|
||||
if system is None or system.deleted_at is not None:
|
||||
return None
|
||||
parents = await _parent_map(session, system.owner_user_id)
|
||||
chain = ancestry(design_system_id, parents)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(DesignSystem).where(DesignSystem.id.in_(chain))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
by_id = {s.id: s for s in rows}
|
||||
return {
|
||||
"id": system.id,
|
||||
"title": system.title,
|
||||
"description": system.description or "",
|
||||
# Outermost ancestor first, so the reader meets the house style before
|
||||
# the app's departures from it.
|
||||
"inherits_from": [
|
||||
by_id[sid].title for sid in reversed(chain[1:]) if sid in by_id
|
||||
],
|
||||
"guidance": [
|
||||
{
|
||||
"design_system_id": sid,
|
||||
"title": by_id[sid].title,
|
||||
"guidance": (by_id[sid].guidance or "").strip(),
|
||||
}
|
||||
for sid in reversed(chain)
|
||||
if sid in by_id and (by_id[sid].guidance or "").strip()
|
||||
],
|
||||
"token_count": len(tokens),
|
||||
"token_groups": sorted({t.group_name for t in tokens if t.group_name}),
|
||||
}
|
||||
|
||||
|
||||
async def update_token(
|
||||
user_id: int, token_id: int, **fields: object
|
||||
) -> DesignToken | None:
|
||||
allowed = {
|
||||
"name", "value_by_mode", "group_name", "purpose", "rationale",
|
||||
"supersedes", "order_index",
|
||||
}
|
||||
async with async_session() as session:
|
||||
token = await session.get(DesignToken, token_id)
|
||||
if token is None or token.deleted_at is not None:
|
||||
return None
|
||||
if not await access.can_write_design_system(user_id, token.design_system_id):
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def delete_token(user_id: int, token_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
token = await session.get(DesignToken, token_id)
|
||||
if token is None or token.deleted_at is not None:
|
||||
return False
|
||||
if not await access.can_write_design_system(user_id, token.design_system_id):
|
||||
return False
|
||||
token.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# --- the project pointer ----------------------------------------------------
|
||||
|
||||
async def set_project_design_system(
|
||||
user_id: int, project_id: int, design_system_id: int | None
|
||||
) -> bool:
|
||||
"""Point a project at a design system, or at nothing (None clears it).
|
||||
|
||||
Needs write on the project and READ on the system: pointing at a system is
|
||||
consuming it, not changing it, so a system shared with you through another
|
||||
project is a legitimate choice here.
|
||||
"""
|
||||
if not await access.can_write_project(user_id, project_id):
|
||||
return False
|
||||
if design_system_id is not None and not await access.can_read_design_system(
|
||||
user_id, design_system_id
|
||||
):
|
||||
return False
|
||||
async with async_session() as session:
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None or project.deleted_at is not None:
|
||||
return False
|
||||
project.design_system_id = design_system_id
|
||||
project.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# --- the master sheet -------------------------------------------------------
|
||||
|
||||
async def stylesheet_for_system(
|
||||
user_id: int, design_system_id: int, root_selector: str = ":root"
|
||||
) -> dict | None:
|
||||
"""The master CSS sheet a design system generates, plus its reuse report.
|
||||
|
||||
Purpose tokens only — the sheet styles no elements. See
|
||||
`services/design_stylesheet.py` for why that split is the design rather than
|
||||
a shortcut.
|
||||
|
||||
Returns None if the caller may not read the system. The `duplicates` half is
|
||||
advisory: two tokens sharing a value are either a deliberate alias or one
|
||||
idea recorded twice, and only the operator knows which.
|
||||
"""
|
||||
resolved = await resolve_design_system(user_id, design_system_id)
|
||||
if resolved is None:
|
||||
return None
|
||||
system = await get_design_system(user_id, design_system_id)
|
||||
|
||||
return {
|
||||
"design_system_id": design_system_id,
|
||||
"css": render_stylesheet(
|
||||
resolved,
|
||||
root_selector=root_selector,
|
||||
title=system.title if system else "",
|
||||
design_system_id=design_system_id,
|
||||
),
|
||||
"token_count": len(resolved),
|
||||
"valueless": [t.name for t in resolved if not t.value_by_mode],
|
||||
"duplicates": duplicate_values(resolved),
|
||||
# Formulas: which tokens are computed from others, and which of those
|
||||
# point at nothing. A broken formula is dropped by the browser without
|
||||
# any error, so the sheet cannot show it for itself.
|
||||
"derivation": derivation_report(resolved),
|
||||
}
|
||||
|
||||
|
||||
async def check_snippets_against_system(
|
||||
user_id: int, design_system_id: int, project_id: int = 0
|
||||
) -> dict | None:
|
||||
"""Which recorded snippets disagree with this design system's sheet.
|
||||
|
||||
The relation the operator named — "the snippets use the tags from the sheet"
|
||||
— turned into a check. For each snippet: `var(--x)` references with no such
|
||||
token, literals the sheet says to stop writing, and custom properties the
|
||||
snippet mints for itself instead of using shared ones.
|
||||
|
||||
Snippets with nothing to report are omitted entirely. A list of everything
|
||||
that is fine is a list nobody reads twice.
|
||||
"""
|
||||
resolved = await resolve_design_system(user_id, design_system_id)
|
||||
if resolved is None:
|
||||
return None
|
||||
|
||||
from scribe.services import snippets as snippets_svc
|
||||
|
||||
# `list_snippets` returns (rows, total) and caps limit at 100; `project_id`
|
||||
# must be None — not 0 — to reach across every project, since 0 would filter
|
||||
# to a project with that id.
|
||||
rows, _total = await snippets_svc.list_snippets(
|
||||
user_id=user_id, project_id=project_id or None, limit=100,
|
||||
)
|
||||
|
||||
findings: list[dict] = []
|
||||
for row in rows:
|
||||
snippet_id = row.get("id")
|
||||
if snippet_id is None:
|
||||
continue
|
||||
# The list rows carry a preview, not the code. The check has to read the
|
||||
# whole body or it would report on a truncation.
|
||||
note = await snippets_svc.get_snippet(user_id=user_id, snippet_id=int(snippet_id))
|
||||
if note is None:
|
||||
continue
|
||||
report = check_code_against_tokens(note.body or "", resolved)
|
||||
if not (
|
||||
report["unknown"] or report["superseded_literals"] or report["local_definitions"]
|
||||
):
|
||||
continue
|
||||
findings.append({
|
||||
"snippet_id": int(snippet_id),
|
||||
"title": note.title or "",
|
||||
**report,
|
||||
})
|
||||
|
||||
return {
|
||||
"design_system_id": design_system_id,
|
||||
"checked": len(rows),
|
||||
"findings": findings,
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import logging
|
||||
import math
|
||||
import os
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import delete, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.embedding import NoteEmbedding
|
||||
@@ -114,7 +116,8 @@ async def semantic_search_notes(
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
project_id: int | None = None,
|
||||
is_task: bool | None = None,
|
||||
note_type: str | None = None,
|
||||
note_type: str | Sequence[str] | None = None,
|
||||
task_kind: str | Sequence[str] | None = None,
|
||||
orphan_only: bool = False,
|
||||
scope: str = "own",
|
||||
) -> list[tuple[float, Note]]:
|
||||
@@ -123,8 +126,15 @@ async def semantic_search_notes(
|
||||
Scores are cosine similarities in [-1, 1]; only notes at or above
|
||||
*threshold* are returned, sorted highest-first.
|
||||
|
||||
`note_type` narrows to a single record kind (e.g. "snippet"), for callers
|
||||
that want prior art rather than everything embedded.
|
||||
`note_type` narrows to a record kind, or several (e.g. "snippet", or
|
||||
("snippet", "note")), for callers that want prior art rather than everything
|
||||
embedded.
|
||||
|
||||
`task_kind` restricts TASKS to the given kinds while leaving non-task notes
|
||||
untouched. That asymmetry is the point: "recorded experience" is issues plus
|
||||
dev-logs, and those differ on `is_task`, so neither `note_type` nor `is_task`
|
||||
alone can express it. With `note_type="note", task_kind="issue"` a caller
|
||||
gets fixed problems and durable notes without the open to-do list.
|
||||
|
||||
`scope` ("own" | "browse" | "read", see access.notes_visibility_clause)
|
||||
decides how far this may see. It exists because this one function serves
|
||||
@@ -179,11 +189,22 @@ async def semantic_search_notes(
|
||||
stmt = stmt.where(Note.status.isnot(None))
|
||||
elif is_task is False:
|
||||
stmt = stmt.where(Note.status.is_(None))
|
||||
# Narrow to one kind of record. Composes with is_task rather than
|
||||
# replacing it — 'snippet' is a non-task note_type, so a caller asking
|
||||
# for prior art gets snippets and not the dev-log that mentions them.
|
||||
# Narrow to one kind of record, or several. Composes with is_task
|
||||
# rather than replacing it — 'snippet' is a non-task note_type, so a
|
||||
# caller asking for prior art gets snippets and not the dev-log that
|
||||
# mentions them.
|
||||
if note_type:
|
||||
stmt = stmt.where(Note.note_type == note_type)
|
||||
kinds = [note_type] if isinstance(note_type, str) else list(note_type)
|
||||
stmt = stmt.where(Note.note_type.in_(kinds))
|
||||
# Restrict TASKS to certain kinds while leaving notes alone. A note
|
||||
# has no task_kind that means anything, so a plain `.in_()` would
|
||||
# drop every dev-log — which is exactly the record a caller asking
|
||||
# for prior experience wants most.
|
||||
if task_kind:
|
||||
tkinds = [task_kind] if isinstance(task_kind, str) else list(task_kind)
|
||||
stmt = stmt.where(
|
||||
or_(Note.status.is_(None), Note.task_kind.in_(tkinds))
|
||||
)
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
|
||||
stmt = stmt.where(distance <= max_distance).order_by(distance.asc()).limit(limit)
|
||||
|
||||
@@ -120,6 +120,12 @@ def _location_clause(parts: dict[str, str]):
|
||||
# the verdict's: jsonpath compares the two fields within the row, so this stays
|
||||
# one index-served predicate rather than a post-filter that would make the
|
||||
# pagination total a lie.
|
||||
#
|
||||
# Same two-dialect discipline as the location filter above, and the same hazard:
|
||||
# THE TWO MUST CHANGE TOGETHER. tests/test_snippet_drift_check.py is the guard —
|
||||
# it walks both dialects over the same cases, including the one that motivated
|
||||
# `attention` existing (an ok verdict whose code_sha has gone stale, which is
|
||||
# neither `drifted` nor `unverified` yet plainly needs looking at).
|
||||
|
||||
def verification_matches(data: dict | None, value: str) -> bool:
|
||||
"""Python dialect of the verification predicate.
|
||||
@@ -205,6 +211,15 @@ def _note_to_item(note: Note) -> dict:
|
||||
# body parsing and stays a plain projection of the column. Omitted entirely
|
||||
# when unchecked, so "no key" and "never verified" don't become two states
|
||||
# the client has to tell apart.
|
||||
# Snippet language, same reasoning as the verdict below: a plain projection of
|
||||
# the `data` mirror, no body parsing. Needed because a prior-art hit in a
|
||||
# DIFFERENT language than the file being written is useful as the shape of a
|
||||
# solution but must not be mistaken for code to paste (#2244) — and the caller
|
||||
# can only say "different" if the language is on the item.
|
||||
language = (note.data or {}).get("language") if note.data else None
|
||||
if language:
|
||||
item["language"] = language
|
||||
|
||||
verdict = (note.data or {}).get("verification") if note.data else None
|
||||
if verdict and verdict.get("status"):
|
||||
item["verification"] = {
|
||||
|
||||
@@ -89,6 +89,38 @@ async def log_error(
|
||||
await session.commit()
|
||||
|
||||
|
||||
def parse_filter_datetime(value: str | None, *, end_of_day: bool = False) -> datetime | None:
|
||||
"""An ISO date/datetime from a query string as an AWARE UTC datetime.
|
||||
|
||||
The admin log filters arrive as raw `request.args` strings and used to be
|
||||
compared straight against `AppLog.created_at`. asyncpg binds a str as
|
||||
VARCHAR and Postgres has no `timestamptz >= text` operator, so supplying
|
||||
either date filter raised — the same defect as #1727 in notifications, in a
|
||||
second place (#2254).
|
||||
|
||||
Returns None for anything unparseable: a malformed filter should narrow
|
||||
nothing rather than 500 the log viewer.
|
||||
|
||||
`end_of_day` matters for the upper bound. A bare "2026-07-30" parses to
|
||||
midnight, so `created_at <= that` would exclude the whole of the day the
|
||||
user asked for — the one day they most likely wanted. With this flag a
|
||||
date-only value is pushed to the last microsecond of that day; a value that
|
||||
already carries a time is left exactly as given.
|
||||
"""
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if end_of_day and len(raw) == 10: # date-only, no time component
|
||||
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
async def get_logs(
|
||||
category: str | None = None,
|
||||
user_id: int | None = None,
|
||||
@@ -118,12 +150,14 @@ async def get_logs(
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
if date_from:
|
||||
query = query.where(AppLog.created_at >= date_from)
|
||||
count_query = count_query.where(AppLog.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.where(AppLog.created_at <= date_to)
|
||||
count_query = count_query.where(AppLog.created_at <= date_to)
|
||||
start = parse_filter_datetime(date_from)
|
||||
end = parse_filter_datetime(date_to, end_of_day=True)
|
||||
if start:
|
||||
query = query.where(AppLog.created_at >= start)
|
||||
count_query = count_query.where(AppLog.created_at >= start)
|
||||
if end:
|
||||
query = query.where(AppLog.created_at <= end)
|
||||
count_query = count_query.where(AppLog.created_at <= end)
|
||||
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
|
||||
@@ -156,26 +156,82 @@ async def get_milestone_progress(milestone_id: int) -> dict:
|
||||
for status, count in rows.fetchall():
|
||||
status_counts[status] = count
|
||||
|
||||
total = sum(status_counts.values())
|
||||
cancelled = status_counts.get("cancelled", 0)
|
||||
completed = status_counts.get("done", 0)
|
||||
# Cancelled tasks are resolved work, not pending — exclude them from the
|
||||
# percent-complete denominator so a milestone whose only open task was
|
||||
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
|
||||
active_total = total - cancelled
|
||||
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
|
||||
# Same rule as the batch path, computed in one place so the two cannot
|
||||
# drift on the cancelled-exclusion.
|
||||
return _progress_from_counts(status_counts)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"pct": pct,
|
||||
"status_counts": {
|
||||
"todo": status_counts.get("todo", 0),
|
||||
"in_progress": status_counts.get("in_progress", 0),
|
||||
"done": status_counts.get("done", 0),
|
||||
"cancelled": cancelled,
|
||||
},
|
||||
}
|
||||
|
||||
def _progress_from_counts(status_counts: dict[str, int]) -> dict:
|
||||
"""The progress shape, computed from already-fetched counts.
|
||||
|
||||
Split out of get_milestone_progress so the batch path can reuse the rule
|
||||
rather than restate it — the cancelled-exclusion below is easy to get
|
||||
subtly different in a second copy, and then two screens disagree about
|
||||
whether a milestone is finished.
|
||||
"""
|
||||
total = sum(status_counts.values())
|
||||
cancelled = status_counts.get("cancelled", 0)
|
||||
completed = status_counts.get("done", 0)
|
||||
# Cancelled tasks are resolved work, not pending — excluded from the
|
||||
# denominator so a milestone whose only open task was cancelled reaches
|
||||
# 100% instead of stalling.
|
||||
active_total = total - cancelled
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"pct": round(completed / active_total * 100, 1) if active_total > 0 else 0.0,
|
||||
"status_counts": {
|
||||
"todo": status_counts.get("todo", 0),
|
||||
"in_progress": status_counts.get("in_progress", 0),
|
||||
"done": status_counts.get("done", 0),
|
||||
"cancelled": cancelled,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_project_milestone_summaries(
|
||||
user_id: int, project_ids: list[int]
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Milestone summaries for MANY projects in two queries total.
|
||||
|
||||
The per-project version below is a nested fan-out: one query to list a
|
||||
project's milestones, then one more per milestone for its progress. Called
|
||||
for 25 projects concurrently it asked for ~250 pooled connections against a
|
||||
pool of 15, and every one of them waited out the 30-second checkout timeout
|
||||
(#2384). This does the same work in two queries and one session.
|
||||
"""
|
||||
if not project_ids:
|
||||
return {}
|
||||
|
||||
async with async_session() as session:
|
||||
milestones = list((await session.execute(
|
||||
select(Milestone).where(
|
||||
Milestone.user_id == user_id,
|
||||
Milestone.project_id.in_(project_ids),
|
||||
Milestone.deleted_at.is_(None),
|
||||
).order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
|
||||
)).scalars().all())
|
||||
|
||||
counts: dict[int, dict[str, int]] = {}
|
||||
if milestones:
|
||||
rows = await session.execute(
|
||||
select(Note.milestone_id, Note.status, func.count(Note.id))
|
||||
.where(
|
||||
Note.milestone_id.in_([m.id for m in milestones]),
|
||||
Note.status.isnot(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Note.milestone_id, Note.status)
|
||||
)
|
||||
for milestone_id, status, count in rows.fetchall():
|
||||
counts.setdefault(milestone_id, {})[status] = count
|
||||
|
||||
out: dict[int, list[dict]] = {pid: [] for pid in project_ids}
|
||||
for m in milestones:
|
||||
entry = m.to_dict()
|
||||
entry.update(_progress_from_counts(counts.get(m.id, {})))
|
||||
out.setdefault(m.project_id, []).append(entry)
|
||||
return out
|
||||
|
||||
|
||||
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
|
||||
|
||||
@@ -10,6 +10,40 @@ from scribe.models.note import Note, TaskPriority, TaskStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def embed_note(note) -> None:
|
||||
"""Refresh a note's embedding, fire-and-forget.
|
||||
|
||||
Lives HERE — at the service, not the route — so every caller gets it by
|
||||
construction. Previously each REST route made this call itself and the MCP
|
||||
tools did not, so a record created through MCP stayed out of semantic search
|
||||
and auto-inject until the next restart's backfill ran (#2056). That is
|
||||
invisible on an instance that redeploys constantly and permanent on one that
|
||||
doesn't, which is the worst shape a bug can have: it only appears where
|
||||
nobody is looking.
|
||||
|
||||
Uses `note.user_id` — the OWNER — rather than the caller. Embeddings belong
|
||||
to the record, and a collaborator editing a shared note must refresh the
|
||||
owner's row rather than mint a second one under their own id.
|
||||
|
||||
Import is lazy so importing this module doesn't pull in the embedding model;
|
||||
exceptions are swallowed because a record that saved must not fail on its
|
||||
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
||||
not an error.
|
||||
"""
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
||||
except RuntimeError:
|
||||
pass # no running loop — a sync caller, not a failure
|
||||
except Exception: # noqa: BLE001 - never let indexing break a write
|
||||
logger.exception("embedding refresh failed for note %s", note.id)
|
||||
|
||||
|
||||
def _normalize_tags(tags: list[str]) -> list[str]:
|
||||
"""Lowercase, strip, deduplicate, and drop empty tags."""
|
||||
seen: set[str] = set()
|
||||
@@ -115,6 +149,8 @@ async def create_note(
|
||||
await session.commit()
|
||||
await session.refresh(note)
|
||||
|
||||
embed_note(note)
|
||||
|
||||
if project_id is not None:
|
||||
await _maybe_reactivate_project(project_id)
|
||||
|
||||
@@ -329,6 +365,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
||||
from scribe.services.note_versions import create_version
|
||||
await create_version(user_id, note_id, old_body, old_title, old_tags)
|
||||
|
||||
embed_note(note)
|
||||
|
||||
if note.project_id is not None:
|
||||
await _maybe_reactivate_project(note.project_id)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime, time, timezone
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
@@ -149,13 +149,25 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
|
||||
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
|
||||
|
||||
|
||||
def utc_day_start(day: date) -> datetime:
|
||||
"""Midnight UTC on `day`, as an AWARE datetime — the reminder dedup window.
|
||||
|
||||
Deliberately a `datetime` and not the bare `date` (#1727). Comparing a
|
||||
`timestamptz` column against a `date` does work, via an implicit cast — but
|
||||
Postgres resolves that cast in the SESSION's TimeZone, so the window would
|
||||
move with a server setting nobody remembers is load-bearing. An aware UTC
|
||||
datetime says what it means and compares the same way everywhere.
|
||||
"""
|
||||
return datetime.combine(day, time.min, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def check_due_tasks() -> None:
|
||||
"""Check for tasks due today and send reminder emails."""
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
today_str = today.isoformat()
|
||||
window_start = utc_day_start(today)
|
||||
|
||||
async with async_session() as session:
|
||||
# Find tasks due today or overdue, not done
|
||||
@@ -188,12 +200,18 @@ async def check_due_tasks() -> None:
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Dedup: check if we already sent a reminder today
|
||||
# Dedup: check if we already sent a reminder today.
|
||||
# `window_start` is an aware datetime, NOT `today.isoformat()`.
|
||||
# asyncpg binds a str as VARCHAR and Postgres has no
|
||||
# `timestamptz >= text` operator, so this raised on every run
|
||||
# that got this far — swallowed by the per-user `except` below,
|
||||
# which is why the only symptom was reminders silently never
|
||||
# sending plus an hourly traceback in the DB log (#1727).
|
||||
dedup_result = await session.execute(
|
||||
select(func.count(AppLog.id)).where(
|
||||
AppLog.action == "task_reminder",
|
||||
AppLog.user_id == user_id,
|
||||
AppLog.created_at >= today_str,
|
||||
AppLog.created_at >= window_start,
|
||||
)
|
||||
)
|
||||
if (dedup_result.scalar() or 0) > 0:
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rulebook import RulebookTopic
|
||||
from scribe.services import design_systems as design_systems_svc
|
||||
from scribe.services import knowledge as knowledge_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
@@ -56,16 +57,110 @@ AUTOINJECT_DEFAULT_ENABLED = True
|
||||
AUTOINJECT_DEFAULT_THRESHOLD = 0.55
|
||||
AUTOINJECT_DEFAULT_TOP_K = 3
|
||||
|
||||
# The write-path trigger (#2082) gets its own on/off switch but SHARES the
|
||||
# threshold and top-k above. One knob for "how loud may Scribe be" is easier to
|
||||
# reason about than two that drift; the switch is separate because wanting prior
|
||||
# art on writes while keeping prompts quiet (or the reverse) is a real
|
||||
# preference, and it's the only part of the gate an operator can judge without
|
||||
# data. The two surfaces log under different `source` values, so if the
|
||||
# telemetry ever shows they want different thresholds, splitting them is a
|
||||
# data-backed change rather than a guess made up front.
|
||||
# The write-path trigger (#2082) gets its own on/off switch, its own threshold,
|
||||
# and shares only top-k. It originally shared the threshold too, on the argument
|
||||
# that one "how loud may Scribe be" knob beats two that drift — and reserved the
|
||||
# split for when telemetry showed the two surfaces wanted different values.
|
||||
#
|
||||
# #2223 is that evidence. Measured against the live instance, the semantic arm's
|
||||
# scores for CODE sit far above what the same threshold means for PROSE:
|
||||
# near-duplicate of a recorded helper 0.73-0.74 (true positive)
|
||||
# unrelated colour math / Vue SFC / CSS 0.55-0.63 (false positive)
|
||||
# `x = 1` 0.58 (false positive)
|
||||
# Any two Python-shaped payloads share keywords, indentation and structure, so
|
||||
# the floor for "some code" is ~0.55-0.63 — auto-inject's 0.55 lands INSIDE that
|
||||
# noise band, and 6 of 8 probe payloads produced a nudge (4 of them noise). The
|
||||
# margin gate can't rescue it either: _AUTOINJECT_BAND is relative to the top
|
||||
# hit, so with a single hit it never engages.
|
||||
#
|
||||
# 0.68 clears every measured false positive with margin and still sits 0.05
|
||||
# below both true positives. Auto-inject keeps 0.55 — it was tuned on prose and
|
||||
# is not implicated. Tune from retrieval_logs (source='write_path') + note_usage
|
||||
# pull-through (#2085) once a real corpus accrues; a cross-encoder rerank
|
||||
# (#1038) would subsume this bump.
|
||||
WRITEPATH_ENABLED_KEY = "kb_writepath_enabled"
|
||||
WRITEPATH_THRESHOLD_KEY = "kb_writepath_threshold"
|
||||
WRITEPATH_DEFAULT_ENABLED = True
|
||||
WRITEPATH_DEFAULT_THRESHOLD = 0.68
|
||||
|
||||
# Minimum SUBSTANCE (non-whitespace chars) a payload must carry before the
|
||||
# semantic arm will run at all — the cheap half of the operator's #89 idea
|
||||
# ("a sliding scale between number of characters and semantic threshold").
|
||||
#
|
||||
# Deliberately NOT a settings knob and deliberately conservative. Its job is
|
||||
# only to drop payloads too small to carry meaning, where an embedding is noise
|
||||
# rather than signal: `x = 1`, a renamed variable, a changed string literal —
|
||||
# which is what most single-line Edits look like, and the majority of Edits are
|
||||
# single-line. 48 sits below the smallest plausible reusable helper (a one-line
|
||||
# `def` with a body runs ~60), so it errs toward keeping recall and leaves
|
||||
# precision to the threshold above, which is where the measured separation is.
|
||||
# The full length↔threshold CURVE is still open in #89 — the operator flagged it
|
||||
# as wanting a brainstorm, so this stays a flat floor rather than an invented
|
||||
# scale. It also saves a pointless embedding round-trip on trivial edits.
|
||||
WRITEPATH_MIN_CODE_CHARS = 48
|
||||
|
||||
# --- concept extraction for the semantic arm's query (#2242) ------------------
|
||||
# A snippet's embedded text is f"{title}\n{body}", and for a snippet that body is
|
||||
# composed markdown: **When to use:**, **Signature:**, **Location:**, then the
|
||||
# fenced code. So `when_to_use` — the description of what the thing is FOR —
|
||||
# appears twice in the vector, and the document is prose-forward.
|
||||
#
|
||||
# The arm used to query it with raw code and no prose at all. Measured on the
|
||||
# deployed instance against snippet #2222, same corpus:
|
||||
# query built from score best unrelated separation
|
||||
# raw code body 0.743 0.630 0.11
|
||||
# name + docstring 0.823 0.602 0.22
|
||||
# hand-written concept prose 0.835 0.583 0.25
|
||||
# A 12-word description beats a near-verbatim reimplementation of the function,
|
||||
# and code-as-query RAISES the noise floor. It is also the cleanest explanation
|
||||
# for the fragment miss recorded on #2223: a short code excerpt has almost no
|
||||
# prose to match against a document that is mostly prose.
|
||||
#
|
||||
# So we send the concept instead — and shape it like a snippet's own title,
|
||||
# "{name} — {when_to_use}", because that is the form the 0.823 measurement used.
|
||||
# Undocumented code yields little, and a Vue SFC or a config file yields nothing;
|
||||
# those fall back to the raw payload and behave exactly as before. This raises
|
||||
# the ceiling for documented helpers rather than fixing every case.
|
||||
|
||||
# Declaration forms, one pattern per shape, every pattern exposing (name, params)
|
||||
# so composition doesn't have to care which matched. Deliberately regex and not a
|
||||
# real parser: this runs on a PreToolUse hook's critical path, the payload is
|
||||
# frequently a FRAGMENT that no parser would accept (an Edit's new_string is
|
||||
# rarely a valid module), and a miss costs only a fallback to today's behaviour.
|
||||
_CONCEPT_DECL_PATTERNS = (
|
||||
# python: def / async def, and class with optional bases
|
||||
re.compile(r"^[ \t]*(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
|
||||
re.compile(r"^[ \t]*class[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))?", re.M),
|
||||
# js/ts: function decl, and the const-arrow form that dominates modern code
|
||||
re.compile(r"^[ \t]*(?:export[ \t]+)?(?:default[ \t]+)?(?:async[ \t]+)?function[ \t]+([A-Za-z_$][\w$]*)[ \t]*(\([^)]*\))", re.M),
|
||||
re.compile(r"^[ \t]*(?:export[ \t]+)?(?:const|let|var)[ \t]+([A-Za-z_$][\w$]*)[ \t]*=[ \t]*(?:async[ \t]*)?(\([^)]*\))[ \t]*=>", re.M),
|
||||
# rust / go
|
||||
re.compile(r"^[ \t]*(?:pub[ \t]+)?fn[ \t]+([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
|
||||
re.compile(r"^[ \t]*func[ \t]+(?:\([^)]*\)[ \t]*)?([A-Za-z_]\w*)[ \t]*(\([^)]*\))", re.M),
|
||||
# posix shell: name() {
|
||||
re.compile(r"^[ \t]*([A-Za-z_]\w*)[ \t]*(\(\))[ \t]*\{", re.M),
|
||||
)
|
||||
|
||||
# Doc forms, tried in order. The Python pattern also matches a triple-quoted
|
||||
# string that isn't a docstring — accepted: a stray literal is still text about
|
||||
# what the code does far more often than it's misleading, and the cost is a
|
||||
# slightly worse query rather than a wrong answer.
|
||||
_CONCEPT_PY_DOC = re.compile(r'("""|\'\'\')(.*?)\1', re.S)
|
||||
_CONCEPT_JSDOC = re.compile(r"/\*\*(.*?)\*/", re.S)
|
||||
_CONCEPT_LEADING_COMMENT = re.compile(r"\A(?:[ \t]*(?://|#)[^\n]*\n?)+")
|
||||
# A shebang is a comment to the regex above but says nothing about what the code
|
||||
# DOES, and it would otherwise open the doc with "/usr/bin/env bash".
|
||||
_CONCEPT_SHEBANG = re.compile(r"\A#![^\n]*\n")
|
||||
_CONCEPT_COMMENT_MARKER = re.compile(r"^[ \t]*(?://+|#+!?)[ \t]?", re.M)
|
||||
_CONCEPT_JSDOC_STAR = re.compile(r"^[ \t]*\*+[ \t]?", re.M)
|
||||
|
||||
# Cap the doc so a long module docstring can't drown out the declaration, and cap
|
||||
# declarations so a 40-function Write doesn't turn into a wall of signatures.
|
||||
_CONCEPT_MAX_DOC_CHARS = 400
|
||||
_CONCEPT_MAX_DECLS = 4
|
||||
# Below this much substance the "concept" is too thin to be a better query than
|
||||
# the code itself (e.g. all we found was `f()`), so we keep the raw payload.
|
||||
_CONCEPT_MIN_CHARS = 16
|
||||
|
||||
# Margin gate: drop any hit more than this far below the top hit's score, so a
|
||||
# single strong match doesn't drag in a wall of barely-passing neighbours.
|
||||
@@ -195,6 +290,70 @@ def _record_kind(note) -> str:
|
||||
return note.note_type or "note"
|
||||
|
||||
|
||||
_REUSE_KINDS = ("snippet", "process")
|
||||
|
||||
|
||||
async def _reserve_slot_for_reuse(
|
||||
user_id: int,
|
||||
query: str,
|
||||
kept: list,
|
||||
cfg: dict,
|
||||
*,
|
||||
project_id: int | None,
|
||||
exclude_ids: set[int],
|
||||
) -> list:
|
||||
"""Guarantee the reuse-shaped kinds one slot, if one clears threshold (#2246).
|
||||
|
||||
Ranking by raw cosine is blind to what KIND of record answers what kind of
|
||||
ask, and the corpus makes that fatal rather than merely imperfect: Scribe's
|
||||
project records are *about software work*, so a task titled "surface snippets
|
||||
before the agent writes code" is a near-perfect lexical match for "write a
|
||||
function…" while being useless as an answer to it. Measured live, a prompt
|
||||
asking for a helper returned three records about BUILDING the retrieval
|
||||
system and zero snippets.
|
||||
|
||||
The bias is structural and gets WORSE as the project record grows — which is
|
||||
the direction Scribe is supposed to grow. Snippets are ~0.5% of the corpus
|
||||
here; no threshold tuning fixes a 200:1 ratio.
|
||||
|
||||
So the reserved hit is deliberately NOT held to the margin band. The band
|
||||
measures distance from the top overall score, and that top score is the very
|
||||
thing snippets lose to. It still has to clear the configured threshold, so a
|
||||
weak snippet cannot buy the slot — silence stays the default.
|
||||
"""
|
||||
if any(_record_kind(n) in _REUSE_KINDS for _s, n in kept):
|
||||
return kept # reuse already represented; nothing to do
|
||||
|
||||
top_k = cfg["top_k"]
|
||||
reuse = await semantic_search_notes(
|
||||
user_id, query,
|
||||
limit=1,
|
||||
threshold=cfg["threshold"],
|
||||
project_id=project_id,
|
||||
exclude_ids=exclude_ids | {int(n.id) for _s, n in kept},
|
||||
note_type=_REUSE_KINDS,
|
||||
scope="browse",
|
||||
)
|
||||
# Verify the kind rather than trusting the query that asked for it, and
|
||||
# dedup on top of exclude_ids. This slot exists FOR reuse kinds — a slot
|
||||
# silently spent on something else is worse than no slot, because the line
|
||||
# is indistinguishable from one that earned its place on score.
|
||||
kept_ids = {int(n.id) for _s, n in kept}
|
||||
fresh = [
|
||||
(s, n) for s, n in reuse
|
||||
if _record_kind(n) in _REUSE_KINDS and int(n.id) not in kept_ids
|
||||
][:1]
|
||||
if not fresh:
|
||||
return kept
|
||||
|
||||
# Take the LAST slot, never the first: the strongest overall hit is still the
|
||||
# best answer to the prompt, and displacing it would trade one blindness for
|
||||
# another.
|
||||
if len(kept) >= top_k:
|
||||
return kept[:top_k - 1] + fresh
|
||||
return (kept + fresh)[:top_k]
|
||||
|
||||
|
||||
async def build_autoinject_hint(
|
||||
user_id: int,
|
||||
query: str,
|
||||
@@ -246,6 +405,10 @@ async def build_autoinject_hint(
|
||||
# Margin gate: keep only hits close to the strongest one.
|
||||
top_score = hits[0][0]
|
||||
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
|
||||
kept = await _reserve_slot_for_reuse(
|
||||
user_id, q, kept, cfg, project_id=(project_id or None),
|
||||
exclude_ids=set(exclude_ids or []),
|
||||
)
|
||||
|
||||
# A collaborator's note can reach this menu via a shared project, and the
|
||||
# operator never asked for it — so say whose it is. Unattributed, it reads as
|
||||
@@ -297,25 +460,195 @@ async def build_autoinject_hint(
|
||||
# this exact file" is a stronger claim than "this resembles something".
|
||||
|
||||
|
||||
def _prior_art_line(item: dict, marker: str, owner: str | None) -> str:
|
||||
"""One menu line: `- #12 [here] "title"`, attributed when it isn't yours."""
|
||||
def _prior_art_line(item: dict, marker: str, owner: str | None, foreign_lang: str = "") -> str:
|
||||
"""One menu line: `- #12 [here] "title"`, attributed when it isn't yours.
|
||||
|
||||
A foreign language is folded into the marker (`[similar 0.72 · python]`)
|
||||
rather than appended after the title, so the reader sees it while still
|
||||
reading the score — the two together are the judgement being offered.
|
||||
"""
|
||||
title = (item.get("title") or "(untitled)").replace("\n", " ").strip()
|
||||
line = f"> - #{item['id']} [{marker}] \"{title}\""
|
||||
mark = f"{marker} · {foreign_lang}" if foreign_lang else marker
|
||||
line = f"> - #{item['id']} [{mark}] \"{title}\""
|
||||
if owner:
|
||||
line += f" — shared by {owner}, treat as a suggestion"
|
||||
return line
|
||||
|
||||
|
||||
# --- cross-language prior art (#2244) ----------------------------------------
|
||||
# Retrieval is concept-shaped now, and concepts are language-agnostic: a query
|
||||
# about a TypeScript union-find matches a PYTHON snippet at 0.72-0.73, comfortably
|
||||
# over the bar. That is a feature — the operator's framing is "borrow the shape of
|
||||
# the solution even when the code isn't directly reusable" — but only if the line
|
||||
# SAYS so. An unlabelled Python hit offered while writing TypeScript either gets
|
||||
# dismissed as irrelevant or, worse, pasted into a .ts file. Measured note: this
|
||||
# cross-language matching predates concept queries; it was always happening, just
|
||||
# never disclosed.
|
||||
#
|
||||
# Deliberately NOT gated behind a stricter threshold for foreign-language hits: a
|
||||
# higher bar would suppress exactly the shape-borrowing this is for. Label, don't
|
||||
# filter.
|
||||
_LANG_BY_EXT = {
|
||||
"py": "python", "pyi": "python",
|
||||
"ts": "typescript", "tsx": "typescript", "mts": "typescript", "cts": "typescript",
|
||||
"js": "javascript", "jsx": "javascript", "mjs": "javascript", "cjs": "javascript",
|
||||
"vue": "vue", "svelte": "svelte",
|
||||
"go": "go", "rs": "rust", "rb": "ruby", "php": "php",
|
||||
"java": "java", "kt": "kotlin", "kts": "kotlin", "scala": "scala",
|
||||
"c": "c", "h": "c", "cc": "cpp", "cpp": "cpp", "cxx": "cpp", "hpp": "cpp",
|
||||
"cs": "csharp", "swift": "swift", "m": "objectivec", "mm": "objectivec",
|
||||
"sh": "shell", "bash": "shell", "zsh": "shell", "fish": "shell",
|
||||
"sql": "sql", "css": "css", "scss": "scss", "less": "less",
|
||||
"html": "html", "htm": "html", "yml": "yaml", "yaml": "yaml",
|
||||
"toml": "toml", "ini": "ini", "dockerfile": "dockerfile",
|
||||
"ex": "elixir", "exs": "elixir", "erl": "erlang", "hs": "haskell",
|
||||
"lua": "lua", "pl": "perl", "r": "r", "dart": "dart", "zig": "zig",
|
||||
}
|
||||
|
||||
# `language` on a snippet is operator-typed free text, so fold the spellings that
|
||||
# mean the same thing before comparing. Anything unrecognised passes through
|
||||
# lowercased — an unknown-but-equal pair still compares equal, which is the only
|
||||
# thing this needs to get right.
|
||||
_LANG_ALIASES = {
|
||||
"py": "python", "python3": "python",
|
||||
"ts": "typescript", "tsx": "typescript",
|
||||
"js": "javascript", "jsx": "javascript", "node": "javascript",
|
||||
"sh": "shell", "bash": "shell", "zsh": "shell", "shell-script": "shell",
|
||||
"c++": "cpp", "cplusplus": "cpp", "c#": "csharp", "objective-c": "objectivec",
|
||||
"golang": "go", "rs": "rust", "rb": "ruby", "yml": "yaml",
|
||||
"postgres": "sql", "postgresql": "sql", "psql": "sql",
|
||||
"vuejs": "vue", "vue3": "vue",
|
||||
}
|
||||
|
||||
|
||||
def _canonical_language(name: str) -> str:
|
||||
"""Fold a free-text language name to a comparable token ("" if absent)."""
|
||||
token = (name or "").strip().lower()
|
||||
return _LANG_ALIASES.get(token, token)
|
||||
|
||||
|
||||
def _language_for_path(path: str) -> str:
|
||||
"""The language implied by a file path's extension ("" when unknown)."""
|
||||
tail = (path or "").rsplit("/", 1)[-1].lower()
|
||||
if tail.startswith("dockerfile"):
|
||||
return "dockerfile"
|
||||
if "." not in tail:
|
||||
return ""
|
||||
return _LANG_BY_EXT.get(tail.rsplit(".", 1)[-1], "")
|
||||
|
||||
|
||||
def _foreign_language(item: dict, target: str) -> str:
|
||||
"""The item's language when it DIFFERS from the target file's, else "".
|
||||
|
||||
Returns "" whenever either side is unknown: we can only claim a mismatch we
|
||||
can actually establish, and a wrong "· python" tag is worse than no tag.
|
||||
Same-language hits stay unlabelled so the common case keeps a clean line.
|
||||
"""
|
||||
if not target:
|
||||
return ""
|
||||
theirs = _canonical_language(item.get("language") or "")
|
||||
if not theirs or theirs == target:
|
||||
return ""
|
||||
return theirs
|
||||
|
||||
|
||||
def _concept_doc(code: str) -> str:
|
||||
"""The first doc-ish prose in `code`: docstring, else JSDoc, else leading comments."""
|
||||
m = _CONCEPT_PY_DOC.search(code)
|
||||
if m:
|
||||
return _collapse(m.group(2))
|
||||
|
||||
m = _CONCEPT_JSDOC.search(code)
|
||||
if m:
|
||||
return _collapse(_CONCEPT_JSDOC_STAR.sub("", m.group(1)))
|
||||
|
||||
# Only a comment block at the very TOP counts. A comment further down is
|
||||
# usually about one line of the implementation, not about the whole thing.
|
||||
m = _CONCEPT_LEADING_COMMENT.match(_CONCEPT_SHEBANG.sub("", code))
|
||||
if m:
|
||||
return _collapse(_CONCEPT_COMMENT_MARKER.sub("", m.group(0)))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _collapse(text: str) -> str:
|
||||
"""One line, single-spaced, length-capped — embedder input, not display text."""
|
||||
return " ".join((text or "").split())[:_CONCEPT_MAX_DOC_CHARS].strip()
|
||||
|
||||
|
||||
def concept_query(code: str) -> str:
|
||||
"""Rewrite a write payload as a CONCEPT query, or "" to keep the raw payload.
|
||||
|
||||
Returns something shaped like a snippet's own title — "name(params) — what it
|
||||
does" — because that is the form that measured best against the prose-forward
|
||||
snippet documents (#2242; see the table at _CONCEPT_DECL_PATTERNS).
|
||||
|
||||
Returns "" rather than raising or guessing whenever there's nothing worth
|
||||
sending: no declarations and no doc, or a result too thin to beat the code it
|
||||
would replace. The caller treats "" as "use the payload as-is", so every
|
||||
unhandled language degrades to exactly the previous behaviour.
|
||||
"""
|
||||
if not code or not code.strip():
|
||||
return ""
|
||||
|
||||
decls: list[str] = []
|
||||
for pattern in _CONCEPT_DECL_PATTERNS:
|
||||
for match in pattern.finditer(code):
|
||||
name, params = match.group(1), match.group(2) or ""
|
||||
label = f"{name}{params}".strip()
|
||||
if label and label not in decls:
|
||||
decls.append(label)
|
||||
if len(decls) >= _CONCEPT_MAX_DECLS:
|
||||
break
|
||||
if len(decls) >= _CONCEPT_MAX_DECLS:
|
||||
break
|
||||
|
||||
doc = _concept_doc(code)
|
||||
|
||||
# NO DOC, NO REWRITE. An identifier alone is not a concept, and it measured
|
||||
# WORSE than the code it would replace: `collapse_into_clusters(edges)` scored
|
||||
# 0.671 against #2222 where the full code body scored 0.743. Separation from
|
||||
# the noise floor is identical (0.113 either way), but the absolute value
|
||||
# drops below the 0.68 bar — so preferring a bare name would convert a
|
||||
# comfortable hit into a miss. Undocumented code keeps the raw payload.
|
||||
if not doc:
|
||||
return ""
|
||||
|
||||
head = ", ".join(decls)
|
||||
query = f"{head} — {doc}" if head else doc
|
||||
|
||||
# Guard against a doc so terse it says nothing ("# TODO", "/** x */").
|
||||
if len("".join(query.split())) < _CONCEPT_MIN_CHARS:
|
||||
return ""
|
||||
return query
|
||||
|
||||
|
||||
async def get_writepath_config(user_id: int) -> dict:
|
||||
"""Write-path trigger settings: its own `enabled`, auto-inject's gates."""
|
||||
"""Write-path trigger settings: its own `enabled` and `threshold`, auto-inject's top_k.
|
||||
|
||||
The threshold OVERRIDES the inherited auto-inject value — code embeddings
|
||||
have a much higher similarity floor than prose, so the two surfaces need
|
||||
different bars. See WRITEPATH_DEFAULT_THRESHOLD for the measurements (#2223).
|
||||
top_k is still shared: "how many titles at once" means the same thing on
|
||||
both surfaces, and nothing suggests they want different ceilings.
|
||||
"""
|
||||
cfg = await get_autoinject_config(user_id)
|
||||
enabled_raw = await get_setting(
|
||||
user_id, WRITEPATH_ENABLED_KEY,
|
||||
"true" if WRITEPATH_DEFAULT_ENABLED else "false",
|
||||
)
|
||||
|
||||
try:
|
||||
threshold = float(await get_setting(
|
||||
user_id, WRITEPATH_THRESHOLD_KEY, str(WRITEPATH_DEFAULT_THRESHOLD)))
|
||||
except (TypeError, ValueError):
|
||||
threshold = WRITEPATH_DEFAULT_THRESHOLD
|
||||
threshold = min(1.0, max(0.0, threshold))
|
||||
|
||||
return {
|
||||
**cfg,
|
||||
"enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"),
|
||||
"threshold": threshold,
|
||||
}
|
||||
|
||||
|
||||
@@ -332,11 +665,14 @@ async def build_write_path_hint(
|
||||
convention snippet locations are recorded in. `code` is what's about to be
|
||||
written, used only as the semantic query.
|
||||
|
||||
Same four anti-bloat gates as auto-inject (threshold, margin, session dedup
|
||||
via `exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH
|
||||
Carries auto-inject's anti-bloat gates (margin, session dedup via
|
||||
`exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH
|
||||
arms — so a file with a lot of recorded history can't turn one edit into a
|
||||
wall of text. Returns empty context when disabled, when there's no path, or
|
||||
when nothing is recorded — which is the common case, and the point.
|
||||
wall of text. Two gates are its OWN, because code is not prose: a stricter
|
||||
similarity threshold, and a minimum-substance floor on `code` below which the
|
||||
semantic arm doesn't run at all (#2223 — see WRITEPATH_DEFAULT_THRESHOLD and
|
||||
WRITEPATH_MIN_CODE_CHARS). Returns empty context when disabled, when there's
|
||||
no path, or when nothing is recorded — which is the common case, and the point.
|
||||
|
||||
Note the repo↔project mapping is deliberately one-way: the hook sends a git
|
||||
remote, which the ROUTE resolves to `project_id` through the repo bindings.
|
||||
@@ -394,6 +730,21 @@ async def build_write_path_hint(
|
||||
scored: list[tuple[str, dict]] = []
|
||||
remaining = top_k - len(placed)
|
||||
query = (code or "").strip()
|
||||
# Drop payloads too small to carry meaning before spending an embedding on
|
||||
# them — a one-line Edit is not a helper being rewritten, and its embedding
|
||||
# scores off the corpus floor rather than off any real resemblance (#2223).
|
||||
# Whitespace doesn't count: code is indentation-heavy, so raw length would
|
||||
# let a deeply-nested one-liner through on padding alone.
|
||||
if len("".join(query.split())) < WRITEPATH_MIN_CODE_CHARS:
|
||||
query = ""
|
||||
# ORDER MATTERS: the floor above judges the RAW payload, this rewrites it.
|
||||
# Snippet documents are prose-forward, so a concept query out-scores the code
|
||||
# itself by a wide margin (#2242). The rewritten query is allowed to be
|
||||
# short — "slugify(t) — turn text into a url slug" is a fine query at 38
|
||||
# chars, and it only exists because the raw payload already cleared the
|
||||
# floor. Applying the floor after this would throw away the best queries.
|
||||
if query:
|
||||
query = concept_query(query) or query
|
||||
if remaining > 0 and query:
|
||||
t0 = time.perf_counter()
|
||||
hits = await semantic_search_notes(
|
||||
@@ -402,7 +753,20 @@ async def build_write_path_hint(
|
||||
threshold=cfg["threshold"],
|
||||
project_id=scope_project,
|
||||
exclude_ids=seen,
|
||||
note_type="snippet",
|
||||
# Snippets AND recorded experience (#2246). This arm was
|
||||
# snippets-only, which is auto-inject's mistake inverted: an issue
|
||||
# saying "we tried this and it deadlocked", or a dev-log recording
|
||||
# how a problem was solved, is prior art for the code about to be
|
||||
# written — arguably better prior art than a resembling helper,
|
||||
# because it says what NOT to do.
|
||||
#
|
||||
# `task_kind="issue"` keeps the open to-do list out. A task titled
|
||||
# "add debouncing to the search box" resembles the code being
|
||||
# written and answers nothing; an ISSUE is corrective work with a
|
||||
# root cause in it, and a non-task note is durable knowledge. Both
|
||||
# earned their place; a todo did not.
|
||||
note_type=("snippet", "note"),
|
||||
task_kind="issue",
|
||||
# Same reasoning as auto-inject: nobody asked for this, so it takes
|
||||
# the browse scope and never surfaces a one-to-one direct share.
|
||||
scope="browse",
|
||||
@@ -410,7 +774,10 @@ async def build_write_path_hint(
|
||||
record_retrieval(
|
||||
user_id=user_id, source="write_path", query=query,
|
||||
threshold=cfg["threshold"], limit=remaining,
|
||||
project_id=scope_project, is_task=False, results=hits,
|
||||
# is_task is None, not False: this arm now returns issues too, and
|
||||
# recording it as a notes-only retrieval would misdescribe the
|
||||
# candidate set the threshold is being tuned against.
|
||||
project_id=scope_project, is_task=None, results=hits,
|
||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||
)
|
||||
if hits:
|
||||
@@ -418,9 +785,23 @@ async def build_write_path_hint(
|
||||
for score, note in hits:
|
||||
if score < top_score - _AUTOINJECT_BAND:
|
||||
continue
|
||||
# Name the kind unless it's a snippet — the menu's default and
|
||||
# the header's default reading. An issue or a dev-log offered
|
||||
# here is a different KIND of claim ("this was already tried")
|
||||
# and an unlabelled line would be read as "here is code to
|
||||
# reuse", which is the opposite of what it says.
|
||||
kind = _record_kind(note)
|
||||
scored.append((
|
||||
f"similar {score:.2f}",
|
||||
{"id": int(note.id), "title": note.title, "user_id": note.user_id},
|
||||
f"similar {score:.2f}" if kind == "snippet"
|
||||
else f"similar {score:.2f} · {kind}",
|
||||
{
|
||||
"id": int(note.id), "title": note.title, "user_id": note.user_id,
|
||||
# Carried so the line can disclose a cross-language hit
|
||||
# (#2244). The semantic arm is where these actually arise —
|
||||
# a snippet recorded at the path you're editing is almost
|
||||
# never in another language, but a concept match easily is.
|
||||
"language": (note.data or {}).get("language") if note.data else None,
|
||||
},
|
||||
))
|
||||
|
||||
menu = (placed + scored)[:top_k]
|
||||
@@ -432,19 +813,38 @@ async def build_write_path_hint(
|
||||
if it.get("user_id") is not None and int(it["user_id"]) != user_id
|
||||
})
|
||||
|
||||
lines = [
|
||||
f"> Prior art already recorded in Scribe for `{path}` — open one with "
|
||||
"`get_snippet(id)` and reuse it rather than writing a fresh one-off "
|
||||
"(titles only; shown once per session):",
|
||||
]
|
||||
note_ids: list[int] = []
|
||||
target_lang = _language_for_path(path)
|
||||
rendered: list[tuple[dict, str, str | None, str]] = []
|
||||
for marker, item in menu:
|
||||
note_ids.append(int(item["id"]))
|
||||
owner_id = item.get("user_id")
|
||||
owner = None
|
||||
if owner_id is not None and int(owner_id) != user_id:
|
||||
owner = owners.get(int(owner_id)) or "another user"
|
||||
lines.append(_prior_art_line(item, marker, owner))
|
||||
rendered.append((item, marker, owner, _foreign_language(item, target_lang)))
|
||||
|
||||
lines = [
|
||||
f"> Prior art already recorded in Scribe for `{path}` — open one with "
|
||||
"`get_snippet(id)` for a snippet, `get_task(id)` for an issue, "
|
||||
"`get_note(id)` otherwise. Reuse a snippet rather than writing a fresh "
|
||||
"one-off; read an issue before repeating what it records "
|
||||
"(titles only; shown once per session):",
|
||||
]
|
||||
# Say what a language tag MEANS, and only when one is actually on the menu.
|
||||
# Without this the reader has to infer why "· python" is attached to a hit on
|
||||
# a .ts file, and the two ways of guessing wrong are both bad: dismiss it as
|
||||
# irrelevant, or paste Python into TypeScript. Retrieval matches on concept,
|
||||
# so these are genuinely useful — as the SHAPE of a solution, not as code.
|
||||
if any(lang for _i, _m, _o, lang in rendered):
|
||||
lines.append(
|
||||
"> A tagged language means that snippet is in a DIFFERENT language "
|
||||
"than this file — it matched on what it does, so treat it as the "
|
||||
"shape of a solution to adapt, not code to copy."
|
||||
)
|
||||
|
||||
note_ids: list[int] = []
|
||||
for item, marker, owner, foreign_lang in rendered:
|
||||
note_ids.append(int(item["id"]))
|
||||
lines.append(_prior_art_line(item, marker, owner, foreign_lang))
|
||||
|
||||
# Split by arm, which is the whole reason this table exists. The place arm
|
||||
# carries no score and so has no home in retrieval_logs; before #2085 a
|
||||
@@ -531,6 +931,37 @@ async def build_session_context(
|
||||
f"Goal: {goal[:200]}" if goal else "",
|
||||
f"Open todo tasks: {open_count}",
|
||||
]
|
||||
|
||||
# A design system binds the same way a rule does, and until this
|
||||
# existed it had no push channel — the standards were reachable only
|
||||
# by an agent that already knew to look for them. Summary only: the
|
||||
# token VALUES are a tool call away, and pasting a hundred of them
|
||||
# into every session would crowd out the context they inform.
|
||||
if project.design_system_id:
|
||||
design = await design_systems_svc.design_context(
|
||||
user_id, project.design_system_id,
|
||||
)
|
||||
if design:
|
||||
inherits = (
|
||||
" (inherits " + " › ".join(design["inherits_from"]) + ")"
|
||||
if design["inherits_from"] else ""
|
||||
)
|
||||
groups = ", ".join(design["token_groups"])
|
||||
lines += [
|
||||
"",
|
||||
f"## Design system: {design['title']} "
|
||||
f"(id {design['id']}){inherits}",
|
||||
f"{design['token_count']} tokens"
|
||||
+ (f" across {groups}" if groups else "")
|
||||
+ ". This project's UI is built from these, not from "
|
||||
"literals — reach for a token before writing a colour, "
|
||||
"size, radius or duration by hand.",
|
||||
f"Values: `resolve_design_system({design['id']})` · "
|
||||
f"stylesheet: `get_design_system_stylesheet({design['id']})` "
|
||||
f"· the prose (aesthetic, voice, where the accent may "
|
||||
f"appear): `enter_project` returns it, or "
|
||||
f"`get_design_system({design['id']})`.",
|
||||
]
|
||||
elif unbound_repo:
|
||||
lines += [
|
||||
"",
|
||||
|
||||
@@ -127,6 +127,86 @@ async def delete_project(user_id: int, project_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def get_project_summaries(
|
||||
user_id: int, project_ids: list[int]
|
||||
) -> dict[int, dict]:
|
||||
"""Summaries for MANY projects — four queries and one session, total.
|
||||
|
||||
Replaces an `asyncio.gather` over the per-project version below, which was
|
||||
a nested fan-out: each project opened its own session for three queries,
|
||||
then called the milestone summary, which opened one more per milestone. For
|
||||
25 projects that asked for roughly 250 pooled connections at once against a
|
||||
pool of 15 (SQLAlchemy's default 5 + 10 overflow), so most of them sat out
|
||||
the 30-second checkout timeout and everything else on the instance queued
|
||||
behind them — including unrelated routes, which is why /api/settings
|
||||
returned 500 while /api/projects took 30.9s (#2384).
|
||||
|
||||
The comment it replaced said "one backend pass instead of N+1 frontend
|
||||
calls". It did remove the N+1 from the network — and recreated it against
|
||||
the connection pool, where it is worse: the browser had at least been
|
||||
serialising those calls.
|
||||
"""
|
||||
if not project_ids:
|
||||
return {}
|
||||
|
||||
async with async_session() as session:
|
||||
task_rows = await session.execute(
|
||||
select(Note.project_id, Note.status, func.count(Note.id))
|
||||
.where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id.in_(project_ids),
|
||||
Note.status.isnot(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Note.project_id, Note.status)
|
||||
)
|
||||
task_counts: dict[int, dict[str, int]] = {}
|
||||
for project_id, status, count in task_rows.fetchall():
|
||||
task_counts.setdefault(project_id, {})[status] = count
|
||||
|
||||
note_rows = await session.execute(
|
||||
select(Note.project_id, func.count(Note.id))
|
||||
.where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id.in_(project_ids),
|
||||
Note.status.is_(None),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(Note.project_id)
|
||||
)
|
||||
note_counts = {pid: count for pid, count in note_rows.fetchall()}
|
||||
|
||||
# Deliberately NOT filtered by deleted_at, matching the per-project
|
||||
# version: "last activity" includes trashing something.
|
||||
activity_rows = await session.execute(
|
||||
select(Note.project_id, func.max(Note.updated_at))
|
||||
.where(Note.user_id == user_id, Note.project_id.in_(project_ids))
|
||||
.group_by(Note.project_id)
|
||||
)
|
||||
last_activity = {pid: ts for pid, ts in activity_rows.fetchall()}
|
||||
|
||||
from scribe.services.milestones import get_project_milestone_summaries
|
||||
milestones = await get_project_milestone_summaries(user_id, project_ids)
|
||||
|
||||
return {
|
||||
pid: {
|
||||
# All three lifecycle keys present so consumers can sum without
|
||||
# `?? 0` guards — the frontend declares them required, and
|
||||
# `undefined + N` renders as NaN.
|
||||
"task_counts": {
|
||||
"todo": 0, "in_progress": 0, "done": 0,
|
||||
**task_counts.get(pid, {}),
|
||||
},
|
||||
"note_count": note_counts.get(pid, 0),
|
||||
"last_activity": (
|
||||
last_activity[pid].isoformat() if last_activity.get(pid) else None
|
||||
),
|
||||
"milestone_summary": milestones.get(pid, []),
|
||||
}
|
||||
for pid in project_ids
|
||||
}
|
||||
|
||||
|
||||
async def get_project_summary(user_id: int, project_id: int) -> dict:
|
||||
"""Return task counts by status, note count, and last activity."""
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -28,7 +28,6 @@ came from.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
@@ -52,25 +51,6 @@ SNIPPET_TAG = "snippet"
|
||||
UNSET: object = object()
|
||||
|
||||
|
||||
def _embed_snippet(note) -> None:
|
||||
"""Fire-and-forget embedding refresh for a snippet.
|
||||
|
||||
A snippet's whole value is *immediate* recall — it must join the semantic /
|
||||
auto-inject pool the moment it's recorded, not wait for the startup backfill.
|
||||
Unlike a plain note (embedded at the REST-route boundary only, so its MCP
|
||||
create path defers to restart-backfill), a snippet is recorded primarily via
|
||||
MCP, so we embed here in the service — covering BOTH the MCP tool and the
|
||||
REST route by construction. Mirrors the route pattern: fire-and-forget,
|
||||
text = title + body. Import lazily so the pure serialize/parse helpers can be
|
||||
imported without pulling in the embedding model.
|
||||
"""
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if not text:
|
||||
return
|
||||
from scribe.services.embeddings import upsert_note_embedding
|
||||
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
||||
|
||||
|
||||
# --- serialize: structured fields -> note (title/body/tags) ------------------
|
||||
|
||||
def compose_title(name: str, when_to_use: str = "") -> str:
|
||||
@@ -640,7 +620,6 @@ async def create_snippet(
|
||||
language=language, code=code, locations=locations,
|
||||
),
|
||||
)
|
||||
_embed_snippet(note)
|
||||
return note
|
||||
|
||||
|
||||
@@ -801,9 +780,6 @@ async def update_snippet(
|
||||
# As the OWNER: update_note is owner-scoped, so a shared editor's own id
|
||||
# would find nothing. The write was authorised by can_write_note above.
|
||||
updated = await notes_svc.update_note(note.user_id, snippet_id, **fields)
|
||||
if updated is not None:
|
||||
# Title/body changed → refresh the embedding so recall reflects the edit.
|
||||
_embed_snippet(updated)
|
||||
return updated
|
||||
|
||||
|
||||
@@ -1025,7 +1001,6 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
|
||||
if batch is not None:
|
||||
merged_ids.append(s.id)
|
||||
|
||||
_embed_snippet(updated)
|
||||
return updated, merged_ids
|
||||
|
||||
|
||||
@@ -1134,5 +1109,4 @@ async def unmerge_snippet(user_id: int, survivor_id: int, source_id: int):
|
||||
)
|
||||
if updated is None:
|
||||
return None
|
||||
_embed_snippet(updated)
|
||||
return updated, restored
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The CI-side token check (#2277).
|
||||
|
||||
The checker is stdlib-only and lives in scripts/ so CI can run it without an
|
||||
install, so these tests import it by path rather than as a package.
|
||||
|
||||
Two properties matter more than the parsing: it must not fire on a COMMENT that
|
||||
discusses a rule, and it must not fire on a longer literal that merely contains a
|
||||
shorter one. Both would make the report untrustworthy, and an untrustworthy
|
||||
report is worse than none — people stop reading it and the check stops working
|
||||
while still passing.
|
||||
"""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
_PATH = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "check_design_tokens.py"
|
||||
_spec = importlib.util.spec_from_file_location("check_design_tokens", _PATH)
|
||||
check = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(check)
|
||||
|
||||
|
||||
SHEET = """
|
||||
:root {
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* page bg */
|
||||
--fs-text-primary: #E8E4D8;
|
||||
}
|
||||
|
||||
/* SUPERSEDES — write the token, not the literal.
|
||||
* #fff -> --fs-text-primary
|
||||
* #ffffff -> --fs-text-primary
|
||||
* bold -> --fs-weight-medium
|
||||
*/
|
||||
"""
|
||||
|
||||
|
||||
# --- reading the sheet ------------------------------------------------------
|
||||
|
||||
def test_declarations_after_a_comment_are_not_lost():
|
||||
"""Anchored on the colon alone. Anchoring on `{` or `;` silently drops every
|
||||
declaration that follows a comment — a mistake made once already in this
|
||||
codebase, which lost three tokens without erroring."""
|
||||
assert check.declared_tokens(SHEET) == {"--fs-surface-page", "--fs-text-primary"}
|
||||
|
||||
|
||||
def test_the_supersedes_block_is_read_from_the_sheet_not_hardcoded():
|
||||
"""Rule #115: the checker must know nothing about any install's palette.
|
||||
Everything it enforces comes out of the stylesheet it is pointed at."""
|
||||
assert check.superseded_literals(SHEET) == {
|
||||
"#fff": "--fs-text-primary",
|
||||
"#ffffff": "--fs-text-primary",
|
||||
"bold": "--fs-weight-medium",
|
||||
}
|
||||
|
||||
|
||||
def test_a_sheet_with_no_supersedes_block_yields_nothing():
|
||||
assert check.superseded_literals(":root { --a: 1px; }") == {}
|
||||
|
||||
|
||||
# --- the literal matcher ----------------------------------------------------
|
||||
|
||||
def test_a_short_hex_does_not_match_inside_a_longer_one():
|
||||
"""`#fff` firing on `#ffffff` would send someone to change correct code."""
|
||||
assert check._literal_pattern("#fff").search("color: #ffffff;") is None
|
||||
assert check._literal_pattern("#fff").search("color: #fff;") is not None
|
||||
|
||||
|
||||
def test_the_match_is_case_insensitive():
|
||||
assert check._literal_pattern("#ffffff").search("color: #FFFFFF;") is not None
|
||||
|
||||
|
||||
def test_a_keyword_does_not_match_inside_a_longer_word():
|
||||
"""`bold` must not fire on `font-weight: bolder` or a class named
|
||||
`.bold-label` — the boundary is what keeps the report readable."""
|
||||
assert check._literal_pattern("bold").search("font-weight: bolder;") is None
|
||||
assert check._literal_pattern("bold").search(".bold-label { }") is None
|
||||
assert check._literal_pattern("bold").search("font-weight: bold;") is not None
|
||||
|
||||
|
||||
# --- reading a component ----------------------------------------------------
|
||||
|
||||
def test_only_the_style_block_of_an_sfc_is_read(tmp_path):
|
||||
"""A hex in a template attribute or a script string is not a stylesheet
|
||||
violation, and reporting it would bury the ones that are."""
|
||||
sfc = tmp_path / "X.vue"
|
||||
sfc.write_text(
|
||||
'<template><div data-x="#fff">white</div></template>\n'
|
||||
'<script setup>const c = "#fff";</script>\n'
|
||||
'<style scoped>.a { color: var(--fs-text-primary); }</style>\n'
|
||||
)
|
||||
css = check.style_source(sfc)
|
||||
assert "--fs-text-primary" in css
|
||||
assert "#fff" not in css
|
||||
|
||||
|
||||
def test_a_comment_explaining_a_rule_is_not_a_violation(tmp_path):
|
||||
"""LOAD-BEARING, and it fired on the first real run. A comment documenting
|
||||
why a literal is avoided necessarily contains that literal — this codebase's
|
||||
own stylesheet says so about `#fff`. A checker that flags the documentation
|
||||
of a rule teaches people to stop documenting rules."""
|
||||
sfc = tmp_path / "Y.vue"
|
||||
sfc.write_text(
|
||||
"<style scoped>\n"
|
||||
"/* Deliberately NOT #fff — pure white is never text. */\n"
|
||||
".a { color: var(--fs-text-primary); }\n"
|
||||
"</style>\n"
|
||||
)
|
||||
css = check.style_source(sfc)
|
||||
assert check._literal_pattern("#fff").search(css) is None
|
||||
|
||||
|
||||
def test_a_plain_css_file_is_read_whole(tmp_path):
|
||||
css_file = tmp_path / "shared.css"
|
||||
css_file.write_text(".a { color: #fff; }")
|
||||
assert "#fff" in check.style_source(css_file)
|
||||
|
||||
|
||||
# --- the gate ---------------------------------------------------------------
|
||||
|
||||
def _run(tmp_path, sheet: str, component: str, monkeypatch, capsys):
|
||||
(tmp_path / "assets").mkdir(parents=True, exist_ok=True)
|
||||
sheet_path = tmp_path / "assets" / "theme.css"
|
||||
sheet_path.write_text(sheet)
|
||||
(tmp_path / "C.vue").write_text(f"<style>{component}</style>")
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
["check", "--sheet", str(sheet_path), "--root", str(tmp_path)],
|
||||
)
|
||||
code = check.main()
|
||||
return code, capsys.readouterr().out
|
||||
|
||||
|
||||
def test_an_unresolvable_reference_fails_the_build(tmp_path, monkeypatch, capsys):
|
||||
"""The one hard gate. It is safe to gate on because the count is zero today —
|
||||
a ratchet holding a line already reached, not a backlog that keeps CI red."""
|
||||
code, out = _run(tmp_path, SHEET, ".a { color: var(--nope); }", monkeypatch, capsys)
|
||||
assert code == 1
|
||||
assert "--nope" in out
|
||||
|
||||
|
||||
def test_a_resolvable_reference_passes(tmp_path, monkeypatch, capsys):
|
||||
code, out = _run(
|
||||
tmp_path, SHEET, ".a { color: var(--fs-text-primary); }", monkeypatch, capsys
|
||||
)
|
||||
assert code == 0
|
||||
assert "every var() reference resolves" in out
|
||||
|
||||
|
||||
def test_a_locally_declared_property_is_not_unresolved(tmp_path, monkeypatch, capsys):
|
||||
"""A component may legitimately define its own custom property for local use —
|
||||
a keyframe variable, a per-instance override. Only a reference to a name that
|
||||
exists NOWHERE is broken."""
|
||||
code, _ = _run(
|
||||
tmp_path, SHEET, ".a { --local: 4px; padding: var(--local); }", monkeypatch, capsys
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
|
||||
def test_a_superseded_literal_reports_but_does_not_fail(tmp_path, monkeypatch, capsys):
|
||||
"""Hundreds exist. Gating would make a permanently-red job, which is a check
|
||||
nobody reads — worse than no check at all."""
|
||||
code, out = _run(tmp_path, SHEET, ".a { color: #fff; }", monkeypatch, capsys)
|
||||
assert code == 0
|
||||
assert "#fff -> --fs-text-primary" in out
|
||||
|
||||
|
||||
def test_a_missing_stylesheet_is_an_error_not_a_pass(tmp_path, monkeypatch, capsys):
|
||||
"""If the sheet moves, the check must fail loudly rather than silently
|
||||
passing with zero tokens to compare against — which would look identical to
|
||||
a clean run."""
|
||||
monkeypatch.setattr(
|
||||
"sys.argv", ["check", "--sheet", str(tmp_path / "gone.css"), "--root", str(tmp_path)]
|
||||
)
|
||||
assert check.main() == 2
|
||||
@@ -0,0 +1,420 @@
|
||||
"""The parent chain: walking it, and refusing to close it (milestone #254 step 1).
|
||||
|
||||
Pure functions over a `{id: parent_id}` literal, so a whole hierarchy is one line
|
||||
of setup and no database is involved. That is the reason the cascade lives in its
|
||||
own import-free module — see services/design_cascade.py.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
|
||||
|
||||
|
||||
# --- ancestry ---------------------------------------------------------------
|
||||
|
||||
def test_ancestry_returns_the_chain_deepest_first():
|
||||
"""Deepest first because that is the order resolution consumes it in: the
|
||||
system being resolved wins over its parent, which wins over the root."""
|
||||
parents = {1: None, 2: 1, 3: 2}
|
||||
assert ancestry(3, parents) == [3, 2, 1]
|
||||
|
||||
|
||||
def test_a_root_is_its_own_whole_chain():
|
||||
"""A family system has no parent, and that is an ordinary state — not an
|
||||
incomplete one. It must resolve to exactly itself."""
|
||||
assert ancestry(1, {1: None}) == [1]
|
||||
|
||||
|
||||
def test_a_missing_parent_truncates_rather_than_raising():
|
||||
"""A parent that was soft-deleted (or filtered out of the caller's scope) is
|
||||
a shorter chain, not a failed request. The alternative — raising — would make
|
||||
one deleted system break every descendant's rendering."""
|
||||
assert ancestry(3, {3: 2}) == [3, 2]
|
||||
|
||||
|
||||
def test_a_system_absent_from_the_map_still_yields_itself():
|
||||
assert ancestry(9, {}) == [9]
|
||||
|
||||
|
||||
def test_ancestry_terminates_on_a_cycle_instead_of_hanging():
|
||||
"""LOAD-BEARING, and the reason a visited-set exists even though writes are
|
||||
guarded. A loop introduced by a direct DB edit or a future bug must degrade
|
||||
to a truncated chain: truncation shows up in the result, a hang shows up as
|
||||
an outage. Every id appears exactly once."""
|
||||
parents = {1: 3, 2: 1, 3: 2}
|
||||
chain = ancestry(1, parents)
|
||||
assert chain == [1, 3, 2]
|
||||
assert len(chain) == len(set(chain))
|
||||
|
||||
|
||||
def test_ancestry_terminates_on_a_self_parent():
|
||||
assert ancestry(1, {1: 1}) == [1]
|
||||
|
||||
|
||||
# --- would_cycle ------------------------------------------------------------
|
||||
|
||||
def test_clearing_the_parent_never_cycles():
|
||||
"""None means "make this a root", which is always safe."""
|
||||
assert would_cycle(2, None, {1: None, 2: 1}) is False
|
||||
|
||||
|
||||
def test_a_system_cannot_be_its_own_parent():
|
||||
assert would_cycle(1, 1, {1: None}) is True
|
||||
|
||||
|
||||
def test_an_ordinary_reparent_is_allowed():
|
||||
"""family <- app is the shape the whole model exists for; it must not trip
|
||||
the guard."""
|
||||
assert would_cycle(2, 1, {1: None, 2: None}) is False
|
||||
|
||||
|
||||
def test_a_direct_swap_is_refused():
|
||||
"""A -> B, then B -> A. The two-system case, and the one a UI produces first
|
||||
because both systems are on screen together."""
|
||||
parents = {1: None, 2: 1}
|
||||
assert would_cycle(1, 2, parents) is True
|
||||
|
||||
|
||||
def test_an_indirect_loop_is_refused():
|
||||
"""Three deep: root <- mid <- leaf, then root's parent set to leaf. Catching
|
||||
this is what makes the check a chain walk rather than a parent comparison."""
|
||||
parents = {1: None, 2: 1, 3: 2}
|
||||
assert would_cycle(1, 3, parents) is True
|
||||
|
||||
|
||||
def test_reparenting_onto_a_sibling_subtree_is_allowed():
|
||||
"""Two branches off one root. Moving one under the other is legitimate — the
|
||||
guard must refuse loops, not reorganisation."""
|
||||
parents = {1: None, 2: 1, 3: 1}
|
||||
assert would_cycle(3, 2, parents) is False
|
||||
|
||||
|
||||
def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
|
||||
"""If a cycle somehow already exists, the guard still has to answer rather
|
||||
than spin — the write path is exactly where such a hierarchy gets repaired."""
|
||||
parents = {1: 2, 2: 1, 3: None}
|
||||
assert would_cycle(3, 1, parents) is False
|
||||
assert would_cycle(1, 2, parents) is True
|
||||
|
||||
|
||||
# --- resolution -------------------------------------------------------------
|
||||
#
|
||||
# The cascade proper (milestone #254 step 2). Hierarchies are stated as literals
|
||||
# because resolve_tokens is pure and duck-typed — which is the whole reason it
|
||||
# lives here rather than inside the service.
|
||||
|
||||
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
|
||||
supersedes=None):
|
||||
return SimpleNamespace(
|
||||
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
||||
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
|
||||
)
|
||||
|
||||
|
||||
# A family (1) and an app inheriting from it (2) — the shape the model exists for.
|
||||
FAMILY, APP = 1, 2
|
||||
PARENTS = {FAMILY: None, APP: FAMILY}
|
||||
|
||||
|
||||
def _by_name(tokens):
|
||||
return {t.name: t for t in tokens}
|
||||
|
||||
|
||||
def test_a_system_with_no_tokens_of_its_own_inherits_the_whole_family_set():
|
||||
"""The correct answer for an app that has not departed from the family yet —
|
||||
and the state every app system starts in."""
|
||||
resolved = resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{FAMILY: [_token("--fs-obsidian", {"base": "#14171a"})], APP: []},
|
||||
)
|
||||
assert [t.name for t in resolved] == ["--fs-obsidian"]
|
||||
assert resolved[0].value_by_mode == {"base": "#14171a"}
|
||||
assert resolved[0].origin_by_mode == {"base": FAMILY}
|
||||
|
||||
|
||||
def test_the_deepest_system_wins_and_says_what_it_overrode():
|
||||
"""Provenance is the point of the whole model: the effective set is just a
|
||||
list without it, and "where does this app depart from the family?" becomes a
|
||||
diff someone has to compute."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
|
||||
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
|
||||
},
|
||||
))
|
||||
accent = resolved["--fs-accent"]
|
||||
assert accent.value_by_mode == {"base": "#5b4a8a"}
|
||||
assert accent.origin_by_mode == {"base": APP}
|
||||
# The shadowed ancestor is still there, behind the winner.
|
||||
assert [c.system_id for c in accent.contributions["base"]] == [APP, FAMILY]
|
||||
assert accent.contributions["base"][1].value == "#6b2118"
|
||||
|
||||
|
||||
def test_overriding_one_mode_leaves_the_others_inherited():
|
||||
"""LOAD-BEARING, and the argument that decided the storage shape. An accent
|
||||
deepened for light backgrounds while dark is left alone must own `base` and
|
||||
still inherit `dark` — which is why merging is per (name, MODE) and why the
|
||||
value column is a map rather than a pair of columns."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-accent", {"base": "#34a877", "dark": "#34a877"})],
|
||||
APP: [_token("--fs-accent", {"base": "#15803d"})],
|
||||
},
|
||||
))
|
||||
accent = resolved["--fs-accent"]
|
||||
assert accent.value_by_mode == {"base": "#15803d", "dark": "#34a877"}
|
||||
assert accent.origin_by_mode == {"base": APP, "dark": FAMILY}
|
||||
|
||||
|
||||
def test_a_token_only_the_app_defines_is_not_an_override():
|
||||
"""Introducing a token and overriding one are different acts, and a UI that
|
||||
labelled both "overridden here" would misdescribe the first."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{FAMILY: [], APP: [_token("--fs-editor-caret", {"base": "#5b4a8a"})]},
|
||||
))
|
||||
caret = resolved["--fs-editor-caret"]
|
||||
assert caret.origin_by_mode == {"base": APP}
|
||||
assert caret.is_overridden_in(APP) is False
|
||||
|
||||
|
||||
def test_is_overridden_in_is_true_only_for_the_system_that_shadowed():
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
|
||||
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
|
||||
},
|
||||
))
|
||||
accent = resolved["--fs-accent"]
|
||||
assert accent.is_overridden_in(APP) is True
|
||||
assert accent.is_overridden_in(FAMILY) is False
|
||||
|
||||
|
||||
def test_three_levels_stack_nearest_first():
|
||||
"""A chain deeper than family->app, to prove the walk isn't a single hop."""
|
||||
parents = {1: None, 2: 1, 3: 2}
|
||||
resolved = _by_name(resolve_tokens(
|
||||
3, parents,
|
||||
{
|
||||
1: [_token("--fs-bg", {"base": "a"})],
|
||||
2: [_token("--fs-bg", {"base": "b"})],
|
||||
3: [_token("--fs-bg", {"base": "c"})],
|
||||
},
|
||||
))
|
||||
bg = resolved["--fs-bg"]
|
||||
assert bg.value_by_mode == {"base": "c"}
|
||||
assert [c.value for c in bg.contributions["base"]] == ["c", "b", "a"]
|
||||
|
||||
|
||||
def test_resolving_the_family_itself_ignores_its_children():
|
||||
"""Inheritance runs one way. A family system resolved on its own must not
|
||||
pick up an app's overrides — otherwise every app would restyle the house."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
|
||||
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
|
||||
},
|
||||
))
|
||||
assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"}
|
||||
|
||||
|
||||
def test_value_for_falls_back_to_the_base_mode():
|
||||
"""A token that isn't mode-dependent carries only `base`, and asking it for
|
||||
"dark" must yield that rather than nothing — the read rule the storage shape
|
||||
implies."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
|
||||
))
|
||||
radius = resolved["--fs-radius-md"]
|
||||
assert radius.value_for("dark") == "8px"
|
||||
assert radius.value_for("base") == "8px"
|
||||
|
||||
|
||||
def test_value_for_prefers_an_explicit_mode_over_the_fallback():
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS,
|
||||
{FAMILY: [_token("--fs-bg", {"base": "#f7f5ef", "dark": "#14171a"})]},
|
||||
))
|
||||
assert resolved["--fs-bg"].value_for("dark") == "#14171a"
|
||||
|
||||
|
||||
def test_metadata_is_inherited_when_the_override_leaves_it_blank():
|
||||
"""A child overriding a colour routinely says nothing about what the token is
|
||||
FOR. Inheriting the family's description beats blanking it — the override was
|
||||
about the value, not the meaning."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token(
|
||||
"--fs-obsidian", {"base": "#14171a"},
|
||||
group_name="surface", purpose="page bg, deepest surface",
|
||||
)],
|
||||
APP: [_token("--fs-obsidian", {"base": "#101317"})],
|
||||
},
|
||||
))
|
||||
obsidian = resolved["--fs-obsidian"]
|
||||
assert obsidian.group_name == "surface"
|
||||
assert obsidian.purpose == "page bg, deepest surface"
|
||||
assert obsidian.value_by_mode == {"base": "#101317"} # the value still won
|
||||
|
||||
|
||||
def test_an_override_that_states_metadata_wins_it_too():
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-x", {"base": "a"}, purpose="family says")],
|
||||
APP: [_token("--fs-x", {"base": "b"}, purpose="app says")],
|
||||
},
|
||||
))
|
||||
assert resolved["--fs-x"].purpose == "app says"
|
||||
|
||||
|
||||
def test_an_override_at_default_order_keeps_the_familys_position():
|
||||
"""order_index 0 is the column DEFAULT, so it reads as unstated. Treating it
|
||||
as "first" would let a colour-only override drag its token to the top of the
|
||||
group — a visible reshuffle nobody asked for."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-x", {"base": "a"}, order_index=7)],
|
||||
APP: [_token("--fs-x", {"base": "b"})],
|
||||
},
|
||||
))
|
||||
assert resolved["--fs-x"].order_index == 7
|
||||
|
||||
|
||||
def test_the_effective_set_is_ordered_by_group_then_position_with_ungrouped_last():
|
||||
resolved = resolve_tokens(
|
||||
FAMILY, PARENTS,
|
||||
{FAMILY: [
|
||||
_token("--fs-z", {"base": "1"}), # ungrouped
|
||||
_token("--fs-b", {"base": "2"}, group_name="text", order_index=1),
|
||||
_token("--fs-a", {"base": "3"}, group_name="surface", order_index=2),
|
||||
_token("--fs-c", {"base": "4"}, group_name="surface", order_index=1),
|
||||
]},
|
||||
)
|
||||
assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"]
|
||||
|
||||
|
||||
def test_resolution_terminates_on_a_corrupt_hierarchy():
|
||||
"""Resolution inherits ancestry's visited-set. A loop reaching this function
|
||||
must produce a truncated set, not a hung request — the defensive half of the
|
||||
cycle guard, exercised end to end."""
|
||||
parents = {1: 2, 2: 1}
|
||||
resolved = _by_name(resolve_tokens(
|
||||
1, parents,
|
||||
{1: [_token("--fs-a", {"base": "one"})], 2: [_token("--fs-b", {"base": "two"})]},
|
||||
))
|
||||
assert set(resolved) == {"--fs-a", "--fs-b"}
|
||||
# Each system contributes exactly once, not endlessly.
|
||||
assert len(resolved["--fs-a"].contributions["base"]) == 1
|
||||
|
||||
|
||||
# --- supersedes -------------------------------------------------------------
|
||||
#
|
||||
# The declaration that replaces a prohibition. A design system stores what things
|
||||
# ARE, so "pure white is never text" has no row — but "write this token instead
|
||||
# of #fff" does, and it is the same fact stated forwards.
|
||||
|
||||
def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
|
||||
"""LOAD-BEARING. A child overriding a colour says nothing about which
|
||||
literals it replaces, and blanking the family's declaration there would
|
||||
silently disarm the check for every app that customises the token."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
|
||||
APP: [_token("--fs-text", {"base": "#f0ece0"})],
|
||||
},
|
||||
))
|
||||
text = resolved["--fs-text"]
|
||||
assert text.supersedes == ("#fff", "#ffffff")
|
||||
assert text.value_by_mode == {"base": "#f0ece0"} # the value still overrode
|
||||
|
||||
|
||||
def test_an_override_that_states_its_own_supersedes_replaces_the_list():
|
||||
"""Whole-list replacement, not a merge — an app that means "only #fff" must
|
||||
be able to say so without inheriting entries it deliberately dropped."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])],
|
||||
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])],
|
||||
},
|
||||
))
|
||||
assert resolved["--fs-text"].supersedes == ("#fff",)
|
||||
|
||||
|
||||
def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
|
||||
"""Most tokens replace nothing. That has to be an empty sequence rather than
|
||||
None, so no caller has to test for two kinds of nothing."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
|
||||
))
|
||||
assert resolved["--fs-radius-md"].supersedes == ()
|
||||
|
||||
|
||||
def test_supersedes_survives_serialisation_as_a_list():
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS,
|
||||
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
||||
))
|
||||
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
|
||||
|
||||
|
||||
def test_the_superseded_literal_need_not_match_the_tokens_own_value():
|
||||
"""The whole reason this is DECLARED rather than derived. `#fff` and
|
||||
Parchment are different colours, so a value-matching rule could never have
|
||||
connected them — which is why the prohibition looked unrepresentable until
|
||||
it was turned around."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS,
|
||||
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
||||
))
|
||||
text = resolved["--fs-text"]
|
||||
assert text.value_by_mode["base"] not in text.supersedes
|
||||
|
||||
|
||||
def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
|
||||
"""Duck-typed input: a caller passing rows from before the column existed
|
||||
must not crash the cascade."""
|
||||
legacy = SimpleNamespace(
|
||||
name="--fs-x", value_by_mode={"base": "a"},
|
||||
group_name=None, purpose=None, order_index=0,
|
||||
)
|
||||
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
|
||||
assert resolved["--fs-x"].supersedes == ()
|
||||
|
||||
|
||||
# --- rationale --------------------------------------------------------------
|
||||
|
||||
def test_rationale_cascades_like_purpose_and_is_a_different_question():
|
||||
"""`purpose` is what the token is FOR; `rationale` is why it is this value.
|
||||
Rules carry the second routinely ("Success = Moss, aligned by design") and a
|
||||
token row had nowhere to put it until now."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [SimpleNamespace(
|
||||
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
|
||||
group_name="semantic", purpose="success states",
|
||||
rationale="equals Moss, aligned by design",
|
||||
order_index=0, supersedes=[],
|
||||
)],
|
||||
APP: [_token("--fs-success", {"base": "#3f5236"})],
|
||||
},
|
||||
))
|
||||
token = resolved["--fs-success"]
|
||||
assert token.rationale == "equals Moss, aligned by design"
|
||||
assert token.purpose == "success states"
|
||||
assert token.value_by_mode == {"base": "#3f5236"} # the value still overrode
|
||||
|
||||
|
||||
def test_a_token_without_a_rationale_resolves_to_none():
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS, {FAMILY: [_token("--fs-x", {"base": "1px"})]},
|
||||
))
|
||||
assert resolved["--fs-x"].rationale is None
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Rulebook prose → checkable claims (milestone #251 step 2).
|
||||
|
||||
This is the piece of the design explorer that most needed to be testable, which
|
||||
is why it lives in Python at all: the frontend has no test runner, so the fiddly
|
||||
extraction happens server-side and the browser only does set arithmetic over it.
|
||||
|
||||
Rule text below is representative of a real design rulebook rather than copied
|
||||
from this operator's — rule #115: the product must work for an install that has
|
||||
none of their data, and a test that only passes against their exact wording would
|
||||
be testing the instance, not the parser.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scribe.services.design_rulebook_import import (
|
||||
expand_token_shorthand,
|
||||
extract_expectations,
|
||||
normalize_hex,
|
||||
)
|
||||
|
||||
|
||||
def _rule(rule_id, title, statement, how_to_apply=None):
|
||||
return SimpleNamespace(
|
||||
id=rule_id, title=title, statement=statement, how_to_apply=how_to_apply
|
||||
)
|
||||
|
||||
|
||||
# --- hex normalisation -------------------------------------------------------
|
||||
|
||||
def test_normalize_hex_makes_shorthand_and_case_comparable():
|
||||
"""LOAD-BEARING. The rulebook writes `#FFFFFF` and components write `#fff`.
|
||||
If those don't compare equal, the single largest drift finding — 67 hardcoded
|
||||
white text colours (#2275) — reads as zero findings."""
|
||||
assert normalize_hex("#fff") == normalize_hex("#FFFFFF") == "#ffffff"
|
||||
assert normalize_hex("#E8E4D8") == "#e8e4d8"
|
||||
assert normalize_hex("#14171a") == "#14171a"
|
||||
|
||||
|
||||
def test_normalize_hex_keeps_alpha_rather_than_inventing_equality():
|
||||
"""`#fff` and `#ffff` are different colours. Dropping the alpha to make them
|
||||
match would manufacture agreement that isn't there."""
|
||||
assert normalize_hex("#ffff") == "#ffffffff"
|
||||
assert normalize_hex("#fff") != normalize_hex("#ffff")
|
||||
|
||||
|
||||
def test_normalize_hex_rejects_non_colours():
|
||||
for junk in ("", " ", "not-a-colour", "#", "#gg", "#12345"):
|
||||
assert normalize_hex(junk) is None
|
||||
|
||||
|
||||
# --- the slash shorthand -----------------------------------------------------
|
||||
|
||||
def test_expand_token_shorthand_handles_every_form_a_rulebook_uses():
|
||||
"""One rule expands all three shapes: take everything up to and including the
|
||||
LAST hyphen of the first segment as the prefix."""
|
||||
assert expand_token_shorthand("--fs-radius-sm/md/lg/xl") == [
|
||||
"--fs-radius-sm", "--fs-radius-md", "--fs-radius-lg", "--fs-radius-xl",
|
||||
]
|
||||
# Prefix is just `--fs-` here, and the same rule finds it.
|
||||
assert expand_token_shorthand("--fs-obsidian/iron/slate/pewter") == [
|
||||
"--fs-obsidian", "--fs-iron", "--fs-slate", "--fs-pewter",
|
||||
]
|
||||
assert expand_token_shorthand("--fs-dur-fast/base/slow") == [
|
||||
"--fs-dur-fast", "--fs-dur-base", "--fs-dur-slow",
|
||||
]
|
||||
|
||||
|
||||
def test_expand_token_shorthand_passes_plain_names_through():
|
||||
assert expand_token_shorthand("--fs-ease") == ["--fs-ease"]
|
||||
|
||||
|
||||
# --- extraction --------------------------------------------------------------
|
||||
|
||||
def test_negation_is_scoped_to_the_sentence_not_the_rule():
|
||||
"""THE trick that makes prohibition detection usable.
|
||||
|
||||
A single rule routinely states what the palette REQUIRES and what it FORBIDS
|
||||
in consecutive sentences. Detecting negation across the whole statement would
|
||||
mark the required colours as forbidden too — inverting the finding rather
|
||||
than missing it, which is worse.
|
||||
"""
|
||||
rule = _rule(
|
||||
52, "Text palette",
|
||||
"Text tokens: Parchment #E8E4D8 (primary), Vellum #C2BFB4 (secondary), "
|
||||
"Ash #9C9A92 (tertiary). Pure white #FFFFFF is NEVER used as text color.",
|
||||
)
|
||||
found = extract_expectations([rule])
|
||||
required = {e.value for e in found if e.kind == "color"}
|
||||
forbidden = {e.value for e in found if e.kind == "prohibited_color"}
|
||||
|
||||
assert required == {"#e8e4d8", "#c2bfb4", "#9c9a92"}
|
||||
assert forbidden == {"#ffffff"}
|
||||
assert not (required & forbidden)
|
||||
|
||||
|
||||
def test_token_names_are_extracted_and_expanded():
|
||||
rule = _rule(
|
||||
72, "CSS custom properties",
|
||||
"Expose the system as custom properties on :root — surfaces "
|
||||
"(--fs-obsidian/iron/slate/pewter), radius (--fs-radius-sm/md/lg/xl), "
|
||||
"and motion (--fs-ease).",
|
||||
)
|
||||
names = {e.value for e in extract_expectations([rule]) if e.kind == "token"}
|
||||
assert "--fs-obsidian" in names and "--fs-pewter" in names
|
||||
assert "--fs-radius-xl" in names
|
||||
assert "--fs-ease" in names
|
||||
assert len(names) == 9
|
||||
|
||||
|
||||
def test_how_to_apply_is_read_as_well_as_the_statement():
|
||||
"""Rulebooks routinely put the concrete values in how_to_apply and keep the
|
||||
statement declarative, so ignoring it would miss the checkable half."""
|
||||
rule = _rule(
|
||||
56, "Per-app accent", "Each app owns exactly one accent.",
|
||||
how_to_apply='[data-app="scribe"] #5B4A8A, [data-app="minstrel"] #4A6B5C.',
|
||||
)
|
||||
colours = {e.value for e in extract_expectations([rule]) if e.kind == "color"}
|
||||
assert colours == {"#5b4a8a", "#4a6b5c"}
|
||||
|
||||
|
||||
def test_claims_are_deduped_across_rules_keeping_the_first_source():
|
||||
"""A colour named by several rules is one expectation, attributed to the rule
|
||||
that introduced it — usually the most specific place to send a reader."""
|
||||
rules = [
|
||||
_rule(51, "Surfaces", "Obsidian #14171A is the page background."),
|
||||
_rule(99, "Elsewhere", "Obsidian #14171A again, mentioned in passing."),
|
||||
]
|
||||
found = [e for e in extract_expectations(rules) if e.kind == "color"]
|
||||
assert len(found) == 1
|
||||
assert found[0].rule_id == 51
|
||||
|
||||
|
||||
def test_prose_with_nothing_checkable_yields_nothing():
|
||||
"""Most rules are judgement, not specification. They must contribute no
|
||||
findings rather than a shrug — a panel that reports unparseable rules as
|
||||
problems would be unusable."""
|
||||
rule = _rule(
|
||||
68, "Voice and tone",
|
||||
"Voice is understated: plain language for anything functional, flavour "
|
||||
"only where the user is waiting or failing. Be brief.",
|
||||
)
|
||||
assert extract_expectations([rule]) == []
|
||||
|
||||
|
||||
def test_every_expectation_carries_the_sentence_it_came_from():
|
||||
"""The panel has to show its working — "the rulebook says X" is only
|
||||
actionable if you can see where, and in what context.
|
||||
|
||||
Asserts the context is the SENTENCE, not the whole statement: a rule that
|
||||
states a requirement and a prohibition in consecutive sentences would
|
||||
otherwise attribute both to the same undifferentiated blob of prose.
|
||||
"""
|
||||
rule = _rule(63, "Radius", "Radius: Small 4px. Pure white #FFFFFF is never used.")
|
||||
found = extract_expectations([rule])
|
||||
assert len(found) == 1
|
||||
|
||||
only = found[0]
|
||||
assert only.kind == "prohibited_color"
|
||||
assert only.rule_id == 63
|
||||
assert only.rule_title == "Radius"
|
||||
assert only.context == "Pure white #FFFFFF is never used."
|
||||
assert "Radius: Small 4px" not in only.context
|
||||
@@ -0,0 +1,435 @@
|
||||
"""Rendering a design system as its master CSS sheet.
|
||||
|
||||
The generator is pure, so a whole sheet is one literal of tokens in and a string
|
||||
out. Two things here are load-bearing rather than cosmetic: what the sheet
|
||||
deliberately does NOT contain, and the fact that a value cannot escape its
|
||||
declaration.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from scribe.services.design_stylesheet import (
|
||||
check_code_against_tokens,
|
||||
derivation_report,
|
||||
duplicate_values,
|
||||
is_valid_token_name,
|
||||
render_stylesheet,
|
||||
safe_comment,
|
||||
safe_value,
|
||||
selector_for_mode,
|
||||
)
|
||||
|
||||
|
||||
def _token(name, value_by_mode, group_name=None, purpose=None):
|
||||
return SimpleNamespace(
|
||||
name=name, value_by_mode=value_by_mode,
|
||||
group_name=group_name, purpose=purpose,
|
||||
)
|
||||
|
||||
|
||||
# --- safety -----------------------------------------------------------------
|
||||
#
|
||||
# Design systems are shareable records. A value that can close its declaration
|
||||
# can inject arbitrary CSS into the page of anyone the system was shared with,
|
||||
# which makes this a real boundary rather than tidiness.
|
||||
|
||||
def test_a_value_that_would_escape_its_declaration_is_refused():
|
||||
"""`red; } body { display: none` is the whole attack: end the declaration,
|
||||
close the block, open your own."""
|
||||
assert safe_value("red; } body { display: none") is None
|
||||
assert safe_value("#fff}") is None
|
||||
assert safe_value("#fff;") is None
|
||||
|
||||
|
||||
def test_at_rules_and_tags_are_refused():
|
||||
assert safe_value("@import url(evil.css)") is None
|
||||
assert safe_value("</style><script>") is None
|
||||
|
||||
|
||||
def test_comment_delimiters_in_a_value_are_refused():
|
||||
"""A value is not rendered inside a comment, but `/*` would comment out
|
||||
every declaration after it — silently blanking the rest of the sheet."""
|
||||
assert safe_value("red /* ") is None
|
||||
assert safe_value("*/ red") is None
|
||||
|
||||
|
||||
def test_a_newline_in_a_value_is_refused():
|
||||
assert safe_value("red\n color: blue") is None
|
||||
|
||||
|
||||
def test_ordinary_values_survive_untouched():
|
||||
for value in ("#14171a", "8px", "cubic-bezier(0.2, 0.6, 0.2, 1)",
|
||||
"0 4px 12px rgba(0,0,0,0.35)", "color-mix(in srgb, red 15%, transparent)"):
|
||||
assert safe_value(value) == value
|
||||
|
||||
|
||||
def test_a_rejected_value_is_dropped_not_cleaned_up():
|
||||
"""Rejecting beats stripping. A partially-sanitised value is one the operator
|
||||
never wrote, and the sheet's entire claim is that it IS the record — quietly
|
||||
rendering a different colour would break that claim invisibly."""
|
||||
css = render_stylesheet([_token("--fs-x", {"base": "red; } body { color: blue"})])
|
||||
assert "body" not in css
|
||||
assert "value rejected" in css
|
||||
|
||||
|
||||
def test_comment_text_cannot_close_its_comment():
|
||||
"""`purpose` is operator prose rendered into a comment — `*/` in it would
|
||||
end the comment and spill the rest into the stylesheet as code."""
|
||||
assert "*/" not in safe_comment("ends the comment */ then color: red")
|
||||
|
||||
|
||||
def test_a_malformed_token_name_is_dropped():
|
||||
"""A name is an identifier. A 'cleaned up' identifier is a different token
|
||||
than the one recorded, so it is dropped rather than repaired."""
|
||||
assert is_valid_token_name("--fs-obsidian")
|
||||
assert not is_valid_token_name("--fs obsidian")
|
||||
assert not is_valid_token_name("color: red")
|
||||
assert not is_valid_token_name("fs-obsidian") # no leading --
|
||||
|
||||
css = render_stylesheet([_token("--bad name", {"base": "red"})])
|
||||
assert "bad name" not in css
|
||||
|
||||
|
||||
def test_a_mode_name_cannot_break_out_of_its_selector():
|
||||
assert selector_for_mode('dark"] body {') == '[data-theme="darkbody"]'
|
||||
|
||||
|
||||
# --- what the sheet is ------------------------------------------------------
|
||||
|
||||
def test_the_sheet_declares_properties_and_styles_no_elements():
|
||||
"""THE shape decision. A sheet that styled elements would restate the same
|
||||
handful of values once per element and grow with the UI. Purpose tokens are
|
||||
stated once and reused; components are snippets that reference them."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
|
||||
_token("--fs-moss", {"base": "#4a5d3f"}, group_name="action"),
|
||||
])
|
||||
assert "--fs-obsidian: #14171a;" in css
|
||||
# No element or class rules — the sheet has exactly one block here, and
|
||||
# every declaration in it is a custom property.
|
||||
assert css.count("{") == 1
|
||||
declarations = [
|
||||
line.strip() for line in css.splitlines()
|
||||
if ":" in line and line.strip().endswith(";")
|
||||
]
|
||||
assert declarations and all(d.startswith("--") for d in declarations)
|
||||
|
||||
|
||||
def test_the_header_says_what_the_sheet_is_for():
|
||||
"""A generated file with no explanation gets hand-edited, and then it has
|
||||
diverged from the record it claims to be."""
|
||||
css = render_stylesheet([_token("--fs-x", {"base": "1px"})], title="FabledSword")
|
||||
assert "FabledSword" in css
|
||||
assert "Generated" in css
|
||||
assert "snippets" in css
|
||||
|
||||
|
||||
# --- modes ------------------------------------------------------------------
|
||||
|
||||
def test_base_goes_on_the_root_selector_and_other_modes_layer_over_it():
|
||||
"""Matches the convention already in the codebase, and the one-way scoping
|
||||
#251 recorded: light on `:root`, dark layered on an attribute selector."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
|
||||
])
|
||||
assert ":root {" in css
|
||||
assert '[data-theme="dark"] {' in css
|
||||
assert css.index(":root {") < css.index('[data-theme="dark"] {')
|
||||
|
||||
|
||||
def test_a_mode_block_contains_only_what_that_mode_declares():
|
||||
"""A mode block is an OVERRIDE layer, exactly as the storage model has it.
|
||||
Repeating every token in every block would make the sheet claim each mode
|
||||
redefines the whole system."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-bg", {"base": "#f5f1e8", "dark": "#14171a"}),
|
||||
_token("--fs-radius-md", {"base": "8px"}),
|
||||
])
|
||||
dark_block = css.split('[data-theme="dark"] {')[1]
|
||||
assert "--fs-bg" in dark_block
|
||||
assert "--fs-radius-md" not in dark_block
|
||||
|
||||
|
||||
def test_the_root_selector_is_caller_chosen():
|
||||
"""A container-scoped preview cannot use `:root`. A generator that hardcoded
|
||||
it could not serve the preview surface at all."""
|
||||
css = render_stylesheet(
|
||||
[_token("--fs-x", {"base": "1px"})], root_selector="[data-preview]"
|
||||
)
|
||||
assert "[data-preview] {" in css
|
||||
assert ":root {" not in css
|
||||
|
||||
|
||||
# --- grouping and honesty ---------------------------------------------------
|
||||
|
||||
def test_tokens_are_grouped_by_purpose_with_the_group_named():
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a"}, group_name="surface"),
|
||||
_token("--fs-radius-md", {"base": "8px"}, group_name="radius"),
|
||||
])
|
||||
assert "/* surface */" in css
|
||||
assert "/* radius */" in css
|
||||
|
||||
|
||||
def test_a_purpose_becomes_an_inline_comment_on_the_base_layer_only():
|
||||
"""Repeating the same prose in every mode block is noise: the token means
|
||||
the same thing in dark mode."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a", "dark": "#000000"},
|
||||
purpose="page bg, deepest surface"),
|
||||
])
|
||||
assert css.count("page bg, deepest surface") == 1
|
||||
|
||||
|
||||
def test_a_declared_token_with_no_value_appears_as_a_comment_not_a_silence():
|
||||
"""The rulebook named it, so its absence is a FINDING. A commented line puts
|
||||
that finding where the reader is already looking; dropping it would make the
|
||||
sheet look complete."""
|
||||
css = render_stylesheet([
|
||||
_token("--fs-obsidian", {"base": "#14171a"}),
|
||||
_token("--fs-radius-sm", {}),
|
||||
])
|
||||
assert "--fs-radius-sm" in css
|
||||
assert "no value set yet" in css
|
||||
# Commented, so it cannot be mistaken for a live declaration.
|
||||
assert " --fs-radius-sm:" not in css
|
||||
|
||||
|
||||
def test_an_empty_system_renders_a_sheet_with_no_blocks_rather_than_failing():
|
||||
"""A system with no tokens is an ordinary state (rule #115), including one
|
||||
that was just created."""
|
||||
css = render_stylesheet([])
|
||||
assert "{" not in css
|
||||
assert "Generated" in css
|
||||
|
||||
|
||||
# --- the reuse report -------------------------------------------------------
|
||||
|
||||
def test_two_tokens_sharing_a_value_are_reported_not_refused():
|
||||
"""The operator's constraint, made checkable: reuse consistent values rather
|
||||
than restating them. But a design system legitimately aligns colours on
|
||||
purpose — "Success = Moss, by design" — so this reports and lets a human
|
||||
decide which it is."""
|
||||
dupes = duplicate_values([
|
||||
_token("--fs-moss", {"base": "#4A5D3F"}),
|
||||
_token("--fs-success", {"base": "#4a5d3f"}),
|
||||
_token("--fs-obsidian", {"base": "#14171a"}),
|
||||
])
|
||||
assert dupes == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
||||
|
||||
|
||||
def test_tokens_that_agree_in_one_mode_but_differ_in_another_are_not_duplicates():
|
||||
"""A near-miss is a different, weaker finding, and calling it a duplicate
|
||||
would send someone to merge two tokens that genuinely diverge."""
|
||||
assert duplicate_values([
|
||||
_token("--fs-a", {"base": "#fff", "dark": "#000"}),
|
||||
_token("--fs-b", {"base": "#fff", "dark": "#111"}),
|
||||
]) == {"#fff": ["--fs-a", "--fs-b"]}
|
||||
|
||||
|
||||
def test_valueless_tokens_never_count_as_duplicates_of_each_other():
|
||||
"""Otherwise every unfilled token would collide with every other one and the
|
||||
report would be nothing but noise on a fresh import."""
|
||||
assert duplicate_values([_token("--fs-a", {}), _token("--fs-b", {})]) == {}
|
||||
|
||||
|
||||
# --- reading the sheet from the other side ----------------------------------
|
||||
#
|
||||
# "The snippets use the tags from the sheet" made checkable. All three findings
|
||||
# below are currently SILENT in this codebase — no error, no failing test.
|
||||
|
||||
def _tok(name, base=None, supersedes=()):
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
value_by_mode={"base": base} if base else {},
|
||||
supersedes=list(supersedes),
|
||||
group_name=None, purpose=None,
|
||||
)
|
||||
|
||||
|
||||
SHEET = [
|
||||
_tok("--fs-obsidian", "#14171a"),
|
||||
_tok("--fs-parchment", "#e8e4d8", supersedes=["#fff", "#ffffff"]),
|
||||
]
|
||||
|
||||
|
||||
def test_a_reference_to_a_token_that_does_not_exist_is_reported():
|
||||
"""THE bug this exists for. `var(--color-accent)` where no such token exists
|
||||
renders as nothing at all — invisible focus rings, unstyled elements, no
|
||||
error and no failing test. It happened in this codebase this session."""
|
||||
report = check_code_against_tokens("outline: 2px solid var(--color-accent);", SHEET)
|
||||
assert report["unknown"] == ["--color-accent"]
|
||||
assert report["used"] == []
|
||||
|
||||
|
||||
def test_a_reference_that_resolves_is_reported_as_used_not_as_a_finding():
|
||||
report = check_code_against_tokens("background: var(--fs-obsidian);", SHEET)
|
||||
assert report["used"] == ["--fs-obsidian"]
|
||||
assert report["unknown"] == []
|
||||
|
||||
|
||||
def test_a_superseded_literal_is_found_and_names_its_replacement():
|
||||
"""The reframe paying off end to end: the finding says what to WRITE, not
|
||||
merely what is wrong. That is only possible because the token declared it."""
|
||||
report = check_code_against_tokens("color: #fff;", SHEET)
|
||||
assert report["superseded_literals"] == [
|
||||
{"literal": "#fff", "use_instead": "--fs-parchment"}
|
||||
]
|
||||
|
||||
|
||||
def test_a_shorter_hex_does_not_match_inside_a_longer_one():
|
||||
"""`#fff` must not fire on `#ffffff` — different colours, and a finding on
|
||||
the wrong one sends someone to change code that was already correct."""
|
||||
report = check_code_against_tokens("color: #ffffffee;", SHEET)
|
||||
assert [f["literal"] for f in report["superseded_literals"]] == []
|
||||
|
||||
|
||||
def test_the_literal_match_is_case_insensitive():
|
||||
"""Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
||||
check would silently find nothing — the same trap normalize_hex exists for."""
|
||||
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
|
||||
assert report["superseded_literals"] == [
|
||||
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
|
||||
]
|
||||
|
||||
|
||||
def test_a_token_defined_and_read_locally_is_reported_once_not_twice():
|
||||
"""A snippet minting its own custom property is the bloat a shared sheet
|
||||
exists to prevent — but when it also reads it back, that is ONE fact. The
|
||||
unknown-reference check owns it, because "--btn-bg does not exist in the
|
||||
sheet" is the more precise statement of the same problem."""
|
||||
report = check_code_against_tokens(".btn { --btn-bg: #333; background: var(--btn-bg); }", SHEET)
|
||||
assert report["unknown"] == ["--btn-bg"]
|
||||
assert report["local_definitions"] == []
|
||||
|
||||
|
||||
def test_a_local_definition_never_read_back_is_still_reported():
|
||||
report = check_code_against_tokens(".btn { --btn-bg: #333; }", SHEET)
|
||||
assert report["local_definitions"] == ["--btn-bg"]
|
||||
|
||||
|
||||
def test_a_declaration_that_is_not_a_custom_property_is_not_mistaken_for_one():
|
||||
report = check_code_against_tokens(".btn { color: red; background: blue; }", SHEET)
|
||||
assert report["local_definitions"] == []
|
||||
|
||||
|
||||
def test_clean_code_reports_nothing_to_act_on():
|
||||
report = check_code_against_tokens(
|
||||
".btn { background: var(--fs-obsidian); color: var(--fs-parchment); }", SHEET
|
||||
)
|
||||
assert report["unknown"] == []
|
||||
assert report["superseded_literals"] == []
|
||||
assert report["local_definitions"] == []
|
||||
assert report["used"] == ["--fs-obsidian", "--fs-parchment"]
|
||||
|
||||
|
||||
def test_the_inline_comment_falls_back_to_rationale_when_there_is_no_purpose():
|
||||
"""A token carrying only the why still says something in the sheet, rather
|
||||
than rendering bare because the other field happened to be empty."""
|
||||
token = SimpleNamespace(
|
||||
name="--fs-success", value_by_mode={"base": "#4a5d3f"},
|
||||
group_name=None, purpose=None, rationale="equals Moss, by design",
|
||||
)
|
||||
assert "equals Moss, by design" in render_stylesheet([token])
|
||||
|
||||
|
||||
def test_purpose_wins_over_rationale_in_the_comment():
|
||||
"""What a token is FOR is what a reader of the stylesheet needs first."""
|
||||
token = SimpleNamespace(
|
||||
name="--fs-x", value_by_mode={"base": "1px"},
|
||||
group_name=None, purpose="hairline borders", rationale="because thin",
|
||||
)
|
||||
css = render_stylesheet([token])
|
||||
assert "hairline borders" in css
|
||||
assert "because thin" not in css
|
||||
|
||||
|
||||
# --- derivation -------------------------------------------------------------
|
||||
#
|
||||
# A formula needs no special storage: `color-mix(..., var(--fs-accent) 15%, ...)`
|
||||
# is a value, and the browser resolves it live. What it needs is a check, because
|
||||
# a formula pointing at a missing token is dropped silently.
|
||||
|
||||
def _dtok(name, base):
|
||||
return SimpleNamespace(
|
||||
name=name, value_by_mode={"base": base},
|
||||
group_name=None, purpose=None, supersedes=[],
|
||||
)
|
||||
|
||||
|
||||
ACCENT = "color-mix(in srgb, var(--fs-accent) 15%, transparent)"
|
||||
|
||||
|
||||
def test_a_formula_survives_the_value_sanitiser_untouched():
|
||||
"""LOAD-BEARING for the whole approach. If `color-mix(... var(...) ...)` were
|
||||
rejected as unsafe, derivation would need a storage shape of its own."""
|
||||
assert safe_value(ACCENT) == ACCENT
|
||||
assert ACCENT in render_stylesheet([_dtok("--fs-accent-soft", ACCENT)])
|
||||
|
||||
|
||||
def test_a_derived_token_reports_what_it_is_computed_from():
|
||||
report = derivation_report([
|
||||
_dtok("--fs-accent", "#5b4a8a"),
|
||||
_dtok("--fs-accent-soft", ACCENT),
|
||||
])
|
||||
assert report["derived"] == {"--fs-accent-soft": ["--fs-accent"]}
|
||||
assert report["unknown_refs"] == {}
|
||||
|
||||
|
||||
def test_a_formula_pointing_at_a_token_that_does_not_exist_is_reported():
|
||||
"""The browser drops the whole declaration — invalid at computed-value time —
|
||||
and nothing errors. Exactly the failure mode this system exists to end."""
|
||||
report = derivation_report([_dtok("--fs-accent-soft", ACCENT)])
|
||||
assert report["unknown_refs"] == {"--fs-accent-soft": ["--fs-accent"]}
|
||||
|
||||
|
||||
def test_a_plain_value_is_not_reported_as_derived():
|
||||
report = derivation_report([_dtok("--fs-obsidian", "#14171a")])
|
||||
assert report["derived"] == {}
|
||||
|
||||
|
||||
def test_a_derivation_loop_is_reported_once():
|
||||
"""CSS resolves a loop to nothing rather than hanging, so this is about
|
||||
telling the operator — but a token that quietly resolves to nothing is
|
||||
precisely the thing worth being told."""
|
||||
report = derivation_report([
|
||||
_dtok("--fs-a", "var(--fs-b)"),
|
||||
_dtok("--fs-b", "var(--fs-a)"),
|
||||
])
|
||||
assert len(report["cycles"]) == 1
|
||||
assert set(report["cycles"][0]) == {"--fs-a", "--fs-b"}
|
||||
|
||||
|
||||
def test_a_chain_of_derivations_is_not_a_cycle():
|
||||
"""a <- b <- c is ordinary and must not trip the loop check."""
|
||||
report = derivation_report([
|
||||
_dtok("--fs-a", "#000"),
|
||||
_dtok("--fs-b", "var(--fs-a)"),
|
||||
_dtok("--fs-c", "var(--fs-b)"),
|
||||
])
|
||||
assert report["cycles"] == []
|
||||
assert report["derived"] == {"--fs-b": ["--fs-a"], "--fs-c": ["--fs-b"]}
|
||||
|
||||
|
||||
def test_a_token_referencing_itself_is_not_treated_as_a_dependency():
|
||||
"""`--fs-x: var(--fs-x, fallback)` is a self-reference with a fallback, not a
|
||||
derivation — counting it would report every such token as a one-node loop."""
|
||||
report = derivation_report([_dtok("--fs-x", "var(--fs-x, 8px)")])
|
||||
assert report["cycles"] == []
|
||||
assert report["derived"] == {}
|
||||
|
||||
|
||||
def test_a_derived_token_needs_only_a_base_value_to_follow_every_mode():
|
||||
"""The reason formulas beat computed literals. One declaration in the base
|
||||
layer tracks its source through dark mode too, because `var()` resolves where
|
||||
it is USED, not where it is written — so there is nothing to re-derive when a
|
||||
colour changes."""
|
||||
css = render_stylesheet([
|
||||
SimpleNamespace(
|
||||
name="--fs-accent", value_by_mode={"base": "#5b4a8a", "dark": "#7a68b0"},
|
||||
group_name=None, purpose=None,
|
||||
),
|
||||
_dtok("--fs-accent-soft", ACCENT),
|
||||
])
|
||||
dark_block = css.split('[data-theme="dark"] {')[1]
|
||||
assert "--fs-accent:" in dark_block
|
||||
assert "--fs-accent-soft" not in dark_block # stated once, follows anyway
|
||||
@@ -0,0 +1,212 @@
|
||||
"""MCP design-system tools — the sentinel translations, mostly.
|
||||
|
||||
The tools are thin wrappers, so the only logic worth testing is where the MCP
|
||||
calling convention meets the service's: an agent cannot omit an argument, so
|
||||
"leave unchanged", "clear" and "set" have to be encoded in the value. Getting
|
||||
that mapping wrong is silent — the call succeeds and changes the wrong thing.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.mcp._context import _user_id_ctx
|
||||
from scribe.services.design_systems import DesignSystemCycle
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_user():
|
||||
token = _user_id_ctx.set(7)
|
||||
yield
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
def _fake_system():
|
||||
s = MagicMock()
|
||||
s.to_dict.return_value = {"id": 1, "title": "FabledSword", "parent_id": None}
|
||||
return s
|
||||
|
||||
|
||||
def _fake_token():
|
||||
t = MagicMock()
|
||||
t.to_dict.return_value = {"id": 9, "name": "--fs-obsidian"}
|
||||
return t
|
||||
|
||||
|
||||
# --- create -----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_without_a_parent_passes_none_not_zero():
|
||||
"""0 is the "omitted" sentinel, and it must not reach the service as a
|
||||
system id — there is no system 0, so the create would fail an ACL check for
|
||||
a record that cannot exist."""
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.create_design_system = AsyncMock(return_value=_fake_system())
|
||||
from scribe.mcp.tools.design_systems import create_design_system
|
||||
await create_design_system(title="FabledSword")
|
||||
assert svc.create_design_system.await_args.kwargs["parent_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_with_a_parent_passes_it_through():
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.create_design_system = AsyncMock(return_value=_fake_system())
|
||||
from scribe.mcp.tools.design_systems import create_design_system
|
||||
await create_design_system(title="Scribe", parent_id=4)
|
||||
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_raises_when_the_parent_is_not_writable():
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.create_design_system = AsyncMock(return_value=None)
|
||||
from scribe.mcp.tools.design_systems import create_design_system
|
||||
with pytest.raises(ValueError):
|
||||
await create_design_system(title="Scribe", parent_id=4)
|
||||
|
||||
|
||||
# --- the three-state parent -------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_with_parent_id_zero_leaves_the_parent_alone():
|
||||
"""The common case — renaming a system must not silently re-root it."""
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_design_system = AsyncMock(return_value=_fake_system())
|
||||
from scribe.mcp.tools.design_systems import update_design_system
|
||||
await update_design_system(design_system_id=1, title="Renamed")
|
||||
fields = svc.update_design_system.await_args.kwargs
|
||||
assert "parent_id" not in fields
|
||||
assert fields["title"] == "Renamed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_with_parent_id_minus_one_clears_it():
|
||||
"""-1 means "make this a family system". It has to arrive at the service as
|
||||
None, which is the value the service reads as "become a root" — where
|
||||
omitting the key means "leave alone"."""
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_design_system = AsyncMock(return_value=_fake_system())
|
||||
from scribe.mcp.tools.design_systems import update_design_system
|
||||
await update_design_system(design_system_id=1, parent_id=-1)
|
||||
assert svc.update_design_system.await_args.kwargs["parent_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_with_a_positive_parent_id_sets_it():
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_design_system = AsyncMock(return_value=_fake_system())
|
||||
from scribe.mcp.tools.design_systems import update_design_system
|
||||
await update_design_system(design_system_id=1, parent_id=4)
|
||||
assert svc.update_design_system.await_args.kwargs["parent_id"] == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found():
|
||||
"""The service raises so this layer can keep the two apart. An agent told
|
||||
"not found" would retry the same call; one told what the loop is can fix it."""
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_design_system = AsyncMock(
|
||||
side_effect=DesignSystemCycle("2 already inherits from 1")
|
||||
)
|
||||
from scribe.mcp.tools.design_systems import update_design_system
|
||||
with pytest.raises(ValueError, match="already inherits"):
|
||||
await update_design_system(design_system_id=1, parent_id=2)
|
||||
|
||||
|
||||
# --- tokens -----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_token_treats_order_index_minus_one_as_unchanged():
|
||||
"""0 is a VALID order_index, so it cannot double as the omitted sentinel —
|
||||
the same reason the systems tools use -1."""
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_token = AsyncMock(return_value=_fake_token())
|
||||
from scribe.mcp.tools.design_systems import update_design_token
|
||||
await update_design_token(token_id=9, purpose="page bg")
|
||||
fields = svc.update_token.await_args.kwargs
|
||||
assert "order_index" not in fields
|
||||
assert fields["purpose"] == "page bg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_token_accepts_order_index_zero():
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_token = AsyncMock(return_value=_fake_token())
|
||||
from scribe.mcp.tools.design_systems import update_design_token
|
||||
await update_design_token(token_id=9, order_index=0)
|
||||
assert svc.update_token.await_args.kwargs["order_index"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_token_can_set_an_empty_value_map():
|
||||
"""`value_by_mode={}` is meaningful — it strips every mode from a token. The
|
||||
guard is `is not None`, not truthiness, or that edit would be unreachable."""
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_token = AsyncMock(return_value=_fake_token())
|
||||
from scribe.mcp.tools.design_systems import update_design_token
|
||||
await update_design_token(token_id=9, value_by_mode={})
|
||||
assert svc.update_token.await_args.kwargs["value_by_mode"] == {}
|
||||
|
||||
|
||||
# --- the project pointer ----------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clearing_a_projects_design_system_passes_none():
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.set_project_design_system = AsyncMock(return_value=True)
|
||||
from scribe.mcp.tools.design_systems import set_project_design_system
|
||||
result = await set_project_design_system(project_id=2, design_system_id=-1)
|
||||
assert svc.set_project_design_system.await_args.args[2] is None
|
||||
assert result["design_system_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_returns_serialised_tokens_with_their_provenance():
|
||||
"""The payload has to carry the shadowed entries, not just the winner —
|
||||
dropping them at the serialisation boundary would discard the one thing
|
||||
resolution was built to preserve."""
|
||||
from scribe.services.design_cascade import resolve_tokens
|
||||
|
||||
class _T:
|
||||
def __init__(self, name, value_by_mode):
|
||||
self.name, self.value_by_mode = name, value_by_mode
|
||||
self.group_name = self.purpose = None
|
||||
self.order_index = 0
|
||||
|
||||
resolved = resolve_tokens(
|
||||
2, {1: None, 2: 1},
|
||||
{1: [_T("--fs-accent", {"base": "#6b2118"})],
|
||||
2: [_T("--fs-accent", {"base": "#5b4a8a"})]},
|
||||
)
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.resolve_design_system = AsyncMock(return_value=resolved)
|
||||
from scribe.mcp.tools.design_systems import resolve_design_system
|
||||
payload = await resolve_design_system(design_system_id=2)
|
||||
|
||||
token = payload["tokens"][0]
|
||||
assert token["value_by_mode"] == {"base": "#5b4a8a"}
|
||||
assert token["origin_by_mode"] == {"base": 2}
|
||||
assert token["contributions"]["base"] == [
|
||||
{"system_id": 2, "value": "#5b4a8a"},
|
||||
{"system_id": 1, "value": "#6b2118"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_token_can_clear_supersedes_with_an_empty_list():
|
||||
"""`[]` means "this token replaces nothing after all" — a real edit. Guarded
|
||||
on `is not None` so it isn't swallowed as "unchanged", the same trap #2077
|
||||
recorded for update_snippet."""
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_token = AsyncMock(return_value=_fake_token())
|
||||
from scribe.mcp.tools.design_systems import update_design_token
|
||||
await update_design_token(token_id=9, supersedes=[])
|
||||
assert svc.update_token.await_args.kwargs["supersedes"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_token_leaves_supersedes_alone_when_omitted():
|
||||
with patch("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
||||
svc.update_token = AsyncMock(return_value=_fake_token())
|
||||
from scribe.mcp.tools.design_systems import update_design_token
|
||||
await update_design_token(token_id=9, purpose="text on action surfaces")
|
||||
assert "supersedes" not in svc.update_token.await_args.kwargs
|
||||
@@ -17,12 +17,16 @@ def _bind_user():
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
def _fake_project(**overrides) -> MagicMock:
|
||||
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
|
||||
p = MagicMock()
|
||||
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
||||
"status": "active", "color": None}
|
||||
base.update(overrides)
|
||||
p.to_dict.return_value = base
|
||||
# Explicit, because a bare MagicMock hands back a truthy auto-attribute —
|
||||
# which would route every project in this file through the design-system
|
||||
# branch and out to a real database.
|
||||
p.design_system_id = design_system_id
|
||||
return p
|
||||
|
||||
|
||||
@@ -176,6 +180,45 @@ async def test_enter_project_composes_full_context():
|
||||
assert out["open_tasks"][0]["id"] == 100
|
||||
assert out["open_tasks"][0]["status"] == "in_progress"
|
||||
assert out["recent_notes"][0]["id"] == 200
|
||||
# No design system on this project -> the key is present and null, not
|
||||
# absent. A caller that has to distinguish "no key" from "no system" will
|
||||
# eventually get it wrong.
|
||||
assert out["design_system"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_hands_back_the_design_system_when_the_project_has_one():
|
||||
"""The handshake is where an agent learns what binds it, and a design
|
||||
system binds the same way a rule does. Before this it was reachable only by
|
||||
an agent that already knew to call resolve_design_system — so the standards
|
||||
were present in the store and absent from the work."""
|
||||
p = _fake_project(id=5, design_system_id=9)
|
||||
design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}],
|
||||
"token_count": 95, "token_groups": ["surface"],
|
||||
"inherits_from": ["House"], "description": ""}
|
||||
|
||||
with patch(
|
||||
"scribe.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=p),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value={"rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": []}),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.notes_svc.list_notes",
|
||||
AsyncMock(side_effect=[([], 0), ([], 0)]),
|
||||
), patch(
|
||||
"scribe.mcp.tools.projects.design_systems_svc.design_context",
|
||||
AsyncMock(return_value=design),
|
||||
) as ctx:
|
||||
out = await enter_project(project_id=5)
|
||||
|
||||
assert out["design_system"]["token_count"] == 95
|
||||
assert out["design_system"]["inherits_from"] == ["House"]
|
||||
assert ctx.await_args.args == (7, 9) # caller's id, the project's system
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -22,6 +22,12 @@ def _note(nid, title, user_id=1):
|
||||
n = MagicMock()
|
||||
n.id, n.title, n.user_id = nid, title, user_id
|
||||
n.note_type = "snippet"
|
||||
# See the same note in test_write_path_trigger: an auto-created mock on
|
||||
# `.data` is truthy and would leak into a rendered menu line (#2244).
|
||||
n.data = None
|
||||
# And on `.is_task`, which the kind marker reads FIRST — truthy there makes
|
||||
# every one of these snippets read as a task (#2246).
|
||||
n.is_task, n.task_kind = False, "work"
|
||||
return n
|
||||
|
||||
|
||||
@@ -183,3 +189,37 @@ async def test_auto_inject_records_what_survived_the_margin_gate():
|
||||
|
||||
assert surfaced.call_args.kwargs["note_ids"] == [1]
|
||||
assert surfaced.call_args.kwargs["source"] == "auto_inject"
|
||||
|
||||
|
||||
def test_every_getter_that_can_be_surfaced_also_records_a_pull():
|
||||
"""Rule #33 contract check — and the one that would have caught #2245.
|
||||
|
||||
`surfaced` and `pulled` only mean something as a PAIR: the rate between them
|
||||
is what #1038 and #2085 gate on. That pair is only closed if the tool which
|
||||
OPENS a record reports it. `get_note` did, `get_snippet` did, `get_task` did
|
||||
NOT — and auto-inject ranks kind-blind over a corpus that is overwhelmingly
|
||||
tasks and issues, so the gap sat exactly where the volume is: every surfaced
|
||||
task counted as never-pulled, dragging measured pull-through toward zero for
|
||||
the menu's own dominant kind.
|
||||
|
||||
Asserted by source inspection rather than by calling the tools, because the
|
||||
failure is a MISSING call — which no behavioural test of the tool's return
|
||||
value can see.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from scribe.mcp.tools import notes as notes_tools
|
||||
from scribe.mcp.tools import snippets as snippet_tools
|
||||
from scribe.mcp.tools import tasks as task_tools
|
||||
|
||||
getters = (
|
||||
(notes_tools, "get_note"),
|
||||
(task_tools, "get_task"),
|
||||
(snippet_tools, "get_snippet"),
|
||||
)
|
||||
for module, name in getters:
|
||||
src = inspect.getsource(getattr(module, name))
|
||||
assert "record_pulled(" in src, (
|
||||
f"{name} can be surfaced in an auto-inject menu but records no pull — "
|
||||
"its pull-through rate will read as zero regardless of real usage"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Structural tests for the design-systems blueprint + the parity guard.
|
||||
|
||||
Modelled on tests/test_routes_snippets.py. The enumerations below are
|
||||
deliberate: relaxing one to a pattern would stop it catching the thing it was
|
||||
written for, which is a capability landing on one surface and not the other.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
|
||||
def test_design_systems_blueprint_registered():
|
||||
from scribe.routes.design_systems import design_systems_bp
|
||||
assert design_systems_bp.name == "design_systems"
|
||||
assert design_systems_bp.url_prefix == "/api"
|
||||
|
||||
|
||||
def test_design_systems_blueprint_registered_in_app():
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
assert "design_systems" in app.blueprints
|
||||
|
||||
|
||||
def test_route_handlers_callable():
|
||||
from scribe.routes import design_systems as routes
|
||||
for name in (
|
||||
"list_design_systems", "create_design_system", "get_design_system",
|
||||
"update_design_system", "delete_design_system", "resolve_design_system",
|
||||
"get_design_system_stylesheet",
|
||||
"check_snippets_against_system",
|
||||
"list_design_tokens", "create_design_token",
|
||||
"update_design_token", "delete_design_token", "set_project_design_system",
|
||||
):
|
||||
assert callable(getattr(routes, name))
|
||||
|
||||
|
||||
def test_every_endpoint_is_reachable_on_the_app():
|
||||
"""The handlers existing is not the same as them being routed. This catches
|
||||
a decorator that was copied without its path changing — two handlers on one
|
||||
rule, where the second silently never runs."""
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
rules = {
|
||||
str(r.rule) for r in app.url_map.iter_rules()
|
||||
if r.endpoint.startswith("design_systems.")
|
||||
}
|
||||
assert rules == {
|
||||
"/api/design-systems",
|
||||
"/api/design-systems/<int:design_system_id>",
|
||||
"/api/design-systems/<int:design_system_id>/resolved",
|
||||
"/api/design-systems/<int:design_system_id>/stylesheet",
|
||||
"/api/design-systems/<int:design_system_id>/snippet-check",
|
||||
"/api/design-systems/<int:design_system_id>/tokens",
|
||||
"/api/design-tokens/<int:token_id>",
|
||||
"/api/projects/<int:project_id>/design-system",
|
||||
}
|
||||
|
||||
|
||||
def test_service_functions_take_user_id():
|
||||
"""Routes must call the services with user_id — verify the contract (#33)."""
|
||||
from scribe.services import design_systems as svc
|
||||
for fn_name in (
|
||||
"create_design_system", "list_design_systems", "get_design_system",
|
||||
"update_design_system", "delete_design_system", "resolve_design_system",
|
||||
"create_token", "list_tokens", "update_token", "delete_token",
|
||||
"set_project_design_system",
|
||||
"stylesheet_for_system", "check_snippets_against_system",
|
||||
):
|
||||
fn = getattr(svc, fn_name)
|
||||
assert callable(fn)
|
||||
assert "user_id" in inspect.signature(fn).parameters
|
||||
|
||||
|
||||
def test_agent_and_web_surfaces_stay_at_parity():
|
||||
"""The MCP tools and the REST routes are two callers of one service; a
|
||||
capability on one has to exist on the other (rule #33).
|
||||
|
||||
This guard exists because the snippet surfaces drifted apart once — MCP had
|
||||
no delete, the web side had no near-duplicate gate — and neither failed
|
||||
anything until someone went looking.
|
||||
"""
|
||||
from scribe.mcp.tools import design_systems as tools
|
||||
from scribe.routes import design_systems as routes
|
||||
|
||||
for name in (
|
||||
"create_design_system", "list_design_systems", "get_design_system",
|
||||
"resolve_design_system", "update_design_system", "delete_design_system",
|
||||
"create_design_token", "list_design_tokens", "update_design_token",
|
||||
"delete_design_token", "set_project_design_system",
|
||||
"get_design_system_stylesheet",
|
||||
):
|
||||
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
|
||||
assert callable(getattr(routes, name)), f"REST route missing: {name}"
|
||||
|
||||
# The snippet check is the one verb whose handler names differ between the
|
||||
# surfaces (the tool says what it checks AGAINST; the route is already under
|
||||
# the system), so the loop above can't pair it. It still has to exist on both.
|
||||
assert callable(tools.check_snippets_against_design_system)
|
||||
assert callable(routes.check_snippets_against_system)
|
||||
|
||||
|
||||
def test_every_mcp_tool_in_the_module_is_registered():
|
||||
"""A tool written but never registered is invisible to an agent, and nothing
|
||||
else in the codebase would notice."""
|
||||
from scribe.mcp.tools import design_systems as tools
|
||||
|
||||
registered = []
|
||||
|
||||
class _Recorder:
|
||||
def tool(self, name):
|
||||
registered.append(name)
|
||||
return lambda fn: fn
|
||||
|
||||
tools.register(_Recorder())
|
||||
|
||||
public = {
|
||||
name for name, obj in vars(tools).items()
|
||||
if inspect.iscoroutinefunction(obj) and not name.startswith("_")
|
||||
}
|
||||
assert set(registered) == public
|
||||
@@ -132,3 +132,40 @@ def test_clauses_are_pure_builders(clause_fn):
|
||||
import inspect
|
||||
assert not inspect.iscoroutinefunction(clause_fn)
|
||||
assert _sql(clause_fn(7)) # builds without touching a database
|
||||
|
||||
|
||||
# --- design systems ----------------------------------------------------------
|
||||
#
|
||||
# A design system is reachable two ways: you own it, or you can see a project
|
||||
# that inherits from it. The gap between what those two grant is the invariant
|
||||
# worth guarding — see get_design_system_permission.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"permission, readable, writable",
|
||||
[
|
||||
("owner", True, True),
|
||||
# Project-derived. Being an EDITOR on a shared project must not confer
|
||||
# the right to rewrite the family system that project inherits from —
|
||||
# that would let one project's collaborator restyle every other project
|
||||
# in the family.
|
||||
("viewer", True, False),
|
||||
(None, False, False),
|
||||
],
|
||||
)
|
||||
async def test_reaching_a_design_system_via_a_project_reads_but_never_writes(
|
||||
permission, readable, writable
|
||||
):
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from scribe.services.access import (
|
||||
can_read_design_system,
|
||||
can_write_design_system,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"scribe.services.access.get_design_system_permission",
|
||||
AsyncMock(return_value=permission),
|
||||
):
|
||||
assert await can_read_design_system(1, 3) is readable
|
||||
assert await can_write_design_system(1, 3) is writable
|
||||
|
||||
@@ -13,16 +13,54 @@ import pytest
|
||||
from scribe.services import backup
|
||||
|
||||
|
||||
def test_backup_version_is_v4():
|
||||
assert backup.BACKUP_VERSION == 4
|
||||
def test_backup_version_is_v5():
|
||||
assert backup.BACKUP_VERSION == 5
|
||||
|
||||
|
||||
def test_not_included_lists_the_known_gaps():
|
||||
# The deferred tables must be surfaced explicitly, not silently dropped.
|
||||
for table in ("groups", "project_shares", "note_shares", "api_keys", "embeddings"):
|
||||
for table in ("groups", "project_shares", "note_shares", "api_keys",
|
||||
"note_embeddings", "retrieval_logs"):
|
||||
assert table in backup._NOT_INCLUDED
|
||||
|
||||
|
||||
def test_every_table_is_either_backed_up_or_explicitly_excluded():
|
||||
"""THE GUARD (#2293), and the only shape of test that catches an ABSENCE.
|
||||
|
||||
A new table gets a model and a migration — both fail loudly if wrong — and
|
||||
then silently never gets a backup section. No error, no warning, and a
|
||||
restore that reports success. That is how `systems`, `record_systems`,
|
||||
`note_usage_events`, `design_systems`, `design_tokens` and `repo_bindings`
|
||||
all went missing, over five migrations, with nothing to notice.
|
||||
|
||||
Extending the export fixes today. THIS fixes the next one: adding a table
|
||||
now fails here until someone either backs it up or states in
|
||||
`_NOT_INCLUDED` that it shouldn't be. Either is fine; silence is not.
|
||||
"""
|
||||
from scribe.models import Base
|
||||
|
||||
schema = set(Base.metadata.tables)
|
||||
accounted = set(backup._BACKED_UP) | set(backup._NOT_INCLUDED)
|
||||
|
||||
unaccounted = schema - accounted
|
||||
assert not unaccounted, (
|
||||
f"{len(unaccounted)} table(s) are neither backed up nor explicitly "
|
||||
f"excluded: {sorted(unaccounted)}. Add each to backup._BACKED_UP (and "
|
||||
f"give it an export + restore section) or to backup._NOT_INCLUDED with "
|
||||
f"a reason in the comment above it."
|
||||
)
|
||||
|
||||
# And the reverse: a name in either list that no longer exists is a lie the
|
||||
# guard would otherwise keep telling. This half is what caught "embeddings",
|
||||
# "invitations" and "password_resets" — three entries that named nothing.
|
||||
phantom = accounted - schema
|
||||
assert not phantom, (
|
||||
f"backup lists table(s) that are not in the schema: {sorted(phantom)}. "
|
||||
f"Renamed or dropped — fix the list rather than leaving it to read as "
|
||||
f"coverage."
|
||||
)
|
||||
|
||||
|
||||
def test_join_table_row_helpers_are_pure():
|
||||
subs = [SimpleNamespace(project_id=1, rulebook_id=2)]
|
||||
rsup = [SimpleNamespace(project_id=1, rule_id=9)]
|
||||
@@ -57,16 +95,18 @@ class _CM:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_full_backup_contains_v3_sections():
|
||||
async def test_export_full_backup_contains_every_declared_section():
|
||||
with patch("scribe.services.backup.async_session", lambda: _CM()):
|
||||
out = await backup.export_full_backup()
|
||||
|
||||
assert out["version"] == 4
|
||||
assert out["version"] == backup.BACKUP_VERSION
|
||||
assert out["scope"] == "full"
|
||||
assert "api_keys" in out["_not_included"]
|
||||
# The sections v2 silently dropped must now be present (empty here).
|
||||
# The sections v2 silently dropped, plus the six v5 added (empty here).
|
||||
for key in ("rulebooks", "rulebook_topics", "rules",
|
||||
"rulebook_subscriptions", "rule_suppressions",
|
||||
"topic_suppressions"):
|
||||
assert key in out, f"missing v3 section: {key}"
|
||||
"topic_suppressions",
|
||||
"systems", "record_systems", "design_systems",
|
||||
"design_tokens", "note_usage_events", "repo_bindings"):
|
||||
assert key in out, f"missing export section: {key}"
|
||||
assert out[key] == []
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""ACL gating and field handling for services/design_systems.py (unit, mocked).
|
||||
|
||||
Mirrors tests/test_services_systems.py — same mocked-session shape, because the
|
||||
question is the same one: does the service refuse before it touches a row?
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services.design_systems import DesignSystemCycle
|
||||
|
||||
|
||||
def _make_mock_session():
|
||||
s = AsyncMock()
|
||||
s.__aenter__ = AsyncMock(return_value=s)
|
||||
s.__aexit__ = AsyncMock(return_value=False)
|
||||
s.add = MagicMock()
|
||||
s.commit = AsyncMock()
|
||||
s.refresh = AsyncMock()
|
||||
return s
|
||||
|
||||
|
||||
# --- creating ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_a_parent_is_denied_without_write_on_that_parent():
|
||||
"""Parenting to someone else's system would let their delete or re-parent
|
||||
silently restyle your app, so the chain may only be built from systems you
|
||||
can write — which, per the ACL, means ones you own."""
|
||||
with patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=False)
|
||||
from scribe.services.design_systems import create_design_system
|
||||
result = await create_design_system(user_id=1, title="Scribe", parent_id=7)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_without_a_parent_never_consults_the_parent_acl():
|
||||
"""A family system has no parent, and creating one must not be gated on a
|
||||
permission check for a system that does not exist."""
|
||||
mock_session = _make_mock_session()
|
||||
captured = {}
|
||||
mock_session.add = MagicMock(
|
||||
side_effect=lambda obj: captured.update(
|
||||
title=obj.title, parent_id=obj.parent_id
|
||||
)
|
||||
)
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=False)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import create_design_system
|
||||
await create_design_system(user_id=1, title=" FabledSword ")
|
||||
assert captured["title"] == "FabledSword" # stripped
|
||||
assert captured["parent_id"] is None
|
||||
acc.can_write_design_system.assert_not_awaited()
|
||||
|
||||
|
||||
# --- the cycle guard, through the service -----------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reparenting_into_a_loop_raises_rather_than_returning_none():
|
||||
"""None already means "not found, or not yours". A caller that conflated the
|
||||
two would report "no such design system" for what is really "that parent is
|
||||
one of its own descendants", so the cycle gets its own exception type."""
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=MagicMock(deleted_at=None, parent_id=None))
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems._parent_map",
|
||||
AsyncMock(return_value={1: None, 2: 1})):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import update_design_system
|
||||
with pytest.raises(DesignSystemCycle):
|
||||
await update_design_system(user_id=1, design_system_id=1, parent_id=2)
|
||||
|
||||
mock_session.commit.assert_not_awaited() # refused BEFORE the write
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clearing_the_parent_is_allowed_and_is_not_read_as_no_change():
|
||||
"""`parent_id=None` means "make this a root" — the one field where None is a
|
||||
value rather than "leave alone", which is why it is handled apart from the
|
||||
others."""
|
||||
system = MagicMock(deleted_at=None, parent_id=5)
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=system)
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems._parent_map",
|
||||
AsyncMock(return_value={1: 5, 5: None})):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import update_design_system
|
||||
await update_design_system(user_id=1, design_system_id=1, parent_id=None)
|
||||
|
||||
assert system.parent_id is None
|
||||
|
||||
|
||||
# --- tokens -----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_denied_without_write_on_the_system():
|
||||
with patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=False)
|
||||
from scribe.services.design_systems import create_token
|
||||
result = await create_token(user_id=1, design_system_id=3, name="--fs-obsidian")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_values_default_to_an_empty_map_not_json_null():
|
||||
"""The column is NOT NULL so that absence has exactly ONE spelling. Passing
|
||||
None straight through would store JSON null and hand every reader back the
|
||||
second empty state the schema was shaped to remove."""
|
||||
mock_session = _make_mock_session()
|
||||
captured = {}
|
||||
mock_session.add = MagicMock(
|
||||
side_effect=lambda obj: captured.update(value_by_mode=obj.value_by_mode)
|
||||
)
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import create_token
|
||||
await create_token(
|
||||
user_id=1, design_system_id=3, name="--fs-radius-md", value_by_mode=None
|
||||
)
|
||||
|
||||
assert captured["value_by_mode"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tokens_denied_returns_empty():
|
||||
with patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_read_design_system = AsyncMock(return_value=False)
|
||||
from scribe.services.design_systems import list_tokens
|
||||
assert await list_tokens(user_id=1, design_system_id=3) == []
|
||||
|
||||
|
||||
# --- the project pointer ----------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pointing_a_project_at_a_system_needs_write_on_the_project():
|
||||
with patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_project = AsyncMock(return_value=False)
|
||||
acc.can_read_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import set_project_design_system
|
||||
assert await set_project_design_system(1, project_id=5, design_system_id=3) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pointing_a_project_needs_only_READ_on_the_system():
|
||||
"""Consuming a design system is not changing it, so a system reachable
|
||||
through another project is a legitimate choice here. Requiring write would
|
||||
make a shared family style unusable by the people it was shared with."""
|
||||
project = MagicMock(deleted_at=None, design_system_id=None)
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=project)
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_project = AsyncMock(return_value=True)
|
||||
acc.can_read_design_system = AsyncMock(return_value=True)
|
||||
acc.can_write_design_system = AsyncMock(return_value=False)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import set_project_design_system
|
||||
assert await set_project_design_system(1, project_id=5, design_system_id=3) is True
|
||||
|
||||
assert project.design_system_id == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clearing_a_projects_design_system_skips_the_system_acl():
|
||||
"""Un-styling a project must not require permission on the system it is
|
||||
letting go of — including one that has since been deleted."""
|
||||
project = MagicMock(deleted_at=None, design_system_id=3)
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=project)
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_project = AsyncMock(return_value=True)
|
||||
acc.can_read_design_system = AsyncMock(return_value=False)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import set_project_design_system
|
||||
assert await set_project_design_system(1, project_id=5, design_system_id=None) is True
|
||||
|
||||
assert project.design_system_id is None
|
||||
acc.can_read_design_system.assert_not_awaited()
|
||||
|
||||
|
||||
# --- resolution (step 2) ----------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_denied_returns_none_not_an_empty_set():
|
||||
"""None and [] mean different things here: "you may not see this" versus
|
||||
"this chain genuinely holds no tokens". A caller that got [] for a denial
|
||||
would render an empty design system as though it were real."""
|
||||
with patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_read_design_system = AsyncMock(return_value=False)
|
||||
from scribe.services.design_systems import resolve_design_system
|
||||
assert await resolve_design_system(user_id=1, design_system_id=3) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller():
|
||||
"""LOAD-BEARING for shared projects. A caller reading through a project
|
||||
shared with them owns no link in the chain, so a caller-scoped parent map
|
||||
returns an empty forest and the cascade truncates to one system — a page
|
||||
that renders with plausible wrong values and no error anywhere.
|
||||
|
||||
The ACL already grants read on the whole chain (reachability propagates
|
||||
upward); this is the loading side keeping that promise.
|
||||
"""
|
||||
owner, caller = 42, 7
|
||||
system = MagicMock(deleted_at=None, owner_user_id=owner)
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=system)
|
||||
mock_session.execute = AsyncMock(
|
||||
return_value=MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: [])))
|
||||
)
|
||||
parent_map = AsyncMock(return_value={3: None})
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems._parent_map", parent_map), \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_read_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import resolve_design_system
|
||||
result = await resolve_design_system(user_id=caller, design_system_id=3)
|
||||
|
||||
assert result == [] # empty chain, not None
|
||||
assert parent_map.await_args.args[1] == owner # NOT `caller`
|
||||
|
||||
|
||||
# --- supersedes (step 6's reframe) ------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
|
||||
"""Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
|
||||
mock_session = _make_mock_session()
|
||||
captured = {}
|
||||
mock_session.add = MagicMock(
|
||||
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
||||
)
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import create_token
|
||||
await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
|
||||
assert captured["supersedes"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_records_the_literals_it_replaces():
|
||||
mock_session = _make_mock_session()
|
||||
captured = {}
|
||||
mock_session.add = MagicMock(
|
||||
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
||||
)
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import create_token
|
||||
await create_token(
|
||||
user_id=1, design_system_id=3, name="--fs-text-on-action",
|
||||
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
|
||||
)
|
||||
assert captured["supersedes"] == ["#fff", "#ffffff"]
|
||||
|
||||
|
||||
# --- the master sheet -------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stylesheet_denied_returns_none():
|
||||
with patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=None)):
|
||||
from scribe.services.design_systems import stylesheet_for_system
|
||||
assert await stylesheet_for_system(1, 3) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
|
||||
"""The CSS alone hides two things a reviewer needs: which tokens are still
|
||||
valueless, and which values are declared twice. Both ride alongside rather
|
||||
than being left for the reader to derive from the text."""
|
||||
from scribe.services.design_cascade import Contribution, ResolvedToken
|
||||
|
||||
def _resolved(name, base=None):
|
||||
return ResolvedToken(
|
||||
name=name,
|
||||
contributions=(
|
||||
{"base": (Contribution(system_id=1, value=base),)} if base else {}
|
||||
),
|
||||
group_name=None, purpose=None, rationale=None,
|
||||
supersedes=(), order_index=0,
|
||||
)
|
||||
|
||||
system = MagicMock()
|
||||
system.title = "FabledSword"
|
||||
with patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=[
|
||||
_resolved("--fs-moss", "#4a5d3f"),
|
||||
_resolved("--fs-success", "#4a5d3f"),
|
||||
_resolved("--fs-radius-sm"),
|
||||
])), \
|
||||
patch("scribe.services.design_systems.get_design_system",
|
||||
AsyncMock(return_value=system)):
|
||||
from scribe.services.design_systems import stylesheet_for_system
|
||||
result = await stylesheet_for_system(1, 3)
|
||||
|
||||
assert result["token_count"] == 3
|
||||
assert result["valueless"] == ["--fs-radius-sm"]
|
||||
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
||||
assert "--fs-moss: #4a5d3f;" in result["css"]
|
||||
assert "FabledSword" in result["css"]
|
||||
|
||||
|
||||
# --- design_context: the delivery side --------------------------------------
|
||||
|
||||
def _ctx_token(name: str, group: str | None):
|
||||
from scribe.services.design_cascade import ResolvedToken
|
||||
return ResolvedToken(
|
||||
name=name, contributions={}, group_name=group,
|
||||
purpose=None, rationale=None, supersedes=(), order_index=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_context_merges_guidance_ANCESTOR_FIRST():
|
||||
"""LOAD-BEARING. A child system holds only what it CHANGES, so its own
|
||||
guidance describes a departure from a house style it never restates. An
|
||||
agent handed the leaf alone builds against a fragment with no signal that
|
||||
the rest exists — which is exactly the failure retiring the design rulebook
|
||||
would otherwise have caused.
|
||||
"""
|
||||
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
|
||||
description="the house", guidance="Dark-mode-first.")
|
||||
family.title = "House"
|
||||
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
|
||||
description="one app", guidance="Accent on the wordmark.")
|
||||
app.title = "App"
|
||||
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=app)
|
||||
mock_session.execute = AsyncMock(return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
|
||||
))
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems._parent_map",
|
||||
AsyncMock(return_value={3: 1, 1: None})), \
|
||||
patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=[
|
||||
_ctx_token("--a", "surface"), _ctx_token("--b", "type"),
|
||||
_ctx_token("--c", "surface"), _ctx_token("--d", None),
|
||||
])):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import design_context
|
||||
out = await design_context(user_id=42, design_system_id=3)
|
||||
|
||||
assert [g["title"] for g in out["guidance"]] == ["House", "App"]
|
||||
assert out["inherits_from"] == ["House"]
|
||||
assert out["token_count"] == 4
|
||||
# Group names deduped and sorted; an ungrouped token contributes nothing.
|
||||
assert out["token_groups"] == ["surface", "type"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_context_denied_returns_none():
|
||||
"""It rides on resolve_design_system's ACL rather than re-deriving one —
|
||||
a second permission path is a second thing to get wrong."""
|
||||
with patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=None)):
|
||||
from scribe.services.design_systems import design_context
|
||||
assert await design_context(user_id=1, design_system_id=3) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_context_omits_systems_with_no_guidance():
|
||||
"""A system that overrides one token and says nothing about it should not
|
||||
contribute an empty section — a heading with nothing under it reads as
|
||||
missing content rather than as an absence of content."""
|
||||
family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
|
||||
description="", guidance="Dark-mode-first.")
|
||||
family.title = "House"
|
||||
app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
|
||||
description="", guidance=" ")
|
||||
app.title = "App"
|
||||
|
||||
mock_session = _make_mock_session()
|
||||
mock_session.get = AsyncMock(return_value=app)
|
||||
mock_session.execute = AsyncMock(return_value=MagicMock(
|
||||
scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
|
||||
))
|
||||
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems._parent_map",
|
||||
AsyncMock(return_value={3: 1, 1: None})), \
|
||||
patch("scribe.services.design_systems.resolve_design_system",
|
||||
AsyncMock(return_value=[])):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import design_context
|
||||
out = await design_context(user_id=42, design_system_id=3)
|
||||
|
||||
assert [g["title"] for g in out["guidance"]] == ["House"]
|
||||
assert out["token_count"] == 0
|
||||
@@ -0,0 +1,64 @@
|
||||
"""services/notes.py — the inline embedding moved here from the routes (#2056).
|
||||
|
||||
Why it moved: embedding was triggered at each REST route and nowhere else, so a
|
||||
record created through MCP stayed out of semantic search and auto-inject until
|
||||
the next restart's backfill. Putting it in the service means every caller — REST,
|
||||
MCP, recurrence, snippets — gets it by construction rather than by remembering.
|
||||
|
||||
These test the helper directly. The point of the change is that there is now ONE
|
||||
place to test.
|
||||
"""
|
||||
from scribe.services import notes as notes_svc
|
||||
|
||||
# --- inline embedding (#2056) -----------------------------------------------
|
||||
|
||||
def test_embed_note_uses_the_OWNER_not_the_caller():
|
||||
"""LOAD-BEARING for shared records. An embedding belongs to the record; a
|
||||
collaborator editing a shared note must refresh the owner's row rather than
|
||||
mint a second one under their own id. The routes this replaced passed the
|
||||
caller's uid on one path and the owner's on another — exactly the kind of
|
||||
inconsistency that moving it to one place removes."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
note = MagicMock(id=5, user_id=42, title="T", body="B")
|
||||
with patch("scribe.services.embeddings.upsert_note_embedding") as upsert, \
|
||||
patch("asyncio.create_task") as create_task:
|
||||
notes_svc.embed_note(note)
|
||||
|
||||
assert create_task.called
|
||||
upsert.assert_called_once()
|
||||
assert upsert.call_args.args[0] == 5
|
||||
assert upsert.call_args.args[1] == 42 # owner, never the caller
|
||||
assert upsert.call_args.args[2] == "T\nB"
|
||||
|
||||
|
||||
def test_embed_note_skips_a_record_with_no_text():
|
||||
"""An empty embedding is worse than none — it is a row that matches nothing
|
||||
and hides the fact that the record was never indexed."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
note = MagicMock(id=5, user_id=42, title="", body="")
|
||||
with patch("asyncio.create_task") as create_task:
|
||||
notes_svc.embed_note(note)
|
||||
assert not create_task.called
|
||||
|
||||
|
||||
def test_embed_note_without_a_running_loop_is_not_an_error():
|
||||
"""Unit tests and scripts call create_note with no event loop. That must be
|
||||
an ordinary case: a write that succeeded cannot be failed by its index
|
||||
refresh."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
note = MagicMock(id=5, user_id=42, title="T", body="B")
|
||||
with patch("asyncio.create_task", side_effect=RuntimeError("no running loop")):
|
||||
notes_svc.embed_note(note) # must not raise
|
||||
|
||||
|
||||
def test_embed_note_swallows_an_indexing_failure():
|
||||
"""Same reason, wider net: the embedding model being unavailable must not
|
||||
turn a successful save into a 500."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
note = MagicMock(id=5, user_id=42, title="T", body="B")
|
||||
with patch("asyncio.create_task", side_effect=ValueError("model gone")):
|
||||
notes_svc.embed_note(note) # must not raise
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user