docs(6208): ProjectView, KnowledgeView, SnippetListView and components.css comments drop citations and stories
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 52s

components.css says why shared recipes live there without the five-copy
.btn-primary count; KnowledgeView's editor-routing and chip comments state
the present reason instead of the bug that once routed snippets wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 21:10:45 -04:00
co-authored by Claude Opus 5.5
parent 637ec66b79
commit 699a4cd986
4 changed files with 39 additions and 53 deletions
+11 -16
View File
@@ -3,11 +3,10 @@
* *
* WHY THIS FILE EXISTS * WHY THIS FILE EXISTS
* *
* `.btn-primary` was defined five times, in five scoped stylesheets, and all * A recipe copied into several scoped stylesheets drifts — paddings, font
* five had drifted: three paddings, three font sizes, three disabled opacities, * sizes, disabled states — and nothing detects it, because a scoped duplicate
* and one view with no disabled style at all (#2273). Nothing detected that, * is invisible to every tool: it isn't a rule violation, isn't a broken
* because a scoped duplicate is invisible to every tool — it isn't a rule * reference, and isn't a recorded snippet. Shared recipes live here instead.
* violation, isn't a broken reference, and isn't a recorded snippet.
* *
* GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or * GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or
* scale change moves the buttons rather than stranding a copy that no longer * scale change moves the buttons rather than stranding a copy that no longer
@@ -301,9 +300,7 @@
/* ── Page container ───────────────────────────────────────────────────────── /* ── Page container ─────────────────────────────────────────────────────────
The one wrapper a top-level view sits in: page width from the layout The one wrapper a top-level view sits in: page width from the layout
tokens, centred, clipped horizontally so a wide child (a kanban, a table) tokens, centred, clipped horizontally so a wide child (a kanban, a table)
scrolls inside itself instead of the page. ProjectListView, ProjectView and scrolls inside itself instead of the page. */
SnippetListView each carried this rule under their own name until #2903
(milestone 299). */
.page-container { .page-container {
max-width: var(--fs-layout-page-max); max-width: var(--fs-layout-page-max);
margin: 2rem auto; margin: 2rem auto;
@@ -311,12 +308,11 @@
overflow-x: clip; overflow-x: clip;
} }
/* ── Form input (fs-surfaces, snippet #2336) ──────────────────────────────── /* ── Form input (fs-surfaces) ───────────────────────────────────────────────
Inputs sit DARKER than the page they're on — an inset well rather than a Inputs sit DARKER than the page they're on — an inset well rather than a
raised panel; that inversion is what makes a field read as writable. The raised panel; that inversion is what makes a field read as writable. The
design system's recipe, verbatim; width/box-sizing stay the caller's design system's recipe, verbatim; width/box-sizing stay the caller's
(an inline select and a full-width textarea differ there). Three scoped (an inline select and a full-width textarea differ there). */
copies of an older input recipe were folded into this in #2903. */
.fs-input { .fs-input {
background: var(--fs-surface-page); background: var(--fs-surface-page);
border: var(--fs-border); border: var(--fs-border);
@@ -331,8 +327,8 @@
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); } .fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; } .fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* Page scaffold + feedback text recipes (milestone 302, note 2917): name /* Page scaffold + feedback text recipes: families that are one recipe
families the consumer map showed to be one recipe living in many views. living in many views.
A view keeps only its deviation as a scoped remainder/override. */ A view keeps only its deviation as a scoped remainder/override. */
.page-header { .page-header {
display: flex; display: flex;
@@ -355,9 +351,8 @@
/* --- usage badge ---------------------------------------------------------- /* --- usage badge ----------------------------------------------------------
"surfaced N×, opened M×" on a list row, for any record kind the retrieval "surfaced N×, opened M×" on a list row, for any record kind the retrieval
surfaces can choose: snippets and notes from note_usage_events, rules from surfaces can choose: snippets and notes from note_usage_events, rules from
rule_usage_events. Promoted here from SnippetListView's scoped block when rule_usage_events. Shared here because a second scoped copy is how a
the rule list needed the same chip (milestone 333 step 5) — a second scoped duplicated CSS family starts.
copy is how the ninth duplicated CSS family starts (#3207).
Geometry and colour only. A view keeps its own spacing as a scoped Geometry and colour only. A view keeps its own spacing as a scoped
remainder, the way it does for every other recipe in this file. */ remainder, the way it does for every other recipe in this file. */
+14 -23
View File
@@ -48,7 +48,7 @@ interface KnowledgeItem {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
/** Surfaced-vs-opened counts, zero-filled by the route for EVERY row, so /** Surfaced-vs-opened counts, zero-filled by the route for EVERY row, so
* "never surfaced" is a value here rather than a missing field (#4230). * "never surfaced" is a value here rather than a missing field.
* `UsageBadge` renders nothing at all below one surfacing. */ * `UsageBadge` renders nothing at all below one surfacing. */
usage?: RecordUsage | null; usage?: RecordUsage | null;
// Set only when another user owns this record — their suggestion, not one of // Set only when another user owns this record — their suggestion, not one of
@@ -69,10 +69,9 @@ interface KnowledgeItem {
// a task KIND (`task` for any, else issue / spike) — because that is what this // a task KIND (`task` for any, else issue / spike) — because that is what this
// feed actually holds. // feed actually holds.
// //
// `plan` is still a valid facet at the API, for the 90 legacy plan-tasks, but // `plan` is still a valid facet at the API, for legacy plan-tasks, but it has
// it has no chip: retired in 0066, it kept a chip of its own for longer than // no chip: plans are milestones now. Those rows are still reachable under
// `issue` — 17% of every task here — went without one (#3128). Those rows are // Tasks, wearing a Plan badge.
// still reachable under Tasks, wearing a Plan badge.
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process" | "lesson"; type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process" | "lesson";
// The facets that select TASKS. Kinds are subsets of `task`, so any of them // The facets that select TASKS. Kinds are subsets of `task`, so any of them
@@ -93,7 +92,7 @@ const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
// The sweep is cross-cutting — a note that has gone false does not care which // 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 // 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 // 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). // is the under-reporting the sweep exists to prevent.
const sweepActive = ref(false); const sweepActive = ref(false);
// ─── Filter state ───────────────────────────────────────────────────────────── // ─── Filter state ─────────────────────────────────────────────────────────────
@@ -201,8 +200,8 @@ const items = ref<KnowledgeItem[]>([]);
// True only when a search actually returned matched passages — not merely when // True only when a search actually returned matched passages — not merely when
// the box has text in it. A keyword-only result set, or an embedder that is // the box has text in it. A keyword-only result set, or an embedder that is
// down, carries `body_opening` rows, and claiming otherwise would be the same // down, carries `body_opening` rows, and labelling those as matched passages
// species of lie this whole change is about (#4243). // would misstate why the row is there.
const showsMatchedPassages = computed( const showsMatchedPassages = computed(
() => () =>
searchQuery.value.trim().length > 0 && searchQuery.value.trim().length > 0 &&
@@ -351,12 +350,8 @@ function isOverdue(item: KnowledgeItem): boolean {
return new Date(item.due_date) < new Date(new Date().toDateString()); 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 // Each record kind opens in ITS OWN editor: the note editor cannot edit a
// /notes/:id, whose save is a plain PATCH of the body — which left the snippet's // snippet's signature, language or locations, so it is not the way in. Processes stay here on
// 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. // purpose: they have no editor of their own and the note editor knows the type.
function openItem(item: KnowledgeItem) { function openItem(item: KnowledgeItem) {
if (item.note_type === 'task') { if (item.note_type === 'task') {
@@ -666,7 +661,7 @@ onUnmounted(() => {
class="shared-tag" class="shared-tag"
:title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`" :title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`"
>by {{ item.owner ?? "another user" }}</span> >by {{ item.owner ?? "another user" }}</span>
<!-- Surfaced-vs-opened (#4230). This is the only list in the UI <!-- Surfaced-vs-opened. This is the only list in the UI
that browses notes and lessons, so it is the only place that browses notes and lessons, so it is the only place
those two kinds can show the counter at all. The advice is those two kinds can show the counter at all. The advice is
looked up per row because this feed is mixed — the remedy looked up per row because this feed is mixed — the remedy
@@ -1015,9 +1010,7 @@ onUnmounted(() => {
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; } .badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; } .badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; } .badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
/* Snippet and process are NEUTRAL on purpose. Both were unstyled — and the /* Snippet and process are NEUTRAL on purpose. Giving them hues would put a third and fourth colour
snippet had no label either, so all 90 of them rendered an empty chip in
this feed (#3128). Giving them hues would put a third and fourth colour
beside KindBadge's warm/cool pair on the same card; a record type that beside KindBadge's warm/cool pair on the same card; a record type that
isn't an alarm reads better as plain. Standard body pair, so the contrast isn't an alarm reads better as plain. Standard body pair, so the contrast
is the one the palette already guarantees. */ is the one the palette already guarantees. */
@@ -1158,11 +1151,9 @@ onUnmounted(() => {
border-bottom: 1px solid var(--fs-border-color); border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0; flex-shrink: 0;
} }
/* RESTORED (#2444). The panel is a flex COLUMN and its header is /* The panel is a flex COLUMN and its header is `flex-shrink: 0`, so this is
`flex-shrink: 0`, so this is the item that takes the remaining height — and the item that takes the remaining height — without it the `height: 100%`
without it the `height: 100%` below resolves against `auto` and does below resolves against `auto` and does nothing. `min-height: 0` is the companion that lets a flex item shrink under
nothing, which made the comment underneath a spec for a rule that could not
work. `min-height: 0` is the companion that lets a flex item shrink under
its content instead of overflowing the panel. */ its content instead of overflowing the panel. */
.graph-embed { .graph-embed {
flex: 1; flex: 1;
+12 -12
View File
@@ -96,7 +96,7 @@ const tasksStore = useTasksStore();
const project = ref<Project | null>(null); const project = ref<Project | null>(null);
// Design system the project is styled from. Loaded separately because an // Design system the project is styled from. Loaded separately because an
// install with none is the ordinary case (rule #115) and the picker simply // install with none is the ordinary case and the picker simply
// doesn't render — a failed fetch must not take the project page with it. // doesn't render — a failed fetch must not take the project page with it.
const designSystems = ref<DesignSystem[]>([]); const designSystems = ref<DesignSystem[]>([]);
const editDesignSystemId = ref<number | null>(null); const editDesignSystemId = ref<number | null>(null);
@@ -189,8 +189,8 @@ function groupKey(group: MilestoneGroup): number {
} }
// A group's Done column starts COLLAPSED, to its header and count. Done work is // A group's Done column starts COLLAPSED, to its header and count. Done work is
// the part of a board nobody is reading, and listed in full it pushed the open // the part of a board nobody is reading, and listed in full it pushes the open
// columns of every group below it off the screen (operator, 2026-09-15). Not // columns of every group below it off the screen. Not
// persisted, matching the milestone collapse beside it: each load starts from // persisted, matching the milestone collapse beside it: each load starts from
// the same defaults. // the same defaults.
const expandedDone = ref<Set<number>>(new Set()); const expandedDone = ref<Set<number>>(new Set());
@@ -418,7 +418,7 @@ async function loadTasks() {
} }
} }
} catch { } catch {
// Say so. This used to swallow the error and leave an empty board, which is // Say so. A swallowed error leaves an empty board, which is
// indistinguishable from a project with no tasks — the same "hidden with no // indistinguishable from a project with no tasks — the same "hidden with no
// indicator" failure as the truncation above, one layer up. // indicator" failure as the truncation above, one layer up.
tasksError.value = "Could not load tasks. Refresh to try again."; tasksError.value = "Could not load tasks. Refresh to try again.";
@@ -465,7 +465,7 @@ async function loadNotes() {
} }
} }
/* ── Pattern-library coverage (#2692) ─────────────────────────── */ /* ── Pattern-library coverage ─────────────────────────────────── */
interface CoverageGap { interface CoverageGap {
dir: string; dir: string;
@@ -488,11 +488,11 @@ interface Coverage {
computed_at: string; computed_at: string;
repos: { repo: string; ref: string; total: number; accounted: number }[]; repos: { repo: string; ref: string; total: number; accounted: number }[];
largest_gaps: CoverageGap[]; largest_gaps: CoverageGap[];
// The mechanical proposer's standing (#2792): canon proposals awaiting an // The mechanical proposer's standing: canon proposals awaiting an
// agent's confirm, and the biggest repeats-with-no-canon families. // agent's confirm, and the biggest repeats-with-no-canon families.
proposed?: number; proposed?: number;
derive_groups?: DeriveGroup[]; derive_groups?: DeriveGroup[];
// The divergence readout (#2793): button B where button A is canon, and // The divergence readout: button B where button A is canon, and
// judged shapes whose bodies moved since they were judged. // judged shapes whose bodies moved since they were judged.
divergent?: number; divergent?: number;
divergence?: Divergence[]; divergence?: Divergence[];
@@ -543,7 +543,7 @@ async function refreshCoverage() {
} }
} }
/* ── Forge pin (#2778) — which of the OWNER's connections serves this /* ── Forge pin — which of the OWNER's connections serves this
project. Owner-only UI: the select lists the viewer's own keyring, which project. Owner-only UI: the select lists the viewer's own keyring, which
is only the eligible set when the viewer IS the owner. ── */ is only the eligible set when the viewer IS the owner. ── */
@@ -737,7 +737,7 @@ async function confirmDelete() {
</p> </p>
</div> </div>
<!-- Inception (milestone 297): the owner of an undecided project is asked <!-- Inception: the owner of an undecided project is asked
what it inherits; once recorded, one line says what was decided. --> what it inherits; once recorded, one line says what was decided. -->
<InceptionCard <InceptionCard
v-if="project.inception == null && isProjectOwner" v-if="project.inception == null && isProjectOwner"
@@ -779,7 +779,7 @@ async function confirmDelete() {
</div> </div>
</div> </div>
<!-- Pattern-library coverage (#2692) — only when a forge is configured; <!-- Pattern-library coverage — only when a forge is configured;
forge-less installs never see this card at all. --> forge-less installs never see this card at all. -->
<div v-if="coverageConfigured" class="coverage-card"> <div v-if="coverageConfigured" class="coverage-card">
<div class="coverage-head"> <div class="coverage-head">
@@ -874,7 +874,7 @@ async function confirmDelete() {
the shape ledger and reports how many are classified against canon. the shape ledger and reports how many are classified against canon.
</p> </p>
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p> <p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
<!-- Forge pin (#2778): owner-only, because the eligible set is the <!-- Forge pin: owner-only, because the eligible set is the
owner's own keyring. Automatic = resolve by repo host. --> owner's own keyring. Automatic = resolve by repo host. -->
<div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row"> <div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
<label for="forge-pin" class="coverage-gaps-label">Forge</label> <label for="forge-pin" class="coverage-gaps-label">Forge</label>
@@ -1200,7 +1200,7 @@ async function confirmDelete() {
:design-system-id="project.design_system_id ?? null" :design-system-id="project.design_system_id ?? null"
/> />
<!-- Family tab (milestone 463): which platforms this project is. --> <!-- Family tab: which platforms this project is. -->
<ProjectFamilyTab <ProjectFamilyTab
v-if="activeTab === 'family'" v-if="activeTab === 'family'"
:project-id="projectId" :project-id="projectId"
+2 -2
View File
@@ -42,7 +42,7 @@ function toggleLocationFilter() {
if (!showLocationFilter.value && locationActive.value) clearLocation(); if (!showLocationFilter.value && locationActive.value) clearLocation();
} }
// Drift check (#2086) — "attention" is everything whose recorded location or // Drift check — "attention" is everything whose recorded location or
// code no longer checks out, plus everything whose verdict expired because the // code no longer checks out, plus everything whose verdict expired because the
// snippet was edited since it was checked. // snippet was edited since it was checked.
const needsAttentionOnly = ref(false); const needsAttentionOnly = ref(false);
@@ -58,7 +58,7 @@ const showMergeModal = ref(false);
const canonicalId = ref<number | null>(null); const canonicalId = ref<number | null>(null);
const merging = ref(false); const merging = ref(false);
// Near-duplicate report (#2088). Loaded on demand, not with the list: it's a // Near-duplicate report. Loaded on demand, not with the list: it's a
// pairwise scan and most visits to this page aren't a tidy-up. // pairwise scan and most visits to this page aren't a tidy-up.
const duplicateGroups = ref<DuplicateGroup[]>([]); const duplicateGroups = ref<DuplicateGroup[]>([]);
const dupLoading = ref(false); const dupLoading = ref(false);