diff --git a/alembic/versions/0075_retire_design_rulebook_setting.py b/alembic/versions/0075_retire_design_rulebook_setting.py new file mode 100644 index 0000000..6a3250a --- /dev/null +++ b/alembic/versions/0075_retire_design_rulebook_setting.py @@ -0,0 +1,47 @@ +"""retire the two settings that designated a design source for the app itself + +Revision ID: 0075 +Revises: 0074 +Create Date: 2026-08-03 + +Two keys, retired for the same reason a week apart, so they go in one change +rather than one migration each: + + design_rulebook_id which rulebook described how this app should look + ui_design_system_id which design system this app's own UI was built from + +Both named a design source for THE RUNNING INSTALL. The design surface is for +the projects an install tracks, and a project already carries its own pointer +(`projects.design_system_id`) — so an install-wide designation had nothing left +to mean. `ui_design_system_id` was introduced by this same migration's first +draft and never reached a deployed database; it is listed here rather than +undone by an 0076 that would reverse a change nobody ran. + +Deleting settings rows by key is safe in a way dropping a column is not — the +table is free-form key/value, so an install that never designated one simply has +no row to delete. + +Downgrade cannot restore what it never recorded, so it is a no-op rather than a +lie: the pointer lives on the project now, and always did for anyone who set it +there. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0075" +down_revision = "0074" +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.execute( + sa.text( + "DELETE FROM settings " + "WHERE key IN ('design_rulebook_id', 'ui_design_system_id')" + ) + ) + + +def downgrade() -> None: + pass diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 25b887f..044e3d6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -300,7 +300,7 @@ onUnmounted(() => { .shortcuts-overlay { position: fixed; inset: 0; - background: var(--color-overlay, rgba(0, 0, 0, 0.45)); + background: var(--color-overlay); z-index: 9000; display: flex; align-items: center; @@ -309,8 +309,8 @@ onUnmounted(() => { .shortcuts-panel { background: var(--color-bg-card); border: 1px solid var(--color-border); - border-radius: var(--radius-md, 8px); - box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2)); + border-radius: var(--radius-md); + box-shadow: 0 8px 32px var(--color-shadow); width: min(420px, 92vw); overflow: hidden; } diff --git a/frontend/src/api/design.ts b/frontend/src/api/design.ts deleted file mode 100644 index cbdfe09..0000000 --- a/frontend/src/api/design.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { apiGet } from "@/api/client"; -import type { ExpectationResponse } from "@/utils/designDrift"; - -/** Checkable claims from the rulebook this install designated as its design system. - * - * `rulebook_id: null` means none has been designated — the normal state for a - * fresh install, not an error. The caller shows an explanatory empty state. */ -export const fetchDesignExpectations = () => - apiGet("/api/design/expectations"); diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts index 89cb9ba..7641ccf 100644 --- a/frontend/src/api/designSystems.ts +++ b/frontend/src/api/designSystems.ts @@ -74,11 +74,28 @@ export const fetchDesignSystems = () => export const fetchDesignSystem = (id: number) => apiGet(`/api/design-systems/${id}`); +export interface StarterRoleGroup { + group: string; + description: string; + token_count: number; + names: string[]; +} + +/** The starter token ROLES offered at creation — names and purposes, never + * values. A default palette would be one install's taste shipped as product + * (rule #115), so the values are always the operator's to fill. */ +export const listStarterRoleGroups = () => + apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>( + "/api/design-systems/starter-roles", + ); + export const createDesignSystem = (body: { title: string; description?: string; guidance?: string; parent_id?: number | null; + starter_role_groups?: string[]; + token_prefix?: string; }) => apiPost("/api/design-systems", body); /** Omit `parent_id` to leave it alone; send `null` to make the system a family. */ @@ -183,6 +200,14 @@ export interface SnippetCheck { findings: SnippetFinding[]; } -/** Which recorded snippets disagree with this design system's sheet. */ -export const checkSnippets = (id: number) => - apiGet(`/api/design-systems/${id}/snippet-check`); +/** Which recorded snippets disagree with this design system's sheet. + * + * `projectId` narrows to the snippets one project owns — which is how a + * project asks about its OWN code. Omit it to check every project, which is + * the right default from the system's side: a component recorded elsewhere + * still has to use the same tags. */ +export const checkSnippets = (id: number, projectId?: number) => + apiGet( + `/api/design-systems/${id}/snippet-check` + + (projectId ? `?project_id=${projectId}` : ""), + ); diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css index eaecc14..fa0af46 100644 --- a/frontend/src/assets/editor-shared.css +++ b/frontend/src/assets/editor-shared.css @@ -98,8 +98,8 @@ color: var(--fs-text-on-action); } .tag-pill.applied { - background: var(--color-success, #2ecc71); - border-color: var(--color-success, #2ecc71); + background: var(--color-success); + border-color: var(--color-success); color: var(--fs-text-on-action); cursor: default; } diff --git a/frontend/src/assets/prose.css b/frontend/src/assets/prose.css index b3cf455..6056d48 100644 --- a/frontend/src/assets/prose.css +++ b/frontend/src/assets/prose.css @@ -219,7 +219,7 @@ } .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); float: left; height: 0; @@ -234,5 +234,5 @@ } .tiptap-wrapper:focus-within { - box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary)); + box-shadow: var(--focus-ring); } diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index ac3ef6e..5ce2c85 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts"; import { useAuthStore } from "@/stores/auth"; import AppLogo from "@/components/AppLogo.vue"; import NotificationBell from "@/components/NotificationBell.vue"; -import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next"; +import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next"; const { theme, toggleTheme } = useTheme(); const { toggleShortcuts } = useShortcuts(); @@ -50,6 +50,12 @@ router.afterEach(() => { Projects Snippets Rulebooks + + Design @@ -64,16 +70,6 @@ router.afterEach(() => { - - - - - @@ -106,9 +102,9 @@ router.afterEach(() => { Projects Snippets Rulebooks + Design Shared
- Design Trash Settings
@@ -129,7 +125,7 @@ router.afterEach(() => { diff --git a/frontend/src/components/DiffView.vue b/frontend/src/components/DiffView.vue index 95531c0..01fb67f 100644 --- a/frontend/src/components/DiffView.vue +++ b/frontend/src/components/DiffView.vue @@ -110,8 +110,8 @@ function markerFor(type: DiffLine['type']): string { font-weight: 600; } -.diff-summary-ins { color: var(--color-success, #2ecc71); } -.diff-summary-del { color: var(--color-danger, #e74c3c); } +.diff-summary-ins { color: var(--color-success); } +.diff-summary-del { color: var(--color-danger); } .diff-scroll { flex: 1; @@ -136,13 +136,13 @@ function markerFor(type: DiffLine['type']): string { } .diff-delete { - background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent); - color: var(--color-danger, #e74c3c); + background: color-mix(in srgb, var(--color-danger) 12%, transparent); + color: var(--color-danger); } .diff-insert { - background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent); - color: var(--color-success, #2ecc71); + background: color-mix(in srgb, var(--color-success) 12%, transparent); + color: var(--color-success); } .diff-equal { diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index a4ddd89..4b2211f 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -403,12 +403,12 @@ onMounted(loadVersions); font-size: 0.85em; line-height: 1; } -.pin-badge-manual { color: var(--color-primary, #6366f1); } -.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); } +.pin-badge-manual { color: var(--color-primary); } +.pin-badge-auto { color: var(--color-text-muted); } .history-item-label { font-size: 0.72rem; - color: var(--color-primary, #6366f1); + color: var(--color-primary); font-style: italic; margin-top: 0.15rem; overflow: hidden; @@ -430,7 +430,7 @@ onMounted(loadVersions); } .pin-state { font-style: italic; - color: var(--color-text-muted, rgba(255, 255, 255, 0.6)); + color: var(--color-text-muted); flex: 1; min-width: 0; overflow: hidden; @@ -442,13 +442,13 @@ onMounted(loadVersions); font-size: 0.78rem; background: transparent; color: inherit; - border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); + border: 1px solid var(--color-border); border-radius: 999px; cursor: pointer; } .btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) { background: rgba(99, 102, 241, 0.12); - border-color: var(--color-primary, #6366f1); + border-color: var(--color-primary); } .btn-unpin:hover:not(:disabled) { background: rgba(239, 68, 68, 0.10); @@ -463,27 +463,27 @@ onMounted(loadVersions); flex: 1; padding: 0.3rem 0.5rem; font-size: 0.85rem; - background: var(--color-input-bg, rgba(255, 255, 255, 0.03)); - border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); - border-radius: var(--radius-sm, 4px); + background: var(--color-input-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); color: inherit; } .pin-label-input:focus { outline: none; - border-color: var(--color-primary, #6366f1); + border-color: var(--color-primary); } .btn-pin-save, .btn-pin-cancel { padding: 0.3rem 0.7rem; font-size: 0.78rem; background: transparent; color: inherit; - border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); - border-radius: var(--radius-sm, 4px); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; } .btn-pin-save:hover:not(:disabled) { 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:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled { diff --git a/frontend/src/components/InlineAssistPanel.vue b/frontend/src/components/InlineAssistPanel.vue index f43dceb..741a5e0 100644 --- a/frontend/src/components/InlineAssistPanel.vue +++ b/frontend/src/components/InlineAssistPanel.vue @@ -135,8 +135,8 @@ const markers: Record = { flex-shrink: 0; } .iap-btn-cancel:hover { - border-color: var(--color-danger, #e74c3c); - color: var(--color-danger, #e74c3c); + border-color: var(--color-danger); + color: var(--color-danger); } .iap-stream-preview { @@ -191,19 +191,19 @@ const markers: Record = { font-weight: var(--fs-weight-medium); } .iap-btn-accept { - background: var(--color-success, #22c55e); + background: var(--color-success); color: var(--fs-text-on-action); } .iap-btn-accept:hover { opacity: 0.85; } .iap-btn-reject { - background: var(--color-bg-card, var(--color-bg)); + background: var(--color-bg-card); color: var(--color-text-secondary); border: 1px solid var(--color-border); } .iap-btn-reject:hover { - border-color: var(--color-danger, #e74c3c); - color: var(--color-danger, #e74c3c); + border-color: var(--color-danger); + color: var(--color-danger); } /* ── Diff ── */ @@ -226,12 +226,12 @@ const markers: Record = { .iap-diff-equal { color: var(--color-text-muted); } .iap-diff-delete { - background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent); - color: var(--color-danger, #e74c3c); + background: color-mix(in srgb, var(--color-danger) 10%, transparent); + color: var(--color-danger); } .iap-diff-insert { - background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent); - color: var(--color-success, #22c55e); + background: color-mix(in srgb, var(--color-success) 10%, transparent); + color: var(--color-success); } .iap-diff-marker { diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 89a0abf..1e42c90 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -64,11 +64,11 @@ function goEdit() { text-decoration: none; color: inherit; 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; } .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); } @@ -89,7 +89,7 @@ function goEdit() { } .note-card.compact:hover { box-shadow: none; - background: rgba(91, 74, 138, 0.04); + background: color-mix(in srgb, var(--color-primary) 4%, transparent); transform: none; } .note-title-compact { diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue index d24c659..d3318d5 100644 --- a/frontend/src/components/NotificationBell.vue +++ b/frontend/src/components/NotificationBell.vue @@ -81,7 +81,7 @@ onUnmounted(() => { position: absolute; top: -5px; right: -5px; - background: var(--color-danger, #ef4444); + background: var(--color-danger); color: var(--fs-text-on-action); font-size: 0.6rem; font-weight: 700; diff --git a/frontend/src/components/ProjectDesignTab.vue b/frontend/src/components/ProjectDesignTab.vue new file mode 100644 index 0000000..01f9bd4 --- /dev/null +++ b/frontend/src/components/ProjectDesignTab.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/components/RecurrenceEditor.vue b/frontend/src/components/RecurrenceEditor.vue index 092426f..865e054 100644 --- a/frontend/src/components/RecurrenceEditor.vue +++ b/frontend/src/components/RecurrenceEditor.vue @@ -159,7 +159,7 @@ const calendarDayMax = computed(() => .rec-num-input { width: 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); background: var(--color-bg); color: var(--color-text); diff --git a/frontend/src/components/StarterRolePicker.vue b/frontend/src/components/StarterRolePicker.vue new file mode 100644 index 0000000..0011fdb --- /dev/null +++ b/frontend/src/components/StarterRolePicker.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index 1564790..b7b963d 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -445,7 +445,7 @@ async function confirmDelete() { } .action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); } .action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; } -.action-delete:hover { color: var(--color-danger, #e74c3c); } +.action-delete:hover { color: var(--color-danger); } /* ── Empty ────────────────────────────────────────────────────── */ .systems-empty { @@ -483,7 +483,7 @@ async function confirmDelete() { /* ── Modal ────────────────────────────────────────────────────── */ .modal-overlay { 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; z-index: 200; } diff --git a/frontend/src/components/TagInput.vue b/frontend/src/components/TagInput.vue index 7fc8a1f..833f4e4 100644 --- a/frontend/src/components/TagInput.vue +++ b/frontend/src/components/TagInput.vue @@ -222,7 +222,7 @@ function focusInput() { } .tag-autocomplete-item:hover, .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); } diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index d933760..307d9d2 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -112,11 +112,11 @@ function isOverdue(): boolean { text-decoration: none; color: inherit; 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; } .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); } @@ -144,19 +144,19 @@ function isOverdue(): boolean { opacity: 0.8; } .dot-todo { - background: var(--color-status-todo, #94a3b8); - border: 2px solid var(--color-status-todo, #94a3b8); + background: var(--color-status-todo); + border: 2px solid var(--color-status-todo); background: transparent; border: 2px solid var(--color-text-muted); } .dot-in-progress { - background: var(--color-status-in-progress, #3b82f6); + background: var(--color-status-in-progress); } .dot-done { - background: var(--color-status-done, #22c55e); + background: var(--color-status-done); } .dot-cancelled { - background: var(--color-status-cancelled, #6b7280); + background: var(--color-status-cancelled); } .task-title-compact { @@ -190,7 +190,7 @@ function isOverdue(): boolean { flex-shrink: 0; } .due-compact.overdue { - color: var(--color-danger, #e74c3c); + color: var(--color-danger); font-weight: 600; } /* Full layout */ diff --git a/frontend/src/components/TokenPreview.vue b/frontend/src/components/TokenPreview.vue new file mode 100644 index 0000000..4accb5e --- /dev/null +++ b/frontend/src/components/TokenPreview.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue index f44855f..83fe67f 100644 --- a/frontend/src/components/WorkspaceNoteEditor.vue +++ b/frontend/src/components/WorkspaceNoteEditor.vue @@ -463,7 +463,7 @@ defineExpose({ reload: loadProjectNotes }); display: flex; flex-direction: column; overflow: hidden; - background: var(--color-bg-card, var(--color-bg-secondary)); + background: var(--color-bg-card); } .rail-header { diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue index 4f6d305..bc32830 100644 --- a/frontend/src/components/WorkspaceTaskPanel.vue +++ b/frontend/src/components/WorkspaceTaskPanel.vue @@ -387,7 +387,7 @@ defineExpose({ reload: loadAll }); .task-add-input { flex: 1; - background: var(--color-input-bg, var(--color-bg)); + background: var(--color-input-bg); border: 1px solid var(--color-border); border-radius: 5px; padding: 0.28rem 0.5rem; @@ -413,7 +413,7 @@ defineExpose({ reload: loadAll }); gap: 0.4rem; width: 100%; 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; cursor: pointer; text-align: left; @@ -433,7 +433,7 @@ defineExpose({ reload: loadAll }); text-transform: capitalize; } .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 { list-style: none; @@ -466,7 +466,7 @@ defineExpose({ reload: loadAll }); justify-content: center; } .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 { flex: 1; @@ -522,7 +522,7 @@ defineExpose({ reload: loadAll }); 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-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:hover { text-decoration: underline; } @@ -614,7 +614,7 @@ defineExpose({ reload: loadAll }); flex-shrink: 0; } .task-due.overdue { - color: var(--color-danger, #e74c3c); + color: var(--color-danger); font-weight: 500; } diff --git a/frontend/src/components/rules/PlanRulesPanel.vue b/frontend/src/components/rules/PlanRulesPanel.vue index 4cab350..1796b87 100644 --- a/frontend/src/components/rules/PlanRulesPanel.vue +++ b/frontend/src/components/rules/PlanRulesPanel.vue @@ -46,7 +46,7 @@ watch(() => props.projectId, load); diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue index 72e1c6c..fee2105 100644 --- a/frontend/src/components/rules/RuleEditorSlideOver.vue +++ b/frontend/src/components/rules/RuleEditorSlideOver.vue @@ -98,8 +98,8 @@ watch(() => props.ruleId, load); .slide-over { position: fixed; top: 0; right: 0; bottom: 0; width: min(520px, 90vw); - background: var(--color-surface, #18181b); - border-left: 2px solid var(--color-primary, #6366f1); + background: var(--color-surface); + border-left: 2px solid var(--color-primary); padding: 1.5rem; overflow-y: auto; box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3); @@ -110,11 +110,11 @@ header h2 { font-family: Fraunces, serif; font-style: italic; } label { display: block; margin-bottom: 1rem; } -.required { color: var(--color-primary, #6366f1); } +.required { color: var(--color-primary); } input, textarea { width: 100%; margin-top: 0.25rem; - background: var(--color-bg, #111113); color: inherit; - border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px; + background: var(--color-bg); color: inherit; + border: 1px solid var(--color-border); border-radius: 6px; padding: 0.5rem; font: inherit; font-family: inherit; } diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue index 65ecac4..b33bd6a 100644 --- a/frontend/src/components/rules/RuleListPane.vue +++ b/frontend/src/components/rules/RuleListPane.vue @@ -22,18 +22,18 @@ const emit = defineEmits<{ diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue index 9d8508f..273fa49 100644 --- a/frontend/src/views/GraphView.vue +++ b/frontend/src/views/GraphView.vue @@ -600,7 +600,7 @@ onUnmounted(() => { .graph-page { display: flex; flex-direction: column; - height: calc(100vh - var(--header-height, 52px)); + height: calc(100vh - var(--header-height)); overflow: hidden; } @@ -750,7 +750,7 @@ onUnmounted(() => { background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-md); - box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15)); + box-shadow: 0 4px 16px var(--color-shadow); padding: 0.5rem 0.75rem; pointer-events: none; z-index: 10; diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 6d01bc4..d9b6553 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -490,7 +490,7 @@ onUnmounted(() => { .knowledge-root { display: flex; flex-direction: column; - height: calc(100vh - var(--header-height, 56px)); + height: calc(100vh - var(--header-height)); overflow: hidden; } @@ -502,7 +502,7 @@ onUnmounted(() => { gap: 12px; padding: 8px 20px; 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; font-size: 0.82rem; flex-wrap: wrap; @@ -539,7 +539,7 @@ onUnmounted(() => { width: var(--sidebar-width); flex-shrink: 0; 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; background: var(--color-bg-secondary); } @@ -548,7 +548,7 @@ onUnmounted(() => { content: '· · ·'; display: block; text-align: center; - color: rgba(91, 74, 138, 0.3); + color: color-mix(in srgb, var(--color-primary) 30%, transparent); font-size: 0.9rem; letter-spacing: 0.4em; padding: 4px 0 12px; @@ -662,7 +662,7 @@ onUnmounted(() => { flex-shrink: 0; } .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); } .filter-tag { font-size: 0.78rem; } @@ -683,7 +683,7 @@ onUnmounted(() => { gap: 10px; padding: 12px 20px; 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 { flex: 1; @@ -701,8 +701,8 @@ onUnmounted(() => { width: 100%; padding: 7px 12px 7px 32px; border-radius: 8px; - border: 1px solid var(--color-border, rgba(255,255,255,0.1)); - background: var(--color-bg-tertiary, rgba(255,255,255,0.04)); + border: 1px solid var(--color-border); + background: var(--color-bg-tertiary); color: var(--color-text); font-size: 0.88rem; outline: none; @@ -712,8 +712,8 @@ onUnmounted(() => { .sort-select { padding: 7px 10px; border-radius: 8px; - border: 1px solid var(--color-border, rgba(255,255,255,0.1)); - background: var(--color-bg-tertiary, rgba(255,255,255,0.04)); + border: 1px solid var(--color-border); + background: var(--color-bg-tertiary); color: var(--color-text); font-size: 0.85rem; cursor: pointer; @@ -735,9 +735,9 @@ onUnmounted(() => { .k-card { position: relative; - background: var(--color-surface, rgba(255,255,255,0.03)); - border: 1px solid var(--color-border, rgba(255,255,255,0.07)); - border-radius: var(--radius-lg, 14px); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); padding: 14px; cursor: pointer; transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s; @@ -749,12 +749,12 @@ onUnmounted(() => { } .k-card:hover { transform: translateY(-2px); - box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3); - border-color: rgba(91, 74, 138, 0.35); + 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: color-mix(in srgb, var(--color-primary) 35%, transparent); } /* 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); } /* Top gradient bars */ @@ -769,7 +769,7 @@ onUnmounted(() => { } .k-card--note::before { right: 0; - background: linear-gradient(90deg, #5B4A8A, #7A6DA8); + background: linear-gradient(90deg, var(--color-primary), #7A6DA8); } .k-card--task::before { right: 0; @@ -788,7 +788,7 @@ onUnmounted(() => { text-transform: uppercase; 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--plan { background: rgba(99,102,241,0.18); color: #818cf8; } @@ -917,7 +917,7 @@ onUnmounted(() => { .graph-panel { width: 500px; 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; flex-direction: column; background: var(--color-bg-secondary); @@ -933,7 +933,7 @@ onUnmounted(() => { padding: 10px 14px; font-size: 0.85rem; 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; } /* Override GraphView's 100vh height so it fills the panel instead */ diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index b7d1d6a..0698d2f 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -709,8 +709,8 @@ onUnmounted(() => assist.clearSelection()); width: 100%; padding: 5px 8px; border-radius: var(--radius-sm); - border: 1px solid var(--color-input-border, rgba(255,255,255,0.12)); - background: var(--color-bg-tertiary, rgba(255,255,255,0.04)); + border: 1px solid var(--color-input-border); + background: var(--color-bg-tertiary); color: var(--color-text); font-size: 0.82rem; font-family: inherit; @@ -822,7 +822,7 @@ onUnmounted(() => assist.clearSelection()); background: var(--color-surface); color: var(--color-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; line-height: 1.55; tab-size: 2; diff --git a/frontend/src/views/ProjectListView.vue b/frontend/src/views/ProjectListView.vue index dd6c1c3..89b851d 100644 --- a/frontend/src/views/ProjectListView.vue +++ b/frontend/src/views/ProjectListView.vue @@ -563,7 +563,7 @@ function overallPct(project: Project): { total: number; pct: number } { .modal-overlay { 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; diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index ad4d7c2..538c01d 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -7,6 +7,7 @@ import { useTasksStore } from "@/stores/tasks"; import { relativeTime } from "@/composables/useRelativeTime"; import { renderMarkdown } from "@/utils/markdown"; import ShareDialog from "@/components/ShareDialog.vue"; +import ProjectDesignTab from "@/components/ProjectDesignTab.vue"; import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue"; import SystemsSection from "@/components/SystemsSection.vue"; import { @@ -108,7 +109,7 @@ async function confirmStartPlanning() { const saving = ref(false); const error = ref(null); -const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks"); +const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks"); const tasks = ref([]); const notes = ref([]); @@ -570,6 +571,9 @@ async function confirmDelete() { + @@ -784,6 +788,16 @@ async function confirmDelete() { + + + @@ -847,9 +861,9 @@ async function confirmDelete() { } .page-header-actions { display: flex; gap: 0.5rem; align-items: center; } .plan-title-input { - background: var(--color-bg, #111113); + background: var(--color-bg); color: inherit; - border: 1px solid var(--color-border, #2a2a2e); + border: 1px solid var(--color-border); border-radius: 6px; padding: 0.4rem 0.6rem; font: inherit; @@ -932,8 +946,8 @@ async function confirmDelete() { flex-shrink: 0; } .dot-todo { background: transparent; border: 2px solid var(--color-text-muted); } -.dot-inprogress { background: var(--color-status-in-progress, #3b82f6); } -.dot-done { background: var(--color-status-done, #22c55e); } +.dot-inprogress { background: var(--color-status-in-progress); } +.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-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); } @@ -1066,7 +1080,7 @@ async function confirmDelete() { .ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); } .ms-plan-editor { width: 100%; - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); font-size: 0.8rem; line-height: 1.5; padding: 0.5rem; @@ -1133,7 +1147,7 @@ async function confirmDelete() { border-radius: var(--radius-sm); } .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 { flex: 1; @@ -1168,8 +1182,8 @@ async function confirmDelete() { border-top: 3px solid; } .col-todo { border-top-color: var(--color-border); } -.col-inprogress { border-top-color: var(--color-status-in-progress, #3b82f6); } -.col-done { border-top-color: var(--color-status-done, #22c55e); } +.col-inprogress { border-top-color: var(--color-status-in-progress); } +.col-done { border-top-color: var(--color-status-done); } .kanban-col-header { display: flex; @@ -1230,7 +1244,7 @@ async function confirmDelete() { transform: translateY(-2px); } /* 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-low { border-left-color: var(--color-success); } @@ -1256,7 +1270,7 @@ async function confirmDelete() { } .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--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; } .priority-dot { @@ -1265,7 +1279,7 @@ async function confirmDelete() { border-radius: 50%; 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-low { background: var(--color-success); } @@ -1310,7 +1324,7 @@ async function confirmDelete() { /* ── Modal ───────────────────────────────────────────────────── */ .modal-overlay { 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; z-index: 200; } diff --git a/frontend/src/views/RulesView.vue b/frontend/src/views/RulesView.vue index d2e82b3..459b619 100644 --- a/frontend/src/views/RulesView.vue +++ b/frontend/src/views/RulesView.vue @@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute); grid-template-columns: 280px 300px 1fr; height: 100vh; gap: 1px; - background: var(--color-border, #2a2a2e); + background: var(--color-border); } .pane.empty { - background: var(--color-surface, #18181b); + background: var(--color-surface); padding: 1rem; opacity: 0.6; font-style: italic; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 813e83c..fd8615f 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -4,7 +4,6 @@ import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client"; -import { listRulebooks } from "@/api/rulebooks"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; import TagInput from "@/components/TagInput.vue"; @@ -32,11 +31,6 @@ const kbWritePathThreshold = ref("0.68"); // gate: that one BLOCKS a create and must be unforgiving of noise, this one only // suggests a merge the operator reviews (services/dedup.py). const kbDuplicateThreshold = ref("0.82"); -// Which rulebook describes this install's design system, for the /design drift -// panel. Empty = none designated, which is the normal state for a fresh install -// rather than a misconfiguration — the panel explains itself when unset. -const designRulebookId = ref(""); -const designRulebooks = ref<{ id: number; title: string }[]>([]); const savingKbInject = ref(false); const kbInjectSaved = ref(false); @@ -106,9 +100,6 @@ async function saveKbInject() { kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false', kb_writepath_threshold: String(wpT), kb_duplicate_threshold: String(dupT), - // Empty string DELETES the setting (see routes/settings.py), which is - // exactly right for "no design rulebook" — absent rather than zero. - design_rulebook_id: designRulebookId.value, }); kbInjectSaved.value = true; setTimeout(() => (kbInjectSaved.value = false), 2000); @@ -499,14 +490,6 @@ onMounted(async () => { if (allSettings.kb_duplicate_threshold !== undefined) { kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold; } - designRulebookId.value = allSettings.design_rulebook_id ?? ""; - // Best-effort: the picker degrades to "none available" rather than blocking - // the whole settings page if rulebooks can't be listed. - try { - designRulebooks.value = (await listRulebooks()).map((r) => ({ id: r.id, title: r.title })); - } catch { - designRulebooks.value = []; - } if (allSettings.notify_task_reminders !== undefined) { notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; } @@ -1277,22 +1260,12 @@ function formatUserDate(iso: string): string { location, not by resemblance.

-
- - -

- Which rulebook describes how this app should look. Once set, the - Design page compares every colour - and token your rules name against what the stylesheet actually resolves - to, and reports where they disagree. Leave it as None if your rules - don't describe a design system — nothing else depends on this. -

-
+ +
{ } .perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); } .perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); } -.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); } +.perm-admin { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); } .empty-msg { color: var(--color-muted); diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue index 28ffed9..625d1c2 100644 --- a/frontend/src/views/SnippetDetailView.vue +++ b/frontend/src/views/SnippetDetailView.vue @@ -239,7 +239,7 @@ async function confirmDelete() { } .snippet-name { margin: 0; - font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace); + font-family: var(--font-mono); font-size: 1.4rem; word-break: break-word; } @@ -291,7 +291,7 @@ async function confirmDelete() { } .meta-grid code, .tag-row + * code { - font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace); + font-family: var(--font-mono); font-size: 0.82rem; background: color-mix(in srgb, var(--color-primary) 12%, transparent); color: var(--color-primary); @@ -379,7 +379,7 @@ async function confirmDelete() { overflow-x: auto; } .code-block code { - font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace); + font-family: var(--font-mono); font-size: 0.85rem; line-height: 1.6; color: var(--color-text); diff --git a/frontend/src/views/SnippetEditorView.vue b/frontend/src/views/SnippetEditorView.vue index 01774b7..435a0ba 100644 --- a/frontend/src/views/SnippetEditorView.vue +++ b/frontend/src/views/SnippetEditorView.vue @@ -447,7 +447,7 @@ function cancel() { box-shadow: var(--focus-ring); } .mono { - font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace); + font-family: var(--font-mono); } .code-area { resize: vertical; @@ -542,7 +542,7 @@ function cancel() { gap: 0.4rem; padding: 0.85rem 1rem; 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; background: var(--color-bg-secondary); } diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue index c0828b8..ed622d3 100644 --- a/frontend/src/views/SnippetListView.vue +++ b/frontend/src/views/SnippetListView.vue @@ -574,7 +574,7 @@ function usageTitle(s: SnippetListItem): string { background: var(--color-bg); color: var(--color-text); font-size: 0.85rem; - font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace); + font-family: var(--font-mono); box-sizing: border-box; } .loc-input-wide { @@ -633,7 +633,7 @@ function usageTitle(s: SnippetListItem): string { color: var(--color-text-muted); } .empty-icon { - font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace); + font-family: var(--font-mono); font-size: 2rem; margin-bottom: 0.75rem; opacity: 0.35; @@ -720,7 +720,7 @@ function usageTitle(s: SnippetListItem): string { min-width: 0; flex: 1; 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. */ @@ -770,7 +770,7 @@ function usageTitle(s: SnippetListItem): string { padding: 0.85rem 1rem; border: 1px solid var(--color-border); border-radius: 8px; - background: var(--color-surface-alt, var(--color-surface)); + background: var(--color-surface-alt); } .dup-empty, @@ -828,8 +828,8 @@ function usageTitle(s: SnippetListItem): string { padding: 0.1rem 0.4rem; border-radius: 4px; white-space: nowrap; - background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent); - color: var(--color-danger, #b91c1c); + background: color-mix(in srgb, var(--color-danger) 15%, transparent); + color: var(--color-danger); } .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 than the danger one, because the record isn't broken, just unearned. */ .usage-tag.usage-dead { - background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent); - color: var(--color-warning, #b45309); + background: color-mix(in srgb, var(--color-warning) 18%, transparent); + color: var(--color-warning); } /* Header + select-mode */ @@ -906,7 +906,7 @@ function usageTitle(s: SnippetListItem): string { .modal-overlay { 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; @@ -955,7 +955,7 @@ function usageTitle(s: SnippetListItem): string { .merge-choice-name { flex: 1; min-width: 0; - font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace); + font-family: var(--font-mono); font-size: 0.85rem; word-break: break-word; } diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index fedbcf9..b0d1932 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -874,7 +874,7 @@ useEditorGuards(dirty, save); padding: 0 0.2rem; flex-shrink: 0; } -.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); } +.btn-clear-parent:hover { color: var(--color-danger); } .parent-dropdown { position: absolute; top: calc(100% + 4px); @@ -1037,13 +1037,13 @@ useEditorGuards(dirty, save); margin: 0.5rem 0 0.25rem; } .task-goal-label { - font-family: var(--font-display, "Fraunces", serif); + font-family: var(--font-display); font-style: italic; font-size: 0.78rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; - color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); + color: var(--color-text-muted); } .task-goal-input { width: 100%; @@ -1053,14 +1053,14 @@ useEditorGuards(dirty, save); font: inherit; font-size: 0.95rem; line-height: 1.4; - color: var(--color-text, inherit); - background: var(--color-input-bg, rgba(255, 255, 255, 0.03)); - border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); - border-radius: var(--radius-md, 8px); + color: var(--color-text); + background: var(--color-input-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); } .task-goal-input:focus { outline: none; - border-color: var(--color-primary, #6366f1); + border-color: var(--color-primary); } /* ── Auto-summary banner + re-consolidate button ─────────────────────────── */ @@ -1072,13 +1072,13 @@ useEditorGuards(dirty, save); margin-bottom: 0.5rem; font-size: 0.82rem; 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); - border-left: 2px solid var(--color-primary, #6366f1); - border-radius: var(--radius-sm, 4px); + border-left: 2px solid var(--color-primary); + border-radius: var(--radius-sm); } .auto-summary-banner-editor .auto-summary-icon { - color: var(--color-primary, #6366f1); + color: var(--color-primary); font-style: normal; } \ No newline at end of file diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 2cc007d..a05e60f 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -561,7 +561,7 @@ const subTaskProgress = computed(() => { } .subtasks-fill { height: 100%; - background: var(--color-status-done, #22c55e); + background: var(--color-status-done); border-radius: 2px; transition: width 0.3s ease; } @@ -602,13 +602,13 @@ const subTaskProgress = computed(() => { border: 2px solid var(--color-text-muted); } .dot-in-progress { - background: var(--color-status-in-progress, #3b82f6); + background: var(--color-status-in-progress); } .dot-done { - background: var(--color-status-done, #22c55e); + background: var(--color-status-done); } .dot-cancelled { - background: var(--color-text-muted, #6b7280); + background: var(--color-text-muted); } .sub-title { flex: 1; @@ -749,26 +749,26 @@ const subTaskProgress = computed(() => { /* ── Goal block + auto-summary banner ─────────────────────────────────────── */ .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; margin: 0.75rem 0 1.25rem; background: rgba(255, 255, 255, 0.02); } .goal-label { - font-family: var(--font-display, "Fraunces", serif); + font-family: var(--font-display); font-style: italic; font-size: 0.78rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; - color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); + color: var(--color-text-muted); margin: 0 0 0.25rem; } .goal-text { margin: 0; font-size: 0.95rem; line-height: 1.45; - color: var(--color-text, inherit); + color: var(--color-text); white-space: pre-wrap; } .auto-summary-banner { @@ -777,11 +777,11 @@ const subTaskProgress = computed(() => { gap: 0.5rem; font-size: 0.78rem; font-style: italic; - color: var(--color-text-muted, rgba(255, 255, 255, 0.55)); + color: var(--color-text-muted); margin: 0 0 0.75rem; } .auto-summary-icon { - color: var(--color-primary, #6366f1); + color: var(--color-primary); font-size: 0.85rem; } diff --git a/frontend/src/views/TrashView.vue b/frontend/src/views/TrashView.vue index 8c087e3..e954f60 100644 --- a/frontend/src/views/TrashView.vue +++ b/frontend/src/views/TrashView.vue @@ -68,7 +68,7 @@ onMounted(() => store.fetchTrash()); .batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; } .batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; } .batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; } -.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--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-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index b0fd4a3..98685c7 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.22", + "version": "0.1.23", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index 4c6b52c..77b5a17 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -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) --- [ -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// what EXECUTES — refreshed only when the +# manifest version changes +# +# So inspecting the clone shows a fix present while the broken copy keeps +# running, and the obvious debugging move actively misleads (#2209). Twice, the +# only detector was the operator saying "I don't think it updated". +# +# Naming the running version in every session makes that answerable from the +# transcript instead of by archaeology in the cache directory. Deliberately NOT +# a server round-trip or a stored per-user record: the state most needing +# diagnosis is the one where credentials never arrive, and this line still +# appears there. +manifest="$here/../.claude-plugin/plugin.json" +if [ -f "$manifest" ]; then + plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version="" + if [ -n "$plugin_version" ]; then + append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes." + fi +fi + # --- Tier 2: dynamic rules + active-project context (best-effort) --- url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index 22c706b..1fb44ca 100755 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -307,6 +307,44 @@ def check_local_prior_art_needs_no_instance() -> None: ok("prior-art local arm: answers with no instance configured") +def check_session_context_reports_its_version() -> None: + """The SessionStart context must name the plugin version it is running. + + An install has two halves and only one self-updates: the marketplace clone + pulls on its own, while the CACHE is what executes and refreshes only when + the manifest version changes. So a shipped fix can sit unreached while + inspecting the clone shows it present — the obvious debugging move + misleads, and twice the only detector was a human saying "I don't think it + updated" (#2209, #2220). + + Asserted WITHOUT credentials on purpose. The state most needing diagnosis + is the one where the token never arrives, and a marker that vanished there + would be missing exactly when it is wanted. + """ + script = HOOKS_DIR / "scribe_session_context.sh" + if not script.is_file() or not shutil.which("jq"): + skip("version marker: hook or jq missing") + return + + manifest_v = manifest_version() + if manifest_v is None: + fail("version marker: could not read the manifest version") + return + + try: + proc = _run_hook(script, json.dumps({"source": "startup"}), {}) + except subprocess.TimeoutExpired: + fail("version marker: hook hung") + return + if proc.returncode != 0: + fail(f"version marker: hook exited {proc.returncode}") + elif manifest_v not in proc.stdout: + fail(f"version marker: session context never names v{manifest_v} — " + f"a stale install would be undetectable from the transcript") + else: + ok(f"version marker: session context reports v{manifest_v}, no credentials needed") + + def _git(*args: str) -> tuple[int, str]: proc = subprocess.run( ["git", *args], capture_output=True, text=True, cwd=ROOT @@ -399,6 +437,7 @@ def main() -> int: check_shellcheck() check_fail_open() check_local_prior_art_needs_no_instance() + check_session_context_reports_its_version() if not args.no_version: check_version_bump(args.base) diff --git a/src/scribe/app.py b/src/scribe/app.py index c5ac59a..e89a190 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -26,7 +26,6 @@ from scribe.routes.profile import profile_bp from scribe.routes.knowledge import knowledge_bp from scribe.routes.rulebooks import rulebooks_bp from scribe.routes.plugin import plugin_bp -from scribe.routes.design import design_bp from scribe.routes.design_systems import design_systems_bp from scribe.routes.trash import trash_bp from scribe.routes.dashboard import dashboard_bp @@ -91,7 +90,6 @@ def create_app() -> Quart: app.register_blueprint(knowledge_bp) app.register_blueprint(rulebooks_bp) app.register_blueprint(plugin_bp) - app.register_blueprint(design_bp) app.register_blueprint(design_systems_bp) app.register_blueprint(trash_bp) app.register_blueprint(dashboard_bp) diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py index c8af5db..63bb7e3 100644 --- a/src/scribe/mcp/tools/design_systems.py +++ b/src/scribe/mcp/tools/design_systems.py @@ -22,6 +22,11 @@ from __future__ import annotations from scribe.mcp._context import current_user_id from scribe.services import design_systems as ds_svc from scribe.services.design_systems import DesignSystemCycle +from scribe.services.design_starter_roles import ( + ALL_GROUPS, + DEFAULT_TOKEN_PREFIX, + describe_groups, +) async def create_design_system( @@ -29,6 +34,8 @@ async def create_design_system( description: str = "", guidance: str = "", parent_id: int = 0, + starter_role_groups: list[str] | None = None, + token_prefix: str = "", ) -> dict: """Create a design system, optionally inheriting from another. @@ -41,20 +48,47 @@ async def create_design_system( parent_id: Inherit from this system — it holds the defaults this one overrides. Omit (0) for a top-level "family" system, which is what a first design system usually is. + starter_role_groups: Seed the system with named but VALUELESS token + roles, so there is something to reach for before a literal gets + written instead. Call list_starter_role_groups() for the catalogue. + Pass ["all"] for every group. Omit for none — a system with three + hand-written tokens is a legitimate design system. + token_prefix: Naming convention for the seeded roles, e.g. "--fs-". + Defaults to a neutral "--ds-"; pass the install's own if it has one. + Ignored when no starter groups are requested. """ uid = current_user_id() + groups = starter_role_groups + if groups and len(groups) == 1 and groups[0] == "all": + groups = list(ALL_GROUPS) system = await ds_svc.create_design_system( uid, title=title, description=description or None, guidance=guidance or None, parent_id=parent_id or None, + starter_role_groups=groups, + token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX, ) if system is None: raise ValueError(f"parent design system {parent_id} not found or not writable") return system.to_dict() +async def list_starter_role_groups() -> dict: + """The starter token ROLES offered at design-system creation. + + Roles, not values. Every group is a set of named questions — "page + background, the deepest surface" — that the operator answers with their own + palette. Nothing here carries a colour, because a default palette would be + one install's taste shipped as product. + + Reach for this before create_design_system so the choice is informed, and + pass the group names you want as `starter_role_groups`. + """ + return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX} + + async def list_design_systems() -> dict: """List your design systems. An empty list is normal — most installs have none.""" uid = current_user_id() @@ -350,6 +384,7 @@ async def set_project_design_system(project_id: int, design_system_id: int = 0) def register(mcp) -> None: for fn in ( create_design_system, + list_starter_role_groups, list_design_systems, get_design_system, resolve_design_system, diff --git a/src/scribe/routes/design.py b/src/scribe/routes/design.py deleted file mode 100644 index 630e710..0000000 --- a/src/scribe/routes/design.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Design-system surface — what the rulebook expects of the stylesheet. - -The client owns the other half of the comparison: it reads live token values from -the browser (see `utils/designTokens.ts`), which is the only place they exist -resolved. This endpoint supplies the claims to check them against. -""" -from quart import Blueprint, jsonify - -from scribe.auth import get_current_user_id, login_required -from scribe.services import design_rulebook_import as design_svc - -design_bp = Blueprint("design", __name__, url_prefix="/api/design") - - -@design_bp.get("/expectations") -@login_required -async def get_expectations(): - """Checkable claims from the rulebook this install designated as its design system. - - Returns `{"rulebook_id": int|null, "expectations": [...]}`. - - `rulebook_id: null` is the NORMAL case, not an error — an install that has - not designated a design rulebook has nothing to compare against, and the - client shows an explanatory empty state (rule #115). Distinguishing it from - "designated but empty" is why the id is returned alongside the list. - """ - uid = get_current_user_id() - result = await design_svc.design_expectations(uid) - return jsonify(result.as_dict()) diff --git a/src/scribe/routes/design_systems.py b/src/scribe/routes/design_systems.py index 6487ed5..1bb3628 100644 --- a/src/scribe/routes/design_systems.py +++ b/src/scribe/routes/design_systems.py @@ -19,6 +19,10 @@ from quart import Blueprint, g, jsonify, request from scribe.auth import login_required from scribe.services import design_systems as ds_svc +from scribe.services.design_starter_roles import ( + DEFAULT_TOKEN_PREFIX, + describe_groups, +) from scribe.services.design_systems import DesignSystemCycle 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, guidance=data.get("guidance") or None, 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: return jsonify({"error": "parent design system not found"}), 404 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/") @login_required async def get_design_system(design_system_id: int): diff --git a/src/scribe/services/design_rulebook_import.py b/src/scribe/services/design_rulebook_import.py deleted file mode 100644 index 7f637fd..0000000 --- a/src/scribe/services/design_rulebook_import.py +++ /dev/null @@ -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)) diff --git a/src/scribe/services/design_starter_roles.py b/src/scribe/services/design_starter_roles.py new file mode 100644 index 0000000..ab9c456 --- /dev/null +++ b/src/scribe/services/design_starter_roles.py @@ -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() + ] diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py index e4bb63b..522829d 100644 --- a/src/scribe/services/design_systems.py +++ b/src/scribe/services/design_systems.py @@ -27,6 +27,10 @@ from scribe.services.design_stylesheet import ( duplicate_values, render_stylesheet, ) +from scribe.services.design_starter_roles import ( + DEFAULT_TOKEN_PREFIX, + starter_tokens, +) from scribe.services.design_cascade import ( ResolvedToken, ancestry, @@ -79,11 +83,23 @@ async def create_design_system( description: str | None = None, guidance: str | None = None, parent_id: int | None = None, + starter_role_groups: list[str] | None = None, + token_prefix: str = DEFAULT_TOKEN_PREFIX, ) -> DesignSystem | None: """Create a system, with or without a parent. Returns None when `parent_id` names a system the caller may not write — which, per the ACL, means one they do not own. + + `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( user_id, parent_id @@ -100,6 +116,11 @@ async def create_design_system( session.add(system) await session.commit() 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 diff --git a/tests/test_design_rulebook_import.py b/tests/test_design_rulebook_import.py deleted file mode 100644 index 9a0c942..0000000 --- a/tests/test_design_rulebook_import.py +++ /dev/null @@ -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 diff --git a/tests/test_design_starter_roles.py b/tests/test_design_starter_roles.py new file mode 100644 index 0000000..7f9e79c --- /dev/null +++ b/tests/test_design_starter_roles.py @@ -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 diff --git a/tests/test_design_stylesheet.py b/tests/test_design_stylesheet.py index 4c2c844..245fc71 100644 --- a/tests/test_design_stylesheet.py +++ b/tests/test_design_stylesheet.py @@ -284,8 +284,9 @@ def test_a_shorter_hex_does_not_match_inside_a_longer_one(): def test_the_literal_match_is_case_insensitive(): - """Rulebooks write `#FFFFFF` and code writes `#ffffff`. A case-sensitive - check would silently find nothing — the same trap normalize_hex exists for.""" + """A record writes `#FFFFFF` and code writes `#ffffff`. A case-sensitive + 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) assert report["superseded_literals"] == [ {"literal": "#ffffff", "use_instead": "--fs-parchment"} diff --git a/tests/test_routes_design_systems.py b/tests/test_routes_design_systems.py index 8b0b54a..8ad2389 100644 --- a/tests/test_routes_design_systems.py +++ b/tests/test_routes_design_systems.py @@ -44,6 +44,11 @@ def test_every_endpoint_is_reachable_on_the_app(): } assert rules == { "/api/design-systems", + # Static segment, declared before the 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/", "/api/design-systems//resolved", "/api/design-systems//stylesheet", @@ -85,7 +90,7 @@ def test_agent_and_web_surfaces_stay_at_parity(): "resolve_design_system", "update_design_system", "delete_design_system", "create_design_token", "list_design_tokens", "update_design_token", "delete_design_token", "set_project_design_system", - "get_design_system_stylesheet", + "get_design_system_stylesheet", "list_starter_role_groups", ): assert callable(getattr(tools, name)), f"MCP tool missing: {name}" assert callable(getattr(routes, name)), f"REST route missing: {name}"