Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914b701a50 | ||
|
|
3f1523b19f | ||
|
|
6c4c1bccfc | ||
|
|
f20c019f2a | ||
|
|
984407f931 | ||
|
|
8d9e96cc6d | ||
|
|
5dcb738ce8 | ||
|
|
45c6b1c88a | ||
|
|
bbba0b3ae3 | ||
|
|
7defc6897c | ||
|
|
3f26aa9485 | ||
|
|
fd7097c6b7 | ||
|
|
24d071619b | ||
|
|
c18139622c | ||
|
|
ac1ce0a7f0 | ||
|
|
ffd08507f1 | ||
|
|
63c213b617 | ||
|
|
07bf58de46 | ||
|
|
11c243c0fa | ||
|
|
a6d6550483 | ||
|
|
46271ccaa7 | ||
|
|
6ac821178f | ||
|
|
4a9744172f | ||
|
|
d8dd017994 | ||
|
|
8087ba4db0 | ||
|
|
7b0984579d | ||
|
|
dcd4efcea0 | ||
|
|
4d2be27935 | ||
|
|
5b824c1626 | ||
|
|
841506b10c | ||
|
|
c34454b840 | ||
|
|
bd60d679d9 | ||
|
|
174ec8af46 | ||
|
|
4852b0d3df | ||
|
|
22f907c44d |
@@ -178,6 +178,15 @@ jobs:
|
|||||||
- name: Design token check
|
- name: Design token check
|
||||||
run: python3 scripts/check_design_tokens.py --report-literals
|
run: python3 scripts/check_design_tokens.py --report-literals
|
||||||
|
|
||||||
|
# Dangling styles: an element whose classes have only modifier rules and
|
||||||
|
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
|
||||||
|
# this way (a link rendering as raw browser blue, a flex row whose parent
|
||||||
|
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
|
||||||
|
# style typechecks perfectly. Reported, not gated — a bare wrapper is
|
||||||
|
# legitimate, so the signal is the count growing.
|
||||||
|
- name: Dangling style check
|
||||||
|
run: python3 scripts/check_dangling_styles.py
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""retire the two settings that designated a design source for the app itself
|
||||||
|
|
||||||
|
Revision ID: 0075
|
||||||
|
Revises: 0074
|
||||||
|
Create Date: 2026-08-03
|
||||||
|
|
||||||
|
Two keys, retired for the same reason a week apart, so they go in one change
|
||||||
|
rather than one migration each:
|
||||||
|
|
||||||
|
design_rulebook_id which rulebook described how this app should look
|
||||||
|
ui_design_system_id which design system this app's own UI was built from
|
||||||
|
|
||||||
|
Both named a design source for THE RUNNING INSTALL. The design surface is for
|
||||||
|
the projects an install tracks, and a project already carries its own pointer
|
||||||
|
(`projects.design_system_id`) — so an install-wide designation had nothing left
|
||||||
|
to mean. `ui_design_system_id` was introduced by this same migration's first
|
||||||
|
draft and never reached a deployed database; it is listed here rather than
|
||||||
|
undone by an 0076 that would reverse a change nobody ran.
|
||||||
|
|
||||||
|
Deleting settings rows by key is safe in a way dropping a column is not — the
|
||||||
|
table is free-form key/value, so an install that never designated one simply has
|
||||||
|
no row to delete.
|
||||||
|
|
||||||
|
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
|
||||||
|
lie: the pointer lives on the project now, and always did for anyone who set it
|
||||||
|
there.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0075"
|
||||||
|
down_revision = "0074"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"DELETE FROM settings "
|
||||||
|
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""note_supersessions; drop the never-written notes.consolidated_at
|
||||||
|
|
||||||
|
Revision ID: 0076
|
||||||
|
Revises: 0075
|
||||||
|
Create Date: 2026-08-07
|
||||||
|
|
||||||
|
Step 1 of milestone #278. Structure only — nothing reads or writes the new
|
||||||
|
table yet, and nothing behaves differently after this runs.
|
||||||
|
|
||||||
|
## What the table is for
|
||||||
|
|
||||||
|
Old records outrank newer ones on the same subject, because a similarity score
|
||||||
|
cannot tell time. A note that accurately described how something worked in June
|
||||||
|
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
|
||||||
|
that, so nothing could act on it.
|
||||||
|
|
||||||
|
The claim points FORWARD — the newer record names what it overtakes — because
|
||||||
|
the older one cannot know it has been overtaken. Many-to-many and partial: a
|
||||||
|
note may supersede parts of several others and be overtaken piecemeal by
|
||||||
|
several later ones, which is why this is a table rather than a column. Both
|
||||||
|
directions are queried: `superseded_id` answers "has this been overtaken?" at
|
||||||
|
ranking time, `superseder_id` answers "what does this replace?" in a record
|
||||||
|
view. An array column could serve one and not the other.
|
||||||
|
|
||||||
|
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
|
||||||
|
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
|
||||||
|
them back. The cascade fires only on `purge_trash`, where the row genuinely
|
||||||
|
goes — and a claim about a row that no longer exists is not actionable.
|
||||||
|
|
||||||
|
## What is being dropped, and why now
|
||||||
|
|
||||||
|
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
|
||||||
|
— while being serialised into every note and task payload as `null`. It cost a
|
||||||
|
column, a line in every response, and worse: it IMPLIED a capability. A reader
|
||||||
|
reasonably concludes notes can be consolidated and this records when.
|
||||||
|
|
||||||
|
That reading was reasonable precisely because merge/unmerge exists for snippets
|
||||||
|
and not for notes, so the column looked like the notes-side half of that
|
||||||
|
feature, modelled and abandoned.
|
||||||
|
|
||||||
|
It is dropped rather than repurposed for supersession, and the distinction is
|
||||||
|
the point (#2483): consolidation folds several records into one survivor and
|
||||||
|
destroys the originals. Merging two snippets is lossless — one helper, several
|
||||||
|
call sites. Folding two dev-logs means writing a summary and losing what each
|
||||||
|
actually said. Supersession is the opposite act: both records survive, and the
|
||||||
|
older one is merely ranked behind. Smuggling one in under a column named for
|
||||||
|
the other would have buried that difference in schema.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
|
||||||
|
restores the shape, not the (nonexistent) data. Drops the table; any recorded
|
||||||
|
supersession claims are lost, which costs ranking its input and nothing else,
|
||||||
|
since no note's own content depends on them.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0076"
|
||||||
|
down_revision = "0075"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"note_supersessions",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"superseder_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"superseded_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
|
||||||
|
),
|
||||||
|
# Declaring that a note supersedes ITSELF is meaningless, and under flat
|
||||||
|
# demotion it would demote a record on its own authority. Refused in the
|
||||||
|
# service too, with a message — this is the backstop that holds when
|
||||||
|
# something writes rows directly.
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("notes", "consolidated_at")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"notes",
|
||||||
|
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
|
||||||
|
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
|
||||||
|
op.drop_table("note_supersessions")
|
||||||
@@ -300,7 +300,7 @@ onUnmounted(() => {
|
|||||||
.shortcuts-overlay {
|
.shortcuts-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
background: var(--color-overlay);
|
||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -309,8 +309,8 @@ onUnmounted(() => {
|
|||||||
.shortcuts-panel {
|
.shortcuts-panel {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
|
box-shadow: 0 8px 32px var(--color-shadow);
|
||||||
width: min(420px, 92vw);
|
width: min(420px, 92vw);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { apiGet } from "@/api/client";
|
|
||||||
import type { ExpectationResponse } from "@/utils/designDrift";
|
|
||||||
|
|
||||||
/** Checkable claims from the rulebook this install designated as its design system.
|
|
||||||
*
|
|
||||||
* `rulebook_id: null` means none has been designated — the normal state for a
|
|
||||||
* fresh install, not an error. The caller shows an explanatory empty state. */
|
|
||||||
export const fetchDesignExpectations = () =>
|
|
||||||
apiGet<ExpectationResponse>("/api/design/expectations");
|
|
||||||
@@ -74,11 +74,28 @@ export const fetchDesignSystems = () =>
|
|||||||
export const fetchDesignSystem = (id: number) =>
|
export const fetchDesignSystem = (id: number) =>
|
||||||
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
||||||
|
|
||||||
|
export interface StarterRoleGroup {
|
||||||
|
group: string;
|
||||||
|
description: string;
|
||||||
|
token_count: number;
|
||||||
|
names: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The starter token ROLES offered at creation — names and purposes, never
|
||||||
|
* values. A default palette would be one install's taste shipped as product
|
||||||
|
* (rule #115), so the values are always the operator's to fill. */
|
||||||
|
export const listStarterRoleGroups = () =>
|
||||||
|
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
|
||||||
|
"/api/design-systems/starter-roles",
|
||||||
|
);
|
||||||
|
|
||||||
export const createDesignSystem = (body: {
|
export const createDesignSystem = (body: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
guidance?: string;
|
guidance?: string;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
|
starter_role_groups?: string[];
|
||||||
|
token_prefix?: string;
|
||||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||||
|
|
||||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||||
@@ -183,6 +200,14 @@ export interface SnippetCheck {
|
|||||||
findings: SnippetFinding[];
|
findings: SnippetFinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which recorded snippets disagree with this design system's sheet. */
|
/** Which recorded snippets disagree with this design system's sheet.
|
||||||
export const checkSnippets = (id: number) =>
|
*
|
||||||
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
* `projectId` narrows to the snippets one project owns — which is how a
|
||||||
|
* project asks about its OWN code. Omit it to check every project, which is
|
||||||
|
* the right default from the system's side: a component recorded elsewhere
|
||||||
|
* still has to use the same tags. */
|
||||||
|
export const checkSnippets = (id: number, projectId?: number) =>
|
||||||
|
apiGet<SnippetCheck>(
|
||||||
|
`/api/design-systems/${id}/snippet-check`
|
||||||
|
+ (projectId ? `?project_id=${projectId}` : ""),
|
||||||
|
);
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
.btn-secondary,
|
.btn-secondary,
|
||||||
.btn-ghost,
|
.btn-ghost,
|
||||||
.btn-danger,
|
.btn-danger,
|
||||||
.btn-danger-outline {
|
.btn-danger-outline,
|
||||||
|
.btn-cta {
|
||||||
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
||||||
@@ -46,6 +47,14 @@
|
|||||||
line-height: var(--fs-leading-body);
|
line-height: var(--fs-leading-body);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
/* So a button carrying an icon centres it against the label without each
|
||||||
|
caller re-inventing the flex row — the shape they all reached for
|
||||||
|
separately, and the reason icon buttons sat a pixel or two off. */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
text-decoration: none;
|
||||||
transition: background var(--fs-dur-fast) var(--fs-ease),
|
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||||
color var(--fs-dur-fast) var(--fs-ease);
|
color var(--fs-dur-fast) var(--fs-ease);
|
||||||
@@ -58,7 +67,8 @@
|
|||||||
.btn-secondary:disabled,
|
.btn-secondary:disabled,
|
||||||
.btn-ghost:disabled,
|
.btn-ghost:disabled,
|
||||||
.btn-danger:disabled,
|
.btn-danger:disabled,
|
||||||
.btn-danger-outline:disabled {
|
.btn-danger-outline:disabled,
|
||||||
|
.btn-cta:disabled {
|
||||||
opacity: var(--fs-disabled-opacity);
|
opacity: var(--fs-disabled-opacity);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -67,7 +77,8 @@
|
|||||||
.btn-secondary:focus-visible,
|
.btn-secondary:focus-visible,
|
||||||
.btn-ghost:focus-visible,
|
.btn-ghost:focus-visible,
|
||||||
.btn-danger:focus-visible,
|
.btn-danger:focus-visible,
|
||||||
.btn-danger-outline:focus-visible {
|
.btn-danger-outline:focus-visible,
|
||||||
|
.btn-cta:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: var(--fs-focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
@@ -150,6 +161,25 @@
|
|||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The one place the accent is allowed on a button: a deliberate brand moment,
|
||||||
|
* never an ordinary action. The system carries `--fs-gradient-cta` and
|
||||||
|
* `--fs-glow-cta` for exactly this and nothing else was using them.
|
||||||
|
*
|
||||||
|
* It exists because ProjectView's Workspace link WAS this button, defined in a
|
||||||
|
* scoped block that the migration deleted — leaving a `:hover` rule with no
|
||||||
|
* base and a link that rendered as raw browser blue. A variant living in one
|
||||||
|
* view is a variant waiting to be deleted by someone tidying another; this is
|
||||||
|
* the shared home so the next sweep can't strand it. */
|
||||||
|
.btn-cta {
|
||||||
|
background: var(--fs-gradient-cta);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
box-shadow: var(--fs-glow-cta);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-cta:not(:disabled):hover {
|
||||||
|
box-shadow: var(--fs-glow-cta-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- size modifiers ------------------------------------------------------
|
/* --- size modifiers ------------------------------------------------------
|
||||||
*
|
*
|
||||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
||||||
@@ -184,7 +214,7 @@
|
|||||||
/* Full width, for a form's single submitting action — the auth screens. Width
|
/* Full width, for a form's single submitting action — the auth screens. Width
|
||||||
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
||||||
.btn-block {
|
.btn-block {
|
||||||
display: block;
|
display: flex; /* not `block` — the shared shape centres with flex */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
||||||
because a full-width button
|
because a full-width button
|
||||||
|
|||||||
@@ -98,8 +98,8 @@
|
|||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.tag-pill.applied {
|
.tag-pill.applied {
|
||||||
background: var(--color-success, #2ecc71);
|
background: var(--color-success);
|
||||||
border-color: var(--color-success, #2ecc71);
|
border-color: var(--color-success);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
color: var(--color-text-muted, var(--color-text-secondary));
|
color: var(--color-text-muted);
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -234,5 +234,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-wrapper:focus-within {
|
.tiptap-wrapper:focus-within {
|
||||||
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
import NotificationBell from "@/components/NotificationBell.vue";
|
import NotificationBell from "@/components/NotificationBell.vue";
|
||||||
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
|
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
|
||||||
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { toggleShortcuts } = useShortcuts();
|
const { toggleShortcuts } = useShortcuts();
|
||||||
@@ -50,6 +50,12 @@ router.afterEach(() => {
|
|||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
<!-- A design system is a RECORD you author, not a setting. It sat in
|
||||||
|
the utility cluster with Trash and Settings while /design was a
|
||||||
|
read-only gallery, and stayed there after it became a record type
|
||||||
|
with its own table, sharing and MCP tools. Content, by the same
|
||||||
|
rule that puts Snippets and Rulebooks here. -->
|
||||||
|
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,16 +70,6 @@ router.afterEach(() => {
|
|||||||
<Moon v-else :size="16" />
|
<Moon v-else :size="16" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Design. An icon rather than a sixth primary nav link: it's a
|
|
||||||
meta-surface like Trash and Settings, but hiding it entirely would
|
|
||||||
defeat the point of having somewhere the design system is visible.
|
|
||||||
Points at the RECORD, not the live-token view — the record is what
|
|
||||||
you work with; the live view is the check on it, and it's a tab
|
|
||||||
away. -->
|
|
||||||
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
|
|
||||||
<Palette :size="16" />
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<!-- Trash link -->
|
<!-- Trash link -->
|
||||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||||
<Trash2 :size="16" />
|
<Trash2 :size="16" />
|
||||||
@@ -106,9 +102,9 @@ router.afterEach(() => {
|
|||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
|
||||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
@@ -129,19 +125,34 @@ router.afterEach(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.app-header {
|
.app-header {
|
||||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
/* Three tracks, not a flex row with an absolutely-centred overlay.
|
||||||
|
*
|
||||||
|
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
|
||||||
|
* not participate in layout: when the header ran out of room it OVERLAPPED the
|
||||||
|
* brand and the utility cluster rather than pushing them, and nothing wrapped
|
||||||
|
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
|
||||||
|
* is an ordinary window on any monitor.
|
||||||
|
*
|
||||||
|
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
|
||||||
|
* neither side can be squeezed below its content, and the two side tracks stay
|
||||||
|
* equal to each other — which is what keeps the bar centred in the viewport
|
||||||
|
* rather than merely centred in the leftover space. Overflow becomes the
|
||||||
|
* header growing, not two things sharing pixels. */
|
||||||
.nav {
|
.nav {
|
||||||
padding: 0.6rem 1.5rem;
|
padding: 0.6rem 1.5rem;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.75rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left — brand */
|
/* Left — brand */
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
|
justify-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -159,9 +170,7 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
/* Center — pill bar */
|
/* Center — pill bar */
|
||||||
.nav-center {
|
.nav-center {
|
||||||
position: absolute;
|
justify-self: center;
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -176,10 +185,12 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
/* Right */
|
/* Right */
|
||||||
.nav-right {
|
.nav-right {
|
||||||
|
justify-self: end;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@@ -197,8 +208,8 @@ router.afterEach(() => {
|
|||||||
.nav-link.router-link-active {
|
.nav-link.router-link-active {
|
||||||
color: var(--color-primary-solid);
|
color: var(--color-primary-solid);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: rgba(91, 74, 138, 0.25);
|
background: color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
box-shadow: 0 0 16px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status indicator */
|
/* Status indicator */
|
||||||
@@ -272,6 +283,12 @@ router.afterEach(() => {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
/* The widest thing on the right and the only one that can give: a long
|
||||||
|
username shouldn't be what decides where the nav bar sits. */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 12ch;
|
||||||
}
|
}
|
||||||
.admin-badge {
|
.admin-badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -346,6 +363,21 @@ router.afterEach(() => {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The grid above means running out of room can no longer cause a collision —
|
||||||
|
but it can still make the header wider than the window, and a horizontally
|
||||||
|
scrolling header is its own defect. So shed width before that happens. The
|
||||||
|
wordmark goes first: the logo beside it says the same thing and is still the
|
||||||
|
link home. */
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.brand-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-center {
|
.nav-center {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Sub-navigation for the Design surface.
|
|
||||||
*
|
|
||||||
* There are two pages here and they are halves of ONE thing: the record that
|
|
||||||
* decides the styling, and what the browser is actually rendering from it. They
|
|
||||||
* were briefly two top-level nav entries, which put the read-only diagnostic
|
|
||||||
* first and buried the editable record under it — backwards, since the record
|
|
||||||
* is the thing you work with and the live view is the check on it.
|
|
||||||
*
|
|
||||||
* A component rather than the same markup pasted into both views: two copies of
|
|
||||||
* a tab bar diverge the moment a third tab appears, and that is the exact shape
|
|
||||||
* of duplication this whole surface exists to make visible.
|
|
||||||
*/
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<nav class="design-tabs" aria-label="Design views">
|
|
||||||
<router-link to="/design-systems" class="design-tab">Design system</router-link>
|
|
||||||
<router-link to="/design" class="design-tab">Live tokens</router-link>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.design-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.design-tab {
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.design-tab:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
|
|
||||||
exact matching would drop the highlight on any future child route. */
|
|
||||||
.design-tab.router-link-active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-bottom-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -110,8 +110,8 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-summary-ins { color: var(--color-success, #2ecc71); }
|
.diff-summary-ins { color: var(--color-success); }
|
||||||
.diff-summary-del { color: var(--color-danger, #e74c3c); }
|
.diff-summary-del { color: var(--color-danger); }
|
||||||
|
|
||||||
.diff-scroll {
|
.diff-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -136,13 +136,13 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.diff-delete {
|
.diff-delete {
|
||||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
|
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-insert {
|
.diff-insert {
|
||||||
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
|
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||||
color: var(--color-success, #2ecc71);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-equal {
|
.diff-equal {
|
||||||
|
|||||||
@@ -403,12 +403,12 @@ onMounted(loadVersions);
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
.pin-badge-manual { color: var(--color-primary); }
|
||||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
.pin-badge-auto { color: var(--color-text-muted); }
|
||||||
|
|
||||||
.history-item-label {
|
.history-item-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--color-primary, #6366f1);
|
color: var(--color-primary);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -430,7 +430,7 @@ onMounted(loadVersions);
|
|||||||
}
|
}
|
||||||
.pin-state {
|
.pin-state {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
color: var(--color-text-muted);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -442,13 +442,13 @@ onMounted(loadVersions);
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||||
background: rgba(99, 102, 241, 0.12);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-unpin:hover:not(:disabled) {
|
.btn-unpin:hover:not(:disabled) {
|
||||||
background: rgba(239, 68, 68, 0.10);
|
background: rgba(239, 68, 68, 0.10);
|
||||||
@@ -463,27 +463,27 @@ onMounted(loadVersions);
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
background: var(--color-input-bg);
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.pin-label-input:focus {
|
.pin-label-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-pin-save, .btn-pin-cancel {
|
.btn-pin-save, .btn-pin-cancel {
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-pin-save:hover:not(:disabled) {
|
.btn-pin-save:hover:not(:disabled) {
|
||||||
background: rgba(99, 102, 241, 0.12);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.iap-btn-cancel:hover {
|
.iap-btn-cancel:hover {
|
||||||
border-color: var(--color-danger, #e74c3c);
|
border-color: var(--color-danger);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-stream-preview {
|
.iap-stream-preview {
|
||||||
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
font-weight: var(--fs-weight-medium);
|
font-weight: var(--fs-weight-medium);
|
||||||
}
|
}
|
||||||
.iap-btn-accept {
|
.iap-btn-accept {
|
||||||
background: var(--color-success, #22c55e);
|
background: var(--color-success);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.iap-btn-accept:hover { opacity: 0.85; }
|
.iap-btn-accept:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.iap-btn-reject {
|
.iap-btn-reject {
|
||||||
background: var(--color-bg-card, var(--color-bg));
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.iap-btn-reject:hover {
|
.iap-btn-reject:hover {
|
||||||
border-color: var(--color-danger, #e74c3c);
|
border-color: var(--color-danger);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Diff ── */
|
/* ── Diff ── */
|
||||||
@@ -226,12 +226,12 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
|
|
||||||
.iap-diff-equal { color: var(--color-text-muted); }
|
.iap-diff-equal { color: var(--color-text-muted); }
|
||||||
.iap-diff-delete {
|
.iap-diff-delete {
|
||||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
.iap-diff-insert {
|
.iap-diff-insert {
|
||||||
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
|
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||||
color: var(--color-success, #22c55e);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-diff-marker {
|
.iap-diff-marker {
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ function goEdit() {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
.note-card:hover {
|
.note-card:hover {
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--color-primary) 14.0%, transparent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ function goEdit() {
|
|||||||
}
|
}
|
||||||
.note-card.compact:hover {
|
.note-card.compact:hover {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background: rgba(91, 74, 138, 0.04);
|
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
.note-title-compact {
|
.note-title-compact {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ onUnmounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px;
|
top: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
background: var(--color-danger, #ef4444);
|
background: var(--color-danger);
|
||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* A project's own code, checked against the design system it is bound to (#2432).
|
||||||
|
*
|
||||||
|
* This is what the design surface is FOR: a project's recorded components
|
||||||
|
* measured against the sheet they are supposed to use. The check itself is not
|
||||||
|
* new — `check_snippets_against_system` has taken a project id since it was
|
||||||
|
* written, and the route has always read `?project_id=`. Nothing on this side
|
||||||
|
* ever passed one, so the capability shipped and stayed unreachable.
|
||||||
|
*
|
||||||
|
* The finding that matters most is the quiet one. `local_definitions` is a
|
||||||
|
* snippet minting its own custom property instead of reaching for the shared
|
||||||
|
* one — the codebase re-solving a solved problem, one component at a time.
|
||||||
|
* Nothing breaks, no test fails, and the duplication only becomes visible when
|
||||||
|
* someone changes the shared value and half the components don't move.
|
||||||
|
*
|
||||||
|
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
|
||||||
|
* snippets — because that is the code Scribe holds. A repository's own sources
|
||||||
|
* are checked where they live, by that project's CI.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
|
||||||
|
|
||||||
|
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
|
||||||
|
|
||||||
|
const check = ref<SnippetCheck | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
check.value = null;
|
||||||
|
failed.value = false;
|
||||||
|
if (props.designSystemId === null) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
check.value = await checkSnippets(props.designSystemId, props.projectId);
|
||||||
|
} catch {
|
||||||
|
// Said out loud rather than rendered as an empty result. "Couldn't check"
|
||||||
|
// and "nothing to report" look identical if you let them, and that is how
|
||||||
|
// a check comes to sit dead without anyone noticing (#2419).
|
||||||
|
failed.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(run);
|
||||||
|
watch(() => [props.projectId, props.designSystemId], run);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pdt">
|
||||||
|
<div v-if="designSystemId === null" class="pdt-note">
|
||||||
|
<strong>No design system for this project.</strong>
|
||||||
|
<p>
|
||||||
|
Bind one in the sidebar and this tab reports where the project's recorded
|
||||||
|
components disagree with it — references to tokens the system doesn't
|
||||||
|
have, literals it says to stop writing, and properties a component mints
|
||||||
|
for itself instead of reusing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets…</p>
|
||||||
|
|
||||||
|
<div v-else-if="failed" class="pdt-note">
|
||||||
|
<strong>The check couldn't run.</strong>
|
||||||
|
<p>Nothing was compared — this is a failure, not a clean result.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="check">
|
||||||
|
<p v-if="!check.checked" class="pdt-muted">
|
||||||
|
This project has no recorded snippets, so nothing was checked. Record the
|
||||||
|
components you reuse and they get measured against the sheet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="!check.findings.length" class="pdt-clean">
|
||||||
|
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
|
||||||
|
every reference resolves, and none mints a property of its own.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="pdt-summary">
|
||||||
|
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
|
||||||
|
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="pdt-list">
|
||||||
|
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
|
||||||
|
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
|
||||||
|
{{ f.title || "Untitled snippet" }}
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<!-- Renders as nothing at all: no error, no failing test, just an
|
||||||
|
element that quietly isn't styled. Leads for that reason. -->
|
||||||
|
<div v-if="f.unknown.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag unknown">no such token</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="f.local_definitions.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag local">defines its own</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="f.superseded_literals.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag superseded">write the token</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
|
||||||
|
<code>{{ s.literal }}</code> → <code>{{ s.use_instead }}</code>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pdt {
|
||||||
|
padding: var(--fs-space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-note {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 3px solid var(--color-warning);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
padding: var(--fs-space-3) var(--fs-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-note p {
|
||||||
|
margin: var(--fs-space-2) 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
line-height: var(--fs-leading-body);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-muted,
|
||||||
|
.pdt-clean,
|
||||||
|
.pdt-summary {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
margin: 0 0 var(--fs-space-3);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-clean {
|
||||||
|
color: var(--color-status-done);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-summary {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--fs-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-finding {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
padding: var(--fs-space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-title {
|
||||||
|
display: block;
|
||||||
|
font-weight: var(--fs-weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: var(--fs-space-2);
|
||||||
|
}
|
||||||
|
.pdt-title:hover { color: var(--color-primary-solid); }
|
||||||
|
|
||||||
|
.pdt-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--fs-tracking-tiny);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.unknown {
|
||||||
|
background: var(--color-priority-high-bg);
|
||||||
|
color: var(--color-priority-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.local {
|
||||||
|
background: var(--color-priority-medium-bg);
|
||||||
|
color: var(--color-priority-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.superseded {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
font-size: var(--fs-size-code);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-swap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -159,7 +159,7 @@ const calendarDayMax = computed(() =>
|
|||||||
.rec-num-input {
|
.rec-num-input {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem 0.4rem;
|
||||||
border: 1px solid var(--color-input-border, var(--color-border));
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The starter token ROLES offered when a design system is created (#2349).
|
||||||
|
*
|
||||||
|
* WHY THIS IS A COMPONENT
|
||||||
|
* DesignSystemsView has two creation forms — the empty state and the one inside
|
||||||
|
* the body — because the empty state is a sibling branch, not a parent. Putting
|
||||||
|
* the checklist inline would make it the third thing in this codebase defined
|
||||||
|
* twice and free to drift, which is what the whole button migration was about.
|
||||||
|
*
|
||||||
|
* WHAT IT OFFERS
|
||||||
|
* Names and purposes, never values. A role is a question the operator answers
|
||||||
|
* with their own palette; a default palette would be one install's taste
|
||||||
|
* shipped as product (rule #115). Every group is individually skippable —
|
||||||
|
* an operator who wants three tokens should get three.
|
||||||
|
*
|
||||||
|
* All groups are checked by default. That default lives HERE rather than in the
|
||||||
|
* service, because the service must never seed rows into a system whose caller
|
||||||
|
* did not ask; a UI default is visible and reversible before the click.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
|
||||||
|
|
||||||
|
// props + emit rather than defineModel, matching TagInput and the rest of
|
||||||
|
// components/ — being the only file using a different binding idiom costs more
|
||||||
|
// than the few lines it saves.
|
||||||
|
const props = defineProps<{ selected: string[]; prefix: string }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:selected": [value: string[]];
|
||||||
|
"update:prefix": [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const groups = ref<StarterRoleGroup[]>([]);
|
||||||
|
const defaultPrefix = ref("--ds-");
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await listStarterRoleGroups();
|
||||||
|
groups.value = data.groups;
|
||||||
|
defaultPrefix.value = data.default_prefix;
|
||||||
|
if (!props.prefix) emit("update:prefix", data.default_prefix);
|
||||||
|
// Everything on by default — see the note above.
|
||||||
|
if (!props.selected.length) {
|
||||||
|
emit("update:selected", data.groups.map((g) => g.group));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A creation form must still work when this fails. Roles are an
|
||||||
|
// accelerator, not a prerequisite: the operator can add tokens by hand.
|
||||||
|
failed.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle(group: string) {
|
||||||
|
emit(
|
||||||
|
"update:selected",
|
||||||
|
props.selected.includes(group)
|
||||||
|
? props.selected.filter((g) => g !== group)
|
||||||
|
: [...props.selected, group],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTokens = () =>
|
||||||
|
groups.value
|
||||||
|
.filter((g) => props.selected.includes(g.group))
|
||||||
|
.reduce((n, g) => n + g.token_count, 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="loading" class="srp-note">Loading starter roles…</div>
|
||||||
|
|
||||||
|
<!-- Failure is not fatal and should not read as one. -->
|
||||||
|
<div v-else-if="failed" class="srp-note">
|
||||||
|
Starter roles unavailable — you can add tokens by hand after creating.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset v-else-if="groups.length" class="srp">
|
||||||
|
<legend class="srp-legend">Start with these token roles</legend>
|
||||||
|
<p class="srp-intro">
|
||||||
|
Named now, valued later. A role you haven't filled in shows as
|
||||||
|
<em>to be decided</em>; a role that doesn't exist is what gets written as a
|
||||||
|
literal instead. Uncheck anything this system won't have.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="srp-grid">
|
||||||
|
<label v-for="g in groups" :key="g.group" class="srp-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="props.selected.includes(g.group)"
|
||||||
|
@change="toggle(g.group)"
|
||||||
|
/>
|
||||||
|
<span class="srp-name">{{ g.group }}</span>
|
||||||
|
<span class="srp-count">{{ g.token_count }}</span>
|
||||||
|
<span class="srp-desc">{{ g.description }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="srp-footer">
|
||||||
|
<label class="srp-prefix">
|
||||||
|
<span>Prefix</span>
|
||||||
|
<input
|
||||||
|
:value="props.prefix" class="input srp-prefix-input" type="text"
|
||||||
|
:placeholder="defaultPrefix"
|
||||||
|
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="srp-total">
|
||||||
|
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.srp {
|
||||||
|
border: var(--fs-border);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
padding: var(--fs-space-4);
|
||||||
|
margin: 0 0 var(--fs-space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-legend {
|
||||||
|
font-size: var(--fs-size-label);
|
||||||
|
font-weight: var(--fs-weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0 var(--fs-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-intro,
|
||||||
|
.srp-note {
|
||||||
|
margin: 0 0 var(--fs-space-3);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: var(--fs-leading-body);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-note {
|
||||||
|
margin-bottom: var(--fs-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
padding: var(--fs-space-1) var(--fs-space-2);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.srp-item:hover { background: var(--color-hover); }
|
||||||
|
|
||||||
|
.srp-name {
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-count {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The description is the useful part on a wide card and the first thing worth
|
||||||
|
dropping on a narrow one — the group name alone still identifies the row. */
|
||||||
|
.srp-desc {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: var(--fs-leading-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--fs-space-3);
|
||||||
|
margin-top: var(--fs-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-prefix {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-prefix-input {
|
||||||
|
width: 8rem;
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: var(--fs-size-code);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-total {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -387,6 +387,41 @@ async function confirmDelete() {
|
|||||||
.system-textarea { resize: vertical; }
|
.system-textarea { resize: vertical; }
|
||||||
|
|
||||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||||
|
|
||||||
|
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
|
||||||
|
`--archived` modifier and the `:hover .system-actions` reveal survived.
|
||||||
|
The card WAS a flex row and every child still says so — `.system-swatch`
|
||||||
|
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
|
||||||
|
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
|
||||||
|
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
|
||||||
|
of text rather than centred against the whole card.
|
||||||
|
|
||||||
|
The list had no rule at all, so it rendered with browser bullets and
|
||||||
|
indent — invisible to the dangling-style check, which can only see a class
|
||||||
|
that is PARTLY styled. A class with no rules anywhere looks exactly like a
|
||||||
|
semantic-only hook.
|
||||||
|
|
||||||
|
Surface values match `.system-form` above, which is the same card shape in
|
||||||
|
this file and the reason they can be recovered rather than guessed. */
|
||||||
|
.systems-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
.system-card--archived { opacity: 0.6; }
|
.system-card--archived { opacity: 0.6; }
|
||||||
|
|
||||||
.system-swatch {
|
.system-swatch {
|
||||||
@@ -445,7 +480,7 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
||||||
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
||||||
.action-delete:hover { color: var(--color-danger, #e74c3c); }
|
.action-delete:hover { color: var(--color-danger); }
|
||||||
|
|
||||||
/* ── Empty ────────────────────────────────────────────────────── */
|
/* ── Empty ────────────────────────────────────────────────────── */
|
||||||
.systems-empty {
|
.systems-empty {
|
||||||
@@ -483,7 +518,7 @@ async function confirmDelete() {
|
|||||||
/* ── Modal ────────────────────────────────────────────────────── */
|
/* ── Modal ────────────────────────────────────────────────────── */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
background: var(--color-overlay);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ function focusInput() {
|
|||||||
}
|
}
|
||||||
.tag-autocomplete-item:hover,
|
.tag-autocomplete-item:hover,
|
||||||
.tag-autocomplete-item.selected {
|
.tag-autocomplete-item.selected {
|
||||||
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
background: var(--color-bg-hover);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
.task-card:hover {
|
.task-card:hover {
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--color-primary) 14.0%, transparent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.dot-todo {
|
.dot-todo {
|
||||||
background: var(--color-status-todo, #94a3b8);
|
background: var(--color-status-todo);
|
||||||
border: 2px solid var(--color-status-todo, #94a3b8);
|
border: 2px solid var(--color-status-todo);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 2px solid var(--color-text-muted);
|
border: 2px solid var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.dot-in-progress {
|
.dot-in-progress {
|
||||||
background: var(--color-status-in-progress, #3b82f6);
|
background: var(--color-status-in-progress);
|
||||||
}
|
}
|
||||||
.dot-done {
|
.dot-done {
|
||||||
background: var(--color-status-done, #22c55e);
|
background: var(--color-status-done);
|
||||||
}
|
}
|
||||||
.dot-cancelled {
|
.dot-cancelled {
|
||||||
background: var(--color-status-cancelled, #6b7280);
|
background: var(--color-status-cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-title-compact {
|
.task-title-compact {
|
||||||
@@ -190,7 +190,7 @@ function isOverdue(): boolean {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.due-compact.overdue {
|
.due-compact.overdue {
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
/* Full layout */
|
/* Full layout */
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* A design system's tokens, drawn rather than listed (#2431).
|
||||||
|
*
|
||||||
|
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
|
||||||
|
* Every value is resolved on an offscreen probe carrying only this system's
|
||||||
|
* declarations (`resolveDeclared`), never read from the page. So a token like
|
||||||
|
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
|
||||||
|
* accent, not the accent of the app you happen to be looking at. Previewing
|
||||||
|
* another project's palette from here is the point; a preview that quietly
|
||||||
|
* borrows the host app's values would be worse than no preview, because it
|
||||||
|
* would look right.
|
||||||
|
*
|
||||||
|
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
|
||||||
|
* A colour is drawn as a swatch, a length as a rule of that length, a font
|
||||||
|
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
|
||||||
|
* naming convention, because the convention is the install's (rule #115) — a
|
||||||
|
* system that calls its spacing `--gap-N` gets the same treatment.
|
||||||
|
*
|
||||||
|
* A token with no value for the chosen mode is shown as undecided rather than
|
||||||
|
* skipped. A named role awaiting a decision is information; a gap in a grid
|
||||||
|
* is not.
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import type { ResolvedToken } from "@/api/designSystems";
|
||||||
|
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
|
||||||
|
|
||||||
|
const props = defineProps<{ tokens: ResolvedToken[] }>();
|
||||||
|
|
||||||
|
const modes = computed(() => modesPresent(props.tokens));
|
||||||
|
const mode = ref(BASE_MODE);
|
||||||
|
|
||||||
|
/** Values as the browser would compute them, for the chosen mode. */
|
||||||
|
const rendered = ref<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
function recompute() {
|
||||||
|
const declared = new Map<string, string>();
|
||||||
|
for (const token of props.tokens) {
|
||||||
|
const value = valueForMode(token.value_by_mode, mode.value);
|
||||||
|
if (value) declared.set(token.name, value);
|
||||||
|
}
|
||||||
|
rendered.value = resolveDeclared(declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.tokens, mode],
|
||||||
|
() => {
|
||||||
|
// Keep the selection only while it still exists — switching systems can
|
||||||
|
// drop a mode, and a stale one would silently render as base.
|
||||||
|
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
|
||||||
|
recompute();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
type Shape = "colour" | "surface" | "length" | "font" | "plain";
|
||||||
|
|
||||||
|
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
|
||||||
|
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
|
||||||
|
const GRADIENT = /gradient\(/;
|
||||||
|
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
|
||||||
|
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
|
||||||
|
/** A stack of family names: commas, no functions, no digits. */
|
||||||
|
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
|
||||||
|
|
||||||
|
function shapeOf(value: string): Shape {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return "plain";
|
||||||
|
if (COLOUR.test(v)) return "colour";
|
||||||
|
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
|
||||||
|
if (LENGTH.test(v)) return "length";
|
||||||
|
if (FONT_STACK.test(v)) return "font";
|
||||||
|
return "plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Specimen {
|
||||||
|
name: string;
|
||||||
|
declared: string;
|
||||||
|
rendered: string;
|
||||||
|
shape: Shape;
|
||||||
|
purpose: string | null;
|
||||||
|
/** True when `var()` substitution changed the value — worth showing on hover. */
|
||||||
|
substituted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
const out = new Map<string, Specimen[]>();
|
||||||
|
for (const token of props.tokens) {
|
||||||
|
const declared = valueForMode(token.value_by_mode, mode.value);
|
||||||
|
const value = rendered.value.get(token.name) ?? "";
|
||||||
|
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
|
||||||
|
bucket.push({
|
||||||
|
name: token.name,
|
||||||
|
declared,
|
||||||
|
rendered: value,
|
||||||
|
shape: shapeOf(value),
|
||||||
|
purpose: token.purpose,
|
||||||
|
substituted: Boolean(declared) && value !== declared,
|
||||||
|
});
|
||||||
|
out.set(token.group_name ?? "ungrouped", bucket);
|
||||||
|
}
|
||||||
|
return [...out.entries()];
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
|
||||||
|
* are visibly different — but a stray `100vw` can't stretch the row.
|
||||||
|
*/
|
||||||
|
function ruleWidth(value: string): string {
|
||||||
|
return `min(${value}, 12rem)`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tp">
|
||||||
|
<div v-if="modes.length > 1" class="tp-modes">
|
||||||
|
<button
|
||||||
|
v-for="m in modes"
|
||||||
|
:key="m"
|
||||||
|
class="tp-mode"
|
||||||
|
:class="{ active: m === mode }"
|
||||||
|
@click="mode = m"
|
||||||
|
>{{ m }}</button>
|
||||||
|
<span class="tp-modes-note">
|
||||||
|
The system's own modes — independent of the theme this app is in.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
|
||||||
|
<h3 class="tp-group-heading">{{ group }}</h3>
|
||||||
|
<ul class="tp-grid">
|
||||||
|
<li v-for="s in specimens" :key="s.name" class="tp-item">
|
||||||
|
<div
|
||||||
|
class="tp-specimen"
|
||||||
|
:class="`is-${s.shape}`"
|
||||||
|
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="s.shape === 'colour'"
|
||||||
|
class="tp-swatch"
|
||||||
|
:style="{ '--tp-fill': s.rendered }"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="s.shape === 'surface'"
|
||||||
|
class="tp-surface"
|
||||||
|
:style="s.rendered.includes('gradient(')
|
||||||
|
? { background: s.rendered }
|
||||||
|
: { boxShadow: s.rendered }"
|
||||||
|
/>
|
||||||
|
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
|
||||||
|
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
|
||||||
|
<span class="tp-rule-label">{{ s.rendered }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="s.shape === 'font'"
|
||||||
|
class="tp-font"
|
||||||
|
:style="{ fontFamily: s.rendered }"
|
||||||
|
>Ag</span>
|
||||||
|
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
|
||||||
|
<span v-else class="tp-plain">{{ s.rendered }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<code class="tp-name">{{ s.name }}</code>
|
||||||
|
<span class="tp-value" :title="s.declared">{{ s.declared || "—" }}</span>
|
||||||
|
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tp-modes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--fs-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-mode {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tp-mode:hover { color: var(--color-text); }
|
||||||
|
.tp-mode.active {
|
||||||
|
color: var(--color-primary-solid);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-modes-note {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-group { margin-bottom: var(--fs-space-6); }
|
||||||
|
|
||||||
|
.tp-group-heading {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--fs-tracking-tiny);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 var(--fs-space-3);
|
||||||
|
padding-bottom: var(--fs-space-2);
|
||||||
|
border-bottom: var(--fs-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-grid {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||||
|
gap: var(--fs-space-4) var(--fs-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-item {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
|
||||||
|
*
|
||||||
|
* The stage itself is plain. An earlier version put the checkerboard here, so
|
||||||
|
* every specimen — including opaque colours and plain text — sat inside a
|
||||||
|
* frame of checks, and the pattern read as the loudest thing on the page. The
|
||||||
|
* checks belong to the ONE case that needs them: a colour that might be
|
||||||
|
* translucent. */
|
||||||
|
.tp-specimen {
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
padding: var(--fs-space-1);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text-bearing specimens get no box at all — a border around a value is a
|
||||||
|
frame around nothing, which is most of what made the grid feel busy. */
|
||||||
|
.tp-specimen.is-plain,
|
||||||
|
.tp-specimen.is-length,
|
||||||
|
.tp-specimen.is-font {
|
||||||
|
background: none;
|
||||||
|
padding: 0 var(--fs-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checks UNDER the colour, not around it: an opaque value hides them
|
||||||
|
completely, and a 15% tint shows exactly as much of them as it should.
|
||||||
|
Layering the fill as a gradient is what lets one element do both. */
|
||||||
|
.tp-swatch {
|
||||||
|
/* Declared here, overridden inline per swatch. Two reasons it is a real
|
||||||
|
default rather than a formality: a token that resolves to nothing renders
|
||||||
|
as bare checks instead of an invalid gradient, and a custom property that
|
||||||
|
exists ONLY as an inline style is invisible to the CI token check — which
|
||||||
|
reads it as an unresolvable reference, correctly, since nothing in any
|
||||||
|
stylesheet declares it. */
|
||||||
|
--tp-fill: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--tp-fill), var(--tp-fill)),
|
||||||
|
repeating-conic-gradient(
|
||||||
|
var(--color-border) 0% 25%,
|
||||||
|
var(--color-bg-secondary) 0% 50%
|
||||||
|
);
|
||||||
|
background-size: auto, 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-surface {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-rule-wrap {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-rule {
|
||||||
|
height: 0.4rem;
|
||||||
|
min-width: 1px;
|
||||||
|
flex: none;
|
||||||
|
background: var(--color-primary-solid);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-rule-label {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-font {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-plain {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: var(--fs-size-code);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-undecided {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One line each, with the full text on hover.
|
||||||
|
*
|
||||||
|
* These wrapped freely at first, so a card was two lines tall or five depending
|
||||||
|
* on how long its `color-mix()` happened to be, and the grid lost any rhythm —
|
||||||
|
* which is most of what "messy" was. A derived value is not something anyone
|
||||||
|
* reads character by character in a gallery; it is something you check the
|
||||||
|
* shape of and open if it matters. */
|
||||||
|
.tp-name,
|
||||||
|
.tp-value,
|
||||||
|
.tp-purpose {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-name {
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-value {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-purpose {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -463,7 +463,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
background: var(--color-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-header {
|
.rail-header {
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ defineExpose({ reload: loadAll });
|
|||||||
|
|
||||||
.task-add-input {
|
.task-add-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--color-input-bg, var(--color-bg));
|
background: var(--color-input-bg);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.28rem 0.5rem;
|
padding: 0.28rem 0.5rem;
|
||||||
@@ -413,7 +413,7 @@ defineExpose({ reload: loadAll });
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.4rem 0.65rem;
|
padding: 0.4rem 0.65rem;
|
||||||
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
|
background: var(--color-surface-raised);
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -433,7 +433,7 @@ defineExpose({ reload: loadAll });
|
|||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||||
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
|
.ms-status-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||||
|
|
||||||
.task-items {
|
.task-items {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -466,7 +466,7 @@ defineExpose({ reload: loadAll });
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
|
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
|
.status-dot.status-done { border-color: var(--color-success); color: var(--color-success); }
|
||||||
|
|
||||||
.task-title {
|
.task-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -522,7 +522,7 @@ defineExpose({ reload: loadAll });
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
||||||
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
|
.status-badge.status-done { border-color: var(--color-success); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 10%, transparent); }
|
||||||
|
|
||||||
.btn-edit-task { margin-left: 0.25rem; }
|
.btn-edit-task { margin-left: 0.25rem; }
|
||||||
.btn-edit-task:hover { text-decoration: underline; }
|
.btn-edit-task:hover { text-decoration: underline; }
|
||||||
@@ -614,7 +614,7 @@ defineExpose({ reload: loadAll });
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.task-due.overdue {
|
.task-due.overdue {
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,19 @@ watch(() => props.projectId, load);
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.plan-rules {
|
.plan-rules {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
border-top: 1px solid var(--color-border);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
.plan-rules h3 {
|
.plan-rules h3 {
|
||||||
font-size: 0.9em; opacity: 0.7;
|
font-size: 0.9em; opacity: 0.7;
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
|
||||||
|
below, and its children carry their own spacing (the h4 keeps the UA
|
||||||
|
margin-top that separates one rulebook group from the next). Nothing here
|
||||||
|
assumes a flex or grid parent, which is the tell that distinguishes this
|
||||||
|
from a base rule someone deleted (#2444). Stated so the next reader doesn't
|
||||||
|
re-open the question. */
|
||||||
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
||||||
.rb h5 {
|
.rb h5 {
|
||||||
font-size: 0.8em; opacity: 0.7;
|
font-size: 0.8em; opacity: 0.7;
|
||||||
@@ -60,7 +66,7 @@ watch(() => props.projectId, load);
|
|||||||
}
|
}
|
||||||
.plan-rules ul {
|
.plan-rules ul {
|
||||||
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
||||||
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ h3 {
|
|||||||
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||||
background: var(--color-primary-bg, rgba(99,102,241,0.15));
|
background: var(--color-primary-bg);
|
||||||
padding: 0.25rem 0.5rem; border-radius: 999px;
|
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||||||
}
|
}
|
||||||
.chip a { cursor: pointer; }
|
.chip a { cursor: pointer; }
|
||||||
@@ -337,25 +337,29 @@ h3 {
|
|||||||
.chip-remove:hover { opacity: 1; }
|
.chip-remove:hover { opacity: 1; }
|
||||||
.add {
|
.add {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px dashed var(--color-border, #2a2a2e);
|
border: 1px dashed var(--color-border);
|
||||||
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--color-bg); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--color-border); border-radius: 6px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
.applicable { margin-top: 2rem; }
|
.applicable { margin-top: 2rem; }
|
||||||
.rb-group { margin-bottom: 1.5rem; }
|
.rb-group { margin-bottom: 1.5rem; }
|
||||||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
||||||
|
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
|
||||||
|
one and the flex row further down), with the h5's own margin-top doing the
|
||||||
|
separating. Its children assume nothing about it, which is what tells it
|
||||||
|
apart from a base rule someone deleted (#2444). */
|
||||||
.topic-group h5 {
|
.topic-group h5 {
|
||||||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
ul { list-style: none; padding: 0; margin: 0; }
|
ul { list-style: none; padding: 0; margin: 0; }
|
||||||
.rule {
|
.rule {
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--color-primary);
|
||||||
padding-left: 0.75rem; margin: 0.5rem 0;
|
padding-left: 0.75rem; margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.rule-head { cursor: pointer; }
|
.rule-head { cursor: pointer; }
|
||||||
@@ -363,12 +367,12 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||||
.rule-detail {
|
.rule-detail {
|
||||||
margin-top: 0.5rem; padding: 0.5rem;
|
margin-top: 0.5rem; padding: 0.5rem;
|
||||||
background: var(--color-bg, #111113); border-radius: 6px;
|
background: var(--color-bg); border-radius: 6px;
|
||||||
}
|
}
|
||||||
.rule-detail > div { margin-bottom: 0.5rem; }
|
.rule-detail > div { margin-bottom: 0.5rem; }
|
||||||
.edit-link {
|
.edit-link {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
|
color: var(--color-primary); padding: 0.5rem 0 0 0;
|
||||||
}
|
}
|
||||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||||
.empty a { cursor: pointer; text-decoration: underline; }
|
.empty a { cursor: pointer; text-decoration: underline; }
|
||||||
@@ -377,18 +381,18 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.new-rule-form {
|
.new-rule-form {
|
||||||
display: flex; flex-direction: column; gap: 0.5rem;
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
padding: 0.75rem; margin: 0.5rem 0;
|
padding: 0.75rem; margin: 0.5rem 0;
|
||||||
background: var(--color-bg, #111113);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--color-border); border-radius: 6px;
|
||||||
}
|
}
|
||||||
.new-rule-form input, .new-rule-form textarea {
|
.new-rule-form input, .new-rule-form textarea {
|
||||||
background: var(--color-surface, #18181b); color: inherit;
|
background: var(--color-surface); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--color-border); border-radius: 6px;
|
||||||
padding: 0.5rem; font: inherit; resize: vertical;
|
padding: 0.5rem; font: inherit; resize: vertical;
|
||||||
}
|
}
|
||||||
.rule-list { margin-top: 0.5rem; }
|
.rule-list { margin-top: 0.5rem; }
|
||||||
.delete-link {
|
.delete-link {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
color: var(--color-destructive); padding: 0.5rem 0 0 0;
|
||||||
}
|
}
|
||||||
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||||
.topic-group h5 {
|
.topic-group h5 {
|
||||||
@@ -400,14 +404,14 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.rule-head-text { flex: 1; cursor: pointer; }
|
.rule-head-text { flex: 1; cursor: pointer; }
|
||||||
.skip-btn {
|
.skip-btn {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-muted, #888); font-size: 0.75rem;
|
color: var(--color-muted); font-size: 0.75rem;
|
||||||
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.topic-group h5:hover .skip-btn,
|
.topic-group h5:hover .skip-btn,
|
||||||
.rule:hover .skip-btn,
|
.rule:hover .skip-btn,
|
||||||
.skip-btn:focus { opacity: 1; }
|
.skip-btn:focus { opacity: 1; }
|
||||||
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
.skip-btn:hover { color: var(--color-destructive); }
|
||||||
/* Suppressed section */
|
/* Suppressed section */
|
||||||
.suppressed { margin-top: 1.5rem; }
|
.suppressed { margin-top: 1.5rem; }
|
||||||
.suppressed-toggle {
|
.suppressed-toggle {
|
||||||
@@ -426,13 +430,13 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.suppressed-kind {
|
.suppressed-kind {
|
||||||
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||||
background: var(--color-bg, #111113);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--color-border, #2a2a2e);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.suppressed-path { flex: 1; }
|
.suppressed-path { flex: 1; }
|
||||||
.reenable-btn {
|
.reenable-btn {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
color: var(--color-primary); font-size: 0.85em;
|
||||||
}
|
}
|
||||||
.reenable-btn:hover { text-decoration: underline; }
|
.reenable-btn:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
|
|||||||
.slide-over {
|
.slide-over {
|
||||||
position: fixed; top: 0; right: 0; bottom: 0;
|
position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
width: min(520px, 90vw);
|
width: min(520px, 90vw);
|
||||||
background: var(--color-surface, #18181b);
|
background: var(--color-surface);
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--color-primary);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||||
@@ -110,11 +110,11 @@ header h2 {
|
|||||||
font-family: Fraunces, serif; font-style: italic;
|
font-family: Fraunces, serif; font-style: italic;
|
||||||
}
|
}
|
||||||
label { display: block; margin-bottom: 1rem; }
|
label { display: block; margin-bottom: 1rem; }
|
||||||
.required { color: var(--color-primary, #6366f1); }
|
.required { color: var(--color-primary); }
|
||||||
input, textarea {
|
input, textarea {
|
||||||
width: 100%; margin-top: 0.25rem;
|
width: 100%; margin-top: 0.25rem;
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--color-bg); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--color-border); border-radius: 6px;
|
||||||
padding: 0.5rem; font: inherit;
|
padding: 0.5rem; font: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,18 @@ const emit = defineEmits<{
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li {
|
li {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--color-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--color-hover); }
|
||||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||||
.new-rule { cursor: pointer; }
|
.new-rule { cursor: pointer; }
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
|
||||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
.always-on-toggle {
|
.always-on-toggle {
|
||||||
@@ -133,18 +133,23 @@ header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem
|
|||||||
.always-on-toggle input { cursor: pointer; }
|
.always-on-toggle input { cursor: pointer; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
li.active { background: var(--color-primary-bg); }
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--color-hover); }
|
||||||
|
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
|
||||||
|
button-or-form whose children style themselves; the second is a `<ul>`, and
|
||||||
|
the bare `ul` rule above already gives it list-style, padding and margin —
|
||||||
|
a base a class-name check cannot see, since it comes from an element
|
||||||
|
selector. Both namespace descendant rules and assume nothing about layout. */
|
||||||
.new-topic input {
|
.new-topic input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--color-bg); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--color-border); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
.subscriptions {
|
.subscriptions {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
border-top: 1px solid var(--color-border);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|||||||
@@ -48,27 +48,27 @@ async function submitNew() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
li.active { background: var(--color-primary-bg); }
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--color-hover); }
|
||||||
.always-on-badge {
|
.always-on-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--color-accent, rgba(91,74,138,0.25));
|
background: var(--color-accent);
|
||||||
color: var(--color-accent-fg, inherit);
|
color: var(--color-accent-fg);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.new-rulebook { margin-top: 1rem; }
|
.new-rulebook { margin-top: 1rem; }
|
||||||
.new-rulebook input {
|
.new-rulebook input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--color-bg); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--color-border); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
|
|||||||
@@ -110,15 +110,11 @@ const router = createRouter({
|
|||||||
component: () => import("@/views/RulesView.vue"),
|
component: () => import("@/views/RulesView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Meta-surface, same family as /rules: it describes the app rather than
|
// The design systems this install RECORDS — for the projects it tracks,
|
||||||
// holding the operator's records.
|
// not for the install itself. There was a sibling `/design` that read the
|
||||||
path: "/design",
|
// running app's own stylesheet out of the browser; it could only ever
|
||||||
name: "design",
|
// inspect the instance it was served from, which made it a mirror rather
|
||||||
component: () => import("@/views/DesignView.vue"),
|
// than a tool (#274).
|
||||||
},
|
|
||||||
{
|
|
||||||
// The editable half of the same surface: /design is what the browser
|
|
||||||
// renders, /design-systems is the record that ought to decide it.
|
|
||||||
path: "/design-systems",
|
path: "/design-systems",
|
||||||
name: "design-systems",
|
name: "design-systems",
|
||||||
component: () => import("@/views/DesignSystemsView.vue"),
|
component: () => import("@/views/DesignSystemsView.vue"),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface Note {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
consolidated_at: string | null;
|
|
||||||
tags: string[];
|
tags: string[];
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
parent_title?: string | null;
|
parent_title?: string | null;
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
|
||||||
*
|
|
||||||
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
|
||||||
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
|
||||||
* can assert on it. What's left here is set arithmetic over live token values,
|
|
||||||
* which is the one thing the browser knows and the server doesn't.
|
|
||||||
*
|
|
||||||
* SCOPE, and it is a real limit rather than an omission. This compares the
|
|
||||||
* rulebook against the TOKENS. It cannot see the third category of drift — a
|
|
||||||
* literal hardcoded in a component where a token should be referenced (#2275,
|
|
||||||
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
|
||||||
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
|
||||||
*
|
|
||||||
* Catching it needs the component sources, which would mean bundling every SFC
|
|
||||||
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
|
|
||||||
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
|
|
||||||
* matters: a drift report that silently omits a category invites the reader to
|
|
||||||
* conclude the category is clean.
|
|
||||||
*/
|
|
||||||
import type { DesignToken } from "@/utils/designTokens";
|
|
||||||
|
|
||||||
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
|
||||||
|
|
||||||
export interface Expectation {
|
|
||||||
kind: ExpectationKind;
|
|
||||||
value: string;
|
|
||||||
rule_id: number;
|
|
||||||
rule_title: string;
|
|
||||||
context: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExpectationResponse {
|
|
||||||
rulebook_id: number | null;
|
|
||||||
expectations: Expectation[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FindingStatus = "ok" | "missing" | "violated";
|
|
||||||
|
|
||||||
export interface Finding {
|
|
||||||
expectation: Expectation;
|
|
||||||
status: FindingStatus;
|
|
||||||
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
|
||||||
matches: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise a colour for comparison — the client-side twin of
|
|
||||||
* `normalize_hex` in services/design_system.py.
|
|
||||||
*
|
|
||||||
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
|
||||||
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
|
||||||
* spellings of one colour, and a comparison that misses any of them under-reports
|
|
||||||
* rather than erroring. The rgb() case is browser-specific and therefore has no
|
|
||||||
* server-side counterpart, which is exactly why it is handled here.
|
|
||||||
*/
|
|
||||||
export function normalizeColour(value: string): string | null {
|
|
||||||
const raw = value.trim().toLowerCase();
|
|
||||||
|
|
||||||
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
|
||||||
if (hex) {
|
|
||||||
let digits = hex[1];
|
|
||||||
if (digits.length === 3 || digits.length === 4) {
|
|
||||||
digits = digits.split("").map((c) => c + c).join("");
|
|
||||||
}
|
|
||||||
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
|
|
||||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
|
||||||
if (rgb) {
|
|
||||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
|
||||||
if (parts.length < 3) return null;
|
|
||||||
const channels = parts.slice(0, 3).map((p) => Number(p));
|
|
||||||
if (channels.some((n) => !Number.isFinite(n))) return null;
|
|
||||||
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
|
||||||
const base = `#${channels.map(hexOf).join("")}`;
|
|
||||||
if (parts.length === 3) return base;
|
|
||||||
const alpha = Number(parts[3]);
|
|
||||||
if (!Number.isFinite(alpha) || alpha >= 1) return base;
|
|
||||||
return `${base}${hexOf(alpha * 255)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
|
||||||
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
|
||||||
const index = new Map<string, string[]>();
|
|
||||||
for (const token of tokens) {
|
|
||||||
const colour = normalizeColour(token.value);
|
|
||||||
if (!colour) continue;
|
|
||||||
const names = index.get(colour);
|
|
||||||
if (names) names.push(token.name);
|
|
||||||
else index.set(colour, [token.name]);
|
|
||||||
}
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare claims against the live tokens.
|
|
||||||
*
|
|
||||||
* A `token` claim asks whether a custom property of that name exists.
|
|
||||||
* A `color` claim asks whether any token resolves to that value.
|
|
||||||
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
|
||||||
*/
|
|
||||||
export function compareToTokens(
|
|
||||||
expectations: Expectation[],
|
|
||||||
tokens: DesignToken[],
|
|
||||||
): Finding[] {
|
|
||||||
const names = new Set(tokens.map((t) => t.name));
|
|
||||||
const colours = colourIndex(tokens);
|
|
||||||
|
|
||||||
return expectations.map((expectation) => {
|
|
||||||
if (expectation.kind === "token") {
|
|
||||||
const present = names.has(expectation.value);
|
|
||||||
return {
|
|
||||||
expectation,
|
|
||||||
status: present ? "ok" : "missing",
|
|
||||||
matches: present ? [expectation.value] : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = colours.get(expectation.value) ?? [];
|
|
||||||
if (expectation.kind === "prohibited_color") {
|
|
||||||
return {
|
|
||||||
expectation,
|
|
||||||
status: matches.length ? "violated" : "ok",
|
|
||||||
matches,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
expectation,
|
|
||||||
status: matches.length ? "ok" : "missing",
|
|
||||||
matches,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriftSummary {
|
|
||||||
ok: number;
|
|
||||||
missing: number;
|
|
||||||
violated: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarise(findings: Finding[]): DriftSummary {
|
|
||||||
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
|
||||||
for (const finding of findings) summary[finding.status] += 1;
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Findings worth leading with.
|
|
||||||
*
|
|
||||||
* A panel that opens with every row gets closed and never reopened — the same
|
|
||||||
* principle the auto-inject menu is built on: a short list that gets read beats
|
|
||||||
* a complete one that doesn't. Violations first (something is actively wrong),
|
|
||||||
* then missing (something was never built), and `ok` rows are not "findings" at
|
|
||||||
* all — they belong behind an expansion.
|
|
||||||
*/
|
|
||||||
export function rankFindings(findings: Finding[]): Finding[] {
|
|
||||||
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
|
||||||
return [...findings].sort((a, b) => {
|
|
||||||
const byStatus = order[a.status] - order[b.status];
|
|
||||||
if (byStatus !== 0) return byStatus;
|
|
||||||
return a.expectation.rule_id - b.expectation.rule_id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
|
||||||
*
|
|
||||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
|
||||||
* against these, and the drift panel compares them to the design rulebook.
|
|
||||||
*
|
|
||||||
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
|
||||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
|
||||||
* its VALUE is not: values contain nested parens, commas inside rgba(),
|
|
||||||
* `var()` references to other tokens, multi-part shadows, and gradients — and
|
|
||||||
* `theme.css` has all of those today. So we take the names from the source and
|
|
||||||
* ask the BROWSER for every value.
|
|
||||||
*
|
|
||||||
* That is not just easier, it is more correct. getComputedStyle reports what
|
|
||||||
* actually won the cascade, resolves `var()` chains, and — critically for this
|
|
||||||
* milestone — reflects live overrides set on a container, which is exactly what
|
|
||||||
* the preview surface needs (see #2261). Parsing the source would report what
|
|
||||||
* the file says rather than what the user is looking at.
|
|
||||||
*
|
|
||||||
* It also means this module needs no unit tests to be trustworthy: the only
|
|
||||||
* logic here is a name regex and a group lookup. The frontend has no test
|
|
||||||
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
|
|
||||||
* error-prone half in the browser rather than in our code is deliberate.
|
|
||||||
*/
|
|
||||||
import themeCss from "@/assets/theme.css?raw";
|
|
||||||
|
|
||||||
export type TokenGroup =
|
|
||||||
| "color"
|
|
||||||
| "radius"
|
|
||||||
| "gradient"
|
|
||||||
| "glow"
|
|
||||||
| "focus"
|
|
||||||
| "layout"
|
|
||||||
| "other";
|
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark";
|
|
||||||
|
|
||||||
export interface DesignToken {
|
|
||||||
/** Full custom-property name, including the leading `--`. */
|
|
||||||
name: string;
|
|
||||||
/** Coarse family, derived from the name prefix. */
|
|
||||||
group: TokenGroup;
|
|
||||||
/** Resolved value in the requested context, straight from the browser. */
|
|
||||||
value: string;
|
|
||||||
/** True when the declaration appears inside the dark block in source. */
|
|
||||||
overriddenInDark: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
|
|
||||||
*
|
|
||||||
* The discriminator is the COLON, not the preceding character. A declaration is
|
|
||||||
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
|
|
||||||
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
|
|
||||||
* actively wrong: an earlier version required the match to follow `{` or `;`,
|
|
||||||
* which silently dropped every declaration that came after a comment —
|
|
||||||
* including `--color-bg`, the first and most-used token in the file.
|
|
||||||
*/
|
|
||||||
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
|
||||||
|
|
||||||
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
|
||||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
|
||||||
|
|
||||||
/** The dark block's selector, as written in theme.css. */
|
|
||||||
const DARK_SELECTOR = '[data-theme="dark"]';
|
|
||||||
|
|
||||||
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
|
||||||
["--color-", "color"],
|
|
||||||
["--radius-", "radius"],
|
|
||||||
["--gradient-", "gradient"],
|
|
||||||
["--glow-", "glow"],
|
|
||||||
["--focus-", "focus"],
|
|
||||||
["--page-", "layout"],
|
|
||||||
["--sidebar-", "layout"],
|
|
||||||
["--chat-", "layout"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function groupFor(name: string): TokenGroup {
|
|
||||||
for (const [prefix, group] of GROUP_PREFIXES) {
|
|
||||||
if (name.startsWith(prefix)) return group;
|
|
||||||
}
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
|
|
||||||
export function tokenNames(css: string = themeCss): string[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
|
|
||||||
const name = match[1];
|
|
||||||
if (!seen.has(name)) {
|
|
||||||
seen.add(name);
|
|
||||||
out.push(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
|
||||||
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
|
||||||
const bare = css.replace(COMMENT, "");
|
|
||||||
const start = bare.indexOf(DARK_SELECTOR);
|
|
||||||
if (start === -1) return new Set();
|
|
||||||
const open = bare.indexOf("{", start);
|
|
||||||
const close = bare.indexOf("}", open);
|
|
||||||
if (open === -1 || close === -1) return new Set();
|
|
||||||
return new Set(tokenNames(bare.slice(open, close)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the resolved value of every token in `host`'s context.
|
|
||||||
*
|
|
||||||
* Pass a container to read the tokens as they apply INSIDE it — which is how
|
|
||||||
* the preview surface reads a scoped override without disturbing the page.
|
|
||||||
* Defaults to the document root, i.e. the app-wide values.
|
|
||||||
*/
|
|
||||||
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
|
||||||
const computed = getComputedStyle(host);
|
|
||||||
const dark = darkOverriddenNames();
|
|
||||||
return tokenNames().map((name) => ({
|
|
||||||
name,
|
|
||||||
group: groupFor(name),
|
|
||||||
value: computed.getPropertyValue(name).trim(),
|
|
||||||
overriddenInDark: dark.has(name),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read tokens as they would resolve in a given mode, without touching the page.
|
|
||||||
*
|
|
||||||
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
|
|
||||||
* mutated to take a reading.
|
|
||||||
*
|
|
||||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
|
||||||
* function: light is declared on `:root` while dark is declared on
|
|
||||||
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
|
||||||
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
|
||||||
* back. So reading "light" from inside a dark page returns the dark values —
|
|
||||||
* the probe has nothing to match.
|
|
||||||
*
|
|
||||||
* Concretely: dark-inside-light previews work, light-inside-dark previews do
|
|
||||||
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
|
||||||
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
|
||||||
* should treat a cross-mode read as best-effort.
|
|
||||||
*/
|
|
||||||
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
|
||||||
const probe = document.createElement("div");
|
|
||||||
probe.setAttribute("data-theme", mode);
|
|
||||||
probe.style.display = "none";
|
|
||||||
document.body.appendChild(probe);
|
|
||||||
try {
|
|
||||||
return readTokens(probe);
|
|
||||||
} finally {
|
|
||||||
probe.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tokens grouped by family, preserving source order within each group. */
|
|
||||||
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
|
|
||||||
const out = new Map<TokenGroup, DesignToken[]>();
|
|
||||||
for (const token of tokens) {
|
|
||||||
const bucket = out.get(token.group);
|
|
||||||
if (bucket) bucket.push(token);
|
|
||||||
else out.set(token.group, [token]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokens declared in the stylesheet that nothing references with `var()`.
|
|
||||||
*
|
|
||||||
* Dead tokens are drift too: `--chat-reading-width` and
|
|
||||||
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
|
|
||||||
* in the MCP-first pivot, and nothing has referenced them since. Takes the
|
|
||||||
* corpus of source files to search as an argument so the caller decides what
|
|
||||||
* "used" means — this module has no opinion about the project layout.
|
|
||||||
*/
|
|
||||||
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
|
|
||||||
const haystack = sources.join("\n");
|
|
||||||
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Turning a design system's RECORDED values into ones you can look at.
|
||||||
|
*
|
||||||
|
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
|
||||||
|
* running app's own stylesheet — names out of a bundled `theme.css`, values out
|
||||||
|
* of `getComputedStyle(document.documentElement)`. That could only ever describe
|
||||||
|
* the install serving the page, and the design surface is for the projects an
|
||||||
|
* install TRACKS (#274). What is left here works on any system's record,
|
||||||
|
* including one for an app this browser has never loaded.
|
||||||
|
*
|
||||||
|
* Nothing in this module reads the document's own tokens or mutates the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
|
||||||
|
export const BASE_MODE = "base";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which declared value applies in `mode`.
|
||||||
|
*
|
||||||
|
* Falls back to base, which is the storage model rather than a convenience: a
|
||||||
|
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
||||||
|
* mode is not missing — it is inheriting, exactly as the generated sheet has it.
|
||||||
|
*/
|
||||||
|
export function valueForMode(
|
||||||
|
valueByMode: Record<string, string>,
|
||||||
|
mode: string,
|
||||||
|
): string {
|
||||||
|
const own = valueByMode[mode];
|
||||||
|
if (own !== undefined && own !== "") return own;
|
||||||
|
return valueByMode[BASE_MODE] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every mode any token in the set declares, base first then the rest by name. */
|
||||||
|
export function modesPresent(
|
||||||
|
tokens: { value_by_mode: Record<string, string> }[],
|
||||||
|
): string[] {
|
||||||
|
const modes = new Set<string>();
|
||||||
|
for (const token of tokens) {
|
||||||
|
for (const [mode, value] of Object.entries(token.value_by_mode)) {
|
||||||
|
if (value) modes.add(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
|
||||||
|
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve declared values the way a browser would, without applying them.
|
||||||
|
*
|
||||||
|
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
|
||||||
|
* as text that is a string; shown as a swatch it needs `var()` substituted and
|
||||||
|
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
|
||||||
|
* offscreen probe and read them back — the substitution is done by the
|
||||||
|
* implementation that would do it for real.
|
||||||
|
*
|
||||||
|
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
|
||||||
|
* sitting in this page would resolve any reference the record leaves undeclared
|
||||||
|
* against the surrounding app's own tokens. Previewing another project's system
|
||||||
|
* would then quietly borrow this one's palette wherever that system was
|
||||||
|
* incomplete, and a token the record already knows is broken (it shows up under
|
||||||
|
* `unknown_refs`) would render as though it were fine.
|
||||||
|
*
|
||||||
|
* So every name referenced but not declared is blanked on the probe first. It
|
||||||
|
* resolves to nothing, which is what the record says it is.
|
||||||
|
*/
|
||||||
|
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
||||||
|
|
||||||
|
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.style.display = "none";
|
||||||
|
for (const value of declared.values()) {
|
||||||
|
for (const match of value.matchAll(VAR_REFERENCE)) {
|
||||||
|
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, value] of declared) probe.style.setProperty(name, value);
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
try {
|
||||||
|
const computed = getComputedStyle(probe);
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const name of declared.keys()) {
|
||||||
|
out.set(name, computed.getPropertyValue(name).trim());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
probe.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||||
|
/* `.dash-head` is deliberately bare: a block header whose two children carry
|
||||||
|
all the spacing between them (h1 zeroed, .dash-sub margined). Nothing in it
|
||||||
|
assumes a flex parent, which is the tell for a deleted rule (#2444). */
|
||||||
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
||||||
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
||||||
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Design systems — editing the stylesheet Scribe holds (milestone #254 step 5).
|
* Design systems — the stylesheets this install RECORDS, for the projects it
|
||||||
|
* tracks (milestone #254 step 5).
|
||||||
*
|
*
|
||||||
* Sibling of /design, which shows the system as the BROWSER has it. This page
|
* It had a sibling, `/design`, which showed the system as the BROWSER had it —
|
||||||
* shows it as the RECORD has it, which is the half you can change.
|
* names out of a bundled stylesheet, values out of `getComputedStyle`. That
|
||||||
|
* could only ever describe the install serving the page, so it was a mirror
|
||||||
|
* rather than a tool and was retired (#274). Everything here works on a system
|
||||||
|
* whose app this browser has never loaded.
|
||||||
*
|
*
|
||||||
* The layout follows the model rather than decorating it. A system with a
|
* The layout follows the model rather than decorating it. A system with a
|
||||||
* parent holds only what it changes, so this page has two lists and they are
|
* parent holds only what it changes, so this page has two lists and they are
|
||||||
@@ -39,9 +43,10 @@ import {
|
|||||||
type SnippetCheck,
|
type SnippetCheck,
|
||||||
type StylesheetResult,
|
type StylesheetResult,
|
||||||
} from "@/api/designSystems";
|
} from "@/api/designSystems";
|
||||||
import DesignTabs from "@/components/DesignTabs.vue";
|
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
||||||
|
import TokenPreview from "@/components/TokenPreview.vue";
|
||||||
|
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
@@ -148,6 +153,10 @@ const newTitle = ref("");
|
|||||||
const newDescription = ref("");
|
const newDescription = ref("");
|
||||||
const newParentId = ref<number | null>(null);
|
const newParentId = ref<number | null>(null);
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
|
// Starter roles (#2349). The picker fills these on mount; empty means the
|
||||||
|
// operator unchecked everything, which is a real answer.
|
||||||
|
const starterGroups = ref<string[]>([]);
|
||||||
|
const tokenPrefix = ref("");
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
const title = newTitle.value.trim();
|
const title = newTitle.value.trim();
|
||||||
@@ -158,11 +167,16 @@ async function submitCreate() {
|
|||||||
title,
|
title,
|
||||||
description: newDescription.value.trim() || undefined,
|
description: newDescription.value.trim() || undefined,
|
||||||
parent_id: newParentId.value,
|
parent_id: newParentId.value,
|
||||||
|
starter_role_groups: starterGroups.value.length ? starterGroups.value : undefined,
|
||||||
|
token_prefix: tokenPrefix.value.trim() || undefined,
|
||||||
});
|
});
|
||||||
newTitle.value = "";
|
newTitle.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
newParentId.value = null;
|
newParentId.value = null;
|
||||||
showCreate.value = false;
|
showCreate.value = false;
|
||||||
|
// NOT reset: the picker owns these and re-seeds on mount. Clearing them
|
||||||
|
// here would race the next mount and silently create the following system
|
||||||
|
// with no roles at all.
|
||||||
await loadSystems();
|
await loadSystems();
|
||||||
selectedId.value = created.id;
|
selectedId.value = created.id;
|
||||||
toast.show(`Created ${created.title}`);
|
toast.show(`Created ${created.title}`);
|
||||||
@@ -479,14 +493,28 @@ watch(selectedId, () => {
|
|||||||
snippetCheck.value = null;
|
snippetCheck.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function isColourish(value: string): boolean {
|
/**
|
||||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
* A colour this row can draw HONESTLY — self-contained, no `var()` inside.
|
||||||
|
*
|
||||||
|
* The provenance list below shows values as the record states them, and a
|
||||||
|
* `var()` reference states nothing on its own: rendering
|
||||||
|
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves
|
||||||
|
* `--accent` against THIS app, so a system that isn't the one Scribe runs on
|
||||||
|
* would be drawn in Scribe's palette. It looked right, which is why it went
|
||||||
|
* unnoticed (#274).
|
||||||
|
*
|
||||||
|
* The preview above resolves values properly, on a probe carrying only that
|
||||||
|
* system's declarations. So this list draws only what needs no resolving, and
|
||||||
|
* leaves the rest to the surface built for it.
|
||||||
|
*/
|
||||||
|
function isSelfContainedColour(value: string): boolean {
|
||||||
|
const v = value.trim();
|
||||||
|
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(v) && !v.includes("var(");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ds-view">
|
<div class="ds-view">
|
||||||
<DesignTabs />
|
|
||||||
|
|
||||||
<header class="ds-header">
|
<header class="ds-header">
|
||||||
<h1>Design systems</h1>
|
<h1>Design systems</h1>
|
||||||
@@ -540,6 +568,10 @@ function isColourish(value: string): boolean {
|
|||||||
placeholder="What it covers"
|
placeholder="What it covers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<StarterRolePicker
|
||||||
|
v-model:selected="starterGroups"
|
||||||
|
v-model:prefix="tokenPrefix"
|
||||||
|
/>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
||||||
{{ creating ? "Creating…" : "Create" }}
|
{{ creating ? "Creating…" : "Create" }}
|
||||||
@@ -601,6 +633,10 @@ function isColourish(value: string): boolean {
|
|||||||
A system with a parent stores only its differences from it.
|
A system with a parent stores only its differences from it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<StarterRolePicker
|
||||||
|
v-model:selected="starterGroups"
|
||||||
|
v-model:prefix="tokenPrefix"
|
||||||
|
/>
|
||||||
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
<button class="btn-primary" :disabled="!newTitle.trim() || creating" @click="submitCreate">
|
||||||
{{ creating ? "Creating…" : "Create" }}
|
{{ creating ? "Creating…" : "Create" }}
|
||||||
</button>
|
</button>
|
||||||
@@ -750,7 +786,7 @@ function isColourish(value: string): boolean {
|
|||||||
<ul class="dupe-list">
|
<ul class="dupe-list">
|
||||||
<li v-for="[value, names] in duplicateEntries" :key="value">
|
<li v-for="[value, names] in duplicateEntries" :key="value">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(value)" class="swatch"
|
v-if="isSelfContainedColour(value)" class="swatch"
|
||||||
:style="{ background: value }" aria-hidden="true"
|
:style="{ background: value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<code>{{ value }}</code> — {{ names.join(", ") }}
|
<code>{{ value }}</code> — {{ names.join(", ") }}
|
||||||
@@ -908,7 +944,7 @@ function isColourish(value: string): boolean {
|
|||||||
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
|
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
|
||||||
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(row.value)" class="swatch"
|
v-if="isSelfContainedColour(row.value)" class="swatch"
|
||||||
:style="{ background: row.value }" aria-hidden="true"
|
:style="{ background: row.value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -946,7 +982,7 @@ function isColourish(value: string): boolean {
|
|||||||
<span class="token-values">
|
<span class="token-values">
|
||||||
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
|
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(value)" class="swatch"
|
v-if="isSelfContainedColour(value)" class="swatch"
|
||||||
:style="{ background: value }" aria-hidden="true"
|
:style="{ background: value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="mode-name">{{ mode }}</span>
|
<span class="mode-name">{{ mode }}</span>
|
||||||
@@ -969,6 +1005,18 @@ function isColourish(value: string): boolean {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- What it looks like. Drawn from the record on an isolated probe,
|
||||||
|
so this is THIS system's palette even when the app around it is
|
||||||
|
running a different one. -->
|
||||||
|
<section v-if="resolved.length" class="ds-section">
|
||||||
|
<h2>Preview</h2>
|
||||||
|
<p class="section-note">
|
||||||
|
{{ selected.title }} as it would render — resolved from the record,
|
||||||
|
not from the stylesheet this app happens to be running.
|
||||||
|
</p>
|
||||||
|
<TokenPreview :tokens="resolved" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Effective set -->
|
<!-- Effective set -->
|
||||||
<section class="ds-section">
|
<section class="ds-section">
|
||||||
<h2>Effective tokens</h2>
|
<h2>Effective tokens</h2>
|
||||||
@@ -1002,7 +1050,7 @@ function isColourish(value: string): boolean {
|
|||||||
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
|
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
|
||||||
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
|
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(origin.value)" class="swatch"
|
v-if="isSelfContainedColour(origin.value)" class="swatch"
|
||||||
:style="{ background: origin.value }" aria-hidden="true"
|
:style="{ background: origin.value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="mode-name">{{ origin.mode }}</span>
|
<span class="mode-name">{{ origin.mode }}</span>
|
||||||
@@ -1023,7 +1071,7 @@ function isColourish(value: string): boolean {
|
|||||||
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
|
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
|
||||||
<span class="mode-chip">
|
<span class="mode-chip">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(origin.value)" class="swatch"
|
v-if="isSelfContainedColour(origin.value)" class="swatch"
|
||||||
:style="{ background: origin.value }" aria-hidden="true"
|
:style="{ background: origin.value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="mode-name">{{ origin.mode }}</span>
|
<span class="mode-name">{{ origin.mode }}</span>
|
||||||
@@ -1054,6 +1102,12 @@ function isColourish(value: string): boolean {
|
|||||||
padding: 1.5rem 1rem 4rem;
|
padding: 1.5rem 1rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Restored with the same sweep that took `.milestone-header` — only the `h1`
|
||||||
|
descendant rule survived, so the header had no spacing of its own. */
|
||||||
|
.ds-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ds-header h1 {
|
.ds-header h1 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
|
|||||||
@@ -1,545 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Design explorer — the gallery (milestone #251 step 3).
|
|
||||||
*
|
|
||||||
* Renders the design system against the tokens that are actually live, read at
|
|
||||||
* runtime rather than parsed from source, so what you see here is what the app
|
|
||||||
* is using right now.
|
|
||||||
*
|
|
||||||
* HONESTY RULE, and the reason parts of this page say "not implemented":
|
|
||||||
* a gallery of hand-written look-alikes drifts from the app within a month and
|
|
||||||
* then lies — which is the same failure this whole surface exists to catch. So
|
|
||||||
* every specimen below is either a REAL component imported from the app, or a
|
|
||||||
* real token read from the browser, or it is explicitly marked as missing.
|
|
||||||
*
|
|
||||||
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
|
|
||||||
* in five `<style scoped>` blocks, all five drifted, and this page reported it
|
|
||||||
* as a gap because drawing a look-alike would have made it a sixth copy.
|
|
||||||
* `assets/components.css` is now the single definition (#2273), so the
|
|
||||||
* specimens below are the app's real classes — they cannot drift from the app
|
|
||||||
* without drifting the app itself.
|
|
||||||
*/
|
|
||||||
import { computed, onMounted, ref } from "vue";
|
|
||||||
|
|
||||||
import { fetchDesignExpectations } from "@/api/design";
|
|
||||||
import DesignTabs from "@/components/DesignTabs.vue";
|
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
|
||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
|
||||||
import TagPill from "@/components/TagPill.vue";
|
|
||||||
import {
|
|
||||||
compareToTokens,
|
|
||||||
rankFindings,
|
|
||||||
summarise,
|
|
||||||
type Expectation,
|
|
||||||
type Finding,
|
|
||||||
} from "@/utils/designDrift";
|
|
||||||
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
|
||||||
|
|
||||||
const tokens = ref<DesignToken[]>([]);
|
|
||||||
const expectations = ref<Expectation[]>([]);
|
|
||||||
const designRulebookId = ref<number | null>(null);
|
|
||||||
const driftLoaded = ref(false);
|
|
||||||
const showCleanRows = ref(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
|
||||||
* which needs the app's stylesheets applied and the theme attribute set.
|
|
||||||
*/
|
|
||||||
onMounted(async () => {
|
|
||||||
tokens.value = readTokens();
|
|
||||||
try {
|
|
||||||
const response = await fetchDesignExpectations();
|
|
||||||
designRulebookId.value = response.rulebook_id;
|
|
||||||
expectations.value = response.expectations;
|
|
||||||
} catch {
|
|
||||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
|
||||||
// "no drift data" rather than taking the page down with it.
|
|
||||||
designRulebookId.value = null;
|
|
||||||
} finally {
|
|
||||||
driftLoaded.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const findings = computed<Finding[]>(() =>
|
|
||||||
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
|
||||||
);
|
|
||||||
const driftSummary = computed(() => summarise(findings.value));
|
|
||||||
const visibleFindings = computed(() =>
|
|
||||||
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const grouped = computed(() => groupTokens(tokens.value));
|
|
||||||
|
|
||||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
|
||||||
const orderedGroups = computed(() =>
|
|
||||||
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** A token whose value reads as a colour is worth showing as a swatch. */
|
|
||||||
function isColourish(value: string): boolean {
|
|
||||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
|
|
||||||
const RULEBOOK_BUTTONS = [
|
|
||||||
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
|
|
||||||
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
|
|
||||||
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
|
|
||||||
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TYPE_SPECIMENS = [
|
|
||||||
{ token: "Display", spec: "40 / 500 / Fraunces" },
|
|
||||||
{ token: "H1", spec: "32 / 500 / Fraunces" },
|
|
||||||
{ token: "H2", spec: "24 / 500 / Fraunces" },
|
|
||||||
{ token: "H3", spec: "18 / 500 / Inter" },
|
|
||||||
{ token: "Body", spec: "15 / 400 / Inter" },
|
|
||||||
{ token: "Body small", spec: "13 / 400 / Inter" },
|
|
||||||
{ token: "Label", spec: "12 / 500 / Inter" },
|
|
||||||
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
|
|
||||||
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="design-view">
|
|
||||||
<DesignTabs />
|
|
||||||
|
|
||||||
<header class="design-header">
|
|
||||||
<h1>Live tokens</h1>
|
|
||||||
<p class="lede">
|
|
||||||
The system as it actually is. Token values are read from the browser at
|
|
||||||
runtime, so this page reflects the live cascade rather than what the
|
|
||||||
stylesheet says. Components shown are the real ones — where a piece of
|
|
||||||
the system has no shared implementation, it is marked missing rather
|
|
||||||
than mocked up.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
|
||||||
<section class="design-section">
|
|
||||||
<h2>Rulebook drift</h2>
|
|
||||||
|
|
||||||
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
|
||||||
|
|
||||||
<div v-else-if="designRulebookId === null" class="gap-notice">
|
|
||||||
<strong>No design rulebook designated.</strong>
|
|
||||||
<p>
|
|
||||||
This install hasn't said which rulebook describes its design system, so
|
|
||||||
there is nothing to check the tokens against. Designate one in
|
|
||||||
<router-link to="/settings">Settings</router-link> and this panel will
|
|
||||||
compare every colour and token the rulebook names against what the
|
|
||||||
stylesheet actually resolves to.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<p class="section-note">
|
|
||||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
|
||||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
|
||||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
|
||||||
claims in rulebook #{{ designRulebookId }}.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="gap-notice">
|
|
||||||
<strong>This compares the rulebook against the TOKENS only.</strong>
|
|
||||||
<p>
|
|
||||||
A value hardcoded in a component — where a token should have been
|
|
||||||
referenced — is invisible here, because the drift isn't in the tokens
|
|
||||||
at all. Reading it would mean bundling every component's source into
|
|
||||||
the app. That check belongs in CI and is tracked separately, so treat
|
|
||||||
a clean panel as "the tokens agree", not "the app agrees".
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="!findings.length" class="muted">
|
|
||||||
The rulebook names nothing this panel can check. Rules that state values
|
|
||||||
— colours, token names — produce claims; rules that state judgement
|
|
||||||
don't, by design.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul v-else class="spec-list">
|
|
||||||
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
|
||||||
<span class="spec-name">
|
|
||||||
<span
|
|
||||||
v-if="finding.expectation.kind !== 'token'"
|
|
||||||
class="swatch"
|
|
||||||
:style="{ background: finding.expectation.value }"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<code>{{ finding.expectation.value }}</code>
|
|
||||||
</span>
|
|
||||||
<span class="spec-detail">
|
|
||||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
|
||||||
<span v-if="finding.matches.length" class="matches">
|
|
||||||
· {{ finding.matches.join(", ") }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="spec-status" :class="finding.status">
|
|
||||||
{{ finding.status === "violated" ? "forbidden, but present"
|
|
||||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="findings.length && driftSummary.ok"
|
|
||||||
class="reveal-toggle"
|
|
||||||
@click="showCleanRows = !showCleanRows"
|
|
||||||
>
|
|
||||||
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Real components: these are imported, not recreated. -->
|
|
||||||
<section class="design-section">
|
|
||||||
<h2>Components</h2>
|
|
||||||
<p class="section-note">Imported from the app. What you see is what ships.</p>
|
|
||||||
|
|
||||||
<div class="specimen">
|
|
||||||
<span class="specimen-label">Status badge</span>
|
|
||||||
<div class="specimen-row">
|
|
||||||
<StatusBadge status="todo" />
|
|
||||||
<StatusBadge status="in_progress" />
|
|
||||||
<StatusBadge status="done" />
|
|
||||||
<StatusBadge status="cancelled" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="specimen">
|
|
||||||
<span class="specimen-label">Priority badge</span>
|
|
||||||
<div class="specimen-row">
|
|
||||||
<PriorityBadge priority="low" />
|
|
||||||
<PriorityBadge priority="medium" />
|
|
||||||
<PriorityBadge priority="high" />
|
|
||||||
<span class="muted">(<code>none</code> renders nothing, by design)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="specimen">
|
|
||||||
<span class="specimen-label">Tag pill</span>
|
|
||||||
<div class="specimen-row">
|
|
||||||
<TagPill tag="design-system" />
|
|
||||||
<TagPill tag="dismissible" dismissible />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- No longer a gap: these are the app's real classes, from the shared
|
|
||||||
sheet. Nothing here is a look-alike — change components.css and these
|
|
||||||
specimens change with it, which is the only way this page stays true. -->
|
|
||||||
<section class="design-section">
|
|
||||||
<h2>Buttons</h2>
|
|
||||||
<div class="button-specimens">
|
|
||||||
<button class="btn-primary">Save</button>
|
|
||||||
<button class="btn-secondary">Detect</button>
|
|
||||||
<button class="btn-ghost">Cancel</button>
|
|
||||||
<button class="btn-danger">Delete</button>
|
|
||||||
<button class="btn-primary" disabled>Disabled</button>
|
|
||||||
</div>
|
|
||||||
<p class="spec-caption">
|
|
||||||
Three sizes, because the app has three kinds of button: a page action, a
|
|
||||||
row action, and an affordance that sits inside a card without disturbing
|
|
||||||
its rhythm.
|
|
||||||
</p>
|
|
||||||
<div class="button-specimens">
|
|
||||||
<button class="btn-primary">Default — page action</button>
|
|
||||||
<button class="btn-primary btn-compact">Compact — row action</button>
|
|
||||||
<button class="btn-primary btn-inline">Inline</button>
|
|
||||||
</div>
|
|
||||||
<ul class="spec-list">
|
|
||||||
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
|
|
||||||
<span class="spec-name">{{ b.name }}</span>
|
|
||||||
<span class="spec-detail">{{ b.spec }}</span>
|
|
||||||
<span class="spec-status ok">shared</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
|
||||||
<section class="design-section">
|
|
||||||
<h2>Type scale</h2>
|
|
||||||
<div class="gap-notice">
|
|
||||||
<strong>Families load; the scale has no tokens.</strong>
|
|
||||||
<p>
|
|
||||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
|
||||||
scale is not expressed as custom properties, so sizes and weights are
|
|
||||||
set ad hoc per component. Listed here as specification, not as a live
|
|
||||||
specimen — there is nothing to read.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ul class="spec-list">
|
|
||||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
|
||||||
<span class="spec-name">{{ t.token }}</span>
|
|
||||||
<span class="spec-detail">{{ t.spec }}</span>
|
|
||||||
<span class="spec-status missing">no token</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Tokens: entirely real, read live. -->
|
|
||||||
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
|
|
||||||
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
|
|
||||||
<ul class="token-list">
|
|
||||||
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
|
|
||||||
<span
|
|
||||||
v-if="isColourish(token.value)"
|
|
||||||
class="swatch"
|
|
||||||
:style="{ background: token.value }"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
|
||||||
<code class="token-name">{{ token.name }}</code>
|
|
||||||
<code class="token-value">{{ token.value || "—" }}</code>
|
|
||||||
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
|
||||||
mode-aware
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p v-if="!tokens.length" class="muted">Reading tokens…</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.design-view {
|
|
||||||
max-width: var(--page-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem var(--page-padding-x) 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.design-header {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
max-width: 60ch;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.design-section {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.design-section h2 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-group-heading {
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-note,
|
|
||||||
.muted {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specimens -------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.specimen {
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.specimen-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.specimen-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gaps ------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.spec-caption {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
max-width: 60ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout only. The buttons inside style themselves from the shared sheet —
|
|
||||||
adding any appearance rule here would recreate the copy this section
|
|
||||||
just stopped being. */
|
|
||||||
.button-specimens {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--fs-space-3);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap-notice {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-left: 3px solid var(--color-warning);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap-notice p {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 70ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-list li {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.4rem 0;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-name {
|
|
||||||
min-width: 8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-detail {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-status {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-status.missing {
|
|
||||||
background: var(--color-priority-medium-bg);
|
|
||||||
color: var(--color-priority-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-status.violated {
|
|
||||||
background: var(--color-priority-high-bg);
|
|
||||||
color: var(--color-priority-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-status.ok {
|
|
||||||
background: var(--color-status-done-bg);
|
|
||||||
color: var(--color-status-done);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-name .swatch {
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matches {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal-toggle {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal-toggle:hover {
|
|
||||||
border-color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tokens ----------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.token-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.3rem 0;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swatch {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
flex: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.swatch-none {
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 3px,
|
|
||||||
var(--color-border) 3px,
|
|
||||||
var(--color-border) 4px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-name {
|
|
||||||
min-width: 16rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-value {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
flex: 1;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-flag {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 0.05rem 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.token-name {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -600,7 +600,7 @@ onUnmounted(() => {
|
|||||||
.graph-page {
|
.graph-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - var(--header-height, 52px));
|
height: calc(100vh - var(--header-height));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +750,7 @@ onUnmounted(() => {
|
|||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15));
|
box-shadow: 0 4px 16px var(--color-shadow);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ onUnmounted(() => {
|
|||||||
.knowledge-root {
|
.knowledge-root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - var(--header-height, 56px));
|
height: calc(100vh - var(--header-height));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +502,7 @@ onUnmounted(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -539,7 +539,7 @@ onUnmounted(() => {
|
|||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-right: 1px solid var(--color-border);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
@@ -548,7 +548,7 @@ onUnmounted(() => {
|
|||||||
content: '· · ·';
|
content: '· · ·';
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(91, 74, 138, 0.3);
|
color: color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
letter-spacing: 0.4em;
|
letter-spacing: 0.4em;
|
||||||
padding: 4px 0 12px;
|
padding: 4px 0 12px;
|
||||||
@@ -662,7 +662,7 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.filter-btn.active .filter-count {
|
.filter-btn.active .filter-count {
|
||||||
background: rgba(91, 74, 138, 0.2);
|
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.filter-tag { font-size: 0.78rem; }
|
.filter-tag { font-size: 0.78rem; }
|
||||||
@@ -683,7 +683,7 @@ onUnmounted(() => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.search-wrap {
|
.search-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -701,8 +701,8 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 12px 7px 32px;
|
padding: 7px 12px 7px 32px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
border: 1px solid var(--color-border);
|
||||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
background: var(--color-bg-tertiary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -712,8 +712,8 @@ onUnmounted(() => {
|
|||||||
.sort-select {
|
.sort-select {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
border: 1px solid var(--color-border);
|
||||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
background: var(--color-bg-tertiary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -735,9 +735,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.k-card {
|
.k-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--color-surface, rgba(255,255,255,0.03));
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border, rgba(255,255,255,0.07));
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg, 14px);
|
border-radius: var(--radius-lg);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||||
@@ -749,12 +749,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.k-card:hover {
|
.k-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 28px color-mix(in srgb, var(--color-primary) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
border-color: rgba(91, 74, 138, 0.35);
|
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Type-specific card DNA */
|
/* Type-specific card DNA */
|
||||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
.k-card--note { border-color: color-mix(in srgb, var(--color-primary) 20%, transparent); }
|
||||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||||
|
|
||||||
/* Top gradient bars */
|
/* Top gradient bars */
|
||||||
@@ -769,7 +769,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.k-card--note::before {
|
.k-card--note::before {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
|
background: linear-gradient(90deg, var(--color-primary), #7A6DA8);
|
||||||
}
|
}
|
||||||
.k-card--task::before {
|
.k-card--task::before {
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -788,7 +788,7 @@ onUnmounted(() => {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
.badge--note { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: #7A6DA8; }
|
||||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||||
|
|
||||||
@@ -917,7 +917,7 @@ onUnmounted(() => {
|
|||||||
.graph-panel {
|
.graph-panel {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-left: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
@@ -933,9 +933,20 @@ onUnmounted(() => {
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-bottom: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
|
||||||
|
`flex-shrink: 0`, so this is the item that takes the remaining height — and
|
||||||
|
without it the `height: 100%` below resolves against `auto` and does
|
||||||
|
nothing, which made the comment underneath a spec for a rule that could not
|
||||||
|
work. `min-height: 0` is the companion that lets a flex item shrink under
|
||||||
|
its content instead of overflowing the panel. */
|
||||||
|
.graph-embed {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||||
.graph-embed :deep(.graph-page) {
|
.graph-embed :deep(.graph-page) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -454,6 +454,8 @@ function clearFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Detail row */
|
/* Detail row */
|
||||||
|
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
|
||||||
|
cells don't carry, and the row exists to scope the rule below (#2444). */
|
||||||
.detail-row td {
|
.detail-row td {
|
||||||
padding: 0 0.75rem 0.75rem;
|
padding: 0 0.75rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -709,8 +709,8 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
|
border: 1px solid var(--color-input-border);
|
||||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
background: var(--color-bg-tertiary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -822,7 +822,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
||||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
background: var(--color-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTasksStore } from "@/stores/tasks";
|
|||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
import { relativeTime } from "@/composables/useRelativeTime";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import ShareDialog from "@/components/ShareDialog.vue";
|
import ShareDialog from "@/components/ShareDialog.vue";
|
||||||
|
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import SystemsSection from "@/components/SystemsSection.vue";
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
import {
|
import {
|
||||||
@@ -108,11 +109,12 @@ async function confirmStartPlanning() {
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
|
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
|
||||||
|
|
||||||
const tasks = ref<NoteItem[]>([]);
|
const tasks = ref<NoteItem[]>([]);
|
||||||
const notes = ref<NoteItem[]>([]);
|
const notes = ref<NoteItem[]>([]);
|
||||||
const tasksLoading = ref(false);
|
const tasksLoading = ref(false);
|
||||||
|
const tasksError = ref<string | null>(null);
|
||||||
const notesLoading = ref(false);
|
const notesLoading = ref(false);
|
||||||
|
|
||||||
const milestones = ref<Milestone[]>([]);
|
const milestones = ref<Milestone[]>([]);
|
||||||
@@ -169,8 +171,49 @@ function toggleMilestoneCollapse(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A milestone IS the plan, so its body carries the whole design — Goal,
|
||||||
|
// Approach, Verification, and often several hundred words of reasoning. Rendered
|
||||||
|
// in full, one milestone's plan pushes every other milestone off the screen, and
|
||||||
|
// the board stops being a board.
|
||||||
|
//
|
||||||
|
// Length is judged on the SOURCE, not by measuring the rendered box. Measuring
|
||||||
|
// would be exact, but it means a ref per milestone, a post-render scrollHeight
|
||||||
|
// read, and a re-measure on every markdown change — a lot of machinery to decide
|
||||||
|
// whether to show one button. This proxy is wrong only in the narrow band around
|
||||||
|
// the threshold, where either answer is fine.
|
||||||
|
const PLAN_CLAMP_CHARS = 400;
|
||||||
|
|
||||||
|
const expandedPlans = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
|
function isPlanLong(ms: Milestone): boolean {
|
||||||
|
return (ms.body || "").length > PLAN_CLAMP_CHARS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlanClamped(ms: Milestone): boolean {
|
||||||
|
return isPlanLong(ms) && !expandedPlans.value.has(ms.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlanExpanded(id: number) {
|
||||||
|
if (expandedPlans.value.has(id)) {
|
||||||
|
expandedPlans.value.delete(id);
|
||||||
|
} else {
|
||||||
|
expandedPlans.value.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Milestones this has already ruled on. Without it, the rule re-applies on every
|
||||||
|
// reload — and `loadMilestones` runs after a task's status changes. So expanding
|
||||||
|
// a finished milestone and then ticking anything snapped it shut again, with no
|
||||||
|
// visible cause. That is half of why the collapse state read as arbitrary: it
|
||||||
|
// wasn't only deciding at START, it was overriding the reader continuously.
|
||||||
|
const autoCollapsedOnce = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
function autoCollapseCompleted(msList: Milestone[]) {
|
function autoCollapseCompleted(msList: Milestone[]) {
|
||||||
for (const ms of msList) {
|
for (const ms of msList) {
|
||||||
|
if (autoCollapsedOnce.value.has(ms.id)) continue;
|
||||||
|
autoCollapsedOnce.value.add(ms.id);
|
||||||
|
// Fully done and non-empty: start collapsed. A finished milestone is
|
||||||
|
// history, and the board is for what's live.
|
||||||
if (ms.total > 0 && ms.completed === ms.total) {
|
if (ms.total > 0 && ms.completed === ms.total) {
|
||||||
collapsedMilestones.value.add(ms.id);
|
collapsedMilestones.value.add(ms.id);
|
||||||
}
|
}
|
||||||
@@ -293,15 +336,45 @@ async function confirmDeleteMilestone() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The route's max_limit. Asking for more is clamped server-side, so this is the
|
||||||
|
// largest page a single request can return.
|
||||||
|
const TASK_PAGE_SIZE = 500;
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
tasksLoading.value = true;
|
tasksLoading.value = true;
|
||||||
|
tasksError.value = null;
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ notes: NoteItem[]; total: number }>(
|
// PAGE UNTIL COMPLETE. This board groups tasks under their milestone and
|
||||||
`/api/projects/${projectId.value}/notes?type=task&limit=100`
|
// shows each milestone's progress beside them, and that progress is counted
|
||||||
);
|
// SERVER-SIDE over every task. A partial fetch therefore doesn't just hide
|
||||||
tasks.value = data.notes;
|
// rows — it makes the bar disagree with the cards under it, and the
|
||||||
|
// auto-collapse rule (100% done starts collapsed) read as arbitrary.
|
||||||
|
//
|
||||||
|
// The original `limit=100` with no second page shipped the day this view was
|
||||||
|
// written, when the project had a couple of dozen tasks. At 166 it was
|
||||||
|
// dropping 66 — the least-recently-updated, so mostly done tasks in
|
||||||
|
// completed milestones, which is exactly where the mismatch is least
|
||||||
|
// visible and most confusing.
|
||||||
|
const url = (offset: number) =>
|
||||||
|
`/api/projects/${projectId.value}/notes?type=task` +
|
||||||
|
`&limit=${TASK_PAGE_SIZE}&offset=${offset}`;
|
||||||
|
|
||||||
|
const first = await apiGet<{ notes: NoteItem[]; total: number }>(url(0));
|
||||||
|
const all = [...first.notes];
|
||||||
|
while (all.length < first.total) {
|
||||||
|
const next = await apiGet<{ notes: NoteItem[]; total: number }>(url(all.length));
|
||||||
|
// A page that returns nothing while `total` still says there is more means
|
||||||
|
// the two disagree. Stop rather than loop forever; showing what we have
|
||||||
|
// beats hanging the board.
|
||||||
|
if (!next.notes.length) break;
|
||||||
|
all.push(...next.notes);
|
||||||
|
}
|
||||||
|
tasks.value = all;
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — tasks just won't show
|
// Say so. This used to swallow the error and leave an empty board, which is
|
||||||
|
// indistinguishable from a project with no tasks — the same "hidden with no
|
||||||
|
// indicator" failure as the truncation above, one layer up.
|
||||||
|
tasksError.value = "Could not load tasks. Refresh to try again.";
|
||||||
} finally {
|
} finally {
|
||||||
tasksLoading.value = false;
|
tasksLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -445,27 +518,27 @@ async function confirmDelete() {
|
|||||||
@keyup.enter="confirmStartPlanning"
|
@keyup.enter="confirmStartPlanning"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn-workspace"
|
class="btn-primary btn-compact"
|
||||||
:disabled="!planTitle.trim() || planningBusy"
|
:disabled="!planTitle.trim() || planningBusy"
|
||||||
@click="confirmStartPlanning"
|
@click="confirmStartPlanning"
|
||||||
>
|
>
|
||||||
Create plan
|
Create plan
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-secondary btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
<button class="btn-ghost btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<button
|
||||||
v-else-if="project"
|
v-else-if="project"
|
||||||
class="btn-workspace"
|
class="btn-ghost btn-compact"
|
||||||
@click="showStartPlanning = true"
|
@click="showStartPlanning = true"
|
||||||
>
|
>
|
||||||
Start planning
|
Start planning
|
||||||
</button>
|
</button>
|
||||||
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-workspace">
|
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-cta btn-compact">
|
||||||
<LayoutGrid :size="16" />
|
<LayoutGrid :size="16" />
|
||||||
Workspace
|
Workspace
|
||||||
</router-link>
|
</router-link>
|
||||||
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @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>
|
<button v-if="project && !showStartPlanning" class="btn-danger-outline btn-compact" @click="showDeleteConfirm = true">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -526,11 +599,15 @@ async function confirmDelete() {
|
|||||||
<h3 class="panel-heading">Details</h3>
|
<h3 class="panel-heading">Details</h3>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Goal</label>
|
<label class="edit-label">Goal</label>
|
||||||
<input v-model="editGoal" type="text" class="edit-input" placeholder="What are you trying to achieve?" />
|
<!-- A textarea, not a single-line input. A project goal is a
|
||||||
|
paragraph in practice — this one showed as "Maintain Scribe as
|
||||||
|
the reliabl" and gave no way to read the rest without arrowing
|
||||||
|
through it. -->
|
||||||
|
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Description</label>
|
<label class="edit-label">Description</label>
|
||||||
<textarea v-model="editDescription" class="edit-textarea" rows="4" placeholder="Optional description..."></textarea>
|
<textarea v-model="editDescription" class="edit-textarea" rows="6" placeholder="Optional description..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Status</label>
|
<label class="edit-label">Status</label>
|
||||||
@@ -570,6 +647,9 @@ async function confirmDelete() {
|
|||||||
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
||||||
Rules
|
Rules
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
|
||||||
|
Design
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tasks tab — milestone-grouped kanban -->
|
<!-- Tasks tab — milestone-grouped kanban -->
|
||||||
@@ -579,6 +659,7 @@ async function confirmDelete() {
|
|||||||
<div class="skel-row skel-row--short"></div>
|
<div class="skel-row skel-row--short"></div>
|
||||||
<div class="skel-row"></div>
|
<div class="skel-row"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else-if="tasksError" class="tasks-error">{{ tasksError }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="milestone-actions">
|
<div class="milestone-actions">
|
||||||
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
|
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
|
||||||
@@ -659,12 +740,21 @@ async function confirmDelete() {
|
|||||||
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
|
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<template v-else>
|
||||||
v-else
|
<div
|
||||||
class="ms-plan-rendered markdown-body"
|
:class="['ms-plan-rendered', 'markdown-body',
|
||||||
@click="startEditPlan(group.milestone.id, group.milestone.body)"
|
{ 'ms-plan-clamped': isPlanClamped(group.milestone) }]"
|
||||||
v-html="renderMarkdown(group.milestone.body || '')"
|
@click="startEditPlan(group.milestone.id, group.milestone.body)"
|
||||||
></div>
|
v-html="renderMarkdown(group.milestone.body || '')"
|
||||||
|
></div>
|
||||||
|
<button
|
||||||
|
v-if="isPlanLong(group.milestone)"
|
||||||
|
class="btn-text ms-plan-toggle"
|
||||||
|
@click.stop="togglePlanExpanded(group.milestone.id)"
|
||||||
|
>
|
||||||
|
{{ expandedPlans.has(group.milestone.id) ? "Show less" : "Show more" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
|
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
|
||||||
@@ -784,6 +874,16 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
<!-- Rules tab -->
|
<!-- Rules tab -->
|
||||||
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
||||||
|
|
||||||
|
<!-- Design tab: this project's recorded components against its sheet.
|
||||||
|
Bound to the SAVED pointer rather than the picker's draft value,
|
||||||
|
so an unsaved change in the sidebar can't make the tab report on
|
||||||
|
a system this project isn't using. -->
|
||||||
|
<ProjectDesignTab
|
||||||
|
v-if="activeTab === 'design'"
|
||||||
|
:project-id="projectId"
|
||||||
|
:design-system-id="project.design_system_id ?? null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -847,18 +947,15 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
.plan-title-input {
|
.plan-title-input {
|
||||||
background: var(--color-bg, #111113);
|
background: var(--color-bg);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); }
|
|
||||||
|
|
||||||
/* Share: Bronze action-secondary — alternate path */
|
|
||||||
.project-title-input {
|
.project-title-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
@@ -932,8 +1029,8 @@ async function confirmDelete() {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.dot-todo { background: transparent; border: 2px solid var(--color-text-muted); }
|
.dot-todo { background: transparent; border: 2px solid var(--color-text-muted); }
|
||||||
.dot-inprogress { background: var(--color-status-in-progress, #3b82f6); }
|
.dot-inprogress { background: var(--color-status-in-progress); }
|
||||||
.dot-done { background: var(--color-status-done, #22c55e); }
|
.dot-done { background: var(--color-status-done); }
|
||||||
|
|
||||||
.stat-todo { background: color-mix(in srgb, var(--color-text-muted) 8%, transparent); color: var(--color-text-secondary); border-color: var(--color-border); }
|
.stat-todo { background: color-mix(in srgb, var(--color-text-muted) 8%, transparent); color: var(--color-text-secondary); border-color: var(--color-border); }
|
||||||
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
|
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
|
||||||
@@ -941,13 +1038,25 @@ async function confirmDelete() {
|
|||||||
.stat-notes { background: color-mix(in srgb, var(--color-primary) 8%, transparent); color: var(--color-primary); border-color: color-mix(in srgb, var(--color-primary) 22%, transparent); }
|
.stat-notes { background: color-mix(in srgb, var(--color-primary) 8%, transparent); color: var(--color-primary); border-color: color-mix(in srgb, var(--color-primary) 22%, transparent); }
|
||||||
|
|
||||||
/* ── Two-column body ─────────────────────────────────────────── */
|
/* ── Two-column body ─────────────────────────────────────────── */
|
||||||
|
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
|
||||||
|
cannot shrink below its content — one wide descendant anywhere in the
|
||||||
|
content column widens the whole column past the grid, and everything inside
|
||||||
|
it then overflows the page and gets cut by `.project-view`'s
|
||||||
|
`overflow-x: clip`.
|
||||||
|
This is the same property the header nav relies on and wants (neither side
|
||||||
|
squeezed under its content); here it is exactly wrong, because the column
|
||||||
|
holds a kanban whose own tracks push outward. `min-width: 0` on the item is
|
||||||
|
the twin half — a grid item's default `min-width: auto` refuses to shrink
|
||||||
|
even when its track will. */
|
||||||
.project-body {
|
.project-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 248px 1fr;
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-area { min-width: 0; }
|
||||||
|
|
||||||
/* ── Edit panel ──────────────────────────────────────────────── */
|
/* ── Edit panel ──────────────────────────────────────────────── */
|
||||||
.edit-panel {
|
.edit-panel {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -1053,6 +1162,29 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
||||||
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
||||||
|
/* RESTORED. Both of these lost their base rule to a CSS sweep and left only
|
||||||
|
modifiers behind — `.milestone-header.clickable`, `.milestone-header:hover`.
|
||||||
|
Every child here (`.ms-chevron`, `.ms-name { flex: 1 }`, the progress track,
|
||||||
|
`.ms-pct`) is written for a flex ROW, so without the parent they stacked
|
||||||
|
vertically and each milestone grew to five lines of mostly nothing. That is
|
||||||
|
the "uses space poorly" the operator saw, and it was a deletion rather than a
|
||||||
|
design change. A dangling `:hover` is the tell, and it is now checked for. */
|
||||||
|
.milestone-group {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.milestone-header.clickable { cursor: pointer; }
|
.milestone-header.clickable { cursor: pointer; }
|
||||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||||
|
|
||||||
@@ -1064,9 +1196,34 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
|
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
|
||||||
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
|
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
|
||||||
|
|
||||||
|
/* max-height rather than -webkit-line-clamp: the body is rendered markdown, so
|
||||||
|
it holds headings, lists and tables. line-clamp counts lines inside ONE inline
|
||||||
|
formatting context and behaves unpredictably once block children are involved,
|
||||||
|
which is most plans. */
|
||||||
|
.ms-plan-clamped {
|
||||||
|
max-height: 6.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* Fades into the plan block's own background, which is a tint over the card —
|
||||||
|
restate it here rather than approximating, or the fade shows as a grey band. */
|
||||||
|
.ms-plan-clamped::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
height: 2.25rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card))
|
||||||
|
);
|
||||||
|
pointer-events: none; /* the text under the fade stays clickable to edit */
|
||||||
|
}
|
||||||
|
.ms-plan-toggle { padding-left: 0; margin-top: 0.15rem; }
|
||||||
.ms-plan-editor {
|
.ms-plan-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -1133,7 +1290,7 @@ async function confirmDelete() {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.ms-action-btn:hover { background: var(--color-bg-card); color: var(--color-text); }
|
.ms-action-btn:hover { background: var(--color-bg-card); color: var(--color-text); }
|
||||||
.ms-action-delete:hover { color: var(--color-danger, #e74c3c); }
|
.ms-action-delete:hover { color: var(--color-danger); }
|
||||||
|
|
||||||
.ms-rename-input {
|
.ms-rename-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1152,7 +1309,10 @@ async function confirmDelete() {
|
|||||||
/* ── Kanban ──────────────────────────────────────────────────── */
|
/* ── Kanban ──────────────────────────────────────────────────── */
|
||||||
.kanban {
|
.kanban {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
/* Same reason as .project-body: three auto-minimum tracks add up to more
|
||||||
|
than the column when a card title or a column header won't compress, and
|
||||||
|
the excess pushes the whole milestone card wider than the page. */
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -1168,8 +1328,8 @@ async function confirmDelete() {
|
|||||||
border-top: 3px solid;
|
border-top: 3px solid;
|
||||||
}
|
}
|
||||||
.col-todo { border-top-color: var(--color-border); }
|
.col-todo { border-top-color: var(--color-border); }
|
||||||
.col-inprogress { border-top-color: var(--color-status-in-progress, #3b82f6); }
|
.col-inprogress { border-top-color: var(--color-status-in-progress); }
|
||||||
.col-done { border-top-color: var(--color-status-done, #22c55e); }
|
.col-done { border-top-color: var(--color-status-done); }
|
||||||
|
|
||||||
.kanban-col-header {
|
.kanban-col-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1230,7 +1390,7 @@ async function confirmDelete() {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
/* Priority left-border colors */
|
/* Priority left-border colors */
|
||||||
.task-card.pri-high { border-left-color: var(--color-danger, #e74c3c); }
|
.task-card.pri-high { border-left-color: var(--color-danger); }
|
||||||
.task-card.pri-medium { border-left-color: #f59e0b; }
|
.task-card.pri-medium { border-left-color: #f59e0b; }
|
||||||
.task-card.pri-low { border-left-color: var(--color-success); }
|
.task-card.pri-low { border-left-color: var(--color-success); }
|
||||||
|
|
||||||
@@ -1256,7 +1416,7 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.task-card:hover .task-advance-btn { opacity: 1; }
|
.task-card:hover .task-advance-btn { opacity: 1; }
|
||||||
.task-advance-btn:hover { background: var(--color-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
|
.task-advance-btn:hover { background: var(--color-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||||
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: var(--fs-text-on-action); }
|
.task-advance-btn--done:hover { background: var(--color-success); border-color: var(--color-success); color: var(--fs-text-on-action); }
|
||||||
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
|
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.priority-dot {
|
.priority-dot {
|
||||||
@@ -1265,7 +1425,7 @@ async function confirmDelete() {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.dot-pri-high { background: var(--color-danger, #e74c3c); }
|
.dot-pri-high { background: var(--color-danger); }
|
||||||
.dot-pri-medium { background: #f59e0b; }
|
.dot-pri-medium { background: #f59e0b; }
|
||||||
.dot-pri-low { background: var(--color-success); }
|
.dot-pri-low { background: var(--color-success); }
|
||||||
|
|
||||||
@@ -1306,11 +1466,15 @@ async function confirmDelete() {
|
|||||||
.note-date { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
|
.note-date { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||||
|
|
||||||
.empty-msg { color: var(--color-text-muted); font-size: 0.875rem; text-align: center; padding: 1rem; }
|
.empty-msg { color: var(--color-text-muted); font-size: 0.875rem; text-align: center; padding: 1rem; }
|
||||||
|
/* Deliberately NOT styled like .empty-msg: "no tasks" and "the tasks did not
|
||||||
|
load" look identical to a user, and conflating them is what let a silent
|
||||||
|
failure read as an empty project. */
|
||||||
|
.tasks-error { color: var(--color-danger); font-size: 0.875rem; padding: 1rem; text-align: center; }
|
||||||
|
|
||||||
/* ── Modal ───────────────────────────────────────────────────── */
|
/* ── Modal ───────────────────────────────────────────────────── */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
background: var(--color-overlay);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute);
|
|||||||
grid-template-columns: 280px 300px 1fr;
|
grid-template-columns: 280px 300px 1fr;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--color-border, #2a2a2e);
|
background: var(--color-border);
|
||||||
}
|
}
|
||||||
.pane.empty {
|
.pane.empty {
|
||||||
background: var(--color-surface, #18181b);
|
background: var(--color-surface);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useSettingsStore } from "@/stores/settings";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
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 { 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 type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
@@ -32,11 +31,6 @@ const kbWritePathThreshold = ref("0.68");
|
|||||||
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
// gate: that one BLOCKS a create and must be unforgiving of noise, this one only
|
||||||
// suggests a merge the operator reviews (services/dedup.py).
|
// suggests a merge the operator reviews (services/dedup.py).
|
||||||
const kbDuplicateThreshold = ref("0.82");
|
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 savingKbInject = ref(false);
|
||||||
const kbInjectSaved = ref(false);
|
const kbInjectSaved = ref(false);
|
||||||
|
|
||||||
@@ -106,9 +100,6 @@ async function saveKbInject() {
|
|||||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||||
kb_writepath_threshold: String(wpT),
|
kb_writepath_threshold: String(wpT),
|
||||||
kb_duplicate_threshold: String(dupT),
|
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;
|
kbInjectSaved.value = true;
|
||||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||||
@@ -499,14 +490,6 @@ onMounted(async () => {
|
|||||||
if (allSettings.kb_duplicate_threshold !== undefined) {
|
if (allSettings.kb_duplicate_threshold !== undefined) {
|
||||||
kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold;
|
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) {
|
if (allSettings.notify_task_reminders !== undefined) {
|
||||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||||
}
|
}
|
||||||
@@ -1277,22 +1260,12 @@ function formatUserDate(iso: string): string {
|
|||||||
location, not by resemblance.
|
location, not by resemblance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<!-- A design system belongs to a PROJECT, and the picker for it lives on
|
||||||
<label for="design-rulebook">Design-system rulebook</label>
|
the project. There was a setting here that designated the system
|
||||||
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
|
this install's own interface was built from; it only ever described
|
||||||
<option value="">None — don't check for design drift</option>
|
the app you were already looking at, which is not what the feature
|
||||||
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
|
is for (#274). -->
|
||||||
{{ 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">
|
<div class="field">
|
||||||
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
|
<label for="kb-duplicate-threshold">Near-duplicate report threshold</label>
|
||||||
<input
|
<input
|
||||||
@@ -2404,7 +2377,7 @@ function formatUserDate(iso: string): string {
|
|||||||
}
|
}
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: rgba(91, 74, 138, 0.08);
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
border-left-color: var(--color-primary);
|
border-left-color: var(--color-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -2789,11 +2762,11 @@ function formatUserDate(iso: string): string {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||||
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
|
.perm-denied { background: color-mix(in srgb, var(--color-danger) 15%, transparent); color: var(--color-danger); }
|
||||||
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||||
.push-error {
|
.push-error {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger);
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2964,6 +2937,8 @@ function formatUserDate(iso: string): string {
|
|||||||
.cell-status { font-family: monospace; font-size: 0.85rem; }
|
.cell-status { font-family: monospace; font-size: 0.85rem; }
|
||||||
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
|
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
|
||||||
.text-error { color: var(--color-danger); }
|
.text-error { color: var(--color-danger); }
|
||||||
|
/* Bare by design, like LogsView's twin of this: a `<tr>` has nothing to style
|
||||||
|
that its cells don't carry (#2444). */
|
||||||
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
|
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
|
||||||
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
|
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
|
||||||
.detail-json {
|
.detail-json {
|
||||||
@@ -3048,7 +3023,7 @@ function formatUserDate(iso: string): string {
|
|||||||
|
|
||||||
.group-card {
|
.group-card {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3166,7 +3141,7 @@ function formatUserDate(iso: string): string {
|
|||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.role-owner { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
|
.role-owner { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
|
||||||
.role-member { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
.role-member { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
||||||
|
|
||||||
.members-empty {
|
.members-empty {
|
||||||
@@ -3278,7 +3253,7 @@ function formatUserDate(iso: string): string {
|
|||||||
}
|
}
|
||||||
.api-key-value {
|
.api-key-value {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--color-surface-2, var(--color-surface));
|
background: var(--color-surface-2);
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -3396,7 +3371,7 @@ function formatUserDate(iso: string): string {
|
|||||||
.mcp-code-row .btn-sm { white-space: nowrap; }
|
.mcp-code-row .btn-sm { white-space: nowrap; }
|
||||||
.mcp-advanced {
|
.mcp-advanced {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
border-top: 1px solid var(--color-border);
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
}
|
}
|
||||||
.mcp-advanced summary {
|
.mcp-advanced summary {
|
||||||
@@ -3470,7 +3445,7 @@ function formatUserDate(iso: string): string {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.voice-library-id {
|
.voice-library-id {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -3511,9 +3486,9 @@ function formatUserDate(iso: string): string {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
.status-on {
|
.status-on {
|
||||||
background: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent);
|
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||||
color: var(--color-success, #22c55e);
|
color: var(--color-success);
|
||||||
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
|
||||||
}
|
}
|
||||||
.status-off {
|
.status-off {
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
||||||
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||||
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
|
.perm-admin { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
|
||||||
|
|
||||||
.empty-msg {
|
.empty-msg {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.snippet-name {
|
.snippet-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.meta-grid code,
|
.meta-grid code,
|
||||||
.tag-row + * code {
|
.tag-row + * code {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
@@ -379,7 +379,7 @@ async function confirmDelete() {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.code-block code {
|
.code-block code {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ function cancel() {
|
|||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.code-area {
|
.code-area {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -542,7 +542,7 @@ function cancel() {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-left: 3px solid var(--color-warning, var(--color-primary));
|
border-left: 3px solid var(--color-warning);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -574,7 +574,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.loc-input-wide {
|
.loc-input-wide {
|
||||||
@@ -633,7 +633,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
@@ -720,7 +720,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Language tag — accent pill per the design system's tag treatment. */
|
/* Language tag — accent pill per the design system's tag treatment. */
|
||||||
@@ -770,7 +770,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--color-surface-alt, var(--color-surface));
|
background: var(--color-surface-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dup-empty,
|
.dup-empty,
|
||||||
@@ -828,8 +828,8 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
|
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||||
color: var(--color-danger, #b91c1c);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-tag {
|
.usage-tag {
|
||||||
@@ -845,8 +845,8 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||||
than the danger one, because the record isn't broken, just unearned. */
|
than the danger one, because the record isn't broken, just unearned. */
|
||||||
.usage-tag.usage-dead {
|
.usage-tag.usage-dead {
|
||||||
background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
|
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
|
||||||
color: var(--color-warning, #b45309);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header + select-mode */
|
/* Header + select-mode */
|
||||||
@@ -906,7 +906,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
background: var(--color-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -955,7 +955,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
.merge-choice-name {
|
.merge-choice-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const toast = useToastStore();
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
const body = ref("");
|
const body = ref("");
|
||||||
const description = ref("");
|
const description = ref("");
|
||||||
const consolidatedAt = ref<string | null>(null);
|
|
||||||
const tags = ref<string[]>([]);
|
const tags = ref<string[]>([]);
|
||||||
const status = ref<TaskStatus>("todo");
|
const status = ref<TaskStatus>("todo");
|
||||||
const priority = ref<TaskPriority>("none");
|
const priority = ref<TaskPriority>("none");
|
||||||
@@ -303,7 +302,6 @@ onMounted(async () => {
|
|||||||
title.value = store.currentTask.title;
|
title.value = store.currentTask.title;
|
||||||
body.value = store.currentTask.body;
|
body.value = store.currentTask.body;
|
||||||
description.value = store.currentTask.description ?? "";
|
description.value = store.currentTask.description ?? "";
|
||||||
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
|
|
||||||
tags.value = [...(store.currentTask.tags || [])];
|
tags.value = [...(store.currentTask.tags || [])];
|
||||||
status.value = store.currentTask.status as TaskStatus;
|
status.value = store.currentTask.status as TaskStatus;
|
||||||
priority.value = store.currentTask.priority as TaskPriority;
|
priority.value = store.currentTask.priority as TaskPriority;
|
||||||
@@ -874,7 +872,7 @@ useEditorGuards(dirty, save);
|
|||||||
padding: 0 0.2rem;
|
padding: 0 0.2rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
.btn-clear-parent:hover { color: var(--color-danger); }
|
||||||
.parent-dropdown {
|
.parent-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
@@ -1037,13 +1035,13 @@ useEditorGuards(dirty, save);
|
|||||||
margin: 0.5rem 0 0.25rem;
|
margin: 0.5rem 0 0.25rem;
|
||||||
}
|
}
|
||||||
.task-goal-label {
|
.task-goal-label {
|
||||||
font-family: var(--font-display, "Fraunces", serif);
|
font-family: var(--font-display);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.task-goal-input {
|
.task-goal-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1053,32 +1051,14 @@ useEditorGuards(dirty, save);
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--color-text, inherit);
|
color: var(--color-text);
|
||||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
background: var(--color-input-bg);
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
.task-goal-input:focus {
|
.task-goal-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
|
|
||||||
.auto-summary-banner-editor {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
|
||||||
background: rgba(99, 102, 241, 0.06);
|
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
}
|
|
||||||
.auto-summary-banner-editor .auto-summary-icon {
|
|
||||||
color: var(--color-primary, #6366f1);
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -365,13 +365,6 @@ const subTaskProgress = computed(() => {
|
|||||||
<p class="goal-text">{{ store.currentTask.description }}</p>
|
<p class="goal-text">{{ store.currentTask.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="store.currentTask.consolidated_at"
|
|
||||||
class="auto-summary-banner"
|
|
||||||
>
|
|
||||||
<span class="auto-summary-icon" aria-hidden="true">✦</span>
|
|
||||||
Auto-summarized from work logs.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="body prose"
|
class="body prose"
|
||||||
@@ -561,7 +554,7 @@ const subTaskProgress = computed(() => {
|
|||||||
}
|
}
|
||||||
.subtasks-fill {
|
.subtasks-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-status-done, #22c55e);
|
background: var(--color-status-done);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -602,13 +595,13 @@ const subTaskProgress = computed(() => {
|
|||||||
border: 2px solid var(--color-text-muted);
|
border: 2px solid var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.dot-in-progress {
|
.dot-in-progress {
|
||||||
background: var(--color-status-in-progress, #3b82f6);
|
background: var(--color-status-in-progress);
|
||||||
}
|
}
|
||||||
.dot-done {
|
.dot-done {
|
||||||
background: var(--color-status-done, #22c55e);
|
background: var(--color-status-done);
|
||||||
}
|
}
|
||||||
.dot-cancelled {
|
.dot-cancelled {
|
||||||
background: var(--color-text-muted, #6b7280);
|
background: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.sub-title {
|
.sub-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -749,39 +742,26 @@ const subTaskProgress = computed(() => {
|
|||||||
|
|
||||||
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
||||||
.task-goal-display {
|
.task-goal-display {
|
||||||
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border-left: 2px solid var(--color-border);
|
||||||
padding: 0.4rem 0 0.4rem 0.9rem;
|
padding: 0.4rem 0 0.4rem 0.9rem;
|
||||||
margin: 0.75rem 0 1.25rem;
|
margin: 0.75rem 0 1.25rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
.goal-label {
|
.goal-label {
|
||||||
font-family: var(--font-display, "Fraunces", serif);
|
font-family: var(--font-display);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
color: var(--color-text-muted);
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
}
|
}
|
||||||
.goal-text {
|
.goal-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--color-text, inherit);
|
color: var(--color-text);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.auto-summary-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.auto-summary-icon {
|
|
||||||
color: var(--color-primary, #6366f1);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ onMounted(() => store.fetchTrash());
|
|||||||
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
|
.batch-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-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
|
||||||
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
.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; }
|
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border); background: none; color: inherit; }
|
||||||
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
|
.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); }
|
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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.22",
|
"version": "0.1.25",
|
||||||
"author": { "name": "Bryan Van Deusen" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"scribe": {
|
||||||
|
|||||||
@@ -61,6 +61,31 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
|
|||||||
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
|
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
|
||||||
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
||||||
|
|
||||||
|
# --- Which version is actually RUNNING (keyless, networkless) ---
|
||||||
|
#
|
||||||
|
# An install has two halves and only one self-updates:
|
||||||
|
#
|
||||||
|
# marketplaces/…/scribe-plugin/ git clone — pulls on its own
|
||||||
|
# cache/…/scribe/<version>/ what EXECUTES — refreshed only when the
|
||||||
|
# manifest version changes
|
||||||
|
#
|
||||||
|
# So inspecting the clone shows a fix present while the broken copy keeps
|
||||||
|
# running, and the obvious debugging move actively misleads (#2209). Twice, the
|
||||||
|
# only detector was the operator saying "I don't think it updated".
|
||||||
|
#
|
||||||
|
# Naming the running version in every session makes that answerable from the
|
||||||
|
# transcript instead of by archaeology in the cache directory. Deliberately NOT
|
||||||
|
# a server round-trip or a stored per-user record: the state most needing
|
||||||
|
# diagnosis is the one where credentials never arrive, and this line still
|
||||||
|
# appears there.
|
||||||
|
manifest="$here/../.claude-plugin/plugin.json"
|
||||||
|
if [ -f "$manifest" ]; then
|
||||||
|
plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
|
||||||
|
if [ -n "$plugin_version" ]; then
|
||||||
|
append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||||
|
|||||||
@@ -51,5 +51,17 @@ for the operator's work, and as your own working memory across sessions.
|
|||||||
`/compact` (name what you logged). You can't run it yourself — surface the
|
`/compact` (name what you logged). You can't run it yourself — surface the
|
||||||
recommendation and let them decide. Suggest it at seams, not every turn.
|
recommendation and let them decide. Suggest it at seams, not every turn.
|
||||||
|
|
||||||
|
**If two Scribe instruction surfaces disagree** — this file, the MCP server's
|
||||||
|
tool instructions, the `using-scribe` skill — **follow the one that assumes
|
||||||
|
least about its own delivery.** This file is the floor: it ships with the
|
||||||
|
plugin and needs no API key and no network, so it still applies in exactly the
|
||||||
|
session where the others never arrived. The others may elaborate on what is
|
||||||
|
written here; they must not contradict it. Weigh a disagreement by which way it
|
||||||
|
fails, not by which surface said more: doing something a push would also have
|
||||||
|
covered costs one redundant call, while skipping it because you expected a push
|
||||||
|
that never came means working without the operator's rules and not knowing.
|
||||||
|
A contradiction between surfaces is a defect in the product — say so, so it
|
||||||
|
gets recorded and fixed rather than silently arbitrated again next session.
|
||||||
|
|
||||||
If the Scribe tools are unavailable, say so rather than silently falling back
|
If the Scribe tools are unavailable, say so rather than silently falling back
|
||||||
to local notes.
|
to local notes.
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ Two constraints on *how* that's achieved:
|
|||||||
(`arose_from_id`) and the subsystem it touches (`system_ids`). Don't bury a
|
(`arose_from_id`) and the subsystem it touches (`system_ids`). Don't bury a
|
||||||
fix as a work-log line on whatever task happened to be open.
|
fix as a work-log line on whatever task happened to be open.
|
||||||
|
|
||||||
|
7. **Tag records to Systems.** `enter_project` lists the project's Systems —
|
||||||
|
its named subsystems/areas. When you create or meaningfully update a record,
|
||||||
|
ask which areas it is *about* and pass `system_ids`. The test: would someone
|
||||||
|
investigating that subsystem want this record in the pile
|
||||||
|
`list_system_records` returns? If the area has no System yet, create one
|
||||||
|
(`create_system`: name + a one-paragraph charter) — an area that plainly
|
||||||
|
exists deserves naming the moment two records would share it; don't wait to
|
||||||
|
be asked. A record about no particular area takes none.
|
||||||
|
|
||||||
|
8. **State updates in place; chronicles don't.** A dev-log records what
|
||||||
|
*happened* — write it once, never rewrite it. A durable finding (how a
|
||||||
|
subsystem works, a measured number) lives in that System's **reference
|
||||||
|
note** ("«System» — reference"), which you UPDATE as facts change — safe,
|
||||||
|
because every meaningful edit is snapshotted and the version history is the
|
||||||
|
changelog. The dev-log then `[[links]]` the reference note instead of
|
||||||
|
restating state. When a new record outright *corrects* an older one (a
|
||||||
|
re-measurement, a reversed decision), pass the old id in `supersedes` so the
|
||||||
|
stale record is demoted and labelled rather than left competing.
|
||||||
|
|
||||||
## Stay inside the active project's scope
|
## Stay inside the active project's scope
|
||||||
|
|
||||||
Once a project is in scope — you called `enter_project`, or the working repo is
|
Once a project is in scope — you called `enter_project`, or the working repo is
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": [
|
|
||||||
"config:recommended"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find elements whose classes have no base rule — the dangling-selector bug.
|
||||||
|
|
||||||
|
THE FAILURE THIS CATCHES
|
||||||
|
|
||||||
|
Deleting a CSS rule from a scoped stylesheet is not the local edit it looks
|
||||||
|
like. Three ways it goes wrong, all of them silent:
|
||||||
|
|
||||||
|
1. A rule is deleted and its `:hover` / modifier survives. The selector still
|
||||||
|
exists, so nothing reads as unused, but the element renders with no base
|
||||||
|
styling at all. `.btn-workspace:hover` outlived `.btn-workspace` and a
|
||||||
|
router-link rendered as raw browser blue for days.
|
||||||
|
|
||||||
|
2. The parent's layout rule is deleted while the children keep theirs.
|
||||||
|
`.milestone-header` was a flex row; its children still declare `flex: 1`
|
||||||
|
and `flex-shrink: 0`. Without the parent they stack vertically, and a
|
||||||
|
milestone that was one line becomes five. Nothing errors — the page just
|
||||||
|
wastes space, which reads as a design decision.
|
||||||
|
|
||||||
|
3. A rule is removed from a comma-separated group, leaving `.a,` dangling in
|
||||||
|
front of the next rule and swallowing it. That one at least has a
|
||||||
|
brace-balance tell; these two do not.
|
||||||
|
|
||||||
|
None of it is visible to `vue-tsc`, which is the frontend's entire check. A
|
||||||
|
dead style typechecks perfectly.
|
||||||
|
|
||||||
|
WHAT IT REPORTS
|
||||||
|
|
||||||
|
An element whose every static class is styled NOWHERE as a base rule, while at
|
||||||
|
least one of them appears in the file's CSS. That conjunction is the signal: a
|
||||||
|
class nobody styles is ordinary (a hook for a test, a semantic label), and a
|
||||||
|
class with only modifier rules is a deletion that went half-way.
|
||||||
|
|
||||||
|
Descendant selectors count as a base — `.panel .row {}` styles `.row` — because
|
||||||
|
from the element's side there is no difference. Only the LAST compound of a
|
||||||
|
selector is what it styles, and a compound's whole class SET is what it
|
||||||
|
requires: `.pane.empty` and `td.num` are base rules for the element carrying
|
||||||
|
those classes, not modifiers. Reading them as modifiers cost this check four
|
||||||
|
false reports on its first run, and a check with false reports is one that gets
|
||||||
|
skimmed.
|
||||||
|
|
||||||
|
REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate,
|
||||||
|
so this cannot be a gate without a suppression mechanism nobody would maintain.
|
||||||
|
A count that grows is the signal to look. Where a wrapper is bare on purpose,
|
||||||
|
say so in a comment beside its descendant rules — the remaining reports here
|
||||||
|
all carry one, so a new entry means something changed.
|
||||||
|
|
||||||
|
KNOWN BLIND SPOT: a class with NO rule anywhere is invisible to this, because
|
||||||
|
it cannot be told from a semantic-only hook. `.systems-list` had lost its
|
||||||
|
entire rule and was rendering with browser bullets; it was found by reading the
|
||||||
|
file next to a class that WAS half-styled, not by this check.
|
||||||
|
|
||||||
|
INSTANCE-AGNOSTIC (rule #115). Nothing here knows a class name, a component, or
|
||||||
|
a convention; point it at any Vue tree.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.S)
|
||||||
|
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
||||||
|
# `class="a b"` only — never `:class="[...]"`, whose value is an expression.
|
||||||
|
# The negative lookbehind is the whole point: a bound class list mentions names
|
||||||
|
# that a static parse would misread as the element's only classes.
|
||||||
|
STATIC_CLASS = re.compile(r'(?<![:\w-])class="([^"{}\[\]]*)"')
|
||||||
|
SELECTOR = re.compile(r"([^{}]+)\{")
|
||||||
|
CLASS_TOKEN = re.compile(r"\.([A-Za-z][\w-]*)")
|
||||||
|
|
||||||
|
|
||||||
|
def shared_classes(sheets: list[pathlib.Path]) -> set[str]:
|
||||||
|
"""Class names any global stylesheet defines — a base rule from elsewhere."""
|
||||||
|
names: set[str] = set()
|
||||||
|
for sheet in sheets:
|
||||||
|
if sheet.exists():
|
||||||
|
names |= set(CLASS_TOKEN.findall(sheet.read_text()))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def base_class_sets(css: str) -> list[frozenset[str]]:
|
||||||
|
"""Class combinations this stylesheet gives a base rule to.
|
||||||
|
|
||||||
|
The last compound of a selector is what the rule styles: in
|
||||||
|
`.panel .row:hover` that is `.row:hover`, a state — but in `.panel .row` it
|
||||||
|
is `.row`, a base.
|
||||||
|
|
||||||
|
A compound may carry more than one class, and a type selector alongside
|
||||||
|
them. `.pane.empty` and `td.num` are both base rules for the element that
|
||||||
|
matches, so each is recorded as the SET of classes it requires; an element
|
||||||
|
is styled when it carries all of them. Recording the classes individually
|
||||||
|
instead would clear `.pane` everywhere on the strength of a rule that only
|
||||||
|
ever applies with `.empty` — precision matters more here than reach, since
|
||||||
|
a missed base is a false report and a wrong one is a defect gone quiet.
|
||||||
|
|
||||||
|
A compound with no class at all (`ul`, `li`) is skipped: it styles by tag,
|
||||||
|
which this cannot verify without parsing the template's elements, and an
|
||||||
|
empty set would clear every element in the file.
|
||||||
|
"""
|
||||||
|
out: list[frozenset[str]] = []
|
||||||
|
for selector in SELECTOR.findall(css):
|
||||||
|
for part in selector.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part or part.startswith("@"):
|
||||||
|
continue
|
||||||
|
last = re.split(r"[\s>+~]+", part)[-1]
|
||||||
|
# A pseudo-class, pseudo-element or attribute selector makes it a
|
||||||
|
# state or a variant, not the element's base appearance.
|
||||||
|
if ":" in last or "[" in last:
|
||||||
|
continue
|
||||||
|
names = CLASS_TOKEN.findall(last)
|
||||||
|
# Everything outside the class tokens must be a bare type selector.
|
||||||
|
if names and re.fullmatch(r"[A-Za-z][\w-]*|\*|", CLASS_TOKEN.sub("", last)):
|
||||||
|
out.append(frozenset(names))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]:
|
||||||
|
source = path.read_text()
|
||||||
|
template = source.split("<style")[0]
|
||||||
|
css = "\n".join(CSS_COMMENT.sub("", block) for block in STYLE_BLOCK.findall(source))
|
||||||
|
if not css.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
base_sets = base_class_sets(css)
|
||||||
|
mentioned = set(CLASS_TOKEN.findall(css))
|
||||||
|
|
||||||
|
findings: list[tuple[str, list[str]]] = []
|
||||||
|
for attr in sorted(set(STATIC_CLASS.findall(template))):
|
||||||
|
names = [n for n in attr.split() if re.fullmatch(r"[A-Za-z][\w-]*", n)]
|
||||||
|
if not names:
|
||||||
|
continue
|
||||||
|
carried = set(names)
|
||||||
|
if any(carried >= required for required in base_sets):
|
||||||
|
continue
|
||||||
|
if any(n in shared for n in names):
|
||||||
|
continue
|
||||||
|
dangling = [n for n in names if n in mentioned]
|
||||||
|
if dangling:
|
||||||
|
findings.append((attr, dangling))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--root", default="frontend/src", help="tree of .vue files")
|
||||||
|
parser.add_argument(
|
||||||
|
"--shared",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
help="global stylesheet whose classes count as a base rule (repeatable)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = pathlib.Path(args.root)
|
||||||
|
if not root.exists():
|
||||||
|
print(f"{root}: no such directory", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
sheets = [pathlib.Path(s) for s in (args.shared or [])]
|
||||||
|
if not sheets:
|
||||||
|
sheets = sorted(root.glob("assets/*.css"))
|
||||||
|
shared = shared_classes(sheets)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for path in sorted(root.rglob("*.vue")):
|
||||||
|
for attr, dangling in scan(path, shared):
|
||||||
|
total += 1
|
||||||
|
print(f'{path}: class="{attr}" — styled but never based: {", ".join(dangling)}')
|
||||||
|
|
||||||
|
print()
|
||||||
|
if total:
|
||||||
|
print(
|
||||||
|
f"REPORT: {total} element(s) whose classes carry modifier rules but no base "
|
||||||
|
f"rule. Each is either a deleted rule that left its :hover behind, or a "
|
||||||
|
f"deliberately bare wrapper."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("OK — every styled class has a base rule.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -307,6 +307,44 @@ def check_local_prior_art_needs_no_instance() -> None:
|
|||||||
ok("prior-art local arm: answers with no instance configured")
|
ok("prior-art local arm: answers with no instance configured")
|
||||||
|
|
||||||
|
|
||||||
|
def check_session_context_reports_its_version() -> None:
|
||||||
|
"""The SessionStart context must name the plugin version it is running.
|
||||||
|
|
||||||
|
An install has two halves and only one self-updates: the marketplace clone
|
||||||
|
pulls on its own, while the CACHE is what executes and refreshes only when
|
||||||
|
the manifest version changes. So a shipped fix can sit unreached while
|
||||||
|
inspecting the clone shows it present — the obvious debugging move
|
||||||
|
misleads, and twice the only detector was a human saying "I don't think it
|
||||||
|
updated" (#2209, #2220).
|
||||||
|
|
||||||
|
Asserted WITHOUT credentials on purpose. The state most needing diagnosis
|
||||||
|
is the one where the token never arrives, and a marker that vanished there
|
||||||
|
would be missing exactly when it is wanted.
|
||||||
|
"""
|
||||||
|
script = HOOKS_DIR / "scribe_session_context.sh"
|
||||||
|
if not script.is_file() or not shutil.which("jq"):
|
||||||
|
skip("version marker: hook or jq missing")
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest_v = manifest_version()
|
||||||
|
if manifest_v is None:
|
||||||
|
fail("version marker: could not read the manifest version")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = _run_hook(script, json.dumps({"source": "startup"}), {})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
fail("version marker: hook hung")
|
||||||
|
return
|
||||||
|
if proc.returncode != 0:
|
||||||
|
fail(f"version marker: hook exited {proc.returncode}")
|
||||||
|
elif manifest_v not in proc.stdout:
|
||||||
|
fail(f"version marker: session context never names v{manifest_v} — "
|
||||||
|
f"a stale install would be undetectable from the transcript")
|
||||||
|
else:
|
||||||
|
ok(f"version marker: session context reports v{manifest_v}, no credentials needed")
|
||||||
|
|
||||||
|
|
||||||
def _git(*args: str) -> tuple[int, str]:
|
def _git(*args: str) -> tuple[int, str]:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["git", *args], capture_output=True, text=True, cwd=ROOT
|
["git", *args], capture_output=True, text=True, cwd=ROOT
|
||||||
@@ -399,6 +437,7 @@ def main() -> int:
|
|||||||
check_shellcheck()
|
check_shellcheck()
|
||||||
check_fail_open()
|
check_fail_open()
|
||||||
check_local_prior_art_needs_no_instance()
|
check_local_prior_art_needs_no_instance()
|
||||||
|
check_session_context_reports_its_version()
|
||||||
if not args.no_version:
|
if not args.no_version:
|
||||||
check_version_bump(args.base)
|
check_version_bump(args.base)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from scribe.routes.profile import profile_bp
|
|||||||
from scribe.routes.knowledge import knowledge_bp
|
from scribe.routes.knowledge import knowledge_bp
|
||||||
from scribe.routes.rulebooks import rulebooks_bp
|
from scribe.routes.rulebooks import rulebooks_bp
|
||||||
from scribe.routes.plugin import plugin_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.design_systems import design_systems_bp
|
||||||
from scribe.routes.trash import trash_bp
|
from scribe.routes.trash import trash_bp
|
||||||
from scribe.routes.dashboard import dashboard_bp
|
from scribe.routes.dashboard import dashboard_bp
|
||||||
@@ -91,7 +90,6 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(knowledge_bp)
|
app.register_blueprint(knowledge_bp)
|
||||||
app.register_blueprint(rulebooks_bp)
|
app.register_blueprint(rulebooks_bp)
|
||||||
app.register_blueprint(plugin_bp)
|
app.register_blueprint(plugin_bp)
|
||||||
app.register_blueprint(design_bp)
|
|
||||||
app.register_blueprint(design_systems_bp)
|
app.register_blueprint(design_systems_bp)
|
||||||
app.register_blueprint(trash_bp)
|
app.register_blueprint(trash_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
|
|||||||
+97
-17
@@ -54,10 +54,29 @@ What each part is for, and when to reach for it:
|
|||||||
system as a rulebook — rules are for behaviour, and tokens kept as prose
|
system as a rulebook — rules are for behaviour, and tokens kept as prose
|
||||||
cannot be resolved, inherited, rendered to a stylesheet, or checked against
|
cannot be resolved, inherited, rendered to a stylesheet, or checked against
|
||||||
code.
|
code.
|
||||||
- System: a per-project, reusable, self-describing subsystem/area. Associate any
|
- System: a per-project, reusable, self-describing subsystem/area — the
|
||||||
record (note, task, issue) with it via system_ids so research, build-work, and
|
project's vocabulary for WHERE work happens. enter_project returns the list.
|
||||||
fixes for the same area line up, and recurring problem-spots surface. Manage
|
TAG AS YOU WRITE: when you create or meaningfully update a note, task, or
|
||||||
with create_system / list_systems / get_system.
|
snippet, ask which of those areas it is about and pass system_ids. The test:
|
||||||
|
would someone investigating that subsystem want this record in the pile
|
||||||
|
list_system_records returns? Cross-cutting records take several; a record
|
||||||
|
about no particular area takes none — don't force it. If the area a record
|
||||||
|
describes has no System yet, CREATE it (create_system: name + a one-paragraph
|
||||||
|
charter) and tag the record — a subsystem that exists in the code deserves a
|
||||||
|
System the moment two records would share it, the same two-or-more test
|
||||||
|
snippets use; don't wait to be asked to name an area that plainly exists.
|
||||||
|
Read a subsystem back with list_system_records, or search(system_id=...) for
|
||||||
|
a ranked cut.
|
||||||
|
- Reference note vs dev-log — STATE vs CHRONICLE. A dev-log records what
|
||||||
|
HAPPENED: write it once, never rewrite it. A durable finding — how a
|
||||||
|
subsystem works, a measured number, an architecture fact — belongs in that
|
||||||
|
System's REFERENCE NOTE ("«System name» — reference", tagged to the System),
|
||||||
|
which is UPDATED IN PLACE as the facts change. Updating loses nothing: every
|
||||||
|
meaningful edit is snapshotted (note versions are the changelog). Create the
|
||||||
|
reference note if the System lacks one; update it if it exists; have the
|
||||||
|
dev-log [[link]] it rather than restating state. State smeared across dated
|
||||||
|
logs is unreachable by search — sixteen near-identical dev-logs tie, and no
|
||||||
|
ranking can pick the right one, because no right one exists.
|
||||||
|
|
||||||
Mechanics:
|
Mechanics:
|
||||||
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
||||||
@@ -83,6 +102,16 @@ not something you wait to be asked for:
|
|||||||
record (update_note / update_task / add_task_log) rather than duplicating.
|
record (update_note / update_task / add_task_log) rather than duplicating.
|
||||||
Only pass force=true when it's genuinely a distinct record — a duplicate both
|
Only pass force=true when it's genuinely a distinct record — a duplicate both
|
||||||
bloats the store and surfaces as a stale competing copy in later searches.
|
bloats the store and surfaces as a stale competing copy in later searches.
|
||||||
|
- When a note genuinely IS new but overtakes an older one, say so: pass the
|
||||||
|
older note's id in `supersedes` on create_note / update_note. Reach for it on
|
||||||
|
a re-measurement, a decision that reverses an earlier one, a dev-log covering
|
||||||
|
ground a previous one covered. The old note stays readable and keeps its
|
||||||
|
place; it stops competing for the same question and arrives labelled. This is
|
||||||
|
the third answer alongside update-instead and force: not everything that
|
||||||
|
resembles an existing record should be folded into it, and not everything
|
||||||
|
distinct should compete with it forever. If a result carries `superseded_by`,
|
||||||
|
a later note claims to have brought it up to date — read it as what was true
|
||||||
|
when written and open the newer one before acting.
|
||||||
- Scope to the project in scope. When a project is active (you called
|
- Scope to the project in scope. When a project is active (you called
|
||||||
enter_project), pass its project_id to search / list_tasks / list_notes so
|
enter_project), pass its project_id to search / list_tasks / list_notes so
|
||||||
results stay inside that project. Querying with no project_id pulls in every
|
results stay inside that project. Querying with no project_id pulls in every
|
||||||
@@ -174,19 +203,30 @@ 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
|
SNIPPET. Reach for a rule when the thing genuinely is a standing instruction
|
||||||
about how to work, and nothing else can hold it.
|
about how to work, and nothing else can hold it.
|
||||||
|
|
||||||
|
Rules are pull-only: CALL list_always_on_rules() YOURSELF at the start of a
|
||||||
|
session, before substantive work. Do not wait to be handed them. The plugin's
|
||||||
|
SessionStart hook also pushes the always-on rules + active-project context, but
|
||||||
|
that is a delivery optimisation, not the bridge — it can be absent (instance
|
||||||
|
unreachable, key not configured, or the hook itself silently inert, which is
|
||||||
|
what #2198 was) and its absence does not announce itself. When the push did
|
||||||
|
arrive, the pull costs one redundant call; when it didn't, skipping the pull
|
||||||
|
means working without the operator's binding rules and having no signal that
|
||||||
|
you are. Those two costs are not comparable, so pull.
|
||||||
|
|
||||||
One thing NOT to do: don't bridge Scribe into a session by writing to the
|
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
|
host's native memory. That explicit pull IS the bridge — it is written into
|
||||||
them unless its always-loaded context says to — but the bridge for that is the
|
every instruction surface the session already loads, so it needs no pointer on
|
||||||
Scribe plugin's SessionStart hook, which pushes the always-on rules +
|
disk. So do NOT create or refresh a "rules live in Scribe" pointer in
|
||||||
active-project context into each session directly. So do NOT create or refresh
|
CLAUDE.md / AGENTS.md / ~/.claude memory, and do NOT keep rules, recall, or
|
||||||
a "rules live in Scribe" pointer in CLAUDE.md / AGENTS.md / ~/.claude memory,
|
plans in those stores in parallel with Scribe — Scribe holds the single copy,
|
||||||
and do NOT keep rules, recall, or plans in those stores in parallel with Scribe
|
and a pointer Scribe cannot update is one that goes stale without anyone
|
||||||
— Scribe holds the single copy. Native auto-memory stays for facts about the
|
noticing. Native auto-memory stays for facts about the user; CLAUDE.md for
|
||||||
user; CLAUDE.md for codebase onboarding. Never make Scribe's correctness depend
|
codebase onboarding. Never make Scribe's correctness depend on the operator
|
||||||
on the operator disabling a native function (e.g. autoMemoryEnabled): the
|
disabling a native function (e.g. autoMemoryEnabled): the plugin must work with
|
||||||
plugin must work with auto-memory at its default. If the plugin is ever removed
|
auto-memory at its default. If the plugin is ever removed the session loses both
|
||||||
the session loses this push and rebuilds context over time — an acceptable cost,
|
the push and these instructions, and rebuilds context over time — an acceptable
|
||||||
and far better than a silent settings change the operator may not know about.
|
cost, and far better than a silent settings change the operator may not know
|
||||||
|
about.
|
||||||
|
|
||||||
When you are working on a specific project, call enter_project(project_id)
|
When you are working on a specific project, call enter_project(project_id)
|
||||||
ONCE at session start (or whenever the active project changes). It returns the
|
ONCE at session start (or whenever the active project changes). It returns the
|
||||||
@@ -225,7 +265,11 @@ Scribe stores reusable Processes — saved prompts/workflows (note_type
|
|||||||
X process" or otherwise references a saved process, call list_processes() /
|
X process" or otherwise references a saved process, call list_processes() /
|
||||||
get_process(name) and follow the returned prompt verbatim, including any
|
get_process(name) and follow the returned prompt verbatim, including any
|
||||||
"clarify first" steps it contains. Author a new one with create_process(title,
|
"clarify first" steps it contains. Author a new one with create_process(title,
|
||||||
body); edit with update_process.
|
body); edit with update_process; retire one with delete_process (recoverable —
|
||||||
|
it goes to the trash like anything else). A near-duplicate is refused at create
|
||||||
|
time, because every Process becomes a skill file that auto-surfaces on the
|
||||||
|
operator's machine: two near-identical procedures don't merely bloat the record,
|
||||||
|
they compete to be followed.
|
||||||
|
|
||||||
Scribe also stores Snippets — reusable functions/components recorded once for
|
Scribe also stores Snippets — reusable functions/components recorded once for
|
||||||
recall (note_type "snippet"): a name, language, signature, canonical location
|
recall (note_type "snippet"): a name, language, signature, canonical location
|
||||||
@@ -267,6 +311,20 @@ operator. "Works for one user" is not done.
|
|||||||
# Tools a read-only API key may call. Anything not listed is treated as a
|
# Tools a read-only API key may call. Anything not listed is treated as a
|
||||||
# write for read keys (default-deny), so a newly-added tool is locked down
|
# write for read keys (default-deny), so a newly-added tool is locked down
|
||||||
# until explicitly classified here.
|
# until explicitly classified here.
|
||||||
|
#
|
||||||
|
# The list stays EXPLICIT rather than being derived from the name. A read key is
|
||||||
|
# what you hand to something you don't fully trust — a dashboard, a CI job, a
|
||||||
|
# shared integration — and a boundary inferred from a naming convention grants
|
||||||
|
# access to whatever a future author happens to call `get_*`. Enumerating it is
|
||||||
|
# the point; staleness is the cost, and test_mcp_auth covers that (a read-shaped
|
||||||
|
# tool must appear here or in _DELIBERATELY_WRITE_SCOPED below, so adding one
|
||||||
|
# forces a decision instead of silently denying it).
|
||||||
|
#
|
||||||
|
# Membership means "reads the operator's data and mutates none of it". Several
|
||||||
|
# getters record a retrieval event via record_pulled; that is telemetry about
|
||||||
|
# the read itself, not a change to what was read, and it must keep working for a
|
||||||
|
# read key or the corpus's surfaced:pulled ratio silently under-counts whichever
|
||||||
|
# consumers hold one.
|
||||||
_READ_ONLY_TOOLS = frozenset({
|
_READ_ONLY_TOOLS = frozenset({
|
||||||
"get_note", "get_project", "get_rule", "get_rulebook",
|
"get_note", "get_project", "get_rule", "get_rulebook",
|
||||||
"get_task", "get_milestone", "get_recent", "enter_project",
|
"get_task", "get_milestone", "get_recent", "enter_project",
|
||||||
@@ -277,8 +335,30 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
# Reports on the snippet corpus. Reads only — the merge it suggests is a
|
# Reports on the snippet corpus. Reads only — the merge it suggests is a
|
||||||
# separate, explicitly-called write.
|
# separate, explicitly-called write.
|
||||||
"find_duplicate_snippets",
|
"find_duplicate_snippets",
|
||||||
|
# Snippets and processes are notes with a kind. A key that may read a note
|
||||||
|
# but not a snippet inverts the sensitivity ordering: it exposes the
|
||||||
|
# free-text records and withholds the structured ones (#2496).
|
||||||
|
"get_snippet", "list_snippets",
|
||||||
|
"get_process", "list_processes",
|
||||||
|
# Design systems: read, resolve (inheritance + mode), render, and compare
|
||||||
|
# against recorded snippets. All four compute from stored records and write
|
||||||
|
# nothing — the drift report is a report, and applying it is a separate
|
||||||
|
# explicit call.
|
||||||
|
"get_design_system", "list_design_systems", "resolve_design_system",
|
||||||
|
"get_design_system_stylesheet", "list_design_tokens",
|
||||||
|
"check_snippets_against_design_system", "list_starter_role_groups",
|
||||||
|
# Which repos map to which project. Read-only by nature; bind_repo /
|
||||||
|
# unbind_repo are the writes.
|
||||||
|
"list_repo_bindings",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
|
||||||
|
# creates on miss, a list that has a side effect. Empty today, and deliberately
|
||||||
|
# kept as a declared escape hatch rather than left implicit: without it, the
|
||||||
|
# completeness test would push a future `get_or_create_*` into the allow-list
|
||||||
|
# above, which is exactly the wrong way to make a test pass.
|
||||||
|
_DELIBERATELY_WRITE_SCOPED: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
async def _buffer_request_body(receive):
|
async def _buffer_request_body(receive):
|
||||||
"""Drain the ASGI request body and return (body_bytes, replay_receive).
|
"""Drain the ASGI request body and return (body_bytes, replay_receive).
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ from __future__ import annotations
|
|||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from scribe.services import design_systems as ds_svc
|
from scribe.services import design_systems as ds_svc
|
||||||
from scribe.services.design_systems import DesignSystemCycle
|
from scribe.services.design_systems import DesignSystemCycle
|
||||||
|
from scribe.services.design_starter_roles import (
|
||||||
|
ALL_GROUPS,
|
||||||
|
DEFAULT_TOKEN_PREFIX,
|
||||||
|
describe_groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_design_system(
|
async def create_design_system(
|
||||||
@@ -29,6 +34,8 @@ async def create_design_system(
|
|||||||
description: str = "",
|
description: str = "",
|
||||||
guidance: str = "",
|
guidance: str = "",
|
||||||
parent_id: int = 0,
|
parent_id: int = 0,
|
||||||
|
starter_role_groups: list[str] | None = None,
|
||||||
|
token_prefix: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a design system, optionally inheriting from another.
|
"""Create a design system, optionally inheriting from another.
|
||||||
|
|
||||||
@@ -41,20 +48,47 @@ async def create_design_system(
|
|||||||
parent_id: Inherit from this system — it holds the defaults this one
|
parent_id: Inherit from this system — it holds the defaults this one
|
||||||
overrides. Omit (0) for a top-level "family" system, which is what
|
overrides. Omit (0) for a top-level "family" system, which is what
|
||||||
a first design system usually is.
|
a first design system usually is.
|
||||||
|
starter_role_groups: Seed the system with named but VALUELESS token
|
||||||
|
roles, so there is something to reach for before a literal gets
|
||||||
|
written instead. Call list_starter_role_groups() for the catalogue.
|
||||||
|
Pass ["all"] for every group. Omit for none — a system with three
|
||||||
|
hand-written tokens is a legitimate design system.
|
||||||
|
token_prefix: Naming convention for the seeded roles, e.g. "--fs-".
|
||||||
|
Defaults to a neutral "--ds-"; pass the install's own if it has one.
|
||||||
|
Ignored when no starter groups are requested.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
|
groups = starter_role_groups
|
||||||
|
if groups and len(groups) == 1 and groups[0] == "all":
|
||||||
|
groups = list(ALL_GROUPS)
|
||||||
system = await ds_svc.create_design_system(
|
system = await ds_svc.create_design_system(
|
||||||
uid,
|
uid,
|
||||||
title=title,
|
title=title,
|
||||||
description=description or None,
|
description=description or None,
|
||||||
guidance=guidance or None,
|
guidance=guidance or None,
|
||||||
parent_id=parent_id or None,
|
parent_id=parent_id or None,
|
||||||
|
starter_role_groups=groups,
|
||||||
|
token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX,
|
||||||
)
|
)
|
||||||
if system is None:
|
if system is None:
|
||||||
raise ValueError(f"parent design system {parent_id} not found or not writable")
|
raise ValueError(f"parent design system {parent_id} not found or not writable")
|
||||||
return system.to_dict()
|
return system.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_starter_role_groups() -> dict:
|
||||||
|
"""The starter token ROLES offered at design-system creation.
|
||||||
|
|
||||||
|
Roles, not values. Every group is a set of named questions — "page
|
||||||
|
background, the deepest surface" — that the operator answers with their own
|
||||||
|
palette. Nothing here carries a colour, because a default palette would be
|
||||||
|
one install's taste shipped as product.
|
||||||
|
|
||||||
|
Reach for this before create_design_system so the choice is informed, and
|
||||||
|
pass the group names you want as `starter_role_groups`.
|
||||||
|
"""
|
||||||
|
return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX}
|
||||||
|
|
||||||
|
|
||||||
async def list_design_systems() -> dict:
|
async def list_design_systems() -> dict:
|
||||||
"""List your design systems. An empty list is normal — most installs have none."""
|
"""List your design systems. An empty list is normal — most installs have none."""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -350,6 +384,7 @@ async def set_project_design_system(project_id: int, design_system_id: int = 0)
|
|||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
create_design_system,
|
create_design_system,
|
||||||
|
list_starter_role_groups,
|
||||||
list_design_systems,
|
list_design_systems,
|
||||||
get_design_system,
|
get_design_system,
|
||||||
resolve_design_system,
|
resolve_design_system,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
|
|||||||
from scribe.services import access as access_svc
|
from scribe.services import access as access_svc
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
|
from scribe.services import supersession as supersession_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
from scribe.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
from scribe.services.note_usage import record_pulled
|
from scribe.services.note_usage import record_pulled
|
||||||
@@ -55,12 +56,42 @@ async def list_notes(
|
|||||||
return {"notes": [n.to_dict() for n in rows], "total": total}
|
return {"notes": [n.to_dict() for n in rows], "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||||
|
"""Add both directions of the supersession relation to a note payload.
|
||||||
|
|
||||||
|
Both, because they answer different questions and only one of them is
|
||||||
|
obvious. `supersedes` is what the author claimed. `superseded_by` is what a
|
||||||
|
READER needs and what the note itself cannot know — a stale record handed
|
||||||
|
over without that marker gets acted on confidently, which is worse than
|
||||||
|
never surfacing it.
|
||||||
|
|
||||||
|
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
|
||||||
|
permanently-empty lists. A field that always says nothing trains readers to
|
||||||
|
skip fields, which is the lesson `consolidated_at` cost us (#2483).
|
||||||
|
"""
|
||||||
|
rel = await supersession_svc.get_relations(uid, note_id)
|
||||||
|
if rel["supersedes"]:
|
||||||
|
data["supersedes"] = rel["supersedes"]
|
||||||
|
if rel["superseded_by"]:
|
||||||
|
data["superseded_by"] = rel["superseded_by"]
|
||||||
|
data["superseded_note"] = (
|
||||||
|
"A later note claims to bring this up to date — see superseded_by. "
|
||||||
|
"Read this as what was true when written, and check the newer one "
|
||||||
|
"before acting on it."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_note(note_id: int) -> dict:
|
async def get_note(note_id: int) -> dict:
|
||||||
"""Fetch the full content of a single Scribe note by its ID.
|
"""Fetch the full content of a single Scribe note by its ID.
|
||||||
|
|
||||||
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
||||||
A note another user shared with you also carries `shared`, `owner` and
|
A note another user shared with you also carries `shared`, `owner` and
|
||||||
`permission` — read it as their suggestion, not as settled practice you set.
|
`permission` — read it as their suggestion, not as settled practice you set.
|
||||||
|
|
||||||
|
IF THE RESULT CARRIES `superseded_by`, a later note claims to have brought
|
||||||
|
this one up to date. It is still here and still readable — supersession
|
||||||
|
demotes, it never hides — but read it as what was true when written, and
|
||||||
|
open the newer note before acting on it.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
loaded = await notes_svc.get_note_for_user(uid, note_id)
|
loaded = await notes_svc.get_note_for_user(uid, note_id)
|
||||||
@@ -74,6 +105,7 @@ async def get_note(note_id: int) -> dict:
|
|||||||
# snippets would leave those permanently at zero pulls and make them look
|
# snippets would leave those permanently at zero pulls and make them look
|
||||||
# like dead weight next to snippets that merely had a counter (#2085).
|
# like dead weight next to snippets that merely had a counter (#2085).
|
||||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
|
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
|
||||||
|
await _attach_supersession(uid, note_id, out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +115,7 @@ async def create_note(
|
|||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
system_ids: list[int] | None = None,
|
system_ids: list[int] | None = None,
|
||||||
|
supersedes: list[int] | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new note in Scribe.
|
"""Create a new note in Scribe.
|
||||||
@@ -94,6 +127,15 @@ async def create_note(
|
|||||||
project_id: Associate with a project (use 0 for no project / orphan note).
|
project_id: Associate with a project (use 0 for no project / orphan note).
|
||||||
system_ids: Ids of the project's Systems to associate this note with
|
system_ids: Ids of the project's Systems to associate this note with
|
||||||
(e.g. research about a subsystem). See list_systems / create_system.
|
(e.g. research about a subsystem). See list_systems / create_system.
|
||||||
|
supersedes: Ids of EARLIER notes this one replaces or brings up to date.
|
||||||
|
Reach for it whenever you write something that overtakes what an
|
||||||
|
older note recorded — a re-measurement, a decision that reverses an
|
||||||
|
earlier one, a dev-log covering ground a previous one covered.
|
||||||
|
The older note stays readable and keeps its place in search; it
|
||||||
|
simply stops competing with this one for the same question, and
|
||||||
|
arrives labelled when it does surface. This records a CLAIM, not a
|
||||||
|
verdict: it never says the older note was wrong, only that it is no
|
||||||
|
longer the current answer.
|
||||||
force: Bypass the near-duplicate gate. By default, if a title- or
|
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||||
meaning-similar note already exists in the same project, creation is
|
meaning-similar note already exists in the same project, creation is
|
||||||
BLOCKED and the existing note's id is returned so you update it
|
BLOCKED and the existing note's id is returned so you update it
|
||||||
@@ -121,11 +163,19 @@ async def create_note(
|
|||||||
)
|
)
|
||||||
if system_ids:
|
if system_ids:
|
||||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||||
|
if supersedes:
|
||||||
|
try:
|
||||||
|
await supersession_svc.set_supersedes(uid, note.id, supersedes)
|
||||||
|
except PermissionError as exc:
|
||||||
|
# The note WAS created — surface the real reason rather than a
|
||||||
|
# not-found, and leave the note rather than silently rolling it back.
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
data = note.to_dict()
|
data = note.to_dict()
|
||||||
if system_ids:
|
if system_ids:
|
||||||
data["systems"] = [
|
data["systems"] = [
|
||||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||||
]
|
]
|
||||||
|
await _attach_supersession(uid, note.id, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +186,7 @@ async def update_note(
|
|||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
system_ids: list[int] | None = None,
|
system_ids: list[int] | None = None,
|
||||||
|
supersedes: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update an existing Scribe note. Only explicitly provided fields are changed.
|
"""Update an existing Scribe note. Only explicitly provided fields are changed.
|
||||||
|
|
||||||
@@ -147,6 +198,9 @@ async def update_note(
|
|||||||
project_id: New project association. Omit (or pass 0) to leave unchanged.
|
project_id: New project association. Omit (or pass 0) to leave unchanged.
|
||||||
system_ids: Replace this note's System associations with these ids
|
system_ids: Replace this note's System associations with these ids
|
||||||
(set-semantics). None = leave unchanged; [] = clear all.
|
(set-semantics). None = leave unchanged; [] = clear all.
|
||||||
|
supersedes: Replace the ids of earlier notes this one replaces
|
||||||
|
(set-semantics). None = leave unchanged; [] = clear all. See
|
||||||
|
create_note for when to reach for it.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -163,11 +217,17 @@ async def update_note(
|
|||||||
raise ValueError(f"note {note_id} not found")
|
raise ValueError(f"note {note_id} not found")
|
||||||
if system_ids is not None:
|
if system_ids is not None:
|
||||||
await systems_svc.set_record_systems(uid, note_id, system_ids)
|
await systems_svc.set_record_systems(uid, note_id, system_ids)
|
||||||
|
if supersedes is not None:
|
||||||
|
try:
|
||||||
|
await supersession_svc.set_supersedes(uid, note_id, supersedes)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
data = note.to_dict()
|
data = note.to_dict()
|
||||||
if system_ids is not None:
|
if system_ids is not None:
|
||||||
data["systems"] = [
|
data["systems"] = [
|
||||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note_id)
|
s.to_dict() for s in await systems_svc.list_record_systems(uid, note_id)
|
||||||
]
|
]
|
||||||
|
await _attach_supersession(uid, note_id, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from scribe.services import access as access_svc
|
from scribe.services import access as access_svc
|
||||||
|
from scribe.services import dedup as dedup_svc
|
||||||
from scribe.services import knowledge as knowledge_svc
|
from scribe.services import knowledge as knowledge_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
|
from scribe.services import trash as trash_svc
|
||||||
|
from scribe.services.note_usage import record_pulled
|
||||||
|
|
||||||
|
|
||||||
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
||||||
@@ -41,17 +44,38 @@ async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
|||||||
return {"processes": procs, "total": total}
|
return {"processes": procs, "total": total}
|
||||||
|
|
||||||
|
|
||||||
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
|
async def create_process(
|
||||||
|
title: str, body: str, tags: list[str] | None = None, force: bool = False,
|
||||||
|
) -> dict:
|
||||||
"""Create a stored process (a reusable saved prompt).
|
"""Create a stored process (a reusable saved prompt).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title: Process name, e.g. "Drift Audit" (required).
|
title: Process name, e.g. "Drift Audit" (required).
|
||||||
body: The full prompt to run later (markdown). Required.
|
body: The full prompt to run later (markdown). Required.
|
||||||
tags: Plain-string tags, no # prefix.
|
tags: Plain-string tags, no # prefix.
|
||||||
|
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||||
|
meaning-similar process already exists, creation is BLOCKED and the
|
||||||
|
existing one's id is returned so you update it instead. Set true
|
||||||
|
only for a genuinely distinct procedure.
|
||||||
|
|
||||||
|
Returns the created process, OR — when a near-duplicate is found and force
|
||||||
|
is false — {"duplicate": true, "existing_id": ..., "message": ...} (nothing
|
||||||
|
created).
|
||||||
|
|
||||||
|
The gate matters more here than for other kinds: every process becomes a
|
||||||
|
skill file that auto-surfaces on the operator's machine, so two near-identical
|
||||||
|
procedures don't merely bloat the corpus — they compete to be followed, and
|
||||||
|
which one wins is decided by a slug.
|
||||||
"""
|
"""
|
||||||
if not (title or "").strip() or not (body or "").strip():
|
if not (title or "").strip() or not (body or "").strip():
|
||||||
raise ValueError("create_process requires a non-empty title and body")
|
raise ValueError("create_process requires a non-empty title and body")
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
|
if not force:
|
||||||
|
dup = await dedup_svc.find_duplicate_note(
|
||||||
|
uid, title, body, is_task=False, note_type="process",
|
||||||
|
)
|
||||||
|
if dup is not None:
|
||||||
|
return dedup_svc.duplicate_response(dup, "process")
|
||||||
note = await notes_svc.create_note(
|
note = await notes_svc.create_note(
|
||||||
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
||||||
)
|
)
|
||||||
@@ -82,6 +106,12 @@ async def get_process(name_or_id: str) -> dict:
|
|||||||
if candidates:
|
if candidates:
|
||||||
out["other_matches"] = candidates
|
out["other_matches"] = candidates
|
||||||
out.update(await access_svc.describe_provenance(uid, note))
|
out.update(await access_svc.describe_provenance(uid, note))
|
||||||
|
# A process is embedded like any other note, so auto-inject can surface one —
|
||||||
|
# and its menu header names THIS tool as the way to open that kind. Without
|
||||||
|
# this, the getter the product points at is the one getter that records
|
||||||
|
# nothing, and every process sits permanently at zero pulls looking like dead
|
||||||
|
# weight beside kinds that merely had a counter (#2476, the repeat of #2245).
|
||||||
|
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_process")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +149,44 @@ async def update_process(process_id: int, title: str = "", body: str = "",
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_process(process_id: int) -> dict:
|
||||||
|
"""Retire a stored process — it moves to the trash and is recoverable.
|
||||||
|
|
||||||
|
Reach for this when a procedure is wrong, superseded, or was never worth
|
||||||
|
keeping. A stored process is installed as a skill file on the operator's
|
||||||
|
machine and auto-surfaces there, so a bad one is followed rather than merely
|
||||||
|
ignored — it costs more than a missing one.
|
||||||
|
|
||||||
|
Deletion was always possible through `delete_note` (a process is a note, and
|
||||||
|
the trash is kind-agnostic), but nothing said so, and a kind whose own tools
|
||||||
|
offer create/read/update reads as one you cannot retire (#2250).
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
loaded = await notes_svc.get_note_for_user(uid, process_id)
|
||||||
|
note = loaded[0] if loaded else None
|
||||||
|
# Check the KIND before deleting: this tool is reached for by name, and
|
||||||
|
# letting it trash an ordinary note because the id happened to resolve would
|
||||||
|
# be a destructive action taken on a mistyped argument.
|
||||||
|
if note is None or note.note_type != "process" or note.deleted_at is not None:
|
||||||
|
raise ValueError(f"process {process_id} not found")
|
||||||
|
batch = await trash_svc.delete(uid, "note", process_id)
|
||||||
|
if batch is None:
|
||||||
|
raise ValueError(f"process {process_id} not found")
|
||||||
|
return {
|
||||||
|
"deleted_batch_id": batch,
|
||||||
|
"message": (
|
||||||
|
f"Process {process_id} moved to trash. Restore with restore('{batch}'). "
|
||||||
|
f"Its skill stub disappears on the operator's next process sync."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (list_processes, create_process, get_process, update_process):
|
for fn in (
|
||||||
|
list_processes,
|
||||||
|
create_process,
|
||||||
|
get_process,
|
||||||
|
update_process,
|
||||||
|
delete_process,
|
||||||
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from scribe.services import milestones as milestones_svc
|
|||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
from scribe.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +55,14 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
|
|
||||||
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
||||||
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
||||||
open_tasks, recent_notes, design_system.
|
open_tasks, recent_notes, design_system, systems.
|
||||||
|
|
||||||
|
`systems` is the project's vocabulary of named subsystems/areas. It is
|
||||||
|
returned here so you can TAG as you write: when creating or meaningfully
|
||||||
|
updating a record, ask which of these areas it is about and pass their ids
|
||||||
|
as `system_ids`. If the area a record describes is missing from this list,
|
||||||
|
create it with create_system rather than leaving the area unmodelled. Read
|
||||||
|
a subsystem's accumulated records with list_system_records.
|
||||||
|
|
||||||
`design_system` is null unless the project points at one. When present it
|
`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
|
carries the chain-merged guidance (the house style AND this project's
|
||||||
@@ -81,6 +89,11 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
uid, is_task=False, project_id=project_id,
|
uid, is_task=False, project_id=project_id,
|
||||||
sort="updated_at", limit=5,
|
sort="updated_at", limit=5,
|
||||||
)
|
)
|
||||||
|
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
|
||||||
|
# records to Systems" is only executable if the list is in front of the
|
||||||
|
# agent when it writes — which it never was, and tagging stopped within
|
||||||
|
# three days of the feature landing (#2546's audit).
|
||||||
|
systems = await systems_svc.list_systems(uid, project_id)
|
||||||
# A project need not have one, and most installs won't — null is ordinary
|
# A project need not have one, and most installs won't — null is ordinary
|
||||||
# here, not a missing prerequisite.
|
# here, not a missing prerequisite.
|
||||||
design_system = None
|
design_system = None
|
||||||
@@ -91,6 +104,15 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"project": project.to_dict(),
|
"project": project.to_dict(),
|
||||||
|
# Trimmed to what tagging needs. The full charter is get_system's job —
|
||||||
|
# this list rides along on every session start, so it stays lean.
|
||||||
|
"systems": [
|
||||||
|
{
|
||||||
|
"id": s.id, "name": s.name,
|
||||||
|
"description": (s.description or "").split("\n")[0][:200],
|
||||||
|
}
|
||||||
|
for s in systems
|
||||||
|
],
|
||||||
"design_system": design_system,
|
"design_system": design_system,
|
||||||
"milestone_summary": milestone_summary,
|
"milestone_summary": milestone_summary,
|
||||||
"applicable_rules": applicable["rules"],
|
"applicable_rules": applicable["rules"],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ async def search(
|
|||||||
content_type: str = "all",
|
content_type: str = "all",
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
|
system_id: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
|
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
|
||||||
|
|
||||||
@@ -39,6 +40,11 @@ async def search(
|
|||||||
enter_project) — otherwise this searches across ALL projects and
|
enter_project) — otherwise this searches across ALL projects and
|
||||||
bleeds unrelated work into the result set. 0 = search everything
|
bleeds unrelated work into the result set. 0 = search everything
|
||||||
(use only when you genuinely want a cross-project sweep).
|
(use only when you genuinely want a cross-project sweep).
|
||||||
|
system_id: Narrow to records tagged to one System (a named
|
||||||
|
subsystem/area — enter_project lists them). Use when investigating
|
||||||
|
a specific subsystem: it cuts the candidates to records someone
|
||||||
|
deliberately filed under that area. 0 = no system filter.
|
||||||
|
list_system_records gives the same slice unranked.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
||||||
@@ -55,6 +61,7 @@ async def search(
|
|||||||
raw = await semantic_search_notes(
|
raw = await semantic_search_notes(
|
||||||
uid, q, limit=limit, is_task=is_task,
|
uid, q, limit=limit, is_task=is_task,
|
||||||
project_id=project_id or None,
|
project_id=project_id or None,
|
||||||
|
system_id=system_id or None,
|
||||||
# An explicit search reaches everything the operator may read, including
|
# An explicit search reaches everything the operator may read, including
|
||||||
# records shared with them one-to-one.
|
# records shared with them one-to-one.
|
||||||
scope="read",
|
scope="read",
|
||||||
|
|||||||
@@ -127,12 +127,19 @@ async def create_snippet(
|
|||||||
force: Bypass the near-duplicate gate (see below).
|
force: Bypass the near-duplicate gate (see below).
|
||||||
|
|
||||||
Returns the created snippet (including a parsed `snippet` field), OR — when a
|
Returns the created snippet (including a parsed `snippet` field), OR — when a
|
||||||
near-duplicate snippet already exists and force is false — {"duplicate": true,
|
duplicate already exists and force is false — {"duplicate": true,
|
||||||
"existing_id": ..., "message": ...} and nothing is created. When that happens
|
"existing_id": ..., "message": ...} and nothing is created. When that happens
|
||||||
and it really is the same reusable thing found in another place, prefer
|
and it really is the same reusable thing found in another place, prefer
|
||||||
merge_snippets(existing_id, [new...]) — or record then merge — to unify them
|
merge_snippets(existing_id, [new...]) — or record then merge — to unify them
|
||||||
into ONE canonical record (which then carries every call site as a location),
|
into ONE canonical record (which then carries every call site as a location),
|
||||||
rather than forcing a second copy with force=true.
|
rather than forcing a second copy with force=true.
|
||||||
|
|
||||||
|
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
|
||||||
|
same repo · path · symbol, or holding byte-identical code. Those are certain,
|
||||||
|
and force is almost never the right answer to them. Only then a semantic
|
||||||
|
check, held to a high bar so that VARIANTS of one component are not refused:
|
||||||
|
`.btn-primary` and `.btn-secondary` read alike and are two different things,
|
||||||
|
so record both (#2518).
|
||||||
"""
|
"""
|
||||||
if not (name or "").strip() or not (code or "").strip():
|
if not (name or "").strip() or not (code or "").strip():
|
||||||
raise ValueError("create_snippet requires a non-empty name and code")
|
raise ValueError("create_snippet requires a non-empty name and code")
|
||||||
@@ -148,6 +155,10 @@ async def create_snippet(
|
|||||||
dup = await dedup_svc.find_duplicate_note(
|
dup = await dedup_svc.find_duplicate_note(
|
||||||
uid, title, body, project_id=project_id or None,
|
uid, title, body, project_id=project_id or None,
|
||||||
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
||||||
|
# The artefact itself, not just its description — the gate compares
|
||||||
|
# location and code before it compares prose (#2518).
|
||||||
|
code=code,
|
||||||
|
locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
|
||||||
)
|
)
|
||||||
if dup is not None:
|
if dup is not None:
|
||||||
return dedup_svc.duplicate_response(dup, "snippet")
|
return dedup_svc.duplicate_response(dup, "snippet")
|
||||||
|
|||||||
@@ -116,7 +116,14 @@ async def update_system(
|
|||||||
async def list_system_records(
|
async def list_system_records(
|
||||||
system_id: int, kind: str = "", open_only: bool = False
|
system_id: int, kind: str = "", open_only: bool = False
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List records associated with a System.
|
"""Everything filed under one System — the way to READ a subsystem.
|
||||||
|
|
||||||
|
Reach for this when investigating a specific area: it returns the notes,
|
||||||
|
tasks, issues and snippets someone deliberately tagged to it — the
|
||||||
|
subsystem's accumulated record, unranked. Start with its reference note if
|
||||||
|
one exists (titled "«System» — reference"); that is the living state, and
|
||||||
|
the rest is history and open work around it. For a ranked cut of the same
|
||||||
|
slice, search(system_id=...) filters semantic search to this association.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
|
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from scribe.models.milestone import Milestone # noqa: E402, F401
|
|||||||
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||||
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
|
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
|
||||||
from scribe.models.note_version import NoteVersion # noqa: E402, F401
|
from scribe.models.note_version import NoteVersion # noqa: E402, F401
|
||||||
|
from scribe.models.note_supersession import NoteSupersession # noqa: E402, F401
|
||||||
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
|
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
|
||||||
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||||
from scribe.models.notification import Notification # noqa: E402, F401
|
from scribe.models.notification import Notification # noqa: E402, F401
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
title: Mapped[str] = mapped_column(Text, default="")
|
title: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[str] = mapped_column(Text, default="")
|
body: Mapped[str] = mapped_column(Text, default="")
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
consolidated_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||||
parent_id: Mapped[int | None] = mapped_column(
|
parent_id: Mapped[int | None] = mapped_column(
|
||||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||||
@@ -101,9 +98,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"body": self.body,
|
"body": self.body,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"consolidated_at": (
|
|
||||||
self.consolidated_at.isoformat() if self.consolidated_at else None
|
|
||||||
),
|
|
||||||
"tags": self.tags or [],
|
"tags": self.tags or [],
|
||||||
"parent_id": self.parent_id,
|
"parent_id": self.parent_id,
|
||||||
"arose_from_id": self.arose_from_id,
|
"arose_from_id": self.arose_from_id,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from scribe.models import Base
|
||||||
|
from scribe.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
|
class NoteSupersession(Base, CreatedAtMixin):
|
||||||
|
"""A newer record's claim that it has overtaken an older one.
|
||||||
|
|
||||||
|
WHY THE RELATION POINTS FORWARD
|
||||||
|
|
||||||
|
The note being WRITTEN declares what it supersedes. The old record cannot
|
||||||
|
know it has been overtaken — asking it to record its own obsolescence is
|
||||||
|
asking it to predict the future. So the claim is made by the party that has
|
||||||
|
the knowledge, and the demotion is derived from the far end.
|
||||||
|
|
||||||
|
WHY A TABLE RATHER THAN A COLUMN
|
||||||
|
|
||||||
|
It is genuinely many-to-many and partial: one note may supersede parts of
|
||||||
|
several others, and a note may be overtaken piecemeal by several later ones.
|
||||||
|
Both directions are queried and neither is rare —
|
||||||
|
`superseded_id` answers the ranking question ("has this been overtaken?"),
|
||||||
|
`superseder_id` answers the record view ("what does this replace?"). An
|
||||||
|
array column on `notes` could be indexed for one and not the other.
|
||||||
|
|
||||||
|
WHAT IT MEANS, AND WHAT IT DOES NOT
|
||||||
|
|
||||||
|
A claim, never a proof. Supersession DEMOTES a record in ranked retrieval;
|
||||||
|
it does not assert the older record was wrong, and it never hides it. A note
|
||||||
|
that accurately described how something worked in June is still accurate
|
||||||
|
about June — it is just no longer the answer to "how does this work".
|
||||||
|
|
||||||
|
CASCADE IS SAFE HERE BECAUSE TRASHING IS NOT A DELETE
|
||||||
|
|
||||||
|
`trash_svc` stamps `deleted_at` (an UPDATE), so a trashed note keeps its
|
||||||
|
claims and `restore` brings them back intact. The cascade fires only on
|
||||||
|
`purge_trash`, where the row genuinely goes — and a supersession claim about
|
||||||
|
a row that no longer exists is not a fact anyone can act on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "note_supersessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
# The newer record, making the claim.
|
||||||
|
superseder_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("notes.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
# The older record, demoted by it.
|
||||||
|
superseded_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("notes.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
|
||||||
|
),
|
||||||
|
# Both directions indexed — see the class docstring for why neither is
|
||||||
|
# the rare one.
|
||||||
|
Index("ix_note_supersessions_superseder", "superseder_id"),
|
||||||
|
Index("ix_note_supersessions_superseded", "superseded_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"superseder_id": self.superseder_id,
|
||||||
|
"superseded_id": self.superseded_id,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
@@ -51,10 +51,23 @@ class NoteUsageEvent(Base):
|
|||||||
note_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
note_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
# 'surfaced' | 'pulled'
|
# 'surfaced' | 'pulled'
|
||||||
event: Mapped[str] = mapped_column(Text, nullable=False)
|
event: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
# Which surface produced it: 'auto_inject' | 'write_path_place' |
|
# Which surface produced it. Kept granular so the place arm and the
|
||||||
# 'write_path_semantic' | 'mcp_get_snippet' | 'mcp_get_note' | 'rest_note'.
|
# semantic arm can be compared — that comparison is the whole reason the
|
||||||
# Kept granular so the place arm and the semantic arm can be compared —
|
# place arm needed logging at all.
|
||||||
# that comparison is the whole reason the place arm needed logging at all.
|
#
|
||||||
|
# A CONVENTION, not a fixed vocabulary: `mcp_<tool>` for an agent call,
|
||||||
|
# `rest_<kind>` for a human opening a detail view, and a bare name for a
|
||||||
|
# hook or background surface ('auto_inject', 'write_path_place',
|
||||||
|
# 'write_path_semantic'). This comment deliberately no longer lists the
|
||||||
|
# members — the previous list had gone stale, naming 'rest_note' that
|
||||||
|
# nothing wrote while omitting sources that existed, and a half-true
|
||||||
|
# enumeration reads as authoritative in exactly the way that misleads
|
||||||
|
# (#2476). `grep -rn record_pulled\\\|record_surfaced src/` is the
|
||||||
|
# authoritative list, and unlike a comment it cannot drift.
|
||||||
|
#
|
||||||
|
# The mcp_/rest_ split is load-bearing. "Is this dead weight?" is served by
|
||||||
|
# any pull; "was that injected line useful?" is served by AGENT pulls only,
|
||||||
|
# so never aggregate across the prefix without saying why (#1038, #2085).
|
||||||
source: Mapped[str] = mapped_column(Text, nullable=False)
|
source: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""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())
|
|
||||||
@@ -19,6 +19,10 @@ from quart import Blueprint, g, jsonify, request
|
|||||||
|
|
||||||
from scribe.auth import login_required
|
from scribe.auth import login_required
|
||||||
from scribe.services import design_systems as ds_svc
|
from scribe.services import design_systems as ds_svc
|
||||||
|
from scribe.services.design_starter_roles import (
|
||||||
|
DEFAULT_TOKEN_PREFIX,
|
||||||
|
describe_groups,
|
||||||
|
)
|
||||||
from scribe.services.design_systems import DesignSystemCycle
|
from scribe.services.design_systems import DesignSystemCycle
|
||||||
|
|
||||||
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
|
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
|
||||||
@@ -56,12 +60,27 @@ async def create_design_system():
|
|||||||
description=data.get("description") or None,
|
description=data.get("description") or None,
|
||||||
guidance=data.get("guidance") or None,
|
guidance=data.get("guidance") or None,
|
||||||
parent_id=data.get("parent_id"),
|
parent_id=data.get("parent_id"),
|
||||||
|
starter_role_groups=data.get("starter_role_groups"),
|
||||||
|
token_prefix=data.get("token_prefix") or DEFAULT_TOKEN_PREFIX,
|
||||||
)
|
)
|
||||||
if system is None:
|
if system is None:
|
||||||
return jsonify({"error": "parent design system not found"}), 404
|
return jsonify({"error": "parent design system not found"}), 404
|
||||||
return jsonify(system.to_dict()), 201
|
return jsonify(system.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@design_systems_bp.get("/design-systems/starter-roles")
|
||||||
|
@login_required
|
||||||
|
async def list_starter_role_groups():
|
||||||
|
"""The starter role catalogue, for the creation form's checklist.
|
||||||
|
|
||||||
|
Roles and purposes only — no values, ever. See services/design_starter_roles.
|
||||||
|
"""
|
||||||
|
return jsonify({
|
||||||
|
"groups": describe_groups(),
|
||||||
|
"default_prefix": DEFAULT_TOKEN_PREFIX,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@design_systems_bp.get("/design-systems/<int:design_system_id>")
|
@design_systems_bp.get("/design-systems/<int:design_system_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_design_system(design_system_id: int):
|
async def get_design_system(design_system_id: int):
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ from scribe.services.notes import (
|
|||||||
update_note,
|
update_note,
|
||||||
)
|
)
|
||||||
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
||||||
|
from scribe.services import supersession as supersession_svc
|
||||||
|
from scribe.services.note_usage import record_pulled
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||||
|
"""Both directions of the supersession relation on a note payload.
|
||||||
|
|
||||||
|
Mirrors the MCP helper of the same name — the two surfaces must agree about
|
||||||
|
what a note's payload says, or the web UI and the agent would disagree about
|
||||||
|
whether a record is current.
|
||||||
|
|
||||||
|
Omitted when empty: a field that always says nothing trains readers to skip
|
||||||
|
fields, which is what `consolidated_at` cost (#2483).
|
||||||
|
"""
|
||||||
|
rel = await supersession_svc.get_relations(uid, note_id)
|
||||||
|
if rel["supersedes"]:
|
||||||
|
data["supersedes"] = rel["supersedes"]
|
||||||
|
if rel["superseded_by"]:
|
||||||
|
data["superseded_by"] = rel["superseded_by"]
|
||||||
from scribe.services.note_versions import list_versions, get_version
|
from scribe.services.note_versions import list_versions, get_version
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -111,7 +130,19 @@ async def create_note_route():
|
|||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
return jsonify(note.to_dict()), 201
|
|
||||||
|
# Same capability as the MCP create path (#33). Without it the web UI would
|
||||||
|
# be the surface on which a supersession claim silently cannot be made.
|
||||||
|
if data.get("supersedes"):
|
||||||
|
try:
|
||||||
|
await supersession_svc.set_supersedes(uid, note.id, data["supersedes"])
|
||||||
|
except PermissionError as exc:
|
||||||
|
# 403, not 400: the request is well-formed and the caller simply
|
||||||
|
# may not write the target. The note itself was created.
|
||||||
|
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
|
||||||
|
out = note.to_dict()
|
||||||
|
await _attach_supersession(uid, note.id, out)
|
||||||
|
return jsonify(out), 201
|
||||||
|
|
||||||
|
|
||||||
@notes_bp.route("/tags", methods=["GET"])
|
@notes_bp.route("/tags", methods=["GET"])
|
||||||
@@ -178,6 +209,14 @@ async def get_note_route(note_id: int):
|
|||||||
note, permission = result
|
note, permission = result
|
||||||
data = note.to_dict()
|
data = note.to_dict()
|
||||||
data["permission"] = permission
|
data["permission"] = permission
|
||||||
|
# Opening the detail view IS a pull — the operator chose to look. Tagged by
|
||||||
|
# SURFACE, not by the record's kind, matching rest_snippet: the kind is a
|
||||||
|
# join away, but which surface asked is not recoverable after the fact.
|
||||||
|
# Keeping rest_* apart from mcp_* is load-bearing, not tidiness — "was that
|
||||||
|
# injected line useful?" is answered by agent pulls alone, and a human
|
||||||
|
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
|
||||||
|
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
|
||||||
|
await _attach_supersession(uid, note_id, data)
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,7 +255,18 @@ async def update_note_route(note_id: int):
|
|||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
if note is None:
|
if note is None:
|
||||||
return not_found("Note")
|
return not_found("Note")
|
||||||
return jsonify(note.to_dict())
|
# Set-semantics, matching MCP and the PATCH route: present-and-empty
|
||||||
|
# clears, absent leaves alone. Scoped by the CALLER, not owner_uid — an
|
||||||
|
# editor-share holder may edit this note and must not thereby inherit the
|
||||||
|
# owner's write access to whatever they name as superseded (#47).
|
||||||
|
if "supersedes" in data:
|
||||||
|
try:
|
||||||
|
await supersession_svc.set_supersedes(uid, note_id, data["supersedes"] or [])
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
out = note.to_dict()
|
||||||
|
await _attach_supersession(uid, note_id, out)
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
|
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
|
||||||
|
|||||||
@@ -34,10 +34,15 @@ async def search_route():
|
|||||||
content_type = request.args.get("content_type", "all")
|
content_type = request.args.get("content_type", "all")
|
||||||
limit = min(request.args.get("limit", 10, type=int), 50)
|
limit = min(request.args.get("limit", 10, type=int), 50)
|
||||||
is_task = _content_type_to_is_task(content_type)
|
is_task = _content_type_to_is_task(content_type)
|
||||||
|
# Same association filter the MCP tool takes (#33). The project filter this
|
||||||
|
# route is still missing is #2463's — it carries a default-scope UI decision
|
||||||
|
# this change must not preempt.
|
||||||
|
system_id = request.args.get("system_id", type=int)
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
results = await semantic_search_notes(
|
results = await semantic_search_notes(
|
||||||
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
||||||
|
system_id=system_id,
|
||||||
# The user typed this, so it reaches everything they may read.
|
# The user typed this, so it reaches everything they may read.
|
||||||
scope="read",
|
scope="read",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ async def create_snippet_route():
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
is_task=False,
|
is_task=False,
|
||||||
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
||||||
|
# Matched on the artefact — location and code — before prose, the
|
||||||
|
# same way the MCP create path does (#2518). Both surfaces must
|
||||||
|
# apply the identical gate or the web UI becomes the way to record
|
||||||
|
# a duplicate the agent would have been stopped from writing.
|
||||||
|
code=data.get("code", ""),
|
||||||
|
locations=snippets_svc.resolve_locations(
|
||||||
|
data.get("repo", ""), data.get("path", ""), data.get("symbol", ""),
|
||||||
|
data.get("locations"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if dup is not None:
|
if dup is not None:
|
||||||
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
|
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from scribe.services.notes import (
|
|||||||
list_notes,
|
list_notes,
|
||||||
update_note,
|
update_note,
|
||||||
)
|
)
|
||||||
|
from scribe.services.note_usage import record_pulled
|
||||||
from scribe.services.planning import start_planning as svc_start_planning
|
from scribe.services.planning import start_planning as svc_start_planning
|
||||||
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
|
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
|
||||||
|
|
||||||
@@ -186,6 +187,9 @@ async def get_task_route(task_id: int):
|
|||||||
parent = await get_note_for_user(uid, task.parent_id)
|
parent = await get_note_for_user(uid, task.parent_id)
|
||||||
data["parent_title"] = parent[0].title if parent else None
|
data["parent_title"] = parent[0].title if parent else None
|
||||||
data["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
|
data["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
|
||||||
|
# Opening the detail view IS a pull — see the note beside rest_note in
|
||||||
|
# routes/notes.py for why the rest_* and mcp_* prefixes stay separable.
|
||||||
|
record_pulled(user_id=uid, note_id=task_id, source="rest_task")
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from scribe.models import async_session
|
|||||||
from scribe.models.milestone import Milestone
|
from scribe.models.milestone import Milestone
|
||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
from scribe.models.note_draft import NoteDraft
|
from scribe.models.note_draft import NoteDraft
|
||||||
|
from scribe.models.note_supersession import NoteSupersession
|
||||||
from scribe.models.note_version import NoteVersion
|
from scribe.models.note_version import NoteVersion
|
||||||
from scribe.models.design_system import DesignSystem, DesignToken
|
from scribe.models.design_system import DesignSystem, DesignToken
|
||||||
from scribe.models.note_usage import NoteUsageEvent
|
from scribe.models.note_usage import NoteUsageEvent
|
||||||
@@ -32,8 +33,11 @@ logger = logging.getLogger(__name__)
|
|||||||
# when the calendar surface was retired — old v3 events are skipped on restore.
|
# 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
|
# v5 (2026-08) added the six tables that had accumulated outside the backup
|
||||||
# entirely (#2293), and the coverage guard that stops the seventh.
|
# entirely (#2293), and the coverage guard that stops the seventh.
|
||||||
|
# v6 (2026-08) added note_supersessions — and the guard did stop the seventh:
|
||||||
|
# the table shipped without a backup section and the coverage test failed the
|
||||||
|
# build, which is the whole reason that list was written.
|
||||||
# Bump when the serialized schema changes.
|
# Bump when the serialized schema changes.
|
||||||
BACKUP_VERSION = 5
|
BACKUP_VERSION = 6
|
||||||
|
|
||||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
# 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
|
# below, these two lists must together account for the entire schema — which is
|
||||||
@@ -50,7 +54,7 @@ _BACKED_UP = [
|
|||||||
"project_topic_suppressions",
|
"project_topic_suppressions",
|
||||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||||
"systems", "record_systems", "design_systems", "design_tokens",
|
"systems", "record_systems", "design_systems", "design_tokens",
|
||||||
"note_usage_events", "repo_bindings",
|
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||||
@@ -110,6 +114,17 @@ def _record_system_rows(rows) -> list[dict]:
|
|||||||
return [{"note_id": r.note_id, "system_id": r.system_id} for r in rows]
|
return [{"note_id": r.note_id, "system_id": r.system_id} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _note_supersession_rows(rows) -> list[dict]:
|
||||||
|
"""Which record has overtaken which. Carried because it is a JUDGEMENT —
|
||||||
|
someone decided this note replaced that one, and nothing in either note's
|
||||||
|
text records the decision. Lose it and the corpus silently reverts to
|
||||||
|
ranking stale material alongside current material."""
|
||||||
|
return [
|
||||||
|
{"superseder_id": r.superseder_id, "superseded_id": r.superseded_id}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _design_system_rows(rows) -> list[dict]:
|
def _design_system_rows(rows) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -171,6 +186,9 @@ async def export_full_backup() -> dict:
|
|||||||
settings = (await session.execute(select(Setting))).scalars().all()
|
settings = (await session.execute(select(Setting))).scalars().all()
|
||||||
systems = (await session.execute(select(System))).scalars().all()
|
systems = (await session.execute(select(System))).scalars().all()
|
||||||
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
||||||
|
supersessions = (
|
||||||
|
await session.execute(select(NoteSupersession))
|
||||||
|
).scalars().all()
|
||||||
# Parent-first, so a restore can resolve parent_id as it goes rather
|
# 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
|
# than needing a second pass — the self-FK is the only ordering
|
||||||
# constraint in this payload.
|
# constraint in this payload.
|
||||||
@@ -354,6 +372,7 @@ async def export_full_backup() -> dict:
|
|||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
"note_usage_events": _usage_event_rows(usage_events),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||||
|
"note_supersessions": _note_supersession_rows(supersessions),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -396,6 +415,17 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
record_systems = (await session.execute(
|
record_systems = (await session.execute(
|
||||||
select(RecordSystem).where(RecordSystem.system_id.in_(system_ids))
|
select(RecordSystem).where(RecordSystem.system_id.in_(system_ids))
|
||||||
)).scalars().all() if system_ids else []
|
)).scalars().all() if system_ids else []
|
||||||
|
# BOTH ends must be this user's notes. A claim spanning out to someone
|
||||||
|
# else's record cannot be restored into a single-user import — the far
|
||||||
|
# id would not be in the map — so carrying it would export a row that
|
||||||
|
# silently vanishes on the way back in. Whole-instance backups have no
|
||||||
|
# such problem and take every row.
|
||||||
|
supersessions = (await session.execute(
|
||||||
|
select(NoteSupersession).where(
|
||||||
|
NoteSupersession.superseder_id.in_(note_ids),
|
||||||
|
NoteSupersession.superseded_id.in_(note_ids),
|
||||||
|
)
|
||||||
|
)).scalars().all() if note_ids else []
|
||||||
design_systems = (await session.execute(
|
design_systems = (await session.execute(
|
||||||
select(DesignSystem).where(DesignSystem.owner_user_id == user_id)
|
select(DesignSystem).where(DesignSystem.owner_user_id == user_id)
|
||||||
.order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id)
|
.order_by(DesignSystem.parent_id.nullsfirst(), DesignSystem.id)
|
||||||
@@ -597,6 +627,7 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
"note_usage_events": _usage_event_rows(usage_events),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||||
|
"note_supersessions": _note_supersession_rows(supersessions),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -700,6 +731,7 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
"topic_suppressions": 0,
|
"topic_suppressions": 0,
|
||||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||||
|
"note_supersessions": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -990,6 +1022,24 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
session.add(RecordSystem(note_id=mapped_nid, system_id=mapped_sid))
|
session.add(RecordSystem(note_id=mapped_nid, system_id=mapped_sid))
|
||||||
stats["record_systems"] += 1
|
stats["record_systems"] += 1
|
||||||
|
|
||||||
|
# 16b. Supersession claims. Guarded by `data.get` like every other
|
||||||
|
# post-v2 section, so a v5 or older payload restores cleanly without it.
|
||||||
|
#
|
||||||
|
# Both ends must map. A claim is about a PAIR — half of one is not a
|
||||||
|
# weaker claim, it is a dangling row pointing at whatever note happens
|
||||||
|
# to hold that id next.
|
||||||
|
for sup in data.get("note_supersessions", []):
|
||||||
|
mapped_new = note_id_map.get(sup.get("superseder_id", 0))
|
||||||
|
mapped_old = note_id_map.get(sup.get("superseded_id", 0))
|
||||||
|
if mapped_new is None or mapped_old is None or mapped_new == mapped_old:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
NoteSupersession(
|
||||||
|
superseder_id=mapped_new, superseded_id=mapped_old
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats["note_supersessions"] += 1
|
||||||
|
|
||||||
# 17. Design systems. The export orders these parent-first, so a
|
# 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 —
|
# 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
|
# no second pass, and a child whose parent is missing lands as a root
|
||||||
|
|||||||
+230
-21
@@ -48,6 +48,27 @@ _MIN_BODY_FOR_SEMANTIC = 200
|
|||||||
# noise). Matches the 0.90 the pre-pivot dedup settled on.
|
# noise). Matches the 0.90 the pre-pivot dedup settled on.
|
||||||
_SEMANTIC_THRESHOLD = 0.90
|
_SEMANTIC_THRESHOLD = 0.90
|
||||||
|
|
||||||
|
# SNIPPETS ARE MEASURED DIFFERENTLY, and #2518 is why. A snippet's embedded
|
||||||
|
# document is mostly PROSE ABOUT the code — name, when-to-reach-for-it,
|
||||||
|
# signature, the comments explaining the choice — with the artefact itself a
|
||||||
|
# minority of the text. Two measurements on the same corpus:
|
||||||
|
#
|
||||||
|
# .btn-danger vs .btn-danger-outline 0.92 siblings, blocked (false positive)
|
||||||
|
# .btn-primary re-recorded verbatim
|
||||||
|
# under a different name <0.90 a literal copy, ALLOWED THROUGH
|
||||||
|
#
|
||||||
|
# The second is the one that settles it. Identical code at an identical
|
||||||
|
# repo·path·symbol sailed past the gate because the description differed, while
|
||||||
|
# two deliberately-parallel variants were refused because theirs did not. The
|
||||||
|
# arm is not mis-tuned; it is reading the wrong field, and no threshold fixes
|
||||||
|
# that — lowering it blocks more siblings, raising it allows more copies.
|
||||||
|
#
|
||||||
|
# So: STRUCTURE decides, and the semantic arm becomes a backstop set above the
|
||||||
|
# band where legitimate variants live (0.92 observed). It still catches a
|
||||||
|
# genuine reword that shares neither location nor code, which is the case the
|
||||||
|
# structural signals cannot see.
|
||||||
|
_SNIPPET_SEMANTIC_THRESHOLD = 0.96
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DuplicateMatch:
|
class DuplicateMatch:
|
||||||
@@ -58,25 +79,124 @@ class DuplicateMatch:
|
|||||||
reason: str # "title" | "semantic"
|
reason: str # "title" | "semantic"
|
||||||
|
|
||||||
|
|
||||||
|
# How each signal describes itself when it blocks a write. The structural ones
|
||||||
|
# are CERTAIN, so they say what was matched instead of hedging with "similar" —
|
||||||
|
# and they point at merge, not update, because two records of one artefact is
|
||||||
|
# what merge exists to fold back together.
|
||||||
|
_REASON_PHRASING = {
|
||||||
|
"location": (
|
||||||
|
"is already recorded at that exact repo · path · symbol",
|
||||||
|
"Update it (update_{kind}), or if you meant to record a second call "
|
||||||
|
"site, use merge_snippets so one record carries both locations.",
|
||||||
|
),
|
||||||
|
"code": (
|
||||||
|
"already holds identical code",
|
||||||
|
"Update it (update_{kind}) rather than keeping two copies that must "
|
||||||
|
"then be kept in step by hand.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def duplicate_response(dup: "DuplicateMatch", kind: str) -> dict:
|
def duplicate_response(dup: "DuplicateMatch", kind: str) -> dict:
|
||||||
"""Standard 'blocked — update instead' payload returned by a create tool
|
"""Standard 'blocked — update instead' payload returned by a create tool
|
||||||
when the gate finds a near-duplicate. `kind` is 'note' or 'task' (drives the
|
when the gate finds a near-duplicate. `kind` is 'note', 'task' or 'snippet'
|
||||||
update_<kind> hint)."""
|
(drives the update_<kind> hint)."""
|
||||||
|
phrasing = _REASON_PHRASING.get(dup.reason)
|
||||||
|
if phrasing:
|
||||||
|
claim, advice = phrasing
|
||||||
|
message = (
|
||||||
|
f'An existing {kind} (id {dup.id}: "{dup.title}") {claim}. '
|
||||||
|
f"{advice.format(kind=kind)} If this really is a distinct {kind}, "
|
||||||
|
f"retry with force=true."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f'A {dup.reason}-similar {kind} already exists (id {dup.id}: '
|
||||||
|
f'"{dup.title}"). Prefer UPDATING it (update_{kind}) over creating a '
|
||||||
|
f"near-duplicate. If this really is a distinct {kind}, retry with "
|
||||||
|
f"force=true."
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"existing_id": dup.id,
|
"existing_id": dup.id,
|
||||||
"existing_title": dup.title,
|
"existing_title": dup.title,
|
||||||
"similarity": dup.similarity,
|
"similarity": dup.similarity,
|
||||||
"match": dup.reason,
|
"match": dup.reason,
|
||||||
"message": (
|
"message": message,
|
||||||
f'A {dup.reason}-similar {kind} already exists (id {dup.id}: '
|
|
||||||
f'"{dup.title}"). Prefer UPDATING it (update_{kind}) over creating a '
|
|
||||||
f"near-duplicate. If this really is a distinct {kind}, retry with "
|
|
||||||
f"force=true."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_snippet_by_structure(
|
||||||
|
user_id: int,
|
||||||
|
code: str,
|
||||||
|
locations: list[dict] | None,
|
||||||
|
project_id: int | None,
|
||||||
|
) -> DuplicateMatch | None:
|
||||||
|
"""Exact-identity duplicate of an incoming snippet, or None.
|
||||||
|
|
||||||
|
Two signals, both index-served off `notes.data` and both CERTAIN rather than
|
||||||
|
probabilistic — which is what the semantic arm could not be (#2518):
|
||||||
|
|
||||||
|
location the same named thing in the same file. Requires BOTH path and
|
||||||
|
symbol: a path alone is a directory of many artefacts, and
|
||||||
|
matching on it would refuse every second snippet from one file.
|
||||||
|
code byte-identical code, wherever it lives. Uses the same
|
||||||
|
fingerprint the drift check uses, so "identical" means the same
|
||||||
|
thing in both places.
|
||||||
|
|
||||||
|
Fail-open like the rest of this module: a failed lookup lets the write
|
||||||
|
through rather than blocking on an infrastructure problem.
|
||||||
|
"""
|
||||||
|
from scribe.services.knowledge import location_jsonpath
|
||||||
|
from scribe.services.snippets import code_sha
|
||||||
|
|
||||||
|
identifying = [
|
||||||
|
loc for loc in (locations or [])
|
||||||
|
if (loc.get("path") or "").strip() and (loc.get("symbol") or "").strip()
|
||||||
|
]
|
||||||
|
if not identifying and not (code or "").strip():
|
||||||
|
return None # nothing to match on — don't open a session for it
|
||||||
|
|
||||||
|
def _scoped(stmt):
|
||||||
|
stmt = stmt.where(
|
||||||
|
Note.user_id == user_id,
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
|
Note.note_type == SNIPPET_NOTE_TYPE,
|
||||||
|
)
|
||||||
|
# Same scoping rule as the title and semantic arms: a project's records
|
||||||
|
# compare only within that project, orphans only to orphans.
|
||||||
|
return (stmt.where(Note.project_id == project_id) if project_id is not None
|
||||||
|
else stmt.where(Note.project_id.is_(None)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
for loc in identifying:
|
||||||
|
parts = {
|
||||||
|
"path": (loc["path"]).strip(),
|
||||||
|
"symbol": (loc["symbol"]).strip(),
|
||||||
|
}
|
||||||
|
repo = (loc.get("repo") or "").strip()
|
||||||
|
if repo:
|
||||||
|
parts["repo"] = repo
|
||||||
|
stmt = _scoped(select(Note)).where(
|
||||||
|
Note.data.path_exists(location_jsonpath(parts))
|
||||||
|
)
|
||||||
|
hit = (await session.execute(stmt.limit(1))).scalars().first()
|
||||||
|
if hit is not None:
|
||||||
|
return DuplicateMatch(hit.id, hit.title, 1.0, "location")
|
||||||
|
|
||||||
|
if (code or "").strip():
|
||||||
|
stmt = _scoped(select(Note)).where(
|
||||||
|
Note.data["code_sha"].astext == code_sha(code)
|
||||||
|
)
|
||||||
|
hit = (await session.execute(stmt.limit(1))).scalars().first()
|
||||||
|
if hit is not None:
|
||||||
|
return DuplicateMatch(hit.id, hit.title, 1.0, "code")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("snippet structural dedup skipped — query failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def find_duplicate_note(
|
async def find_duplicate_note(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -84,11 +204,19 @@ async def find_duplicate_note(
|
|||||||
project_id: int | None = None,
|
project_id: int | None = None,
|
||||||
is_task: bool | None = None,
|
is_task: bool | None = None,
|
||||||
note_type: str = "note",
|
note_type: str = "note",
|
||||||
|
code: str = "",
|
||||||
|
locations: list[dict] | None = None,
|
||||||
) -> DuplicateMatch | None:
|
) -> DuplicateMatch | None:
|
||||||
"""Best near-duplicate of (title, body) within the same owner + project +
|
"""Best near-duplicate of (title, body) within the same owner + project +
|
||||||
kind, or None. Title match first (cheap, exact), then semantic when the body
|
kind, or None. Title match first (cheap, exact), then — for snippets — the
|
||||||
is long enough to be meaningful. Never raises — embedder failure degrades to
|
structural signals, then semantic when the body is long enough to be
|
||||||
title-only (callers should still be able to create)."""
|
meaningful. Never raises — embedder failure degrades to title-only (callers
|
||||||
|
should still be able to create).
|
||||||
|
|
||||||
|
`code` and `locations` are the snippet's structured fields. They are ignored
|
||||||
|
for every other kind, and passing them is what lets the gate compare
|
||||||
|
ARTEFACTS rather than descriptions of artefacts (#2518).
|
||||||
|
"""
|
||||||
norm = " ".join((title or "").split()).lower()
|
norm = " ".join((title or "").split()).lower()
|
||||||
|
|
||||||
# --- Signal 1: normalized-title exact match (same scope) ---
|
# --- Signal 1: normalized-title exact match (same scope) ---
|
||||||
@@ -118,9 +246,25 @@ async def find_duplicate_note(
|
|||||||
logger.debug("dedup title check skipped — query failed", exc_info=True)
|
logger.debug("dedup title check skipped — query failed", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# --- Signal 2: semantic similarity (only with a substantial body) ---
|
# --- Signal 2: structural identity (snippets only) ---
|
||||||
|
# Ahead of the semantic arm because it is exact: when it fires there is
|
||||||
|
# nothing to weigh, and its verdict is the one worth showing.
|
||||||
|
if note_type == SNIPPET_NOTE_TYPE:
|
||||||
|
structural = await _find_snippet_by_structure(
|
||||||
|
user_id, code, locations, project_id
|
||||||
|
)
|
||||||
|
if structural is not None:
|
||||||
|
return structural
|
||||||
|
|
||||||
|
# --- Signal 3: semantic similarity (only with a substantial body) ---
|
||||||
if body and len(body.strip()) >= _MIN_BODY_FOR_SEMANTIC:
|
if body and len(body.strip()) >= _MIN_BODY_FOR_SEMANTIC:
|
||||||
query = f"{title}\n{body}".strip()
|
# Built by the SAME function the corpus was embedded with. This one is
|
||||||
|
# the copy that mattered most and was easiest to miss: it is a QUERY
|
||||||
|
# document, compared against embedded ones. Shaped differently from the
|
||||||
|
# corpus it searches, the gate degrades silently — it still returns
|
||||||
|
# neighbours, just less apt ones, and no signal says the query and the
|
||||||
|
# index stopped agreeing (found by the guard in test_embedding_text).
|
||||||
|
query = embeddings_svc.embedding_text(title, body)
|
||||||
# Scope the semantic check the same way as the title check: a record in
|
# Scope the semantic check the same way as the title check: a record in
|
||||||
# project P compares only to P; a project-less (orphan) record compares
|
# project P compares only to P; a project-less (orphan) record compares
|
||||||
# only to other orphans (orphan_only), NOT across every project — without
|
# only to other orphans (orphan_only), NOT across every project — without
|
||||||
@@ -129,12 +273,20 @@ async def find_duplicate_note(
|
|||||||
hits = await embeddings_svc.semantic_search_notes(
|
hits = await embeddings_svc.semantic_search_notes(
|
||||||
user_id, query, project_id=project_id, is_task=is_task,
|
user_id, query, project_id=project_id, is_task=is_task,
|
||||||
orphan_only=(project_id is None),
|
orphan_only=(project_id is None),
|
||||||
limit=3, threshold=_SEMANTIC_THRESHOLD,
|
limit=3,
|
||||||
|
threshold=(_SNIPPET_SEMANTIC_THRESHOLD
|
||||||
|
if note_type == SNIPPET_NOTE_TYPE else _SEMANTIC_THRESHOLD),
|
||||||
# Owner-only, deliberately: this gate BLOCKS a create and tells the
|
# Owner-only, deliberately: this gate BLOCKS a create and tells the
|
||||||
# caller to update the match instead. Matching someone else's record
|
# caller to update the match instead. Matching someone else's record
|
||||||
# would refuse their write and point them at something they may not
|
# would refuse their write and point them at something they may not
|
||||||
# be able to edit.
|
# be able to edit.
|
||||||
scope="own",
|
scope="own",
|
||||||
|
# NOT demoted by supersession (#278). A superseded record is still a
|
||||||
|
# duplicate of what you are about to write — the claim is that it is
|
||||||
|
# no longer CURRENT, not that it is gone. Demoting it here would let
|
||||||
|
# the same note be recorded a second time, and the second copy would
|
||||||
|
# be the one nothing warns about.
|
||||||
|
demote_superseded=False,
|
||||||
)
|
)
|
||||||
for score, note in hits:
|
for score, note in hits:
|
||||||
# semantic_search_notes doesn't filter note_type — enforce it here so
|
# semantic_search_notes doesn't filter note_type — enforce it here so
|
||||||
@@ -222,6 +374,52 @@ def group_pairs(pairs: list[tuple[int, int, float]]) -> list[list[int]]:
|
|||||||
key=lambda g: (-len(g), g[0]))
|
key=lambda g: (-len(g), g[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def _symbols(data: dict | None) -> set[str]:
|
||||||
|
"""Every symbol a snippet claims, from its indexed location mirror."""
|
||||||
|
return {
|
||||||
|
(loc.get("symbol") or "").strip()
|
||||||
|
for loc in (data or {}).get("locations") or []
|
||||||
|
if (loc.get("symbol") or "").strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_sibling_pairs(
|
||||||
|
pairs: list[tuple[int, int, float]], records: dict[int, dict]
|
||||||
|
) -> list[tuple[int, int, float]]:
|
||||||
|
"""Remove pairs that are VARIANTS of one thing rather than copies of it.
|
||||||
|
|
||||||
|
Two snippets that both name a symbol, name DIFFERENT symbols, and hold
|
||||||
|
different code are two artefacts. The author asserted that by naming them
|
||||||
|
apart, and merging them would destroy a distinction someone made on purpose.
|
||||||
|
|
||||||
|
Without this, a design system's button family reports as a single merge set:
|
||||||
|
eight recipes, every direct pair over the floor, top score 0.92 (#2518).
|
||||||
|
They resemble each other because variants of one component are SUPPOSED to —
|
||||||
|
same selector prefix, same token families, deliberately parallel prose. The
|
||||||
|
similarity is read correctly; it just does not mean "duplicate".
|
||||||
|
|
||||||
|
THE COST, stated plainly: a helper genuinely recorded twice under two names
|
||||||
|
— `debounce` and `useDebouncedRef` — is no longer reported. That is real
|
||||||
|
recall lost. It is the better trade because the report is a merge PROPOSAL:
|
||||||
|
a missed pair costs a duplicate nobody was going to notice anyway, while a
|
||||||
|
wrong set invites an operator to collapse a component family in one click.
|
||||||
|
Same-symbol and no-symbol duplicates, which is how re-recording usually
|
||||||
|
looks, still report.
|
||||||
|
"""
|
||||||
|
kept = []
|
||||||
|
for left, right, score in pairs:
|
||||||
|
left_data, right_data = records.get(left) or {}, records.get(right) or {}
|
||||||
|
left_syms, right_syms = _symbols(left_data), _symbols(right_data)
|
||||||
|
shas = (left_data.get("code_sha"), right_data.get("code_sha"))
|
||||||
|
identical_code = shas[0] is not None and shas[0] == shas[1]
|
||||||
|
# Both named, no name in common, and the code differs → siblings.
|
||||||
|
if (left_syms and right_syms and not (left_syms & right_syms)
|
||||||
|
and not identical_code):
|
||||||
|
continue
|
||||||
|
kept.append((left, right, score))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
async def find_duplicate_snippets(
|
async def find_duplicate_snippets(
|
||||||
user_id: int, *, threshold: float | None = None, limit: int = _MAX_DUPLICATE_PAIRS
|
user_id: int, *, threshold: float | None = None, limit: int = _MAX_DUPLICATE_PAIRS
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -280,21 +478,32 @@ async def find_duplicate_snippets(
|
|||||||
if not pairs:
|
if not pairs:
|
||||||
return {"groups": [], "pairs": [], "threshold": floor}
|
return {"groups": [], "pairs": [], "threshold": floor}
|
||||||
|
|
||||||
best: dict[tuple[int, int], float] = {(a, b): s for a, b, s in pairs}
|
# Titles + the structural fields, for presentation AND for the sibling
|
||||||
grouped = group_pairs(pairs)
|
# filter below. One fetch covers every id the scan proposed.
|
||||||
|
scanned = sorted({n for pair in pairs for n in pair[:2]})
|
||||||
# Titles for presentation. One fetch for every id in the report.
|
|
||||||
ids = sorted({n for g in grouped for n in g})
|
|
||||||
titles: dict[int, str] = {}
|
titles: dict[int, str] = {}
|
||||||
|
records: dict[int, dict] = {}
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (await session.execute(
|
rows = (await session.execute(
|
||||||
select(Note.id, Note.title).where(Note.id.in_(ids))
|
select(Note.id, Note.title, Note.data).where(Note.id.in_(scanned))
|
||||||
)).all()
|
)).all()
|
||||||
titles = {int(i): t for i, t in rows}
|
titles = {int(i): t for i, t, _ in rows}
|
||||||
|
records = {int(i): (d or {}) for i, _, d in rows}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("duplicate report titles unavailable", exc_info=True)
|
logger.debug("duplicate report titles unavailable", exc_info=True)
|
||||||
|
|
||||||
|
# Fails OPEN, and the direction matters: with `records` empty the filter
|
||||||
|
# below keeps every pair, so a lookup failure degrades to the unfiltered
|
||||||
|
# report rather than to an empty one. A report that silently returns
|
||||||
|
# nothing reads as "your corpus is clean", which is the wrong lie.
|
||||||
|
pairs = _drop_sibling_pairs(pairs, records)
|
||||||
|
if not pairs:
|
||||||
|
return {"groups": [], "pairs": [], "threshold": floor}
|
||||||
|
|
||||||
|
best: dict[tuple[int, int], float] = {(a, b): s for a, b, s in pairs}
|
||||||
|
grouped = group_pairs(pairs)
|
||||||
|
|
||||||
groups = []
|
groups = []
|
||||||
for members in grouped:
|
for members in grouped:
|
||||||
scores = [
|
scores = [
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
"""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,185 @@
|
|||||||
|
"""A starter set of token ROLES, offered when a design system is created.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
---------------
|
||||||
|
A literal gets written into a stylesheet when there is no role to reach for.
|
||||||
|
That is the mechanism, and this codebase produced a clean demonstration of it:
|
||||||
|
the house style had no "text on a filled colour" role, so 76 call sites wrote
|
||||||
|
a pure-white literal — not out of defiance, but because nothing existed to write
|
||||||
|
instead (#2275). The correction was not a better ban list. It was declaring the
|
||||||
|
missing role.
|
||||||
|
|
||||||
|
So the useful moment is CREATION. A system whose roles are named on day one
|
||||||
|
never presents the occasion for a literal, and never needs a list of values it
|
||||||
|
forbids.
|
||||||
|
|
||||||
|
WHAT SHIPS AND WHAT DOES NOT (rule #115)
|
||||||
|
----------------------------------------
|
||||||
|
The ROLES ship: `surface-page`, `text-primary`, `action-destructive` are
|
||||||
|
generic CSS-design vocabulary, not one operator's kit. Every install that has a
|
||||||
|
page has a page background.
|
||||||
|
|
||||||
|
The VALUES never ship. Each token is created with an empty `value_by_mode`, so
|
||||||
|
a fresh system is a set of named, deliberately-unanswered questions. No hex
|
||||||
|
appears anywhere in this file, and none should ever be added to it — a default
|
||||||
|
palette would be this operator's palette wearing product clothes.
|
||||||
|
|
||||||
|
A valueless token is already legible downstream: `render_stylesheet` emits it as
|
||||||
|
a commented-out declaration in its group (#2299), and `stylesheet_for_system`
|
||||||
|
reports it under `valueless`. So a blank role reads as "to be decided" rather
|
||||||
|
than as breakage, without anything new.
|
||||||
|
|
||||||
|
THE PREFIX IS THE INSTALL'S
|
||||||
|
---------------------------
|
||||||
|
`--fs-` is FabledSword's convention, not the product's. The prefix is a
|
||||||
|
parameter with a neutral default; a caller that has a house convention passes
|
||||||
|
it. Baking `--fs-` in would put one family's naming into every install.
|
||||||
|
|
||||||
|
FLAT, NOT PRESET
|
||||||
|
----------------
|
||||||
|
One list, every group individually skippable, all on by default (operator's
|
||||||
|
call, 2026-08-03). Presets keyed to app shape — web / CLI / docs — were
|
||||||
|
considered and rejected: they would require the product to hold opinions about
|
||||||
|
app categories, and a wrong category is worse than a generic list someone
|
||||||
|
prunes once.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
DEFAULT_TOKEN_PREFIX = "--ds-"
|
||||||
|
|
||||||
|
# group -> (what the group is for, ((role suffix, purpose), ...))
|
||||||
|
#
|
||||||
|
# Purposes are written as the QUESTION the operator is answering, because that
|
||||||
|
# is what an unfilled role is. "Page background, the deepest surface" tells you
|
||||||
|
# what to put there; "Colour 1" does not.
|
||||||
|
STARTER_ROLE_GROUPS: dict[str, tuple[str, tuple[tuple[str, str], ...]]] = {
|
||||||
|
"surface": (
|
||||||
|
"Backgrounds, by elevation",
|
||||||
|
(
|
||||||
|
("surface-page", "Page background, the deepest surface"),
|
||||||
|
("surface-raised", "Cards and raised elements"),
|
||||||
|
("surface-hover", "Hovered surfaces, secondary elevation"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"text": (
|
||||||
|
"Foreground colours, by emphasis",
|
||||||
|
(
|
||||||
|
("text-primary", "Primary text on a page or raised surface"),
|
||||||
|
("text-secondary", "Secondary text and captions"),
|
||||||
|
("text-tertiary", "Hints and metadata"),
|
||||||
|
# The role whose absence caused 76 literals. It is in the starter
|
||||||
|
# set deliberately: text on a filled colour is NOT the page text
|
||||||
|
# colour, because the surface under it does not change with the
|
||||||
|
# mode while the page does.
|
||||||
|
("text-on-action", "Text on a filled colour — buttons, badges"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"action": (
|
||||||
|
"What the user can do — kept separate from the accent, which is identity",
|
||||||
|
(
|
||||||
|
("action-primary", "The confirming action: Save, Submit"),
|
||||||
|
("action-secondary", "Non-destructive alternates"),
|
||||||
|
("action-destructive", "Irreversible actions — delete, revoke"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"semantic": (
|
||||||
|
"What the system is telling you",
|
||||||
|
(
|
||||||
|
("success", "Something worked"),
|
||||||
|
("warning", "Something needs attention"),
|
||||||
|
("error", "Something failed — distinct from destructive"),
|
||||||
|
("info", "Neutral information"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"border": (
|
||||||
|
"Boundaries and dividers",
|
||||||
|
(
|
||||||
|
("border-color", "The line colour itself"),
|
||||||
|
("border", "The default structural border, as a shorthand"),
|
||||||
|
("border-hover", "Border on hover or emphasis"),
|
||||||
|
("border-active", "Selected or current — the one border that may carry the accent"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"accent": (
|
||||||
|
"This install's identity — not its actions",
|
||||||
|
(
|
||||||
|
("accent", "The single signature colour"),
|
||||||
|
("accent-soft", "Tinted backgrounds — pills, tags"),
|
||||||
|
("accent-faint", "The faintest wash"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"radius": (
|
||||||
|
"Corner rounding",
|
||||||
|
(
|
||||||
|
("radius-sm", "Pills, tags, code spans"),
|
||||||
|
("radius-md", "Buttons, inputs, small cards"),
|
||||||
|
("radius-lg", "Cards, panels, modals"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"space": (
|
||||||
|
"The spacing scale — a gap not on the scale is a decision to justify",
|
||||||
|
tuple((f"space-{i}", f"Spacing step {i}") for i in range(1, 11)),
|
||||||
|
),
|
||||||
|
"motion": (
|
||||||
|
"Transition timing — motion supports the interaction, never performs",
|
||||||
|
(
|
||||||
|
("ease", "The one easing curve, used by every transition"),
|
||||||
|
("dur-fast", "Hovers, colour and border changes"),
|
||||||
|
("dur-base", "Most state changes"),
|
||||||
|
("dur-slow", "Larger surface or layout shifts"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"state": (
|
||||||
|
"Cross-cutting states that are otherwise improvised per view",
|
||||||
|
(
|
||||||
|
("disabled-opacity", "Opacity for disabled controls"),
|
||||||
|
("overlay", "Scrim behind modals and dialogs"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_GROUPS: tuple[str, ...] = tuple(STARTER_ROLE_GROUPS)
|
||||||
|
|
||||||
|
|
||||||
|
def starter_tokens(
|
||||||
|
groups: list[str] | tuple[str, ...] | None = None,
|
||||||
|
prefix: str = DEFAULT_TOKEN_PREFIX,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Token rows for the chosen groups — names and purposes only, no values.
|
||||||
|
|
||||||
|
`groups` of None means every group; an empty list means none, which is a
|
||||||
|
real answer and not the same as None. An operator who wants three tokens
|
||||||
|
should be able to get three.
|
||||||
|
|
||||||
|
Unknown group names are ignored rather than raising: this feeds a
|
||||||
|
checkbox list, and a stale name from an older client should not fail a
|
||||||
|
creation that is otherwise fine.
|
||||||
|
"""
|
||||||
|
chosen = ALL_GROUPS if groups is None else [g for g in groups if g in STARTER_ROLE_GROUPS]
|
||||||
|
rows: list[dict] = []
|
||||||
|
for group in chosen:
|
||||||
|
_, roles = STARTER_ROLE_GROUPS[group]
|
||||||
|
for index, (suffix, purpose) in enumerate(roles, start=1):
|
||||||
|
rows.append({
|
||||||
|
"name": f"{prefix}{suffix}",
|
||||||
|
"group_name": group,
|
||||||
|
"purpose": purpose,
|
||||||
|
# Empty, not absent: the column is NOT NULL with a {} default,
|
||||||
|
# so absence has exactly one spelling here as it does there.
|
||||||
|
"value_by_mode": {},
|
||||||
|
"order_index": index,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def describe_groups() -> list[dict]:
|
||||||
|
"""The catalogue, for a UI to render as a checklist."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"group": group,
|
||||||
|
"description": description,
|
||||||
|
"token_count": len(roles),
|
||||||
|
"names": [suffix for suffix, _ in roles],
|
||||||
|
}
|
||||||
|
for group, (description, roles) in STARTER_ROLE_GROUPS.items()
|
||||||
|
]
|
||||||
@@ -27,6 +27,10 @@ from scribe.services.design_stylesheet import (
|
|||||||
duplicate_values,
|
duplicate_values,
|
||||||
render_stylesheet,
|
render_stylesheet,
|
||||||
)
|
)
|
||||||
|
from scribe.services.design_starter_roles import (
|
||||||
|
DEFAULT_TOKEN_PREFIX,
|
||||||
|
starter_tokens,
|
||||||
|
)
|
||||||
from scribe.services.design_cascade import (
|
from scribe.services.design_cascade import (
|
||||||
ResolvedToken,
|
ResolvedToken,
|
||||||
ancestry,
|
ancestry,
|
||||||
@@ -79,11 +83,23 @@ async def create_design_system(
|
|||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
guidance: str | None = None,
|
guidance: str | None = None,
|
||||||
parent_id: int | None = None,
|
parent_id: int | None = None,
|
||||||
|
starter_role_groups: list[str] | None = None,
|
||||||
|
token_prefix: str = DEFAULT_TOKEN_PREFIX,
|
||||||
) -> DesignSystem | None:
|
) -> DesignSystem | None:
|
||||||
"""Create a system, with or without a parent.
|
"""Create a system, with or without a parent.
|
||||||
|
|
||||||
Returns None when `parent_id` names a system the caller may not write —
|
Returns None when `parent_id` names a system the caller may not write —
|
||||||
which, per the ACL, means one they do not own.
|
which, per the ACL, means one they do not own.
|
||||||
|
|
||||||
|
`starter_role_groups` seeds the system with named, VALUELESS token roles
|
||||||
|
(#2349) — the moment a role is missing is the moment a literal gets written
|
||||||
|
instead, so the cheapest time to name them is now. Pass a list of group
|
||||||
|
names to choose, `[]` for none, or None for none.
|
||||||
|
|
||||||
|
None and `[]` deliberately mean the same thing here, unlike in
|
||||||
|
`starter_tokens` where None means "all": creation must not seed 40 rows
|
||||||
|
into a system whose caller never asked. Opting in is the caller's job, and
|
||||||
|
the UI's default of everything-checked lives in the UI.
|
||||||
"""
|
"""
|
||||||
if parent_id is not None and not await access.can_write_design_system(
|
if parent_id is not None and not await access.can_write_design_system(
|
||||||
user_id, parent_id
|
user_id, parent_id
|
||||||
@@ -100,6 +116,11 @@ async def create_design_system(
|
|||||||
session.add(system)
|
session.add(system)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(system)
|
await session.refresh(system)
|
||||||
|
|
||||||
|
if starter_role_groups:
|
||||||
|
for row in starter_tokens(starter_role_groups, prefix=token_prefix):
|
||||||
|
session.add(DesignToken(design_system_id=system.id, **row))
|
||||||
|
await session.commit()
|
||||||
return system
|
return system
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,94 @@ def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|||||||
return dot / (mag_a * mag_b)
|
return dot / (mag_a * mag_b)
|
||||||
|
|
||||||
|
|
||||||
|
# How much a superseded record is pushed down the ranking (#278).
|
||||||
|
#
|
||||||
|
# Chosen against a measurement, not by feel. On 2026-08-07 dev-log #2420 sat at
|
||||||
|
# 0.6120 on a query made of its own title phrase, 8th, behind #1759 at 0.6506 —
|
||||||
|
# a deficit of 0.039 to the top and ~0.014 to its nearest neighbours. A penalty
|
||||||
|
# of 0.05 clears that whole band, so demoting a cluster's stale members actually
|
||||||
|
# reorders it rather than shuffling within a tie.
|
||||||
|
#
|
||||||
|
# It is deliberately NOT large. Supersession is a claim about SOME of a record's
|
||||||
|
# content, so a superseded note that strongly answers a question nothing else
|
||||||
|
# answers should still surface — just behind anything comparable that is
|
||||||
|
# current. A penalty big enough to bury it outright would be hiding by another
|
||||||
|
# name, which is the thing the operator ruled out.
|
||||||
|
_SUPERSESSION_PENALTY = 0.05
|
||||||
|
|
||||||
|
# Candidates fetched per requested result when a re-rank follows. Three ranks of
|
||||||
|
# headroom is far more than a 0.05 penalty can move anything through in a corpus
|
||||||
|
# whose neighbours sit ~0.01-0.02 apart.
|
||||||
|
_SUPERSESSION_OVERFETCH = 3
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_supersession_penalty(
|
||||||
|
scored: list[tuple[float, "Note"]], limit: int
|
||||||
|
) -> list[tuple[float, "Note"]]:
|
||||||
|
"""Push superseded records below their equals, then take the top `limit`.
|
||||||
|
|
||||||
|
The penalty is applied to the RANKING score and the returned score, so
|
||||||
|
downstream gates see the adjusted value — the auto-inject margin band in
|
||||||
|
particular, which exists to stop near-ties dragging in neighbours and would
|
||||||
|
otherwise re-tie exactly what this just separated.
|
||||||
|
|
||||||
|
It is NOT applied to the relevance threshold: the floor decides whether a
|
||||||
|
record is relevant at all, the penalty decides which relevant record comes
|
||||||
|
first. Applying it to the floor would drop a superseded record out of the
|
||||||
|
results entirely — hiding, which is the one thing this must not do.
|
||||||
|
|
||||||
|
Stable within a tie: Python's sort preserves the distance order the database
|
||||||
|
already established, so equal-scoring records keep their original sequence
|
||||||
|
rather than reshuffling per call.
|
||||||
|
"""
|
||||||
|
if not scored:
|
||||||
|
return []
|
||||||
|
from scribe.services.supersession import superseded_ids
|
||||||
|
|
||||||
|
try:
|
||||||
|
stale = await superseded_ids([int(note.id) for _score, note in scored])
|
||||||
|
except Exception:
|
||||||
|
# Fail OPEN, and the direction matters: ranking without the penalty is
|
||||||
|
# the behaviour that shipped for months. Returning nothing, or raising,
|
||||||
|
# would turn a supersession-lookup hiccup into a broken search.
|
||||||
|
logger.warning("Supersession lookup failed — ranking unpenalised", exc_info=True)
|
||||||
|
return scored[:limit]
|
||||||
|
|
||||||
|
if not stale:
|
||||||
|
return scored[:limit]
|
||||||
|
adjusted = [
|
||||||
|
(score - _SUPERSESSION_PENALTY if int(note.id) in stale else score, note)
|
||||||
|
for score, note in scored
|
||||||
|
]
|
||||||
|
adjusted.sort(key=lambda pair: pair[0], reverse=True)
|
||||||
|
return adjusted[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def embedding_text(title: str | None, body: str | None) -> str:
|
||||||
|
"""The document a record is embedded AS.
|
||||||
|
|
||||||
|
One definition, deliberately. This was written out three times — the write
|
||||||
|
path (`notes.embed_note`), the recurring-task spawn, and the startup
|
||||||
|
backfill — and identical copies of a formatting rule are three chances to
|
||||||
|
change one and not the others. The spawn path is the dangerous one: a
|
||||||
|
recurring task embedded to a different shape than everything else would be
|
||||||
|
ranked against a corpus it doesn't match, and nothing would report it.
|
||||||
|
|
||||||
|
It is also a PRECONDITION for changing the shape at all (#2486). Measured,
|
||||||
|
a dev-log's vector separates from five unrelated dev-logs by 0.023 while a
|
||||||
|
snippet's separates by 0.153 — the difference being that a snippet states
|
||||||
|
its purpose twice in a short document, so the purpose dominates. Testing an
|
||||||
|
alternative shape against three copies would mean testing a shape that is
|
||||||
|
not the one in production.
|
||||||
|
|
||||||
|
Whether `title\\n{body}` is the RIGHT shape is the open question. That it is
|
||||||
|
one shape is what makes the question answerable.
|
||||||
|
"""
|
||||||
|
title = title or ""
|
||||||
|
body = body or ""
|
||||||
|
return f"{title}\n{body}".strip() if body else title
|
||||||
|
|
||||||
|
|
||||||
async def upsert_note_embedding(note_id: int, user_id: int, text: str) -> None:
|
async def upsert_note_embedding(note_id: int, user_id: int, text: str) -> None:
|
||||||
"""Generate and persist an embedding for a note. Safe to fire-and-forget."""
|
"""Generate and persist an embedding for a note. Safe to fire-and-forget."""
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
@@ -120,6 +208,8 @@ async def semantic_search_notes(
|
|||||||
task_kind: str | Sequence[str] | None = None,
|
task_kind: str | Sequence[str] | None = None,
|
||||||
orphan_only: bool = False,
|
orphan_only: bool = False,
|
||||||
scope: str = "own",
|
scope: str = "own",
|
||||||
|
demote_superseded: bool = True,
|
||||||
|
system_id: int | None = None,
|
||||||
) -> list[tuple[float, Note]]:
|
) -> list[tuple[float, Note]]:
|
||||||
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
||||||
|
|
||||||
@@ -151,6 +241,13 @@ async def semantic_search_notes(
|
|||||||
so a similarity floor of *threshold* is a distance ceiling of
|
so a similarity floor of *threshold* is a distance ceiling of
|
||||||
``1 - threshold`` and similarity is recovered as ``1 - distance``.
|
``1 - threshold`` and similarity is recovered as ``1 - distance``.
|
||||||
|
|
||||||
|
`demote_superseded` applies the supersession penalty (#278): a record a
|
||||||
|
later note claims to have overtaken ranks below its equals. Callers asking
|
||||||
|
"what is the current answer" want it; the near-duplicate gate does NOT, and
|
||||||
|
passes False — a superseded record is still a duplicate of what you are
|
||||||
|
about to write, and demoting it there would let the same note be recorded
|
||||||
|
twice, the second time invisibly.
|
||||||
|
|
||||||
Returns an empty list if the embedder is unavailable or on any error.
|
Returns an empty list if the embedder is unavailable or on any error.
|
||||||
"""
|
"""
|
||||||
if not query or not query.strip():
|
if not query or not query.strip():
|
||||||
@@ -185,6 +282,19 @@ async def semantic_search_notes(
|
|||||||
stmt = stmt.where(Note.project_id.is_(None))
|
stmt = stmt.where(Note.project_id.is_(None))
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Note.project_id == project_id)
|
stmt = stmt.where(Note.project_id == project_id)
|
||||||
|
# Narrow to records tagged to one System (subsystem/area). An
|
||||||
|
# association filter, not a ranking signal — membership in the
|
||||||
|
# candidate set, decided before scoring, like project_id above.
|
||||||
|
if system_id is not None:
|
||||||
|
from scribe.models.system import RecordSystem
|
||||||
|
stmt = stmt.where(
|
||||||
|
select(RecordSystem.id)
|
||||||
|
.where(
|
||||||
|
RecordSystem.note_id == Note.id,
|
||||||
|
RecordSystem.system_id == system_id,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
if is_task is True:
|
if is_task is True:
|
||||||
stmt = stmt.where(Note.status.isnot(None))
|
stmt = stmt.where(Note.status.isnot(None))
|
||||||
elif is_task is False:
|
elif is_task is False:
|
||||||
@@ -207,14 +317,38 @@ async def semantic_search_notes(
|
|||||||
)
|
)
|
||||||
if exclude_ids:
|
if exclude_ids:
|
||||||
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
|
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
|
||||||
stmt = stmt.where(distance <= max_distance).order_by(distance.asc()).limit(limit)
|
# OVER-FETCH when a re-rank follows, so the demotion can actually
|
||||||
|
# move something. Demoting after a LIMIT k would be theatre: the cut
|
||||||
|
# already happened, so a superseded record pushed down still sits in
|
||||||
|
# the results and the live record that should have replaced it was
|
||||||
|
# never fetched.
|
||||||
|
#
|
||||||
|
# Ordering stays on RAW distance so pgvector's HNSW index still
|
||||||
|
# serves it (migration 0067). Ordering by `distance + penalty`
|
||||||
|
# instead would be exact, and would turn an indexed top-k into a
|
||||||
|
# scan-and-sort of every embedded note.
|
||||||
|
#
|
||||||
|
# The cost of that trade, stated plainly: a live record outside the
|
||||||
|
# over-fetch window cannot be promoted into the results. With a
|
||||||
|
# penalty far smaller than the window's score spread, that case
|
||||||
|
# needs the true answer to be more than _SUPERSESSION_OVERFETCH
|
||||||
|
# ranks down, which no observed query comes close to.
|
||||||
|
fetch = limit * _SUPERSESSION_OVERFETCH if demote_superseded else limit
|
||||||
|
stmt = (
|
||||||
|
stmt.where(distance <= max_distance)
|
||||||
|
.order_by(distance.asc())
|
||||||
|
.limit(fetch)
|
||||||
|
)
|
||||||
rows = list((await session.execute(stmt)).all())
|
rows = list((await session.execute(stmt)).all())
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to query note embeddings", exc_info=True)
|
logger.warning("Failed to query note embeddings", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Recover similarity (1 - distance) and preserve the highest-first contract.
|
# Recover similarity (1 - distance) and preserve the highest-first contract.
|
||||||
return [(1.0 - float(dist), note) for note, dist in rows]
|
scored = [(1.0 - float(dist), note) for note, dist in rows]
|
||||||
|
if not demote_superseded:
|
||||||
|
return scored[:limit]
|
||||||
|
return await _apply_supersession_penalty(scored, limit)
|
||||||
|
|
||||||
|
|
||||||
async def backfill_note_embeddings() -> None:
|
async def backfill_note_embeddings() -> None:
|
||||||
@@ -248,7 +382,7 @@ async def backfill_note_embeddings() -> None:
|
|||||||
logger.info("Embedding backfill: generating embeddings for %d notes", len(notes_to_embed))
|
logger.info("Embedding backfill: generating embeddings for %d notes", len(notes_to_embed))
|
||||||
success = 0
|
success = 0
|
||||||
for note_id, user_id, title, body in notes_to_embed:
|
for note_id, user_id, title, body in notes_to_embed:
|
||||||
text = f"{title}\n{body}".strip() if body else (title or "")
|
text = embedding_text(title, body)
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
await upsert_note_embedding(note_id, user_id, text)
|
await upsert_note_embedding(note_id, user_id, text)
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ def embed_note(note) -> None:
|
|||||||
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
||||||
not an error.
|
not an error.
|
||||||
"""
|
"""
|
||||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from scribe.services.embeddings import upsert_note_embedding
|
from scribe.services.embeddings import embedding_text, upsert_note_embedding
|
||||||
|
text = embedding_text(note.title, note.body)
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass # no running loop — a sync caller, not a failure
|
pass # no running loop — a sync caller, not a failure
|
||||||
@@ -373,17 +373,16 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
|||||||
return note
|
return note
|
||||||
|
|
||||||
|
|
||||||
async def delete_note(user_id: int, note_id: int) -> bool:
|
# A hard `delete_note(user_id, note_id)` lived here with ZERO callers, and was
|
||||||
async with async_session() as session:
|
# removed with #278 step 1. It is recorded rather than silently dropped because
|
||||||
result = await session.execute(
|
# the danger was never that it ran — it is that it was findable by name. Someone
|
||||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
# wanting to delete a note greps `delete_note`, finds a function in the notes
|
||||||
)
|
# service with exactly the right signature, and permanently destroys a record
|
||||||
note = result.scalars().first()
|
# every path downstream expects to be recoverable.
|
||||||
if note is None:
|
#
|
||||||
return False
|
# The delete path is `trash_svc.delete`, which soft-deletes an entity AND its
|
||||||
await session.delete(note)
|
# descendants under one batch_id so `restore(batch)` works. `purge_trash` owns
|
||||||
await session.commit()
|
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from scribe.services import snippets as snippets_svc
|
|||||||
from scribe.services.access import label_shared_items, owner_names_for
|
from scribe.services.access import label_shared_items, owner_names_for
|
||||||
from scribe.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
from scribe.services.note_usage import record_surfaced
|
from scribe.services.note_usage import record_surfaced
|
||||||
|
from scribe.services.supersession import superseded_ids
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
from scribe.services.settings import get_setting
|
from scribe.services.settings import get_setting
|
||||||
|
|
||||||
@@ -425,11 +426,19 @@ async def build_autoinject_hint(
|
|||||||
"`get_note(id)`, or `get_snippet` / `get_process` for those kinds "
|
"`get_note(id)`, or `get_snippet` / `get_process` for those kinds "
|
||||||
"(titles only; injected once per session):",
|
"(titles only; injected once per session):",
|
||||||
]
|
]
|
||||||
|
# A superseded record is DEMOTED, not removed (#278) — so one can still reach
|
||||||
|
# this menu, and when it does the reader has to be told. An agent handed
|
||||||
|
# stale material with nothing marking it acts on it with full confidence,
|
||||||
|
# which is worse than never having surfaced it. One query for the whole menu.
|
||||||
|
stale = await superseded_ids([int(n.id) for _s, n in kept])
|
||||||
|
|
||||||
note_ids: list[int] = []
|
note_ids: list[int] = []
|
||||||
for score, note in kept:
|
for score, note in kept:
|
||||||
note_ids.append(int(note.id))
|
note_ids.append(int(note.id))
|
||||||
title = (note.title or "(untitled)").replace("\n", " ").strip()
|
title = (note.title or "(untitled)").replace("\n", " ").strip()
|
||||||
line = f"> - #{note.id} [{_record_kind(note)}] \"{title}\" ({score:.2f})"
|
line = f"> - #{note.id} [{_record_kind(note)}] \"{title}\" ({score:.2f})"
|
||||||
|
if int(note.id) in stale:
|
||||||
|
line += " — SUPERSEDED, a later record covers this; check that first"
|
||||||
if note.user_id != user_id:
|
if note.user_id != user_id:
|
||||||
who = owners.get(int(note.user_id)) or "another user"
|
who = owners.get(int(note.user_id)) or "another user"
|
||||||
line += f" — shared by {who}, treat as a suggestion"
|
line += f" — shared by {who}, treat as a suggestion"
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def spawn_recurring_tasks() -> int:
|
|||||||
|
|
||||||
Returns the number of tasks spawned.
|
Returns the number of tasks spawned.
|
||||||
"""
|
"""
|
||||||
from scribe.services.embeddings import upsert_note_embedding
|
from scribe.services.embeddings import embedding_text, upsert_note_embedding
|
||||||
from scribe.services.notes import create_note
|
from scribe.services.notes import create_note
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -139,7 +139,7 @@ async def spawn_recurring_tasks() -> int:
|
|||||||
milestone_id=task.milestone_id,
|
milestone_id=task.milestone_id,
|
||||||
recurrence_rule=task.recurrence_rule,
|
recurrence_rule=task.recurrence_rule,
|
||||||
)
|
)
|
||||||
text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "")
|
text = embedding_text(child.title, child.body)
|
||||||
if text:
|
if text:
|
||||||
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
|
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -96,6 +96,27 @@ def _normalize_locations(locations: list[dict] | None) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_locations(
|
||||||
|
repo: str = "", path: str = "", symbol: str = "",
|
||||||
|
locations: list[dict] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The location list a caller meant, from either calling convention.
|
||||||
|
|
||||||
|
`locations` is the general form (one entry per call site); repo/path/symbol
|
||||||
|
are the single-location shorthand and apply only when `locations` was not
|
||||||
|
given — passing both is not a merge, it is the caller having decided.
|
||||||
|
|
||||||
|
Extracted because compose_body, create_snippet and the dedup gate must all
|
||||||
|
read the shorthand the SAME way. They each had their own copy of the
|
||||||
|
`if locations is None` fallback, which is fine until one of them gains a
|
||||||
|
rule the others don't — and the gate (#2518) is the one where a disagreement
|
||||||
|
would mean comparing a location the record won't actually be stored with.
|
||||||
|
"""
|
||||||
|
if locations is None:
|
||||||
|
locations = [{"repo": repo, "path": path, "symbol": symbol}]
|
||||||
|
return _normalize_locations(locations)
|
||||||
|
|
||||||
|
|
||||||
def _location_str(loc: dict) -> str:
|
def _location_str(loc: dict) -> str:
|
||||||
"""`repo` · `path` · `symbol` — only the non-empty parts."""
|
"""`repo` · `path` · `symbol` — only the non-empty parts."""
|
||||||
parts = [(loc.get(k) or "").strip() for k in ("repo", "path", "symbol")]
|
parts = [(loc.get(k) or "").strip() for k in ("repo", "path", "symbol")]
|
||||||
@@ -186,9 +207,7 @@ def compose_body(
|
|||||||
a back-compat shorthand for one location and are used only when ``locations``
|
a back-compat shorthand for one location and are used only when ``locations``
|
||||||
is not given.
|
is not given.
|
||||||
"""
|
"""
|
||||||
if locations is None:
|
locs = resolve_locations(repo, path, symbol, locations)
|
||||||
locations = [{"repo": repo, "path": path, "symbol": symbol}]
|
|
||||||
locs = _normalize_locations(locations)
|
|
||||||
|
|
||||||
header: list[str] = []
|
header: list[str] = []
|
||||||
if (when_to_use or "").strip():
|
if (when_to_use or "").strip():
|
||||||
@@ -601,8 +620,7 @@ async def create_snippet(
|
|||||||
"""Create a snippet note (embedded on create for immediate recall). Returns
|
"""Create a snippet note (embedded on create for immediate recall). Returns
|
||||||
the created Note. Pass ``locations`` for the multi-location case; the single
|
the created Note. Pass ``locations`` for the multi-location case; the single
|
||||||
``repo``/``path``/``symbol`` are the one-location shorthand."""
|
``repo``/``path``/``symbol`` are the one-location shorthand."""
|
||||||
if locations is None:
|
locations = resolve_locations(repo, path, symbol, locations)
|
||||||
locations = [{"repo": repo, "path": path, "symbol": symbol}]
|
|
||||||
note = await notes_svc.create_note(
|
note = await notes_svc.create_note(
|
||||||
user_id,
|
user_id,
|
||||||
title=compose_title(name, when_to_use),
|
title=compose_title(name, when_to_use),
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Which records have been overtaken by which — the claim, not the ranking.
|
||||||
|
|
||||||
|
Step 2 of milestone #278. This module only records and reads the relation; the
|
||||||
|
demotion that makes it matter lives in the retrieval layer.
|
||||||
|
|
||||||
|
WHY THE CLAIM POINTS FORWARD
|
||||||
|
|
||||||
|
The note being written declares what it supersedes. The older record cannot
|
||||||
|
know it has been overtaken — asking it to record its own obsolescence is asking
|
||||||
|
it to predict the future. So the party with the knowledge makes the claim, and
|
||||||
|
"has this been superseded?" is derived by looking at the far end.
|
||||||
|
|
||||||
|
WHAT IT MEANS
|
||||||
|
|
||||||
|
A claim, never a proof. It demotes a record in ranked retrieval; it does not
|
||||||
|
assert the older record was wrong and it never hides it. A note that accurately
|
||||||
|
described how something worked in June is still accurate about June.
|
||||||
|
|
||||||
|
Partial and many-to-many by nature: one note may supersede parts of several
|
||||||
|
others, and be overtaken piecemeal by several later ones.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.note import Note
|
||||||
|
from scribe.models.note_supersession import NoteSupersession
|
||||||
|
from scribe.services import access
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _closes_a_cycle(session, superseder_id: int, superseded_id: int) -> bool:
|
||||||
|
"""True if `superseder -> superseded` would complete a loop.
|
||||||
|
|
||||||
|
Walks the existing graph from `superseded_id` following superseder→superseded
|
||||||
|
edges. If the walk reaches `superseder_id`, the new edge closes a cycle.
|
||||||
|
|
||||||
|
Why refuse rather than tolerate: a cycle claims every member is obsolete, and
|
||||||
|
under FLAT demotion (see the milestone) that demotes all of them equally —
|
||||||
|
so a set of records that supersede each other in a ring would vanish from
|
||||||
|
ranked retrieval together, which is the opposite of the intent. Nothing about
|
||||||
|
the data would say why.
|
||||||
|
|
||||||
|
Iterative with a visited set, not recursion: the graph is user-supplied and
|
||||||
|
a deep chain must not become a stack overflow in a write path.
|
||||||
|
"""
|
||||||
|
seen: set[int] = set()
|
||||||
|
frontier = [superseded_id]
|
||||||
|
while frontier:
|
||||||
|
current = frontier.pop()
|
||||||
|
if current == superseder_id:
|
||||||
|
return True
|
||||||
|
if current in seen:
|
||||||
|
continue
|
||||||
|
seen.add(current)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(NoteSupersession.superseded_id)
|
||||||
|
.where(NoteSupersession.superseder_id == current)
|
||||||
|
)).scalars().all()
|
||||||
|
frontier.extend(int(r) for r in rows)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def set_supersedes(
|
||||||
|
user_id: int, note_id: int, superseded_ids: list[int]
|
||||||
|
) -> list[int] | None:
|
||||||
|
"""Replace what `note_id` claims to supersede (set semantics).
|
||||||
|
|
||||||
|
Returns the resulting list, or None if the caller cannot write the note
|
||||||
|
making the claim.
|
||||||
|
|
||||||
|
WHAT IS SILENTLY DROPPED, and why each is a drop rather than an error:
|
||||||
|
- ids that don't exist or are trashed — the claim has no subject
|
||||||
|
- the note's own id — meaningless, and the DB CHECK would refuse it anyway
|
||||||
|
- an id that would close a cycle — see _closes_a_cycle
|
||||||
|
|
||||||
|
WHAT IS REFUSED OUTRIGHT: a target the caller cannot WRITE. That is not a
|
||||||
|
silent drop, because it is the one case where the caller might reasonably
|
||||||
|
believe they succeeded and be wrong in a way that matters — demoting someone
|
||||||
|
else's record out of their retrieval is damage you cannot see from the
|
||||||
|
outside. Rule #47.
|
||||||
|
"""
|
||||||
|
if not await access.can_write_note(user_id, note_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
wanted: list[int] = []
|
||||||
|
for target in dict.fromkeys(superseded_ids): # de-dup, keep order
|
||||||
|
target = int(target)
|
||||||
|
if target == note_id:
|
||||||
|
continue
|
||||||
|
note = await session.get(Note, target)
|
||||||
|
if note is None or note.deleted_at is not None:
|
||||||
|
continue
|
||||||
|
if not await access.can_write_note(user_id, target):
|
||||||
|
raise PermissionError(
|
||||||
|
f"note {target} is not yours to supersede — you need write "
|
||||||
|
f"access to it, not just read. Superseding demotes a record "
|
||||||
|
f"in its owner's retrieval too."
|
||||||
|
)
|
||||||
|
if await _closes_a_cycle(session, note_id, target):
|
||||||
|
continue
|
||||||
|
wanted.append(target)
|
||||||
|
|
||||||
|
existing = set((await session.execute(
|
||||||
|
select(NoteSupersession.superseded_id)
|
||||||
|
.where(NoteSupersession.superseder_id == note_id)
|
||||||
|
)).scalars().all())
|
||||||
|
wanted_set = set(wanted)
|
||||||
|
|
||||||
|
to_remove = existing - wanted_set
|
||||||
|
if to_remove:
|
||||||
|
await session.execute(
|
||||||
|
delete(NoteSupersession).where(
|
||||||
|
NoteSupersession.superseder_id == note_id,
|
||||||
|
NoteSupersession.superseded_id.in_(to_remove),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for target in wanted:
|
||||||
|
if target not in existing:
|
||||||
|
session.add(
|
||||||
|
NoteSupersession(superseder_id=note_id, superseded_id=target)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
|
async def get_relations(user_id: int, note_id: int) -> dict[str, list[int]]:
|
||||||
|
"""Both directions for one note: what it supersedes, and what supersedes it.
|
||||||
|
|
||||||
|
ONE query and ONE ACL check, because this runs on every note read. Asking
|
||||||
|
the two questions separately doubled the round trips on the hottest path in
|
||||||
|
the product to save a two-line partition — the wrong trade, and one I made
|
||||||
|
on the first attempt.
|
||||||
|
|
||||||
|
Returns {"supersedes": [...], "superseded_by": [...]}, both sorted. Empty
|
||||||
|
lists when the caller cannot read the note.
|
||||||
|
|
||||||
|
`superseded_by` is the direction that matters to a READER and the one the
|
||||||
|
note itself cannot know. An agent handed a stale record with no marker acts
|
||||||
|
on it confidently, which is worse than never surfacing it at all.
|
||||||
|
"""
|
||||||
|
empty: dict[str, list[int]] = {"supersedes": [], "superseded_by": []}
|
||||||
|
if not await access.can_read_note(user_id, note_id):
|
||||||
|
return empty
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(
|
||||||
|
NoteSupersession.superseder_id, NoteSupersession.superseded_id
|
||||||
|
).where(
|
||||||
|
(NoteSupersession.superseder_id == note_id)
|
||||||
|
| (NoteSupersession.superseded_id == note_id)
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
supersedes = sorted(
|
||||||
|
int(old) for new, old in rows if int(new) == note_id
|
||||||
|
)
|
||||||
|
superseded_by = sorted(
|
||||||
|
int(new) for new, old in rows if int(old) == note_id
|
||||||
|
)
|
||||||
|
return {"supersedes": supersedes, "superseded_by": superseded_by}
|
||||||
|
|
||||||
|
|
||||||
|
async def superseded_ids(note_ids: list[int]) -> set[int]:
|
||||||
|
"""Of `note_ids`, which have been superseded by anything. One query.
|
||||||
|
|
||||||
|
Deliberately NOT ACL-scoped: this feeds ranking over a candidate set the
|
||||||
|
caller has already been authorised to see, and re-checking per candidate
|
||||||
|
would be a per-result round trip on a hot path. Callers must pass an
|
||||||
|
already-scoped set — which is why this takes ids rather than a user.
|
||||||
|
"""
|
||||||
|
if not note_ids:
|
||||||
|
return set()
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(NoteSupersession.superseded_id)
|
||||||
|
.where(NoteSupersession.superseded_id.in_(note_ids))
|
||||||
|
)).scalars().all()
|
||||||
|
return {int(r) for r in rows}
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
"""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,136 @@
|
|||||||
|
"""The starter role set (#2349).
|
||||||
|
|
||||||
|
The premise: a literal gets written when there is no role to reach for. This
|
||||||
|
codebase demonstrated it — the house style had no "text on a filled colour"
|
||||||
|
role, so 76 call sites wrote `color: #fff` (#2275). Naming the roles at
|
||||||
|
creation removes the occasion.
|
||||||
|
|
||||||
|
The tests that matter here are about the BOUNDARY, not the content. Roles ship
|
||||||
|
with the product; values never do. A default palette would be one operator's
|
||||||
|
taste shipped as product code (rule #115), and it would be very easy to add by
|
||||||
|
accident while "being helpful".
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.services import design_starter_roles as roles
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_role_carries_a_VALUE():
|
||||||
|
"""THE rule-115 guard. Every seeded token is a named question with no
|
||||||
|
answer. The moment one ships a hex, the product is prescribing an install's
|
||||||
|
palette."""
|
||||||
|
for row in roles.starter_tokens():
|
||||||
|
assert row["value_by_mode"] == {}, f"{row['name']} shipped a value"
|
||||||
|
|
||||||
|
|
||||||
|
def _colour_literals(text: str) -> list[str]:
|
||||||
|
"""Hex colours in `text`, NOT counting issue references.
|
||||||
|
|
||||||
|
`#2275` is four hex-valid digits and also how this codebase cites an issue —
|
||||||
|
so the naive pattern flags its own documentation, which is the third time
|
||||||
|
that has happened here (#2353). A real colour either contains a letter
|
||||||
|
a–f or is a full 6/8-digit value; an all-decimal 3- or 4-digit match is an
|
||||||
|
issue number.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for m in re.findall(r"#[0-9a-fA-F]{3,8}\b", text):
|
||||||
|
digits = m[1:]
|
||||||
|
if len(digits) not in (3, 4, 6, 8):
|
||||||
|
continue
|
||||||
|
if len(digits) in (6, 8) or any(c in "abcdefABCDEF" for c in digits):
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_module_contains_no_colour_literals_at_all():
|
||||||
|
"""Belt and braces on the above, and the stronger claim: not just that
|
||||||
|
tokens are blank, but that no palette hides in a comment or a docstring
|
||||||
|
waiting to be pasted in. Checks the SOURCE, not the output."""
|
||||||
|
import pathlib
|
||||||
|
src = pathlib.Path(roles.__file__).read_text()
|
||||||
|
hexes = _colour_literals(src)
|
||||||
|
assert not hexes, f"colour literals in product code: {hexes}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_colour_check_does_not_flag_issue_references():
|
||||||
|
"""Pins the exclusion above, because without it this file fails on its own
|
||||||
|
citations and the obvious 'fix' is to delete the check."""
|
||||||
|
assert _colour_literals("see #2275 and #2349") == []
|
||||||
|
assert _colour_literals("color: #fff") == ["#fff"]
|
||||||
|
assert _colour_literals("#E8E4D8 on #14171A") == ["#E8E4D8", "#14171A"]
|
||||||
|
assert _colour_literals("#000000") == ["#000000"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_group_is_individually_selectable():
|
||||||
|
"""Operator's call: one flat list, all skippable. An install that wants
|
||||||
|
three tokens must be able to get three."""
|
||||||
|
only_text = roles.starter_tokens(["text"])
|
||||||
|
assert {r["group_name"] for r in only_text} == {"text"}
|
||||||
|
assert len(only_text) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_selection_yields_nothing_and_is_not_the_same_as_None():
|
||||||
|
"""`[]` is a real answer — "none of them" — and must not be read as
|
||||||
|
"unspecified, so give me everything". Getting this backwards would seed 40
|
||||||
|
rows into a system whose creator explicitly declined."""
|
||||||
|
assert roles.starter_tokens([]) == []
|
||||||
|
assert len(roles.starter_tokens(None)) > 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_group_names_are_ignored_not_fatal():
|
||||||
|
"""This feeds a checkbox list. A stale name from an older client should not
|
||||||
|
fail an otherwise-fine creation."""
|
||||||
|
out = roles.starter_tokens(["text", "not-a-real-group"])
|
||||||
|
assert {r["group_name"] for r in out} == {"text"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prefix_is_the_installs_choice():
|
||||||
|
"""`--fs-` is FabledSword's convention, not the product's. Baking it in
|
||||||
|
would put one family's naming into every install."""
|
||||||
|
assert all(r["name"].startswith("--ds-") for r in roles.starter_tokens(["text"]))
|
||||||
|
custom = roles.starter_tokens(["text"], prefix="--acme-")
|
||||||
|
assert all(r["name"].startswith("--acme-") for r in custom)
|
||||||
|
assert "--acme-text-primary" in {r["name"] for r in custom}
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_on_action_is_in_the_starter_set():
|
||||||
|
"""The specific role whose absence produced 76 literals. It is separate
|
||||||
|
from text-primary on purpose: the surfaces it sits on do not change with
|
||||||
|
the mode, while the page does — so reusing text-primary there passes in
|
||||||
|
dark and fails contrast in light (#2275)."""
|
||||||
|
names = {r["name"] for r in roles.starter_tokens(["text"])}
|
||||||
|
assert "--ds-text-on-action" in names
|
||||||
|
assert "--ds-text-primary" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_names_are_valid_custom_properties():
|
||||||
|
"""They go straight into a stylesheet; an invalid name is a silent no-op
|
||||||
|
rather than an error, which is the worst failure mode available."""
|
||||||
|
valid = re.compile(r"^--[A-Za-z0-9_-]+$")
|
||||||
|
for row in roles.starter_tokens():
|
||||||
|
assert valid.match(row["name"]), row["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_duplicate_names_across_the_whole_set():
|
||||||
|
"""A design system has a partial-unique index on (system, name); a
|
||||||
|
duplicate in the starter set would make creation fail at the DB with a
|
||||||
|
constraint error rather than anything legible."""
|
||||||
|
names = [r["name"] for r in roles.starter_tokens()]
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_role_states_a_purpose():
|
||||||
|
"""An unfilled role is only useful if it says what belongs there. "Colour
|
||||||
|
1" is a blank with extra steps."""
|
||||||
|
for row in roles.starter_tokens():
|
||||||
|
assert row["purpose"].strip(), row["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_groups_matches_what_starter_tokens_produces():
|
||||||
|
"""The catalogue a UI renders and the rows creation writes must not drift —
|
||||||
|
a checklist offering a group that seeds nothing is a lie in the UI."""
|
||||||
|
described = {g["group"]: g["token_count"] for g in roles.describe_groups()}
|
||||||
|
for group, count in described.items():
|
||||||
|
assert len(roles.starter_tokens([group])) == count
|
||||||
@@ -284,8 +284,9 @@ def test_a_shorter_hex_does_not_match_inside_a_longer_one():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_literal_match_is_case_insensitive():
|
def test_the_literal_match_is_case_insensitive():
|
||||||
"""Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
"""A record writes `#FFFFFF` and code writes `#ffffff`. A case-sensitive
|
||||||
check would silently find nothing — the same trap normalize_hex exists for."""
|
check would silently find nothing — the same trap `normalizeColour` in
|
||||||
|
utils/designDrift.ts exists for on the client side."""
|
||||||
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
|
report = check_code_against_tokens("color: #FFFFFF;", SHEET)
|
||||||
assert report["superseded_literals"] == [
|
assert report["superseded_literals"] == [
|
||||||
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
|
{"literal": "#ffffff", "use_instead": "--fs-parchment"}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""The document shape a record is embedded as, and the guard that keeps it one.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
|
||||||
|
`f"{title}\\n{body}"` was written out three times — the write path, the
|
||||||
|
recurring-task spawn, and the startup backfill. Identical copies of a formatting
|
||||||
|
rule are three chances to change one and not the others, and the spawn path is
|
||||||
|
the dangerous one: a recurring task embedded to a different shape than the rest
|
||||||
|
of the corpus is ranked against documents it doesn't match, and nothing reports
|
||||||
|
it. A wrong vector returns results; it just returns the wrong ones.
|
||||||
|
|
||||||
|
It is also the precondition for #2486. A dev-log's vector separates from five
|
||||||
|
unrelated dev-logs by 0.023 where a snippet separates by 0.153, and the leading
|
||||||
|
explanation is shape — a snippet states its purpose twice in a short document.
|
||||||
|
Testing an alternative against three copies would mean testing a shape that
|
||||||
|
isn't the one in production.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from scribe.services.embeddings import embedding_text
|
||||||
|
|
||||||
|
SERVICES = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_and_body_are_joined_by_a_newline():
|
||||||
|
assert embedding_text("A title", "A body") == "A title\nA body"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bodyless_record_embeds_as_its_title_alone():
|
||||||
|
"""Not "title\\n" — the trailing separator would be a token's worth of noise
|
||||||
|
on the shortest documents, which are the ones least able to spare it."""
|
||||||
|
assert embedding_text("Just a title", "") == "Just a title"
|
||||||
|
assert embedding_text("Just a title", None) == "Just a title"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_record_yields_an_empty_string():
|
||||||
|
"""Callers gate on falsiness to skip embedding entirely, so this must be
|
||||||
|
empty rather than a stray newline."""
|
||||||
|
assert embedding_text("", "") == ""
|
||||||
|
assert embedding_text(None, None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_surrounding_whitespace_is_stripped():
|
||||||
|
assert embedding_text(" A title ", " A body ") == "A title \n A body"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_else_builds_the_embedding_document_itself():
|
||||||
|
"""The guard. A fourth copy is how the first three happened.
|
||||||
|
|
||||||
|
Source inspection, because this is the shape no behavioural test catches:
|
||||||
|
an inlined copy produces the same string today and diverges silently the day
|
||||||
|
the shape changes. Matches the f-string pattern itself rather than a
|
||||||
|
variable name, so a copy that renames its locals is still caught.
|
||||||
|
"""
|
||||||
|
offenders = []
|
||||||
|
for path in SERVICES.rglob("*.py"):
|
||||||
|
source = path.read_text()
|
||||||
|
for node in ast.walk(ast.parse(source)):
|
||||||
|
if not isinstance(node, ast.JoinedStr):
|
||||||
|
continue
|
||||||
|
# An f-string whose literal parts are exactly a newline, with a
|
||||||
|
# substitution either side: `f"{x}\n{y}"`.
|
||||||
|
literals = [
|
||||||
|
v.value for v in node.values
|
||||||
|
if isinstance(v, ast.Constant) and isinstance(v.value, str)
|
||||||
|
]
|
||||||
|
subs = [v for v in node.values if isinstance(v, ast.FormattedValue)]
|
||||||
|
if literals == ["\n"] and len(subs) == 2:
|
||||||
|
offenders.append(f"{path.relative_to(SERVICES)}:{node.lineno}")
|
||||||
|
|
||||||
|
# embeddings.py holds the one definition.
|
||||||
|
offenders = [o for o in offenders if not o.startswith("services/embeddings.py")]
|
||||||
|
assert not offenders, (
|
||||||
|
f"these build the embedding document inline instead of calling "
|
||||||
|
f"embedding_text(): {offenders}. One definition — an inlined copy is "
|
||||||
|
f"ranked against a corpus it no longer matches the moment the shape "
|
||||||
|
f"changes, and nothing reports it (#2486)."
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""The instruction surfaces must agree that the agent pulls the rules itself.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
|
||||||
|
Rule #119 makes the instruction surfaces the SPECIFICATION for product
|
||||||
|
behaviour — there is no other place the "load the operator's rules" obligation
|
||||||
|
is written down, and no code path enforces it. So a surface that states it
|
||||||
|
differently isn't a documentation slip; it is the product behaving differently.
|
||||||
|
|
||||||
|
That happened (#2497). `_INSTRUCTIONS` said the SessionStart hook "is the
|
||||||
|
bridge" for getting rules into a session, while the `using-scribe` skill said to
|
||||||
|
pull them yourself and treat any push as a bonus. An agent weighting the first
|
||||||
|
would reasonably skip the pull.
|
||||||
|
|
||||||
|
#2198 is the case where that is wrong: every plugin hook was silently inert for
|
||||||
|
an extended period, and nothing announced it. An agent trusting the push would
|
||||||
|
have run with no binding rules and no signal — while those rules govern branch,
|
||||||
|
commit, push and other hard-to-reverse actions.
|
||||||
|
|
||||||
|
The asymmetry is the whole argument, and it is what these tests pin: pulling
|
||||||
|
when a push also arrived costs one redundant call; not pulling when the push
|
||||||
|
never came costs the operator's rules entirely.
|
||||||
|
|
||||||
|
WHAT THIS DOES NOT DO
|
||||||
|
|
||||||
|
It cannot tell whether two surfaces contradict each other in prose generally —
|
||||||
|
that needs a reader. It pins the one instruction whose absence is known to be
|
||||||
|
load-bearing, and the specific shape the #2497 defect took: naming the push
|
||||||
|
without also stating the pull.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# The pull instruction, however a surface phrases the surrounding prose.
|
||||||
|
PULL = "list_always_on_rules"
|
||||||
|
|
||||||
|
# Surfaces a session loads before substantive work. Hand-written because
|
||||||
|
# "is this a session-start surface?" is an editorial fact, not a derivable one —
|
||||||
|
# but each entry is asserted to EXIST, so a move or rename fails loudly here
|
||||||
|
# instead of quietly dropping that surface from the check.
|
||||||
|
SESSION_START_SURFACES = (
|
||||||
|
ROOT / "src" / "scribe" / "mcp" / "server.py",
|
||||||
|
ROOT / "plugin" / "hooks" / "scribe_static_context.md",
|
||||||
|
ROOT / "plugin" / "skills" / "using-scribe" / "SKILL.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_surfaces() -> list[tuple[str, str]]:
|
||||||
|
"""(label, text) for every file a SESSION loads as instructions.
|
||||||
|
|
||||||
|
Deliberately not every markdown file under plugin/: `README.md` describes
|
||||||
|
the push channel to the operator installing the plugin, and telling a human
|
||||||
|
what the hook does is not the same act as telling an agent it need not pull.
|
||||||
|
The boundary is "does a session read this", which is skills (loaded by
|
||||||
|
description match), the hook-injected static context, and the MCP server's
|
||||||
|
own instructions.
|
||||||
|
"""
|
||||||
|
found = [(str(p.relative_to(ROOT)), p.read_text())
|
||||||
|
for p in (ROOT / "plugin" / "skills").rglob("SKILL.md")]
|
||||||
|
found += [(str(p.relative_to(ROOT)), p.read_text())
|
||||||
|
for p in (ROOT / "plugin" / "hooks").glob("*.md")]
|
||||||
|
server = ROOT / "src" / "scribe" / "mcp" / "server.py"
|
||||||
|
found.append((str(server.relative_to(ROOT)), server.read_text()))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_session_start_surface_states_the_pull():
|
||||||
|
missing = []
|
||||||
|
for path in SESSION_START_SURFACES:
|
||||||
|
assert path.exists(), (
|
||||||
|
f"{path.relative_to(ROOT)} is gone — it was one of the surfaces "
|
||||||
|
f"carrying the load-the-rules instruction. If it moved, update "
|
||||||
|
f"SESSION_START_SURFACES; if it was retired, check the instruction "
|
||||||
|
f"still lives somewhere a fresh session reads."
|
||||||
|
)
|
||||||
|
if PULL not in path.read_text():
|
||||||
|
missing.append(str(path.relative_to(ROOT)))
|
||||||
|
assert not missing, (
|
||||||
|
f"these surfaces no longer tell the agent to call {PULL}(): {missing}. "
|
||||||
|
f"The rules are pull-only and the push is best-effort, so a surface "
|
||||||
|
f"that omits this leaves a session bound by nothing (#2198, #2497)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_surface_names_the_push_without_stating_the_pull():
|
||||||
|
"""The exact shape #2497 took.
|
||||||
|
|
||||||
|
Mentioning the SessionStart hook is fine and often useful. Mentioning it
|
||||||
|
*instead of* the pull is the defect: it reads as "this is handled", and the
|
||||||
|
surface that says so is the one an agent has least reason to doubt.
|
||||||
|
"""
|
||||||
|
offenders = [
|
||||||
|
label for label, text in _all_surfaces()
|
||||||
|
if "SessionStart" in text and PULL not in text
|
||||||
|
]
|
||||||
|
assert not offenders, (
|
||||||
|
f"these surfaces describe the SessionStart push but never state the "
|
||||||
|
f"explicit pull: {offenders}. The push is a delivery optimisation, not "
|
||||||
|
f"the bridge — it can be absent without saying so. Name it if it helps, "
|
||||||
|
f"but say to call {PULL}() regardless."
|
||||||
|
)
|
||||||
@@ -92,3 +92,68 @@ def test_body_calls_write_tool_classifies_correctly():
|
|||||||
json.dumps({"method": "tools/list"}).encode()
|
json.dumps({"method": "tools/list"}).encode()
|
||||||
) is False
|
) is False
|
||||||
assert _body_calls_write_tool(b"not json") is False
|
assert _body_calls_write_tool(b"not json") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_read_shaped_tool_is_explicitly_classified():
|
||||||
|
"""A read-shaped tool must be classified, not left to default-deny.
|
||||||
|
|
||||||
|
`_READ_ONLY_TOOLS` is hand-maintained, and default-deny means a getter
|
||||||
|
omitted from it fails CLOSED — safe, but silent. That is how a read key
|
||||||
|
ended up able to `get_note` and not `get_snippet`, both pure reads of the
|
||||||
|
same table, while design systems were unreachable entirely (#2496). The
|
||||||
|
`find_duplicate_snippets` entry was the tell: someone classified the report
|
||||||
|
and missed the getters beside it.
|
||||||
|
|
||||||
|
This is the same shape as #2476 (record_pulled on three of four getters) —
|
||||||
|
a hand-written enumeration that missed the members added after it. The fix
|
||||||
|
there and here is the same: derive the CANDIDATES, keep the DECISION
|
||||||
|
explicit. Deriving the decision itself would be worse than a stale list —
|
||||||
|
it would make a security boundary follow a naming convention, so any future
|
||||||
|
`get_*` grants itself access.
|
||||||
|
|
||||||
|
So: every tool whose name reads like a read must appear in one of the two
|
||||||
|
sets. Adding a getter then forces a choice at review time.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from scribe.mcp.server import _DELIBERATELY_WRITE_SCOPED, _READ_ONLY_TOOLS
|
||||||
|
|
||||||
|
tools_dir = (pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "scribe" / "mcp" / "tools")
|
||||||
|
read_shaped = {
|
||||||
|
node.name
|
||||||
|
for path in tools_dir.glob("*.py") if path.name != "__init__.py"
|
||||||
|
for node in ast.parse(path.read_text()).body
|
||||||
|
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
|
||||||
|
and node.name.startswith(("get_", "list_", "search", "resolve_",
|
||||||
|
"check_", "find_"))
|
||||||
|
}
|
||||||
|
assert read_shaped, "found no read-shaped tools — the tools package moved"
|
||||||
|
|
||||||
|
unclassified = sorted(read_shaped - _READ_ONLY_TOOLS
|
||||||
|
- _DELIBERATELY_WRITE_SCOPED)
|
||||||
|
assert not unclassified, (
|
||||||
|
f"these read-shaped tools are classified by neither set: {unclassified}. "
|
||||||
|
f"They currently fail closed for read-only keys, silently. Add each to "
|
||||||
|
f"_READ_ONLY_TOOLS if it mutates nothing, or to "
|
||||||
|
f"_DELIBERATELY_WRITE_SCOPED with a comment saying what it writes."
|
||||||
|
)
|
||||||
|
|
||||||
|
# The reverse: a name in either set that no longer exists is a rename or a
|
||||||
|
# deletion, and a stale grant is worth surfacing even though it grants
|
||||||
|
# access to nothing. `enter_project` is the one read tool without a read
|
||||||
|
# prefix, so it is checked against the full tool set, not `read_shaped`.
|
||||||
|
all_tools = {
|
||||||
|
node.name
|
||||||
|
for path in tools_dir.glob("*.py") if path.name != "__init__.py"
|
||||||
|
for node in ast.parse(path.read_text()).body
|
||||||
|
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
|
||||||
|
and not node.name.startswith("_") and node.name != "register"
|
||||||
|
}
|
||||||
|
phantom = sorted((_READ_ONLY_TOOLS | _DELIBERATELY_WRITE_SCOPED) - all_tools)
|
||||||
|
assert not phantom, (
|
||||||
|
f"these names are classified but are not tools: {phantom}. They were "
|
||||||
|
f"renamed or removed — drop them, and check whatever replaced them got "
|
||||||
|
f"classified."
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ def _bind_user():
|
|||||||
_user_id_ctx.reset(token)
|
_user_id_ctx.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_supersession():
|
||||||
|
"""Every note read/write now asks for its supersession relations (#278).
|
||||||
|
|
||||||
|
These are unit tests of the TOOL layer and this job has no database — the
|
||||||
|
same hazard the `_fake_note` comment below records for note 2109. Stubbed
|
||||||
|
to "no relations", which is the state of essentially every note; the
|
||||||
|
relation's own behaviour is covered in test_services_supersession.py, and
|
||||||
|
the attachment is covered explicitly below.
|
||||||
|
"""
|
||||||
|
with patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
|
||||||
|
AsyncMock(return_value={"supersedes": [], "superseded_by": []})):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def _fake_note(*, user_id: int = 7, **overrides) -> MagicMock:
|
def _fake_note(*, user_id: int = 7, **overrides) -> MagicMock:
|
||||||
note = MagicMock()
|
note = MagicMock()
|
||||||
base = {"id": 1, "title": "t", "body": "b", "tags": [], "is_task": False}
|
base = {"id": 1, "title": "t", "body": "b", "tags": [], "is_task": False}
|
||||||
@@ -123,6 +138,30 @@ async def test_get_note_returns_dict():
|
|||||||
assert out["title"] == "found"
|
assert out["title"] == "found"
|
||||||
# Own record: no provenance noise.
|
# Own record: no provenance noise.
|
||||||
assert "shared" not in out
|
assert "shared" not in out
|
||||||
|
# No supersession relations: both keys ABSENT, not present-and-empty. A
|
||||||
|
# field that always says nothing trains readers to skip fields (#2483).
|
||||||
|
assert "supersedes" not in out
|
||||||
|
assert "superseded_by" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_note_warns_in_words_when_a_later_note_overtook_it():
|
||||||
|
"""The label is the point, not the ids.
|
||||||
|
|
||||||
|
A superseded record still surfaces — supersession demotes, it never hides —
|
||||||
|
so an agent WILL read stale material. Handing it over with only a numeric
|
||||||
|
field to notice would be worse than not surfacing it, because the reader
|
||||||
|
acts on it confidently either way.
|
||||||
|
"""
|
||||||
|
fake = _fake_note(id=5, title="June's answer")
|
||||||
|
with patch("scribe.mcp.tools.notes.notes_svc.get_note_for_user",
|
||||||
|
AsyncMock(return_value=(fake, "owner"))), \
|
||||||
|
patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
|
||||||
|
AsyncMock(return_value={"supersedes": [], "superseded_by": [9]})):
|
||||||
|
out = await get_note(note_id=5)
|
||||||
|
assert out["superseded_by"] == [9]
|
||||||
|
assert "superseded_note" in out
|
||||||
|
assert "before acting" in out["superseded_note"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ async def test_create_process_requires_title_and_body():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_process_sets_note_type():
|
async def test_create_process_sets_note_type():
|
||||||
created = _fake_note()
|
created = _fake_note()
|
||||||
with patch("scribe.services.notes.create_note",
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.services.notes.create_note",
|
||||||
AsyncMock(return_value=created)) as mock_create:
|
AsyncMock(return_value=created)) as mock_create:
|
||||||
from scribe.mcp.tools.processes import create_process
|
from scribe.mcp.tools.processes import create_process
|
||||||
out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"])
|
out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"])
|
||||||
@@ -47,6 +49,39 @@ async def test_create_process_sets_note_type():
|
|||||||
assert mock_create.await_args.kwargs["title"] == "Drift Audit"
|
assert mock_create.await_args.kwargs["title"] == "Drift Audit"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_process_blocks_a_near_duplicate():
|
||||||
|
"""The gate matters more for processes than for other kinds: each one becomes
|
||||||
|
a skill file that auto-surfaces, so two near-identical procedures don't just
|
||||||
|
bloat the corpus — they compete to be followed (#2250)."""
|
||||||
|
from scribe.services.dedup import DuplicateMatch
|
||||||
|
|
||||||
|
# The real dataclass, not a MagicMock: a mock answers every attribute, so it
|
||||||
|
# would pass whatever field names this test happened to guess and prove
|
||||||
|
# nothing about the payload the tool actually returns.
|
||||||
|
match = DuplicateMatch(id=42, title="Drift Audit", similarity=0.94, reason="semantic")
|
||||||
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock(return_value=match)), \
|
||||||
|
patch("scribe.services.notes.create_note", AsyncMock()) as mock_create:
|
||||||
|
from scribe.mcp.tools.processes import create_process
|
||||||
|
out = await create_process(title="Drift Audit", body="the prompt")
|
||||||
|
assert out["duplicate"] is True
|
||||||
|
assert out["existing_id"] == 42
|
||||||
|
mock_create.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_process_force_bypasses_the_gate():
|
||||||
|
created = _fake_note()
|
||||||
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock()) as find_mock, \
|
||||||
|
patch("scribe.services.notes.create_note",
|
||||||
|
AsyncMock(return_value=created)):
|
||||||
|
from scribe.mcp.tools.processes import create_process
|
||||||
|
await create_process(title="Drift Audit", body="the prompt", force=True)
|
||||||
|
find_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_process_returns_body_and_candidates():
|
async def test_get_process_returns_body_and_candidates():
|
||||||
note = _fake_note(id=7)
|
note = _fake_note(id=7)
|
||||||
@@ -117,7 +152,42 @@ async def test_update_process_refuses_a_read_only_share_with_the_reason():
|
|||||||
mock_update.assert_not_awaited()
|
mock_update.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_register_attaches_four_tools():
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_process_trashes_it_recoverably():
|
||||||
|
proc = _fake_note(id=4)
|
||||||
|
proc.deleted_at = None
|
||||||
|
with patch("scribe.services.notes.get_note_for_user",
|
||||||
|
AsyncMock(return_value=(proc, "owner"))), \
|
||||||
|
patch("scribe.mcp.tools.processes.trash_svc.delete",
|
||||||
|
AsyncMock(return_value="batch-1")) as mock_delete:
|
||||||
|
from scribe.mcp.tools.processes import delete_process
|
||||||
|
out = await delete_process(process_id=4)
|
||||||
|
assert out["deleted_batch_id"] == "batch-1"
|
||||||
|
# Through the trash, not a hard delete — restorable like every other kind.
|
||||||
|
assert mock_delete.await_args.args[1] == "note"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_process_refuses_a_plain_note():
|
||||||
|
"""This tool is reached for by name. Letting it trash an ordinary note
|
||||||
|
because the id happened to resolve would be a destructive action taken on a
|
||||||
|
mistyped argument."""
|
||||||
|
plain = _fake_note(id=3, note_type="note")
|
||||||
|
plain.deleted_at = None
|
||||||
|
with patch("scribe.services.notes.get_note_for_user",
|
||||||
|
AsyncMock(return_value=(plain, "owner"))), \
|
||||||
|
patch("scribe.mcp.tools.processes.trash_svc.delete", AsyncMock()) as mock_delete:
|
||||||
|
from scribe.mcp.tools.processes import delete_process
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await delete_process(process_id=3)
|
||||||
|
mock_delete.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_attaches_every_tool_in_the_module():
|
||||||
|
"""Derived from the module rather than listed: a tool written but never
|
||||||
|
registered is invisible to an agent, and nothing else would notice."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
from scribe.mcp.tools import processes
|
from scribe.mcp.tools import processes
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
|
|
||||||
@@ -129,6 +199,8 @@ def test_register_attaches_four_tools():
|
|||||||
return deco
|
return deco
|
||||||
|
|
||||||
processes.register(FakeMcp())
|
processes.register(FakeMcp())
|
||||||
assert set(names) == {
|
public = {
|
||||||
"list_processes", "create_process", "get_process", "update_process",
|
name for name, obj in vars(processes).items()
|
||||||
|
if inspect.iscoroutinefunction(obj) and not name.startswith("_")
|
||||||
}
|
}
|
||||||
|
assert set(names) == public
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ def _bind_user():
|
|||||||
_user_id_ctx.reset(token)
|
_user_id_ctx.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_systems():
|
||||||
|
"""enter_project now surfaces the project's Systems as the tagging
|
||||||
|
vocabulary (#2546). These are tool-layer unit tests with no database, so
|
||||||
|
the lookup is stubbed to the common case — a project with none. The
|
||||||
|
populated shape is asserted in its own test below.
|
||||||
|
"""
|
||||||
|
with patch("scribe.mcp.tools.projects.systems_svc.list_systems",
|
||||||
|
AsyncMock(return_value=[])):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
|
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
|
||||||
p = MagicMock()
|
p = MagicMock()
|
||||||
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
||||||
@@ -184,6 +196,48 @@ async def test_enter_project_composes_full_context():
|
|||||||
# absent. A caller that has to distinguish "no key" from "no system" will
|
# absent. A caller that has to distinguish "no key" from "no system" will
|
||||||
# eventually get it wrong.
|
# eventually get it wrong.
|
||||||
assert out["design_system"] is None
|
assert out["design_system"] is None
|
||||||
|
# No Systems -> present-and-empty, NOT absent: this key is the tagging
|
||||||
|
# vocabulary, and "this project has no named areas yet" is information the
|
||||||
|
# create-the-System instruction acts on.
|
||||||
|
assert out["systems"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enter_project_surfaces_the_systems_vocabulary():
|
||||||
|
"""The tagging instruction is only executable if the vocabulary is in
|
||||||
|
front of the agent when it writes. It never was, and tagging stopped three
|
||||||
|
days after the feature landed — one System, nothing tagged since July 28
|
||||||
|
(#2546's audit). Trimmed to id/name/first-line: it rides on every session
|
||||||
|
start, and the full charter is get_system's job."""
|
||||||
|
p = _fake_project(id=5)
|
||||||
|
sys1 = MagicMock()
|
||||||
|
sys1.id = 3
|
||||||
|
sys1.name = "retrieval"
|
||||||
|
sys1.description = "Embeddings, ranking, auto-inject.\nLong detail below."
|
||||||
|
|
||||||
|
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.systems_svc.list_systems",
|
||||||
|
AsyncMock(return_value=[sys1]),
|
||||||
|
):
|
||||||
|
out = await enter_project(project_id=5)
|
||||||
|
|
||||||
|
assert out["systems"] == [
|
||||||
|
{"id": 3, "name": "retrieval",
|
||||||
|
"description": "Embeddings, ranking, auto-inject."}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user