Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2be27935 | ||
|
|
5b824c1626 | ||
|
|
841506b10c | ||
|
|
c34454b840 | ||
|
|
bd60d679d9 | ||
|
|
174ec8af46 | ||
|
|
4852b0d3df | ||
|
|
22f907c44d |
@@ -0,0 +1,44 @@
|
|||||||
|
"""retire the design_rulebook_id setting
|
||||||
|
|
||||||
|
Revision ID: 0075
|
||||||
|
Revises: 0074
|
||||||
|
Create Date: 2026-08-03
|
||||||
|
|
||||||
|
The /design panel used to compare a design RULEBOOK's prose claims against the
|
||||||
|
live tokens. That rulebook was imported into the design system and retired, and
|
||||||
|
the panel now compares the running app against the design system it was
|
||||||
|
generated from (#2419). Its designation moved with it:
|
||||||
|
|
||||||
|
design_rulebook_id -> ui_design_system_id
|
||||||
|
|
||||||
|
Nothing reads the old key any more, so this deletes the row rather than leaving
|
||||||
|
an inert one behind (rule #22 — remove the old path, including the setting it
|
||||||
|
read from). The values are not translatable: a rulebook id and a design system
|
||||||
|
id are ids in different tables, and guessing a mapping would silently point the
|
||||||
|
new panel at the wrong system.
|
||||||
|
|
||||||
|
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 operator re-designates in Settings.
|
||||||
|
"""
|
||||||
|
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 = 'design_rulebook_id'")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -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 +1,15 @@
|
|||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import type { ExpectationResponse } from "@/utils/designDrift";
|
|
||||||
|
|
||||||
/** Checkable claims from the rulebook this install designated as its design system.
|
export interface UiSystemResponse {
|
||||||
|
design_system_id: number | null;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The design system this install says its own UI is built from.
|
||||||
*
|
*
|
||||||
* `rulebook_id: null` means none has been designated — the normal state for a
|
* Both nulls means none designated — the normal state for a fresh install, not
|
||||||
* fresh install, not an error. The caller shows an explanatory empty state. */
|
* an error; the caller shows an explanatory empty state. An id with a null
|
||||||
export const fetchDesignExpectations = () =>
|
* title means designated but deleted or unreadable, which is a
|
||||||
apiGet<ExpectationResponse>("/api/design/expectations");
|
* misconfiguration and must not be rendered as "none". */
|
||||||
|
export const fetchUiDesignSystem = () =>
|
||||||
|
apiGet<UiSystemResponse>("/api/design/ui-system");
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,7 +125,7 @@ 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;
|
||||||
}
|
}
|
||||||
.nav {
|
.nav {
|
||||||
@@ -197,8 +193,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 */
|
||||||
@@ -346,6 +342,22 @@ router.afterEach(() => {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The pill bar is absolutely centred, so when the header runs out of room it
|
||||||
|
OVERLAPS the brand and the utility cluster rather than pushing them — nothing
|
||||||
|
wraps, it just collides. Six primary links reach that point sooner than five
|
||||||
|
did, so reclaim the width here instead of leaving one out of the bar.
|
||||||
|
The wordmark goes first: the logo beside it says the same thing and is still
|
||||||
|
the link home. */
|
||||||
|
@media (max-width: 1150px) {
|
||||||
|
.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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -445,7 +445,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 +483,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 */
|
||||||
|
|||||||
@@ -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,7 +46,7 @@ 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 {
|
||||||
@@ -60,7 +60,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,13 +337,13 @@ 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; }
|
||||||
@@ -355,7 +355,7 @@ select {
|
|||||||
}
|
}
|
||||||
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 +363,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 +377,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 +400,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 +426,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,18 @@ 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 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; }
|
||||||
|
|||||||
+167
-101
@@ -1,58 +1,80 @@
|
|||||||
/**
|
/**
|
||||||
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
* Agreement — does the running app match the design system it was built from?
|
||||||
*
|
*
|
||||||
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
* This panel used to compare a design RULEBOOK's prose claims against the live
|
||||||
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
* tokens. That rulebook was retired into the design system on 2026-08-01, and
|
||||||
* can assert on it. What's left here is set arithmetic over live token values,
|
* the feature spent two days rendering a reassuring empty state instead of
|
||||||
* which is the one thing the browser knows and the server doesn't.
|
* failing (#2419). Its replacement is not the same question re-aimed: comparing
|
||||||
|
* a design system against a stylesheet generated from that same design system
|
||||||
|
* would be a tautology.
|
||||||
|
*
|
||||||
|
* The question that survives is the one no server can answer. A sheet still has
|
||||||
|
* to be LOADED and APPLIED, and until now nothing checked that it was. Three
|
||||||
|
* failures live in that gap:
|
||||||
|
*
|
||||||
|
* absent the app has no such token at all — the sheet was never
|
||||||
|
* regenerated after the record changed, or never loaded
|
||||||
|
* differs the app has the token with another value — a stale copy of the
|
||||||
|
* sheet, or a later rule that overrode it
|
||||||
|
* unrecorded the app declares a token in the record's own family that the
|
||||||
|
* record has never heard of — hand-editing that outlived its reason
|
||||||
*
|
*
|
||||||
* SCOPE, and it is a real limit rather than an omission. This compares the
|
* 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
|
* record against the TOKENS. A literal hardcoded in a component where a token
|
||||||
* literal hardcoded in a component where a token should be referenced (#2275,
|
* should be referenced is invisible here, because the drift isn't in the tokens
|
||||||
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
* at all — that check has the component sources and belongs in CI (#2277).
|
||||||
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
* Saying so in the panel matters: a report that silently omits a category
|
||||||
*
|
* invites the reader to conclude the category is clean.
|
||||||
* 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";
|
import type { DesignToken } from "@/utils/designTokens";
|
||||||
|
|
||||||
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
/** The base mode's key in a token's `value_by_mode`, mirroring services/design_stylesheet. */
|
||||||
|
export const BASE_MODE = "base";
|
||||||
|
|
||||||
export interface Expectation {
|
/** One token as the RECORD has it, already narrowed to the mode being checked. */
|
||||||
kind: ExpectationKind;
|
export interface RecordedToken {
|
||||||
|
name: string;
|
||||||
|
/** Declared value for this mode, or "" when the role is named but unvalued. */
|
||||||
value: string;
|
value: string;
|
||||||
rule_id: number;
|
groupName: string | null;
|
||||||
rule_title: string;
|
|
||||||
context: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExpectationResponse {
|
export type AgreementStatus = "ok" | "absent" | "differs" | "unrecorded";
|
||||||
rulebook_id: number | null;
|
|
||||||
expectations: Expectation[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FindingStatus = "ok" | "missing" | "violated";
|
export interface Agreement {
|
||||||
|
name: string;
|
||||||
export interface Finding {
|
groupName: string | null;
|
||||||
expectation: Expectation;
|
/** What the record declares, resolved. Empty for an `unrecorded` row. */
|
||||||
status: FindingStatus;
|
recorded: string;
|
||||||
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
/** What the browser resolved. Empty for an `absent` row. */
|
||||||
matches: string[];
|
live: string;
|
||||||
|
status: AgreementStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise a colour for comparison — the client-side twin of
|
* Which declared value applies when the page is in `mode`.
|
||||||
* `normalize_hex` in services/design_system.py.
|
|
||||||
*
|
*
|
||||||
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
* Falls back to base, which is the storage model rather than a convenience: a
|
||||||
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
||||||
* spellings of one colour, and a comparison that misses any of them under-reports
|
* mode is not missing — it is inheriting, exactly as the sheet has it.
|
||||||
* 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 valueForMode(
|
||||||
|
valueByMode: Record<string, string>,
|
||||||
|
mode: string,
|
||||||
|
): string {
|
||||||
|
const own = valueByMode[mode];
|
||||||
|
if (own !== undefined && own !== "") return own;
|
||||||
|
return valueByMode[BASE_MODE] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a colour for comparison.
|
||||||
|
*
|
||||||
|
* A record writes `#FFFFFF`, a sheet writes `#fff`, and
|
||||||
|
* getComputedStyle can hand back `rgb(255, 255, 255)` — three spellings of one
|
||||||
|
* colour, and a comparison that misses any of them over-reports drift, which is
|
||||||
|
* the failure that gets a panel ignored. The rgb() case is browser-specific and
|
||||||
|
* therefore has no server-side counterpart, which is exactly why it is here.
|
||||||
*/
|
*/
|
||||||
export function normalizeColour(value: string): string | null {
|
export function normalizeColour(value: string): string | null {
|
||||||
const raw = value.trim().toLowerCase();
|
const raw = value.trim().toLowerCase();
|
||||||
@@ -66,7 +88,9 @@ export function normalizeColour(value: string): string | null {
|
|||||||
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
|
// getComputedStyle reports real colour properties as rgb()/rgba(), never as
|
||||||
|
// authored. Custom properties are token streams and usually come back as
|
||||||
|
// written, so this arm is insurance rather than the common path.
|
||||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||||
if (rgb) {
|
if (rgb) {
|
||||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||||
@@ -84,86 +108,128 @@ export function normalizeColour(value: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
/**
|
||||||
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
* Compare two CSS values for sameness, not for identical text.
|
||||||
const index = new Map<string, string[]>();
|
*
|
||||||
for (const token of tokens) {
|
* Whitespace inside a compound value is not meaningful — `0 2px 10px` and
|
||||||
const colour = normalizeColour(token.value);
|
* `0 2px 10px` are one shadow — and neither is case, since a custom property
|
||||||
if (!colour) continue;
|
* carries no font names or content strings that would be changed by folding it.
|
||||||
const names = index.get(colour);
|
* Colours go through the normaliser first so spelling differences don't read as
|
||||||
if (names) names.push(token.name);
|
* drift.
|
||||||
else index.set(colour, [token.name]);
|
*/
|
||||||
}
|
export function sameValue(a: string, b: string): boolean {
|
||||||
return index;
|
const canon = (v: string) => {
|
||||||
|
const trimmed = v.trim();
|
||||||
|
return normalizeColour(trimmed) ?? trimmed.replace(/\s+/g, " ").toLowerCase();
|
||||||
|
};
|
||||||
|
return canon(a) === canon(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare claims against the live tokens.
|
* The families the record claims, as name prefixes.
|
||||||
*
|
*
|
||||||
* A `token` claim asks whether a custom property of that name exists.
|
* Used to decide which live tokens count as `unrecorded`. An app's stylesheet
|
||||||
* A `color` claim asks whether any token resolves to that value.
|
* legitimately carries names the record never owned — Scribe's own sheet keeps
|
||||||
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
* a `--color-*` alias layer over the design system's `--fs-*` block — and
|
||||||
|
* reporting those as drift would bury the real findings under a compatibility
|
||||||
|
* shim. So the record is treated as owning a FAMILY, identified by the prefix
|
||||||
|
* up to the first separator, and nothing outside it is judged.
|
||||||
|
*
|
||||||
|
* Derived from the data rather than configured, because the prefix is the
|
||||||
|
* install's choice (see design_starter_roles) and hardcoding one would put a
|
||||||
|
* single operator's naming into every install (rule #115).
|
||||||
*/
|
*/
|
||||||
export function compareToTokens(
|
export function recordedFamilies(names: Iterable<string>): string[] {
|
||||||
expectations: Expectation[],
|
const families = new Set<string>();
|
||||||
tokens: DesignToken[],
|
for (const name of names) {
|
||||||
): Finding[] {
|
const match = /^(--[A-Za-z0-9]+-)/.exec(name);
|
||||||
const names = new Set(tokens.map((t) => t.name));
|
if (match) families.add(match[1]);
|
||||||
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] : [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return [...families];
|
||||||
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 {
|
/**
|
||||||
|
* Compare the record against the running app.
|
||||||
|
*
|
||||||
|
* `resolved` is the record's declared values after the browser has substituted
|
||||||
|
* `var()` in them (see `resolveDeclared`) — the same treatment the live values
|
||||||
|
* already received, which is what makes the two comparable.
|
||||||
|
*
|
||||||
|
* Tokens the record names but has no value for are SKIPPED, not reported. A
|
||||||
|
* valueless token is a role awaiting a decision, and the stylesheet already
|
||||||
|
* reports those under `valueless`; counting them as drift would mean a system
|
||||||
|
* created with starter roles opens this panel red on day one.
|
||||||
|
*/
|
||||||
|
export function compareToApp(
|
||||||
|
recorded: RecordedToken[],
|
||||||
|
resolved: Map<string, string>,
|
||||||
|
live: DesignToken[],
|
||||||
|
): Agreement[] {
|
||||||
|
const liveByName = new Map<string, string>();
|
||||||
|
for (const token of live) liveByName.set(token.name, token.value);
|
||||||
|
const out: Agreement[] = [];
|
||||||
|
|
||||||
|
for (const token of recorded) {
|
||||||
|
if (!token.value) continue;
|
||||||
|
const declared = resolved.get(token.name) ?? token.value;
|
||||||
|
const actual = liveByName.get(token.name) ?? "";
|
||||||
|
out.push({
|
||||||
|
name: token.name,
|
||||||
|
groupName: token.groupName,
|
||||||
|
recorded: declared,
|
||||||
|
live: actual,
|
||||||
|
status: !actual ? "absent" : sameValue(declared, actual) ? "ok" : "differs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = new Set(recorded.map((t) => t.name));
|
||||||
|
const families = recordedFamilies(known);
|
||||||
|
for (const token of live) {
|
||||||
|
if (known.has(token.name)) continue;
|
||||||
|
if (!families.some((prefix) => token.name.startsWith(prefix))) continue;
|
||||||
|
out.push({
|
||||||
|
name: token.name,
|
||||||
|
groupName: null,
|
||||||
|
recorded: "",
|
||||||
|
live: token.value,
|
||||||
|
status: "unrecorded",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgreementSummary {
|
||||||
ok: number;
|
ok: number;
|
||||||
missing: number;
|
absent: number;
|
||||||
violated: number;
|
differs: number;
|
||||||
|
unrecorded: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarise(findings: Finding[]): DriftSummary {
|
export function summarise(agreements: Agreement[]): AgreementSummary {
|
||||||
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
const summary: AgreementSummary = {
|
||||||
for (const finding of findings) summary[finding.status] += 1;
|
ok: 0, absent: 0, differs: 0, unrecorded: 0, total: agreements.length,
|
||||||
|
};
|
||||||
|
for (const a of agreements) summary[a.status] += 1;
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Findings worth leading with.
|
* Findings worth leading with.
|
||||||
*
|
*
|
||||||
* A panel that opens with every row gets closed and never reopened — the same
|
* A panel that opens with every row gets closed and never reopened. `absent`
|
||||||
* principle the auto-inject menu is built on: a short list that gets read beats
|
* leads because it is the one status that can mean the whole sheet is missing;
|
||||||
* a complete one that doesn't. Violations first (something is actively wrong),
|
* `differs` next, because a wrong value is being rendered right now;
|
||||||
* then missing (something was never built), and `ok` rows are not "findings" at
|
* `unrecorded` last, since it is a bookkeeping gap rather than a visible fault.
|
||||||
* all — they belong behind an expansion.
|
* `ok` rows are not findings at all and belong behind an expansion.
|
||||||
*/
|
*/
|
||||||
export function rankFindings(findings: Finding[]): Finding[] {
|
export function rankAgreements(agreements: Agreement[]): Agreement[] {
|
||||||
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
const order: Record<AgreementStatus, number> = {
|
||||||
return [...findings].sort((a, b) => {
|
absent: 0, differs: 1, unrecorded: 2, ok: 3,
|
||||||
|
};
|
||||||
|
return [...agreements].sort((a, b) => {
|
||||||
const byStatus = order[a.status] - order[b.status];
|
const byStatus = order[a.status] - order[b.status];
|
||||||
if (byStatus !== 0) return byStatus;
|
return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name);
|
||||||
return a.expectation.rule_id - b.expectation.rule_id;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||||
*
|
*
|
||||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
* Foundation for the design explorer (milestone #251): the gallery renders
|
||||||
* against these, and the drift panel compares them to the design rulebook.
|
* against these, and the agreement panel compares them to the design system the
|
||||||
|
* install says its UI is built from (#2419).
|
||||||
*
|
*
|
||||||
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||||
@@ -42,8 +43,8 @@ export interface DesignToken {
|
|||||||
group: TokenGroup;
|
group: TokenGroup;
|
||||||
/** Resolved value in the requested context, straight from the browser. */
|
/** Resolved value in the requested context, straight from the browser. */
|
||||||
value: string;
|
value: string;
|
||||||
/** True when the declaration appears inside the dark block in source. */
|
/** True when the token is re-declared under a mode selector in source. */
|
||||||
overriddenInDark: boolean;
|
modeAware: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,8 +62,21 @@ const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
|||||||
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||||
|
|
||||||
/** The dark block's selector, as written in theme.css. */
|
/**
|
||||||
const DARK_SELECTOR = '[data-theme="dark"]';
|
* Any mode-override block, whichever mode it names.
|
||||||
|
*
|
||||||
|
* This used to hardcode `[data-theme="dark"]`, and that stopped being true the
|
||||||
|
* day the sheet went dark-first: `:root` now carries dark and
|
||||||
|
* `[data-theme="light"]` overrides it. The hardcoded selector matched nothing,
|
||||||
|
* `overriddenInDark` was false for all 186 tokens, and the "mode-aware" flag
|
||||||
|
* silently vanished from the gallery — a UI that kept rendering, wrongly.
|
||||||
|
*
|
||||||
|
* Matching the SHAPE rather than one mode name is what makes that unrepeatable,
|
||||||
|
* and it is also the only version that holds for an install whose modes aren't
|
||||||
|
* light and dark (rule #115). `selector_for_mode` in services/design_stylesheet
|
||||||
|
* emits exactly this shape, so the two ends agree by construction.
|
||||||
|
*/
|
||||||
|
const MODE_SELECTOR = /\[data-theme=["']?[\w-]+["']?\]/g;
|
||||||
|
|
||||||
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||||
["--color-", "color"],
|
["--color-", "color"],
|
||||||
@@ -96,15 +110,21 @@ export function tokenNames(css: string = themeCss): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
/** The subset re-declared under a mode selector — i.e. tokens that change with mode. */
|
||||||
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
export function modeOverriddenNames(css: string = themeCss): Set<string> {
|
||||||
const bare = css.replace(COMMENT, "");
|
const bare = css.replace(COMMENT, "");
|
||||||
const start = bare.indexOf(DARK_SELECTOR);
|
const names = new Set<string>();
|
||||||
if (start === -1) return new Set();
|
for (const match of bare.matchAll(MODE_SELECTOR)) {
|
||||||
const open = bare.indexOf("{", start);
|
if (match.index === undefined) continue;
|
||||||
|
const open = bare.indexOf("{", match.index + match[0].length);
|
||||||
|
if (open === -1) continue;
|
||||||
|
// A custom-property block is flat, so the first `}` closes it. Anything
|
||||||
|
// nested would be a rule, not a declaration, and has no tokens to find.
|
||||||
const close = bare.indexOf("}", open);
|
const close = bare.indexOf("}", open);
|
||||||
if (open === -1 || close === -1) return new Set();
|
if (close === -1) continue;
|
||||||
return new Set(tokenNames(bare.slice(open, close)));
|
for (const name of tokenNames(bare.slice(open, close))) names.add(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,15 +136,46 @@ export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
|||||||
*/
|
*/
|
||||||
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||||
const computed = getComputedStyle(host);
|
const computed = getComputedStyle(host);
|
||||||
const dark = darkOverriddenNames();
|
const modal = modeOverriddenNames();
|
||||||
return tokenNames().map((name) => ({
|
return tokenNames().map((name) => ({
|
||||||
name,
|
name,
|
||||||
group: groupFor(name),
|
group: groupFor(name),
|
||||||
value: computed.getPropertyValue(name).trim(),
|
value: computed.getPropertyValue(name).trim(),
|
||||||
overriddenInDark: dark.has(name),
|
modeAware: modal.has(name),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a set of DECLARED values as the browser would resolve them.
|
||||||
|
*
|
||||||
|
* The point is to compare like with like. A design system records
|
||||||
|
* `color-mix(in srgb, var(--fs-accent) 15%, transparent)`; the browser reports
|
||||||
|
* the same token with `var()` already substituted. Comparing those two strings
|
||||||
|
* marks every derived token as drift, which is a report nobody can read.
|
||||||
|
*
|
||||||
|
* So both sides go through the same engine: set the declarations on an
|
||||||
|
* offscreen probe, read them back, and the substitution is done by the
|
||||||
|
* implementation that will do it for real rather than by a parser of ours.
|
||||||
|
* Undeclared references fall through to the page's own values, which is what
|
||||||
|
* the cascade would do anyway.
|
||||||
|
*/
|
||||||
|
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.style.display = "none";
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read tokens as they would resolve in a given mode, without touching the page.
|
* Read tokens as they would resolve in a given mode, without touching the page.
|
||||||
*
|
*
|
||||||
@@ -132,16 +183,15 @@ export function readTokens(host: Element = document.documentElement): DesignToke
|
|||||||
* mutated to take a reading.
|
* mutated to take a reading.
|
||||||
*
|
*
|
||||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
* 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
|
* function: mode scoping is one-way. Whichever mode the sheet treats as its
|
||||||
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
* BASE lives on `:root` and has no attribute selector of its own, so a probe
|
||||||
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
* can add an overriding mode to a subtree but can never add the base mode back.
|
||||||
* 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
|
* The sheet is dark-first today — `:root` carries dark, `[data-theme="light"]`
|
||||||
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
* overrides it — so light-inside-dark previews work and dark-inside-light ones
|
||||||
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
* return the light values. That direction flipped when the sheet did, which is
|
||||||
* should treat a cross-mode read as best-effort.
|
* why this says "the base mode" rather than naming one: callers should treat a
|
||||||
|
* cross-mode read as best-effort either way.
|
||||||
*/
|
*/
|
||||||
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||||
const probe = document.createElement("div");
|
const probe = document.createElement("div");
|
||||||
@@ -155,13 +205,25 @@ export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tokens grouped by family, preserving source order within each group. */
|
/**
|
||||||
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
|
* Tokens grouped by family, preserving source order within each group.
|
||||||
const out = new Map<TokenGroup, DesignToken[]>();
|
*
|
||||||
|
* `overrides` maps a token name to the group it should sit under, and exists
|
||||||
|
* because the prefix table above can only know the families that shipped with
|
||||||
|
* the product. An install's own design system knows the groups it authored, so
|
||||||
|
* a caller holding the record passes them here rather than the taxonomy growing
|
||||||
|
* one operator's prefixes (rule #115).
|
||||||
|
*/
|
||||||
|
export function groupTokens(
|
||||||
|
tokens: DesignToken[],
|
||||||
|
overrides: Map<string, string> = new Map(),
|
||||||
|
): Map<string, DesignToken[]> {
|
||||||
|
const out = new Map<string, DesignToken[]>();
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
const bucket = out.get(token.group);
|
const group = overrides.get(token.name) ?? token.group;
|
||||||
|
const bucket = out.get(group);
|
||||||
if (bucket) bucket.push(token);
|
if (bucket) bucket.push(token);
|
||||||
else out.set(token.group, [token]);
|
else out.set(group, [token]);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
import DesignTabs from "@/components/DesignTabs.vue";
|
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";
|
||||||
|
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
@@ -148,6 +149,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 +163,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}`);
|
||||||
@@ -540,6 +550,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 +615,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>
|
||||||
|
|||||||
+237
-102
@@ -6,7 +6,7 @@
|
|||||||
* runtime rather than parsed from source, so what you see here is what the app
|
* runtime rather than parsed from source, so what you see here is what the app
|
||||||
* is using right now.
|
* is using right now.
|
||||||
*
|
*
|
||||||
* HONESTY RULE, and the reason parts of this page say "not implemented":
|
* HONESTY RULE, and the reason parts of this page can say "not implemented":
|
||||||
* a gallery of hand-written look-alikes drifts from the app within a month and
|
* 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
|
* 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
|
* every specimen below is either a REAL component imported from the app, or a
|
||||||
@@ -18,87 +18,179 @@
|
|||||||
* `assets/components.css` is now the single definition (#2273), so the
|
* `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
|
* specimens below are the app's real classes — they cannot drift from the app
|
||||||
* without drifting the app itself.
|
* without drifting the app itself.
|
||||||
|
*
|
||||||
|
* The panel at the top applies the same rule one level up: the sheet the app
|
||||||
|
* loads is generated from a design system, and nothing checked that the app
|
||||||
|
* ever loaded it (#2419).
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
import { fetchDesignExpectations } from "@/api/design";
|
import { fetchUiDesignSystem } from "@/api/design";
|
||||||
|
import { fetchResolvedTokens, type ResolvedToken } from "@/api/designSystems";
|
||||||
import DesignTabs from "@/components/DesignTabs.vue";
|
import DesignTabs from "@/components/DesignTabs.vue";
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
|
import { useTheme } from "@/composables/useTheme";
|
||||||
import {
|
import {
|
||||||
compareToTokens,
|
compareToApp,
|
||||||
rankFindings,
|
rankAgreements,
|
||||||
summarise,
|
summarise,
|
||||||
type Expectation,
|
valueForMode,
|
||||||
type Finding,
|
type Agreement,
|
||||||
|
type RecordedToken,
|
||||||
} from "@/utils/designDrift";
|
} from "@/utils/designDrift";
|
||||||
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
import {
|
||||||
|
groupTokens,
|
||||||
|
readTokens,
|
||||||
|
resolveDeclared,
|
||||||
|
type DesignToken,
|
||||||
|
type TokenGroup,
|
||||||
|
} from "@/utils/designTokens";
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const tokens = ref<DesignToken[]>([]);
|
const tokens = ref<DesignToken[]>([]);
|
||||||
const expectations = ref<Expectation[]>([]);
|
|
||||||
const designRulebookId = ref<number | null>(null);
|
/** The designation, and the two ways it can be absent — see api/design.ts. */
|
||||||
const driftLoaded = ref(false);
|
const systemId = ref<number | null>(null);
|
||||||
const showCleanRows = ref(false);
|
const systemTitle = ref<string | null>(null);
|
||||||
|
const checkLoaded = ref(false);
|
||||||
|
const checkFailed = ref(false);
|
||||||
|
const showAgreeingRows = ref(false);
|
||||||
|
|
||||||
|
/** The record, as fetched. Kept raw because the mode narrowing has to be redone
|
||||||
|
* whenever the theme changes — a comparison against the wrong mode's values
|
||||||
|
* would report every mode-aware token as drift. */
|
||||||
|
const records = ref<ResolvedToken[]>([]);
|
||||||
|
const recorded = ref<RecordedToken[]>([]);
|
||||||
|
const resolved = ref<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
/** Narrow the record to the live mode and re-read the app. Idempotent. */
|
||||||
|
function recheck() {
|
||||||
|
tokens.value = readTokens();
|
||||||
|
recorded.value = records.value.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
value: valueForMode(t.value_by_mode, theme.value),
|
||||||
|
groupName: t.group_name,
|
||||||
|
}));
|
||||||
|
const declared = new Map<string, string>();
|
||||||
|
for (const token of recorded.value) {
|
||||||
|
if (token.value) declared.set(token.name, token.value);
|
||||||
|
}
|
||||||
|
resolved.value = resolveDeclared(declared);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
* 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.
|
* which needs the app's stylesheets applied and the theme attribute set.
|
||||||
*/
|
*/
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
tokens.value = readTokens();
|
recheck();
|
||||||
try {
|
try {
|
||||||
const response = await fetchDesignExpectations();
|
const designation = await fetchUiDesignSystem();
|
||||||
designRulebookId.value = response.rulebook_id;
|
systemId.value = designation.design_system_id;
|
||||||
expectations.value = response.expectations;
|
systemTitle.value = designation.title;
|
||||||
|
if (designation.design_system_id !== null && designation.title !== null) {
|
||||||
|
records.value = (await fetchResolvedTokens(designation.design_system_id)).tokens;
|
||||||
|
recheck();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
// The gallery is useful without the panel, so a failed fetch degrades to
|
||||||
// "no drift data" rather than taking the page down with it.
|
// "couldn't check" rather than taking the page down with it. It says so
|
||||||
designRulebookId.value = null;
|
// rather than showing the same empty state as "nothing designated" — that
|
||||||
|
// conflation is what let this feature sit dead (#2419).
|
||||||
|
checkFailed.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
driftLoaded.value = true;
|
checkLoaded.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const findings = computed<Finding[]>(() =>
|
// Toggling the theme on this page is the most likely thing anyone does here.
|
||||||
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
watch(theme, () => recheck());
|
||||||
|
|
||||||
|
const agreements = computed<Agreement[]>(() =>
|
||||||
|
rankAgreements(compareToApp(recorded.value, resolved.value, tokens.value)),
|
||||||
);
|
);
|
||||||
const driftSummary = computed(() => summarise(findings.value));
|
const summary = computed(() => summarise(agreements.value));
|
||||||
const visibleFindings = computed(() =>
|
const visibleAgreements = computed(() =>
|
||||||
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
showAgreeingRows.value
|
||||||
|
? agreements.value
|
||||||
|
: agreements.value.filter((a) => a.status !== "ok"),
|
||||||
|
);
|
||||||
|
/** Named but unvalued roles — skipped by the comparison, worth stating once. */
|
||||||
|
const unvaluedCount = computed(
|
||||||
|
() => records.value.filter((t) => !valueForMode(t.value_by_mode, theme.value)).length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const grouped = computed(() => groupTokens(tokens.value));
|
const STATUS_LABEL: Record<Agreement["status"], string> = {
|
||||||
|
absent: "not in the app",
|
||||||
|
differs: "app renders another value",
|
||||||
|
unrecorded: "not in the record",
|
||||||
|
ok: "agrees",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Gallery ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups come from the RECORD where there is one, and from the name prefix
|
||||||
|
* otherwise. The prefix table in designTokens.ts can only know the families
|
||||||
|
* that shipped with the product; the record knows the ones this install
|
||||||
|
* authored, and grouping 110 tokens under "other" because a table never heard
|
||||||
|
* of their prefix is a gallery nobody reads.
|
||||||
|
*/
|
||||||
|
const recordGroups = computed(() => {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const token of records.value) {
|
||||||
|
if (token.group_name) out.set(token.name, token.group_name);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = computed(() => groupTokens(tokens.value, recordGroups.value));
|
||||||
|
|
||||||
|
/** Order for the prefix-derived groups only; record groups sort by name. */
|
||||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
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)! })),
|
const orderedGroups = computed(() => {
|
||||||
);
|
const fromRecord = new Set(recordGroups.value.values());
|
||||||
|
const rank = (g: string) => {
|
||||||
|
const i = GROUP_ORDER.indexOf(g as TokenGroup);
|
||||||
|
return i < 0 ? GROUP_ORDER.length : i;
|
||||||
|
};
|
||||||
|
const keys = [...grouped.value.keys()].sort((a, b) => {
|
||||||
|
// The record's own groups lead: they are the system, and the prefix-derived
|
||||||
|
// ones are whatever else the sheet happens to carry.
|
||||||
|
const byOrigin = Number(!fromRecord.has(a)) - Number(!fromRecord.has(b));
|
||||||
|
if (byOrigin !== 0) return byOrigin;
|
||||||
|
if (fromRecord.has(a)) return a.localeCompare(b);
|
||||||
|
return rank(a) - rank(b);
|
||||||
|
});
|
||||||
|
return keys.map((group) => ({ group, tokens: grouped.value.get(group)! }));
|
||||||
|
});
|
||||||
|
|
||||||
/** A token whose value reads as a colour is worth showing as a swatch. */
|
/** A token whose value reads as a colour is worth showing as a swatch. */
|
||||||
function isColourish(value: string): boolean {
|
function isColourish(value: string): boolean {
|
||||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
|
/** The button family as shared classes, described by the roles they reach for. */
|
||||||
const RULEBOOK_BUTTONS = [
|
const BUTTON_VARIANTS = [
|
||||||
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
|
{ name: "Primary", spec: "action-primary background, text-on-action label, no border" },
|
||||||
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
|
{ name: "Secondary", spec: "action-secondary background, text-on-action label, no border" },
|
||||||
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
|
{ name: "Ghost", spec: "transparent, primary text, one-pixel border" },
|
||||||
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
|
{ name: "Danger", spec: "action-destructive background, text-on-action label" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TYPE_SPECIMENS = [
|
/**
|
||||||
{ token: "Display", spec: "40 / 500 / Fraunces" },
|
* The type scale, read from whatever size tokens the sheet actually declares.
|
||||||
{ token: "H1", spec: "32 / 500 / Fraunces" },
|
*
|
||||||
{ token: "H2", spec: "24 / 500 / Fraunces" },
|
* Was a hand-written table of nine sizes marked "no token" — true when written,
|
||||||
{ token: "H3", spec: "18 / 500 / Inter" },
|
* and false since the scale was recorded. Deriving it from the live tokens is
|
||||||
{ token: "Body", spec: "15 / 400 / Inter" },
|
* what stops it going stale a second time: if the scale is removed, this
|
||||||
{ token: "Body small", spec: "13 / 400 / Inter" },
|
* section empties out and says so.
|
||||||
{ token: "Label", spec: "12 / 500 / Inter" },
|
*/
|
||||||
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
|
const sizeTokens = computed(() => tokens.value.filter((t) => /-size(-|$)/.test(t.name)));
|
||||||
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -116,78 +208,108 @@ const TYPE_SPECIMENS = [
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
<!-- Does the running app match the design system it was generated from? -->
|
||||||
<section class="design-section">
|
<section class="design-section">
|
||||||
<h2>Rulebook drift</h2>
|
<h2>Agreement with the record</h2>
|
||||||
|
|
||||||
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
<p v-if="!checkLoaded" class="muted">
|
||||||
|
Checking the running app against its design system…
|
||||||
|
</p>
|
||||||
|
|
||||||
<div v-else-if="designRulebookId === null" class="gap-notice">
|
<div v-else-if="checkFailed" class="gap-notice">
|
||||||
<strong>No design rulebook designated.</strong>
|
<strong>The design system couldn't be read.</strong>
|
||||||
<p>
|
<p>
|
||||||
This install hasn't said which rulebook describes its design system, so
|
The gallery below is still live — it comes from the browser, not the
|
||||||
there is nothing to check the tokens against. Designate one in
|
server — but nothing is being compared against the record right now.
|
||||||
<router-link to="/settings">Settings</router-link> and this panel will
|
This is a failure, not an empty result.
|
||||||
compare every colour and token the rulebook names against what the
|
</p>
|
||||||
stylesheet actually resolves to.
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="systemId === null" class="gap-notice">
|
||||||
|
<strong>No design system designated for this UI.</strong>
|
||||||
|
<p>
|
||||||
|
This install hasn't said which design system its own interface is
|
||||||
|
built from, so there is nothing to check the running app against.
|
||||||
|
Designate one in <router-link to="/settings">Settings</router-link>
|
||||||
|
and this panel will report every token the record declares that the
|
||||||
|
app doesn't have, renders differently, or has never heard of.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="systemTitle === null" class="gap-notice">
|
||||||
|
<strong>Design system #{{ systemId }} could not be read.</strong>
|
||||||
|
<p>
|
||||||
|
It is designated in <router-link to="/settings">Settings</router-link>,
|
||||||
|
but it has since been deleted or is no longer shared with you. Nothing
|
||||||
|
is being checked — this is a misconfiguration, not a clean result.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="section-note">
|
<p class="section-note">
|
||||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
<strong>{{ summary.absent }}</strong> not in the app ·
|
||||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
<strong>{{ summary.differs }}</strong> rendering another value ·
|
||||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
{{ summary.unrecorded }} not in the record ·
|
||||||
claims in rulebook #{{ designRulebookId }}.
|
{{ summary.ok }} agreeing, across {{ summary.total }} tokens checked
|
||||||
|
against <strong>{{ systemTitle }}</strong> in
|
||||||
|
<code>{{ theme }}</code> mode.
|
||||||
|
<template v-if="unvaluedCount">
|
||||||
|
{{ unvaluedCount }} recorded {{ unvaluedCount === 1 ? "role has" : "roles have" }}
|
||||||
|
no value yet and {{ unvaluedCount === 1 ? "was" : "were" }} not checked.
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="gap-notice">
|
<div class="gap-notice">
|
||||||
<strong>This compares the rulebook against the TOKENS only.</strong>
|
<strong>This compares the record against the TOKENS only.</strong>
|
||||||
<p>
|
<p>
|
||||||
A value hardcoded in a component — where a token should have been
|
A value hardcoded in a component — where a token should have been
|
||||||
referenced — is invisible here, because the drift isn't in the tokens
|
referenced — is invisible here, because the drift isn't in the
|
||||||
at all. Reading it would mean bundling every component's source into
|
tokens at all. Reading it would mean bundling every component's
|
||||||
the app. That check belongs in CI and is tracked separately, so treat
|
source into the app. That check belongs in CI and is tracked
|
||||||
a clean panel as "the tokens agree", not "the app agrees".
|
separately, so treat a clean panel as "the tokens agree", not "the
|
||||||
|
app agrees".
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!findings.length" class="muted">
|
<p v-if="!agreements.length" class="muted">
|
||||||
The rulebook names nothing this panel can check. Rules that state values
|
The record declares no valued tokens, so there is nothing to compare
|
||||||
— colours, token names — produce claims; rules that state judgement
|
yet. Give its roles values and this panel starts reporting.
|
||||||
don't, by design.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul v-else class="spec-list">
|
<ul v-else class="spec-list">
|
||||||
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
<li v-for="row in visibleAgreements" :key="row.name">
|
||||||
<span class="spec-name">
|
<span class="spec-name">
|
||||||
<span
|
<span
|
||||||
v-if="finding.expectation.kind !== 'token'"
|
v-if="isColourish(row.live || row.recorded)"
|
||||||
class="swatch"
|
class="swatch"
|
||||||
:style="{ background: finding.expectation.value }"
|
:style="{ background: row.live || row.recorded }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<code>{{ finding.expectation.value }}</code>
|
<code>{{ row.name }}</code>
|
||||||
</span>
|
</span>
|
||||||
<span class="spec-detail">
|
<span class="spec-detail">
|
||||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
<template v-if="row.status === 'differs'">
|
||||||
<span v-if="finding.matches.length" class="matches">
|
record <code>{{ row.recorded }}</code> · app
|
||||||
· {{ finding.matches.join(", ") }}
|
<code>{{ row.live }}</code>
|
||||||
</span>
|
</template>
|
||||||
</span>
|
<template v-else-if="row.status === 'unrecorded'">
|
||||||
<span class="spec-status" :class="finding.status">
|
app <code>{{ row.live }}</code>
|
||||||
{{ finding.status === "violated" ? "forbidden, but present"
|
</template>
|
||||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
<template v-else>
|
||||||
|
record <code>{{ row.recorded }}</code>
|
||||||
|
</template>
|
||||||
|
<span v-if="row.groupName" class="matches"> · {{ row.groupName }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="spec-status" :class="row.status">{{ STATUS_LABEL[row.status] }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="findings.length && driftSummary.ok"
|
v-if="agreements.length && summary.ok"
|
||||||
class="reveal-toggle"
|
class="reveal-toggle"
|
||||||
@click="showCleanRows = !showCleanRows"
|
@click="showAgreeingRows = !showAgreeingRows"
|
||||||
>
|
>
|
||||||
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
{{ showAgreeingRows ? "Hide" : "Show" }} the {{ summary.ok }} agreeing tokens
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -249,7 +371,7 @@ const TYPE_SPECIMENS = [
|
|||||||
<button class="btn-primary btn-inline">Inline</button>
|
<button class="btn-primary btn-inline">Inline</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="spec-list">
|
<ul class="spec-list">
|
||||||
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
|
<li v-for="b in BUTTON_VARIANTS" :key="b.name">
|
||||||
<span class="spec-name">{{ b.name }}</span>
|
<span class="spec-name">{{ b.name }}</span>
|
||||||
<span class="spec-detail">{{ b.spec }}</span>
|
<span class="spec-detail">{{ b.spec }}</span>
|
||||||
<span class="spec-status ok">shared</span>
|
<span class="spec-status ok">shared</span>
|
||||||
@@ -257,23 +379,21 @@ const TYPE_SPECIMENS = [
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
<!-- Type: rendered at the sizes the sheet declares, not described. -->
|
||||||
<section class="design-section">
|
<section class="design-section">
|
||||||
<h2>Type scale</h2>
|
<h2>Type scale</h2>
|
||||||
<div class="gap-notice">
|
<div v-if="!sizeTokens.length" class="gap-notice">
|
||||||
<strong>Families load; the scale has no tokens.</strong>
|
<strong>The scale has no tokens.</strong>
|
||||||
<p>
|
<p>
|
||||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
Nothing in the stylesheet declares a size token, so sizes are being set
|
||||||
scale is not expressed as custom properties, so sizes and weights are
|
ad hoc per component. There is nothing live to show here.
|
||||||
set ad hoc per component. Listed here as specification, not as a live
|
|
||||||
specimen — there is nothing to read.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="spec-list">
|
<ul v-else class="spec-list">
|
||||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
<li v-for="t in sizeTokens" :key="t.name">
|
||||||
<span class="spec-name">{{ t.token }}</span>
|
<span class="spec-name" :style="{ fontSize: t.value }">Ag</span>
|
||||||
<span class="spec-detail">{{ t.spec }}</span>
|
<span class="spec-detail"><code>{{ t.name }}</code></span>
|
||||||
<span class="spec-status missing">no token</span>
|
<span class="spec-status ok">{{ t.value || "—" }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -292,7 +412,7 @@ const TYPE_SPECIMENS = [
|
|||||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||||
<code class="token-name">{{ token.name }}</code>
|
<code class="token-name">{{ token.name }}</code>
|
||||||
<code class="token-value">{{ token.value || "—" }}</code>
|
<code class="token-value">{{ token.value || "—" }}</code>
|
||||||
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
<span v-if="token.modeAware" class="token-flag" title="Re-declared under a mode selector">
|
||||||
mode-aware
|
mode-aware
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -430,6 +550,7 @@ const TYPE_SPECIMENS = [
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spec-status {
|
.spec-status {
|
||||||
@@ -438,6 +559,25 @@ const TYPE_SPECIMENS = [
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `absent` is the one status that can mean the whole sheet failed to load, so
|
||||||
|
it carries the same weight as a high-priority finding; `differs` is a wrong
|
||||||
|
value on screen right now; `unrecorded` is bookkeeping and reads quietest. */
|
||||||
|
.spec-status.absent {
|
||||||
|
background: var(--color-priority-high-bg);
|
||||||
|
color: var(--color-priority-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-status.differs {
|
||||||
|
background: var(--color-priority-medium-bg);
|
||||||
|
color: var(--color-priority-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-status.unrecorded {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spec-status.missing {
|
.spec-status.missing {
|
||||||
@@ -445,11 +585,6 @@ const TYPE_SPECIMENS = [
|
|||||||
color: var(--color-priority-medium);
|
color: var(--color-priority-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spec-status.violated {
|
|
||||||
background: var(--color-priority-high-bg);
|
|
||||||
color: var(--color-priority-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spec-status.ok {
|
.spec-status.ok {
|
||||||
background: var(--color-status-done-bg);
|
background: var(--color-status-done-bg);
|
||||||
color: var(--color-status-done);
|
color: var(--color-status-done);
|
||||||
|
|||||||
@@ -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,7 +933,7 @@ 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;
|
||||||
}
|
}
|
||||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -847,9 +847,9 @@ 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;
|
||||||
@@ -932,8 +932,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); }
|
||||||
@@ -1066,7 +1066,7 @@ async function confirmDelete() {
|
|||||||
.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); }
|
||||||
.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 +1133,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;
|
||||||
@@ -1168,8 +1168,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 +1230,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 +1256,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 +1265,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); }
|
||||||
|
|
||||||
@@ -1310,7 +1310,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 @@ 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 { fetchDesignSystems } from "@/api/designSystems";
|
||||||
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 +32,12 @@ 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
|
// Which design system this install's own UI is built from, for the /design
|
||||||
// panel. Empty = none designated, which is the normal state for a fresh install
|
// agreement panel. Empty = none designated, which is the normal state for a
|
||||||
// rather than a misconfiguration — the panel explains itself when unset.
|
// fresh install rather than a misconfiguration — the panel explains itself when
|
||||||
const designRulebookId = ref("");
|
// unset. Replaced design_rulebook_id when the rulebook was retired (#2419).
|
||||||
const designRulebooks = ref<{ id: number; title: string }[]>([]);
|
const uiDesignSystemId = ref("");
|
||||||
|
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||||
const savingKbInject = ref(false);
|
const savingKbInject = ref(false);
|
||||||
const kbInjectSaved = ref(false);
|
const kbInjectSaved = ref(false);
|
||||||
|
|
||||||
@@ -107,8 +108,8 @@ async function saveKbInject() {
|
|||||||
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
|
// Empty string DELETES the setting (see routes/settings.py), which is
|
||||||
// exactly right for "no design rulebook" — absent rather than zero.
|
// exactly right for "no design system" — absent rather than zero.
|
||||||
design_rulebook_id: designRulebookId.value,
|
ui_design_system_id: uiDesignSystemId.value,
|
||||||
});
|
});
|
||||||
kbInjectSaved.value = true;
|
kbInjectSaved.value = true;
|
||||||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||||
@@ -499,13 +500,15 @@ 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 ?? "";
|
uiDesignSystemId.value = allSettings.ui_design_system_id ?? "";
|
||||||
// Best-effort: the picker degrades to "none available" rather than blocking
|
// Best-effort: the picker degrades to "none available" rather than blocking
|
||||||
// the whole settings page if rulebooks can't be listed.
|
// the whole settings page if design systems can't be listed.
|
||||||
try {
|
try {
|
||||||
designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title }));
|
designSystems.value = (await fetchDesignSystems()).design_systems.map(
|
||||||
|
(s) => ({ id: s.id, title: s.title }),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
designRulebooks.value = [];
|
designSystems.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";
|
||||||
@@ -1278,19 +1281,22 @@ function formatUserDate(iso: string): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="design-rulebook">Design-system rulebook</label>
|
<label for="ui-design-system">This app's design system</label>
|
||||||
<select id="design-rulebook" v-model="designRulebookId" class="input" style="max-width: 22rem">
|
<select id="ui-design-system" v-model="uiDesignSystemId" class="input" style="max-width: 22rem">
|
||||||
<option value="">None — don't check for design drift</option>
|
<option value="">None — don't check the interface against a record</option>
|
||||||
<option v-for="rb in designRulebooks" :key="rb.id" :value="String(rb.id)">
|
<option v-for="ds in designSystems" :key="ds.id" :value="String(ds.id)">
|
||||||
{{ rb.title }}
|
{{ ds.title }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
Which rulebook describes how this app should look. Once set, the
|
Which design system this interface is supposed to be built from. Once
|
||||||
<router-link to="/design">Design</router-link> page compares every colour
|
set, the <router-link to="/design">Design</router-link> page compares
|
||||||
and token your rules name against what the stylesheet actually resolves
|
every token the system declares against what the browser has actually
|
||||||
to, and reports where they disagree. Leave it as None if your rules
|
resolved, and reports the ones the app is missing, renders differently,
|
||||||
don't describe a design system — nothing else depends on this.
|
or has never heard of. That catches a stylesheet that was regenerated
|
||||||
|
but never shipped — which the record alone cannot tell you, since the
|
||||||
|
sheet is generated from it. Leave it as None if this install's
|
||||||
|
interface isn't described by one of your design systems.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -2404,7 +2410,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 +2795,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3048,7 +3054,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 +3172,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 +3284,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 +3402,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 +3476,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 +3517,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -874,7 +874,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 +1037,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,14 +1053,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 + re-consolidate button ─────────────────────────── */
|
||||||
@@ -1072,13 +1072,13 @@ useEditorGuards(dirty, save);
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
color: var(--color-text-muted);
|
||||||
background: rgba(99, 102, 241, 0.06);
|
background: rgba(99, 102, 241, 0.06);
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--color-primary);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.auto-summary-banner-editor .auto-summary-icon {
|
.auto-summary-banner-editor .auto-summary-icon {
|
||||||
color: var(--color-primary, #6366f1);
|
color: var(--color-primary);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -561,7 +561,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 +602,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,26 +749,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 {
|
.auto-summary-banner {
|
||||||
@@ -777,11 +777,11 @@ const subTaskProgress = computed(() => {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
color: var(--color-text-muted);
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
.auto-summary-icon {
|
.auto-summary-icon {
|
||||||
color: var(--color-primary, #6366f1);
|
color: var(--color-primary);
|
||||||
font-size: 0.85rem;
|
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.23",
|
||||||
"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:-}}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+31
-15
@@ -1,29 +1,45 @@
|
|||||||
"""Design-system surface — what the rulebook expects of the stylesheet.
|
"""This install's UI surface — which design system it claims to be built from.
|
||||||
|
|
||||||
The client owns the other half of the comparison: it reads live token values from
|
Kept separate from the design-systems CRUD blueprint on purpose. That one is
|
||||||
the browser (see `utils/designTokens.ts`), which is the only place they exist
|
the RECORD: create a system, move a token, read the cascade. This one answers a
|
||||||
resolved. This endpoint supplies the claims to check them against.
|
question about the RUNNING APP, and it exists because those are not the same
|
||||||
|
question. A design system can be a perfect record of a stylesheet the app never
|
||||||
|
loaded.
|
||||||
|
|
||||||
|
The client owns the other half. `utils/designTokens.ts` reads what the browser
|
||||||
|
actually resolved, which is the one thing no server can report, and compares it
|
||||||
|
to what this endpoint's system declares. So the comparison is
|
||||||
|
"does the app agree with its own sheet?" rather than "is the record
|
||||||
|
self-consistent?", which would be a tautology — the sheet is generated from the
|
||||||
|
record (#2419).
|
||||||
"""
|
"""
|
||||||
from quart import Blueprint, jsonify
|
from quart import Blueprint, jsonify
|
||||||
|
|
||||||
from scribe.auth import get_current_user_id, login_required
|
from scribe.auth import get_current_user_id, login_required
|
||||||
from scribe.services import design_rulebook_import as design_svc
|
from scribe.services import design_systems as ds_svc
|
||||||
|
|
||||||
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
|
design_bp = Blueprint("design", __name__, url_prefix="/api/design")
|
||||||
|
|
||||||
|
|
||||||
@design_bp.get("/expectations")
|
@design_bp.get("/ui-system")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_expectations():
|
async def get_ui_system():
|
||||||
"""Checkable claims from the rulebook this install designated as its design system.
|
"""The design system this install designated as the source of its own UI.
|
||||||
|
|
||||||
Returns `{"rulebook_id": int|null, "expectations": [...]}`.
|
Returns `{"design_system_id": int|null, "title": str|null}`.
|
||||||
|
|
||||||
`rulebook_id: null` is the NORMAL case, not an error — an install that has
|
Both nulls is the NORMAL case, not an error — an install that has not
|
||||||
not designated a design rulebook has nothing to compare against, and the
|
designated one has nothing to check the running app against, and the client
|
||||||
client shows an explanatory empty state (rule #115). Distinguishing it from
|
shows an explanatory empty state (rule #115).
|
||||||
"designated but empty" is why the id is returned alongside the list.
|
|
||||||
|
An id with a null title is the third case and the reason the id is returned
|
||||||
|
separately: designated, but deleted or not readable by this caller. Folding
|
||||||
|
that into "none designated" is precisely how a feature comes to render a
|
||||||
|
reassuring empty state forever.
|
||||||
"""
|
"""
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
result = await design_svc.design_expectations(uid)
|
system_id, system = await ds_svc.ui_design_system(uid)
|
||||||
return jsonify(result.as_dict())
|
return jsonify({
|
||||||
|
"design_system_id": system_id,
|
||||||
|
"title": system.title if system else None,
|
||||||
|
})
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,12 +27,17 @@ 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,
|
||||||
resolve_tokens,
|
resolve_tokens,
|
||||||
would_cycle,
|
would_cycle,
|
||||||
)
|
)
|
||||||
|
from scribe.services.settings import get_setting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -79,11 +84,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 +117,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
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +135,40 @@ async def get_design_system(user_id: int, design_system_id: int) -> DesignSystem
|
|||||||
return system
|
return system
|
||||||
|
|
||||||
|
|
||||||
|
# Which design system this install's own UI is built from. A plain setting
|
||||||
|
# rather than a column: no migration, discoverable in the Settings UI (rule
|
||||||
|
# #25), and honest about being a per-install claim rather than a property of the
|
||||||
|
# system — the same system can be the record for an app that never loads it.
|
||||||
|
UI_DESIGN_SYSTEM_SETTING = "ui_design_system_id"
|
||||||
|
|
||||||
|
|
||||||
|
async def ui_design_system(user_id: int) -> tuple[int | None, DesignSystem | None]:
|
||||||
|
"""The design system this install says its UI is built from.
|
||||||
|
|
||||||
|
Returns `(id, system)`. Three outcomes, deliberately distinguishable:
|
||||||
|
|
||||||
|
- `(None, None)` — nothing designated. The NORMAL state for any install but
|
||||||
|
the one that set it up (rule #115), not an error.
|
||||||
|
- `(id, None)` — designated, but gone or not readable by this caller. A
|
||||||
|
misconfiguration worth naming rather than silently degrading to "none",
|
||||||
|
which is exactly the failure that orphaned the panel this feeds (#2419).
|
||||||
|
- `(id, system)` — designated and readable.
|
||||||
|
|
||||||
|
A non-numeric setting value reads as nothing designated: the value is only
|
||||||
|
ever written by a `<select>` of real ids, so garbage here means hand-edited
|
||||||
|
or stale, and refusing to guess is better than raising on a page load.
|
||||||
|
"""
|
||||||
|
raw = (await get_setting(user_id, UI_DESIGN_SYSTEM_SETTING, "")).strip()
|
||||||
|
if not raw:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
system_id = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Ignoring non-numeric %s: %r", UI_DESIGN_SYSTEM_SETTING, raw)
|
||||||
|
return None, None
|
||||||
|
return system_id, await get_design_system(user_id, system_id)
|
||||||
|
|
||||||
|
|
||||||
async def list_design_systems(user_id: int) -> list[DesignSystem]:
|
async def list_design_systems(user_id: int) -> list[DesignSystem]:
|
||||||
"""The caller's own systems, ordered by title. Empty is normal."""
|
"""The caller's own systems, ordered by title. Empty is normal."""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
|||||||
@@ -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,55 @@
|
|||||||
|
"""The /api/design blueprint — this install's UI, not the design-system record.
|
||||||
|
|
||||||
|
Modelled on tests/test_routes_design_systems.py. The URL enumeration is
|
||||||
|
deliberate rather than a pattern match: this blueprint was repointed from
|
||||||
|
`/expectations` to `/ui-system` (#2419), and the failure worth catching is the
|
||||||
|
old rule surviving the change — a route nothing serves any more, answering with
|
||||||
|
whatever the last handler registered on it did.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_blueprint_registered():
|
||||||
|
from scribe.routes.design import design_bp
|
||||||
|
assert design_bp.name == "design"
|
||||||
|
assert design_bp.url_prefix == "/api/design"
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_blueprint_registered_in_app():
|
||||||
|
from scribe.app import create_app
|
||||||
|
app = create_app()
|
||||||
|
assert "design" in app.blueprints
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_blueprint_serves_exactly_one_rule():
|
||||||
|
from scribe.app import create_app
|
||||||
|
app = create_app()
|
||||||
|
rules = {
|
||||||
|
str(r.rule) for r in app.url_map.iter_rules()
|
||||||
|
if r.endpoint.startswith("design.")
|
||||||
|
}
|
||||||
|
assert rules == {"/api/design/ui-system"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_designation_reader_takes_user_id():
|
||||||
|
"""The setting is per-user, so the read must be too (rule #78). A
|
||||||
|
module-level or admin-scoped read would hand one user another's designation."""
|
||||||
|
from scribe.services import design_systems as svc
|
||||||
|
assert "user_id" in inspect.signature(svc.ui_design_system).parameters
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_setting_key_is_the_new_one():
|
||||||
|
"""Named explicitly because the panel silently reading a retired key is the
|
||||||
|
exact shape of the bug this replaced: a feature that renders, and reports
|
||||||
|
nothing, forever."""
|
||||||
|
from scribe.services.design_systems import UI_DESIGN_SYSTEM_SETTING
|
||||||
|
assert UI_DESIGN_SYSTEM_SETTING == "ui_design_system_id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rulebook_expectation_extractor_is_gone():
|
||||||
|
"""It was retired with the rulebook it parsed (#2288, #2419). Left importable
|
||||||
|
it would keep looking like a live path to the next reader."""
|
||||||
|
with pytest.raises(ModuleNotFoundError):
|
||||||
|
import scribe.services.design_rulebook_import # noqa: F401
|
||||||
@@ -44,6 +44,11 @@ def test_every_endpoint_is_reachable_on_the_app():
|
|||||||
}
|
}
|
||||||
assert rules == {
|
assert rules == {
|
||||||
"/api/design-systems",
|
"/api/design-systems",
|
||||||
|
# Static segment, declared before the <int:...> rule reads it — Quart's
|
||||||
|
# int converter will not match "starter-roles", so the two cannot
|
||||||
|
# collide. Worth stating: a static-vs-dynamic sibling on the same prefix
|
||||||
|
# is exactly where a silently-shadowed route hides.
|
||||||
|
"/api/design-systems/starter-roles",
|
||||||
"/api/design-systems/<int:design_system_id>",
|
"/api/design-systems/<int:design_system_id>",
|
||||||
"/api/design-systems/<int:design_system_id>/resolved",
|
"/api/design-systems/<int:design_system_id>/resolved",
|
||||||
"/api/design-systems/<int:design_system_id>/stylesheet",
|
"/api/design-systems/<int:design_system_id>/stylesheet",
|
||||||
@@ -85,7 +90,7 @@ def test_agent_and_web_surfaces_stay_at_parity():
|
|||||||
"resolve_design_system", "update_design_system", "delete_design_system",
|
"resolve_design_system", "update_design_system", "delete_design_system",
|
||||||
"create_design_token", "list_design_tokens", "update_design_token",
|
"create_design_token", "list_design_tokens", "update_design_token",
|
||||||
"delete_design_token", "set_project_design_system",
|
"delete_design_token", "set_project_design_system",
|
||||||
"get_design_system_stylesheet",
|
"get_design_system_stylesheet", "list_starter_role_groups",
|
||||||
):
|
):
|
||||||
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
|
assert callable(getattr(tools, name)), f"MCP tool missing: {name}"
|
||||||
assert callable(getattr(routes, name)), f"REST route missing: {name}"
|
assert callable(getattr(routes, name)), f"REST route missing: {name}"
|
||||||
|
|||||||
Reference in New Issue
Block a user