From d0a2733cb601c5b824ffa5ede5da83d6d15824e5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 27 Aug 2026 21:38:09 -0400 Subject: [PATCH 01/23] fix(design): text on a tint of itself now clears AA app-wide, and the check gates it (#3141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge fix (#3132) exposed the same defect everywhere: 48 rules painting a token as TEXT on an inline color-mix tint of that same token. Worst raw measurements, across every tint strength in use, both modes, over page/raised/hover: accent 1.53:1 · success 1.67:1 · text-tertiary 2.15:1 warning 2.32:1 · error 2.36:1 against AA's 4.5 THE DEFECT IS IN THE HOUSE, NOT IN SCRIBE. The semantic hues are shared family-wide, and the accent case was measured against every app's real accent, not assumed from Scribe's: Minstrel 1.81, Forge 1.87, Steward 1.65, Roundtable 3.01 — all failing. So the six -fg tokens are recorded on FabledSword (design system 1), where their parents live, rather than copied into each app. 45% toward --fs-text-primary clears AA for ALL FIVE accents (4.56-5.00), so this is one house token rather than five overrides, and it keeps deriving from --fs-accent — an app that overrides its accent still gets a legible tinted-text colour in its own colour, the same mechanism as --fs-accent-soft. The tokens are additive: a sibling app is unaffected until it regenerates its own stylesheet. One token is honestly redundant. --fs-text-secondary already passes at 4.82:1, and --fs-text-secondary-fg barely moves it. It exists so the rule has NO exceptions, because the alternative is a permanent allow-list entry for the one case that happens to pass — and a guard with an invisible exception is a guard that erodes. 46 substitutions across 18 files, each rewriting only the `color:` inside a block that tints its own background. THE CHECK NOW GATES BOTH SPELLINGS. It previously reported the inline form, because a gate nobody can satisfy on the day it lands gets switched off. Both are clean, so both fail the build now. And the check had a false-positive bug worth naming: its `color\s*:` regex matched the tail of `border-color`, `border-left-color` and `outline-color`, so it flagged seven rules that were already correct. A border is a non-text graphic with a 3:1 floor, not text at 4.5. A check that cries wolf on correct code is one that gets muted, so that mattered more than the noise. Verified by construction, not by passing: reintroduced each defect form (exit 1 each), and confirmed a legitimate border-only rule still exits 0. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/assets/editor-shared.css | 6 +-- frontend/src/assets/theme.css | 6 +++ frontend/src/assets/viewer-shared.css | 2 +- frontend/src/components/AppHeader.vue | 4 +- frontend/src/components/DiffView.vue | 4 +- frontend/src/components/InlineAssistPanel.vue | 4 +- frontend/src/components/MarkdownToolbar.vue | 2 +- frontend/src/components/SystemsSection.vue | 4 +- frontend/src/components/TagInput.vue | 2 +- .../src/components/WorkspaceNoteEditor.vue | 4 +- .../src/components/WorkspaceTaskPanel.vue | 8 ++-- frontend/src/views/GraphView.vue | 4 +- frontend/src/views/KnowledgeView.vue | 4 +- frontend/src/views/NoteViewerView.vue | 2 +- frontend/src/views/ProjectView.vue | 8 ++-- frontend/src/views/SettingsView.vue | 16 ++++---- frontend/src/views/SharedWithMeView.vue | 6 +-- frontend/src/views/SnippetDetailView.vue | 2 +- frontend/src/views/SnippetListView.vue | 10 ++--- scripts/check_design_tokens.py | 37 +++++++++++-------- 20 files changed, 74 insertions(+), 61 deletions(-) diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css index 9637035..7cd8ff3 100644 --- a/frontend/src/assets/editor-shared.css +++ b/frontend/src/assets/editor-shared.css @@ -127,7 +127,7 @@ border: 1px solid var(--fs-error); border-radius: var(--fs-radius-sm); font-size: 0.85rem; - color: var(--fs-error); + color: var(--fs-error-fg); } .diff-view { border: 1px solid var(--fs-border-color); @@ -147,11 +147,11 @@ } .diff-delete { background: color-mix(in srgb, var(--fs-error) 12%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } .diff-insert { background: color-mix(in srgb, var(--fs-success) 12%, transparent); - color: var(--fs-success); + color: var(--fs-success-fg); } .diff-equal { color: var(--fs-text-tertiary); diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index c4d7d9d..f174443 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -31,6 +31,7 @@ --fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */ --fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */ --fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */ + --fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary)); /* Accent TEXT on an accent tint */ --fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep)); --fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent); --fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent); @@ -102,8 +103,11 @@ /* semantic */ --fs-success: var(--fs-action-primary); + --fs-success-fg: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary)); /* Success TEXT on a success tint */ --fs-warning: #8B6F1E; + --fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-text-primary)); /* Warning TEXT on a warning tint */ --fs-error: #C04A1F; + --fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-text-primary)); /* Error TEXT on an error tint */ --fs-info: #3D5A6E; --fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */ @@ -148,7 +152,9 @@ /* text */ --fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */ --fs-text-secondary: #C2BFB4; + --fs-text-secondary-fg: color-mix(in srgb, var(--fs-text-secondary) 90%, var(--fs-text-primary)); /* Secondary TEXT on a secondary tint (barely moves; no exceptions) */ --fs-text-tertiary: #9C9A92; + --fs-text-tertiary-fg: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary)); /* Tertiary TEXT on a tertiary tint */ --fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */ /* type */ diff --git a/frontend/src/assets/viewer-shared.css b/frontend/src/assets/viewer-shared.css index 644fe1d..864c62d 100644 --- a/frontend/src/assets/viewer-shared.css +++ b/frontend/src/assets/viewer-shared.css @@ -25,7 +25,7 @@ border-color: var(--fs-accent); } .ctx-crumb-project { - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); text-decoration: none; diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index f12f472..0363f46 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -206,7 +206,7 @@ router.afterEach(() => { background: var(--fs-accent-soft); } .nav-link.router-link-active { - color: var(--fs-accent); + color: var(--fs-accent-fg); font-weight: 500; background: color-mix(in srgb, var(--fs-accent) 25%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent); @@ -257,7 +257,7 @@ router.afterEach(() => { font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm); diff --git a/frontend/src/components/DiffView.vue b/frontend/src/components/DiffView.vue index b2d4dde..7fb8754 100644 --- a/frontend/src/components/DiffView.vue +++ b/frontend/src/components/DiffView.vue @@ -137,12 +137,12 @@ function markerFor(type: DiffLine['type']): string { .diff-delete { background: color-mix(in srgb, var(--fs-error) 12%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } .diff-insert { background: color-mix(in srgb, var(--fs-success) 12%, transparent); - color: var(--fs-success); + color: var(--fs-success-fg); } .diff-equal { diff --git a/frontend/src/components/InlineAssistPanel.vue b/frontend/src/components/InlineAssistPanel.vue index 313008b..60e89a5 100644 --- a/frontend/src/components/InlineAssistPanel.vue +++ b/frontend/src/components/InlineAssistPanel.vue @@ -227,11 +227,11 @@ const markers: Record = { .iap-diff-equal { color: var(--fs-text-tertiary); } .iap-diff-delete { background: color-mix(in srgb, var(--fs-error) 10%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } .iap-diff-insert { background: color-mix(in srgb, var(--fs-success) 10%, transparent); - color: var(--fs-success); + color: var(--fs-success-fg); } .iap-diff-marker { diff --git a/frontend/src/components/MarkdownToolbar.vue b/frontend/src/components/MarkdownToolbar.vue index 7ba034a..8741855 100644 --- a/frontend/src/components/MarkdownToolbar.vue +++ b/frontend/src/components/MarkdownToolbar.vue @@ -156,7 +156,7 @@ const groups = [ .md-btn.active { background: color-mix(in srgb, var(--fs-accent) 14%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent); } diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index 6bfbbf3..d3411f7 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -679,7 +679,7 @@ async function confirmDelete() { font-weight: 500; background: color-mix(in srgb, var(--fs-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); border-radius: 999px; padding: 0.05rem 0.45rem; flex-shrink: 0; @@ -689,7 +689,7 @@ async function confirmDelete() { font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--fs-text-tertiary); + color: var(--fs-text-tertiary-fg); background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent); border-radius: 999px; padding: 0.05rem 0.45rem; diff --git a/frontend/src/components/TagInput.vue b/frontend/src/components/TagInput.vue index 986225b..00a4403 100644 --- a/frontend/src/components/TagInput.vue +++ b/frontend/src/components/TagInput.vue @@ -168,7 +168,7 @@ function focusInput() { border-radius: 999px; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); border: 1px solid var(--fs-accent); - color: var(--fs-accent); + color: var(--fs-accent-fg); font-size: 0.8rem; white-space: nowrap; } diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue index d54fb55..a3462a4 100644 --- a/frontend/src/components/WorkspaceNoteEditor.vue +++ b/frontend/src/components/WorkspaceNoteEditor.vue @@ -548,7 +548,7 @@ defineExpose({ reload: loadProjectNotes }); .note-tag-pill { font-size: 0.58rem; - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); border-radius: 999px; padding: 0 0.3rem; @@ -645,7 +645,7 @@ defineExpose({ reload: loadProjectNotes }); .btn-tag-suggestion.applied { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); border-color: var(--fs-accent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } .link-suggest-strip { diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue index f8b101e..d98bdae 100644 --- a/frontend/src/components/WorkspaceTaskPanel.vue +++ b/frontend/src/components/WorkspaceTaskPanel.vue @@ -424,8 +424,8 @@ defineExpose({ reload: loadAll }); border-radius: 10px; text-transform: capitalize; } -.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); } -.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); } +.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); } +.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success-fg); } .task-items { list-style: none; @@ -516,8 +516,8 @@ defineExpose({ reload: loadAll }); user-select: none; margin-left: auto; } -.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); } -.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); } +.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); } +.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 10%, transparent); } .btn-edit-task { margin-left: 0.25rem; } .btn-edit-task:hover { text-decoration: underline; } diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue index 6642522..e6eec38 100644 --- a/frontend/src/views/GraphView.vue +++ b/frontend/src/views/GraphView.vue @@ -774,7 +774,7 @@ onUnmounted(() => { .tag-chip { font-size: 0.7rem; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); border-radius: 999px; padding: 0.1rem 0.4rem; } @@ -928,7 +928,7 @@ onUnmounted(() => { } .peek-linked-item:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised)); - color: var(--fs-accent); + color: var(--fs-accent-fg); } .peek-linked-type { diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index bb38f57..46ad3ed 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -753,7 +753,7 @@ onUnmounted(() => { } .filter-btn.active .filter-count { background: color-mix(in srgb, var(--fs-accent) 20%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } .filter-tag { font-size: 0.78rem; } @@ -926,7 +926,7 @@ onUnmounted(() => { border-radius: 4px; white-space: nowrap; background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent); - color: var(--fs-text-secondary); + color: var(--fs-text-secondary-fg); } /* ── Task card ──────────────────────────────────────────── */ diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue index 9c09f32..9f65ae4 100644 --- a/frontend/src/views/NoteViewerView.vue +++ b/frontend/src/views/NoteViewerView.vue @@ -416,7 +416,7 @@ async function convertToTask() { } .badge-note { background: color-mix(in srgb, var(--fs-accent) 12%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent); } .badge-task { diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 512d677..4901c3d 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -1287,8 +1287,8 @@ async function confirmDelete() { .stat-todo { background: color-mix(in srgb, var(--fs-text-tertiary) 8%, transparent); color: var(--fs-text-secondary); border-color: var(--fs-border-color); } .stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); } -.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); } -.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); } +.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success-fg); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); } +.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent-fg); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); } /* ── Pattern-library coverage card ───────────────────────────── */ .coverage-card { @@ -1471,7 +1471,7 @@ async function confirmDelete() { .tab-btn.active .tab-count { background: color-mix(in srgb, var(--fs-accent) 12%, transparent); border-color: color-mix(in srgb, var(--fs-accent) 30%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } /* ── Tasks view ──────────────────────────────────────────────── */ @@ -1707,7 +1707,7 @@ async function confirmDelete() { border-radius: 3px; margin-left: auto; } -.col-add-btn:hover { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); } +.col-add-btn:hover { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); } .kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; } diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 2d94d75..360447a 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2732,7 +2732,7 @@ async function deleteUser(userId: number) { background: var(--fs-surface-raised); } .sidebar-item.active { - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 8%, transparent); border-left-color: var(--fs-accent); font-weight: 500; @@ -3099,7 +3099,7 @@ async function deleteUser(userId: number) { border-radius: var(--fs-radius-sm); } .role-admin { - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); } .role-user { @@ -3179,9 +3179,9 @@ async function deleteUser(userId: number) { text-transform: uppercase; letter-spacing: 0.05em; padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm); } -.cat-audit { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); } -.cat-usage { color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 15%, transparent); } -.cat-error { color: var(--fs-error); background: color-mix(in srgb, var(--fs-error) 15%, transparent); } +.cat-audit { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); } +.cat-usage { color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 15%, transparent); } +.cat-error { color: var(--fs-error-fg); background: color-mix(in srgb, var(--fs-error) 15%, transparent); } .method-tag { display: inline-block; font-size: 0.65rem; font-weight: 500; font-family: monospace; @@ -3346,8 +3346,8 @@ async function deleteUser(userId: number) { padding: 0.15rem 0.4rem; border-radius: 4px; } -.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); } -.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); } +.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); } +.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); } .members-empty { color: var(--fs-text-tertiary); @@ -3528,7 +3528,7 @@ async function deleteUser(userId: number) { .day-btn.active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); border-color: var(--fs-accent); - color: var(--fs-accent); + color: var(--fs-accent-fg); font-weight: 500; } diff --git a/frontend/src/views/SharedWithMeView.vue b/frontend/src/views/SharedWithMeView.vue index 13d7e75..fe5720b 100644 --- a/frontend/src/views/SharedWithMeView.vue +++ b/frontend/src/views/SharedWithMeView.vue @@ -242,9 +242,9 @@ onMounted(async () => { border-radius: 4px; white-space: nowrap; } -.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); } -.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); } -.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); } +.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); } +.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); } +.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); } .empty-msg { margin: 0; diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue index 08657dc..f7d2720 100644 --- a/frontend/src/views/SnippetDetailView.vue +++ b/frontend/src/views/SnippetDetailView.vue @@ -288,7 +288,7 @@ async function confirmDelete() { font-family: var(--fs-font-mono); font-size: 0.82rem; background: color-mix(in srgb, var(--fs-accent) 12%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); padding: 0.08rem 0.35rem; border-radius: var(--fs-radius-sm); word-break: break-all; diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue index 4a739ad..99541e9 100644 --- a/frontend/src/views/SnippetListView.vue +++ b/frontend/src/views/SnippetListView.vue @@ -710,7 +710,7 @@ function usageTitle(s: SnippetListItem): string { flex-shrink: 0; white-space: nowrap; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } .snippet-when { @@ -739,7 +739,7 @@ function usageTitle(s: SnippetListItem): string { border-radius: 4px; white-space: nowrap; background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); - color: var(--fs-text-tertiary); + color: var(--fs-text-tertiary-fg); } .dup-action { @@ -754,7 +754,7 @@ function usageTitle(s: SnippetListItem): string { border-radius: 4px; white-space: nowrap; background: color-mix(in srgb, var(--fs-error) 15%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } .usage-tag { @@ -764,14 +764,14 @@ function usageTitle(s: SnippetListItem): string { white-space: nowrap; font-variant-numeric: tabular-nums; background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); - color: var(--fs-text-tertiary); + color: var(--fs-text-tertiary-fg); } /* Dead weight is a nudge, not an error — it warns in the warning colour rather than the danger one, because the record isn't broken, just unearned. */ .usage-tag.usage-dead { background: color-mix(in srgb, var(--fs-warning) 18%, transparent); - color: var(--fs-warning); + color: var(--fs-warning-fg); } /* Header + select-mode */ diff --git a/scripts/check_design_tokens.py b/scripts/check_design_tokens.py index 6670c7e..f6db544 100644 --- a/scripts/check_design_tokens.py +++ b/scripts/check_design_tokens.py @@ -119,15 +119,20 @@ def same_hue_text_on_tint(css: str) -> tuple[list[str], list[str]]: dangerous of the two — it does not even name a `-bg` token, so nothing about it looks like the pattern until you measure it. - Returned separately because they are at different stages. The token form - is CLEAN and therefore gates. The inline form has a live backlog (48 sites - when this split was written, 26 of them --fs-accent), so it reports with a - count: a gate nobody can satisfy today gets switched off, and then it - guards nothing. + Returned separately because they were paid down separately — the token + form first (7 badge pairs), then the inline form (46 sites across 18 + files, 26 of them --fs-accent). Both are clean now, so BOTH gate. The + split is kept because the two spellings need different error text: one + names a -bg token you can search for, the other names nothing at all. """ token_form, inline_form = [], [] for body in re.findall(r"\{([^{}]*)\}", css): - fg = set(re.findall(r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body)) + # (? int: print("OK — no text painted with a token on a tint of its own -bg.\n") if inline_tint_hits: - by_tok: dict[str, int] = {} - for _p, tok in inline_tint_hits: - by_tok[tok] = by_tok.get(tok, 0) + 1 - print(f"REPORT — {len(inline_tint_hits)} rule(s) paint text with a token on " - f"an INLINE color-mix tint of that same token.") - print(" Same defect, spelled without a -bg token so it does not gate yet.") - print(" Worst offenders: " + ", ".join( - f"{t} x{n}" for t, n in sorted(by_tok.items(), key=lambda kv: -kv[1])[:4]) + "\n") + print(f"FAIL — {len(inline_tint_hits)} rule(s) paint text with a token on an " + f"INLINE color-mix tint of that same token.") + print(" Identical defect to the block above, spelled without a -bg token —") + print(" which is what let it hide: nothing about it LOOKS like the pattern.") + print(" Use the token's -fg sibling.\n") + for path, tok in inline_tint_hits: + print(f" {path}: color: var({tok}) on an inline tint -> var({tok}-fg)") + print() + else: + print("OK — no text painted with a token on an inline tint of itself.\n") if args.report_literals: print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.") print(" Advisory: a literal is a value stated outside the system, so it " "cannot follow a palette change.\n") - return 1 if (unresolved or same_hue_hits) else 0 + return 1 if (unresolved or same_hue_hits or inline_tint_hits) else 0 if __name__ == "__main__": From f80401d58e41ddf7c924b747fcdb59deabdc9886 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 28 Aug 2026 12:06:43 -0400 Subject: [PATCH 02/23] fix(knowledge): the browse vocabulary catches up three kinds, and a snippet's mirror survives the generic door (#3128 recs 2-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike #3128 found the storage sound and the retrieval vocabulary frozen before `issue` shipped (0065). Five things, in the order they had to land. **The mirror (rec 5, the data-integrity one).** `notes.data` is DERIVED from a snippet's body, but only `update_snippet` knew that. `update_note` is a hasattr loop with no snippet awareness, and both doors reach it — so PATCH /api/notes/ {body} rewrote the body and left the mirror behind. `snippet_fields` PREFERS the mirror, so the row went on reporting its old repo/path/symbol to the location reverse lookup and to prior-art recall while displaying its new body: surfaced with full authority, and wrong. `snippets.recompose_data` rebuilds it from the body, carrying `verification` and `provenance` (neither is in the body to parse). An explicit `data` still wins, so every snippet-service write is untouched. **One facet table (rec 3), before adding any facet.** The type predicate was written three times — SQL, Python over semantic candidates, and a ternary computing the `is_task` pre-filter — and agreed only by luck. Adding `issue` to the SQL arm alone would have set the pre-filter to is_task=False, handed the Python arm a candidate set with no tasks in it, and returned an empty semantic half for the Issues facet forever with nothing red. `_FACETS` now generates all three. The Python arm also regains the `status IS NULL` half its SQL twin always had. **Issue and spike become facets (rec 2).** 435 issues — 17% of every task — were filterable nowhere on the human surface, while retired `plan` (90 rows) had a chip of its own. `_VALID_TYPES` was a hand-kept copy and is now derived. `plan` stays a valid facet for its legacy rows; it loses its chip. **Snippets stop being half-present in the feed (rec 4).** All 90 were in the All list, in no count, wearing an empty badge, and opening in the note editor. Counts now group by task_kind — every kind for the same two round-trips, which is why `issue` had no number — and total includes snippets, so the All chip matches the list it labels. Snippet cards route to /snippets/:id. **The prose that excused it (rec 6).** `snippet_fields` and the `data` column both still said pre-0070 rows were "never backfilled". True when 0070 landed, false since `backfill_snippet_data` shipped, and it read as licence for a stale mirror. Tests: the pre-filter can never exclude a row its own facet accepts (the regression, parameterised over every facet); both dialects select exactly their own rows; an unknown facet matches nothing; the mirror follows a body or title write, carries the verdict, and yields to an explicit `data`. `compiled_sql` moves to tests/helpers rather than becoming a third copy. Write-up: note #3161. --- frontend/src/views/KnowledgeView.vue | 67 +++++++-- src/scribe/models/note.py | 7 +- src/scribe/routes/knowledge.py | 16 +- src/scribe/services/knowledge.py | 175 ++++++++++++++-------- src/scribe/services/notes.py | 24 +++ src/scribe/services/snippets.py | 54 ++++++- tests/helpers.py | 10 ++ tests/test_knowledge_facets.py | 120 +++++++++++++++ tests/test_services_access_visibility.py | 5 +- tests/test_services_knowledge_counts.py | 91 ++++++++--- tests/test_snippet_mirror_generic_door.py | 111 ++++++++++++++ 11 files changed, 576 insertions(+), 104 deletions(-) create mode 100644 tests/test_knowledge_facets.py create mode 100644 tests/test_snippet_mirror_generic_door.py diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 46ad3ed..281da8b 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -24,7 +24,7 @@ const router = useRouter(); interface KnowledgeItem { id: number; - note_type: "note" | "task" | "process"; + note_type: "note" | "task" | "process" | "snippet"; title: string; snippet: string; tags: string[]; @@ -42,9 +42,34 @@ interface KnowledgeItem { task_kind?: TaskKind; } +// ─── The facet vocabulary ───────────────────────────────────────────────────── +// Mirrors services/knowledge._FACETS, which is where it is defined for real. +// A facet spans BOTH typing axes — a record TYPE (note / process / snippet) or +// a task KIND (`task` for any, else issue / spike) — because that is what this +// feed actually holds. +// +// `plan` is still a valid facet at the API, for the 90 legacy plan-tasks, but +// it has no chip: retired in 0066, it kept a chip of its own for longer than +// `issue` — 17% of every task here — went without one (#3128). Those rows are +// still reachable under Tasks, wearing a Plan badge. +type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process"; + +// The facets that select TASKS. Kinds are subsets of `task`, so any of them +// means the duplicate report should be comparing tasks. +const TASK_FACETS = new Set(["task", "issue", "spike"]); + +const FACET_CHIPS: [Exclude, string][] = [ + ["note", "Notes"], + ["task", "Tasks"], + ["issue", "Issues"], + ["spike", "Spikes"], + ["snippet", "Snippets"], + ["process", "Processes"], +]; + // ─── Filter state ───────────────────────────────────────────────────────────── -const activeType = ref<"" | "note" | "task" | "plan" | "process">(""); +const activeType = ref(""); const activeTag = ref(""); const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified"); const searchQuery = ref(""); @@ -70,9 +95,10 @@ const dupGroups = ref([]); const dupSuggestion = ref(""); const dupLoading = ref(false); const dupChecked = ref(false); -// The report follows the type filter: viewing tasks checks tasks. Anything -// else (all / plan / process) checks notes — the kind with the most to find. -const dupKind = computed(() => (activeType.value === "task" ? "task" : "note")); +// The report follows the type filter: viewing tasks — under ANY task facet, +// including a single kind — checks tasks. Everything else checks notes, the +// kind with the most to find. +const dupKind = computed(() => (TASK_FACETS.has(activeType.value) ? "task" : "note")); async function loadDuplicates() { dupLoading.value = true; @@ -96,8 +122,11 @@ watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; }); // ─── Type counts ────────────────────────────────────────────────────────────── -interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number } -const typeCounts = ref({ note: 0, task: 0, plan: 0, process: 0, total: 0 }); +// One number per facet, plus the grand total. Partial because the server sends +// a key only for a facet it has rows for. Kinds are subsets of `task` and are +// deliberately absent from `total` — including them would count an issue twice. +type KnowledgeCounts = Partial, number>> & { total: number }; +const typeCounts = ref({ total: 0 }); async function fetchCounts() { try { @@ -274,9 +303,18 @@ function isOverdue(item: KnowledgeItem): boolean { return new Date(item.due_date) < new Date(new Date().toDateString()); } +// Each record kind opens in ITS OWN editor. A snippet used to fall through to +// /notes/:id, whose save is a plain PATCH of the body — which left the snippet's +// derived `data` mirror describing the previous version (#3128). The service now +// recomposes the mirror either way, so this is no longer the guard; it is simply +// that the note editor cannot edit a snippet's signature, language or locations, +// and offering it as the way in was always wrong. Processes stay here on +// purpose: they have no editor of their own and the note editor knows the type. function openItem(item: KnowledgeItem) { if (item.note_type === 'task') { router.push(`/tasks/${item.id}`); + } else if (item.note_type === 'snippet') { + router.push(`/snippets/${item.id}`); } else { router.push(`/notes/${item.id}`); } @@ -380,14 +418,14 @@ onUnmounted(() => { {{ typeCounts.total }} @@ -501,6 +539,7 @@ onUnmounted(() => { Note {{ item.task_kind === 'plan' ? 'Plan' : 'Task' }} Process + Snippet +

+ Nothing to check. + {{ neverOnly + ? "Every note that carries a check has been confirmed at least once." + : "No note carries a check yet — add one to a note that asserts a fact." }} +

+ +
    +
  1. +
    + + + {{ n.days_since_verified === null + ? "never checked" + : `${n.days_since_verified}d ago` }} + +
    + +
    +
    Check
    +
    {{ n.verify_with }}
    + +
    + +
    + + +
    +
  2. +
+ +

+ Record a result only after actually running the check. “No longer true” stores nothing + on purpose — the note is wrong rather than in a state worth recording, so it keeps its + place here until you correct it, supersede it, or remove its check. +

+ + + + diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 0c00d75..42be645 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -31,6 +31,8 @@ export const useNotesStore = defineStore("notes", () => { project_id?: number | null; milestone_id?: number | null; note_type?: string; + verify_with?: string; + expires_when?: string; }): Promise { try { return await apiPost("/api/notes", data); @@ -42,7 +44,11 @@ export const useNotesStore = defineStore("notes", () => { async function updateNote( id: number, - data: Partial> + data: Partial> ): Promise { try { const note = await apiPut(`/api/notes/${id}`, data); diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index c11a9d3..2850ea3 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -34,6 +34,15 @@ export interface Note { is_task: boolean; note_type: NoteType; task_kind?: TaskKind; + // The note's own check (milestone 317). Empty on almost every note — that + // is the normal case: a note with no `verify_with` is a DECISION, and there + // is nothing to go and check. Only a note asserting a fact about something + // outside the operator's control carries one. `verified_at` null while + // `verify_with` is set means NOBODY HAS EVER CONFIRMED IT, which is the + // state the sweep ranks first. + verify_with?: string; + expires_when?: string; + verified_at?: string | null; systems?: System[]; arose_from_id?: number | null; created_at: string; diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue index 281da8b..61ca964 100644 --- a/frontend/src/views/KnowledgeView.vue +++ b/frontend/src/views/KnowledgeView.vue @@ -4,6 +4,7 @@ import { useRouter } from "vue-router"; import { apiGet } from "@/api/client"; import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note"; import KindBadge from "@/components/KindBadge.vue"; +import NoteSweepPane from "@/components/NoteSweepPane.vue"; import StatusBadge from "@/components/StatusBadge.vue"; import PriorityBadge from "@/components/PriorityBadge.vue"; import GraphView from "@/views/GraphView.vue"; @@ -13,6 +14,7 @@ import { Workflow, Search, Share2, + ShieldCheck, ChevronLeft, ChevronRight, X, @@ -67,6 +69,13 @@ const FACET_CHIPS: [Exclude, string][] = [ ["process", "Processes"], ]; +// ─── View mode ──────────────────────────────────────────────────────────────── +// The sweep is cross-cutting — a note that has gone false does not care which +// facet it sits under — so it REPLACES the browse list rather than filtering +// it. Filtering would mean the answer depended on which chip was active, which +// is the under-reporting the sweep exists to prevent (milestone 317 step 4). +const sweepActive = ref(false); + // ─── Filter state ───────────────────────────────────────────────────────────── const activeType = ref(""); @@ -263,6 +272,10 @@ function onSearchInput() { } watch([activeType, sortMode], () => resetAndReobserve()); +// Closing the sweep remounts the feed, and with it the scroll sentinel — a +// fresh element the old observer is not watching. Without this the list loads +// its first page and then never loads another. +watch(sweepActive, (open) => { if (!open) resetAndReobserve(); }); watch(activeTag, () => { fetchCounts(); resetAndReobserve(); }); // ─── Today bar ──────────────────────────────────────────────────────────────── @@ -471,6 +484,15 @@ onUnmounted(() => { Graph + + + + + diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 620467b..33bff3b 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -34,6 +34,14 @@ const tags = ref([]); const projectId = ref(null); const milestoneId = ref(null); const noteType = ref("note"); + +// The note's own check (milestone 317). Offered only for a plain note: a +// task's decay is its status, and a snippet has verify_snippet — the service +// refuses both, so the form must not ask for what the save would reject. +const verifyWith = ref(""); +const expiresWhen = ref(""); +const verifiedAt = ref(null); +const canCarryCheck = computed(() => noteType.value === "note"); const dirty = ref(false); const saving = ref(false); const showPreview = ref(false); @@ -198,6 +206,41 @@ let savedTags: string[] = []; let savedProjectId: number | null = null; let savedMilestoneId: number | null = null; let savedNoteType: NoteType = "note"; +let savedVerifyWith = ""; +let savedExpiresWhen = ""; + +/** The write, in one place. Three call sites (save, create, auto-save) each + * spelled this out, so every new field had to be added three times — which is + * how one of them ends up not carrying it. */ +function payload() { + return { + title: title.value, + body: body.value, + tags: tags.value, + project_id: projectId.value, + milestone_id: milestoneId.value, + note_type: noteType.value, + // "" clears the check: the REST door reads an empty string as NULL + // (NULLABLE_NOTE_TEXT), which is how a cleared form input says "remove + // this" without needing the MCP door's explicit `clear` list. + verify_with: canCarryCheck.value ? verifyWith.value : "", + expires_when: canCarryCheck.value ? expiresWhen.value : "", + }; +} + +/** What the form last agreed with the server about — the other half of the + * same list, and for the same reason. */ +function snapshot() { + savedTitle = title.value; + savedBody = body.value; + savedTags = [...tags.value]; + savedProjectId = projectId.value; + savedMilestoneId = milestoneId.value; + savedNoteType = noteType.value; + savedVerifyWith = verifyWith.value; + savedExpiresWhen = expiresWhen.value; + dirty.value = false; +} function markDirty() { dirty.value = @@ -206,7 +249,9 @@ function markDirty() { JSON.stringify(tags.value) !== JSON.stringify(savedTags) || projectId.value !== savedProjectId || milestoneId.value !== savedMilestoneId || - noteType.value !== savedNoteType; + noteType.value !== savedNoteType || + verifyWith.value !== savedVerifyWith || + expiresWhen.value !== savedExpiresWhen; } function onBodyUpdate(newVal: string) { @@ -224,12 +269,10 @@ onMounted(async () => { projectId.value = store.currentNote.project_id ?? null; milestoneId.value = store.currentNote.milestone_id ?? null; noteType.value = (store.currentNote.note_type as NoteType) || "note"; - savedTitle = title.value; - savedBody = body.value; - savedTags = [...tags.value]; - savedProjectId = projectId.value; - savedMilestoneId = milestoneId.value; - savedNoteType = noteType.value; + verifyWith.value = store.currentNote.verify_with || ""; + expiresWhen.value = store.currentNote.expires_when || ""; + verifiedAt.value = store.currentNote.verified_at ?? null; + snapshot(); } } else { // New note: read type from query param @@ -260,31 +303,11 @@ async function save() { const finalBody = body.value; try { if (isEditing.value) { - await store.updateNote(noteId.value!, { - title: title.value, - body: finalBody, - tags: tags.value, - project_id: projectId.value, - milestone_id: milestoneId.value, - note_type: noteType.value, - }); - savedTitle = title.value; - savedBody = body.value; - savedTags = [...tags.value]; - savedProjectId = projectId.value; - savedMilestoneId = milestoneId.value; - savedNoteType = noteType.value; - dirty.value = false; + await store.updateNote(noteId.value!, { ...payload(), body: finalBody }); + snapshot(); toast.show("Note saved"); } else { - const note = await store.createNote({ - title: title.value, - body: finalBody, - tags: tags.value, - project_id: projectId.value, - milestone_id: milestoneId.value, - note_type: noteType.value, - }); + const note = await store.createNote({ ...payload(), body: finalBody }); dirty.value = false; toast.show("Note created"); router.push(`/notes/${note.id}`); @@ -321,18 +344,8 @@ async function doAutoSave() { saving.value = true; const finalBody = body.value; try { - await store.updateNote(noteId.value!, { - title: title.value, body: finalBody, tags: tags.value, - project_id: projectId.value, milestone_id: milestoneId.value, - note_type: noteType.value, - }); - savedTitle = title.value; - savedBody = body.value; - savedTags = [...tags.value]; - savedProjectId = projectId.value; - savedMilestoneId = milestoneId.value; - savedNoteType = noteType.value; - dirty.value = false; + await store.updateNote(noteId.value!, { ...payload(), body: finalBody }); + snapshot(); toast.show("Auto-saved"); } catch { // Silent @@ -496,6 +509,41 @@ onUnmounted(() => assist.clearSelection()); + + +