feat(design): a project reports drift in its own recorded components
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 43s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 43s
The check has taken a project id since it was written — check_snippets_against_
system(user_id, design_system_id, project_id=0), and the route has always read
?project_id=. Nothing on the frontend ever passed one and no project-side
surface existed, so the capability shipped and stayed unreachable.
A Design tab on the project, beside Systems and Rules, reporting three things
per snippet:
no such token var(--x) the system doesn't declare. Renders as NOTHING —
no error, no failing test, just an element quietly unstyled.
Leads for that reason.
defines its own a component minting a custom property instead of reaching
for the shared one. This is the DRY finding and the reason
the surface exists: the codebase re-solving a solved
problem, one component at a time, visible only when someone
changes the shared value and half the components don't move.
write the token a literal the sheet says to stop writing, paired with what
to write instead.
Three empty states, kept distinct, because collapsing them is how a check comes
to sit dead: no design system bound, no snippets recorded (nothing was
checked), and checked-and-clean. The last one says how many were checked.
Bound to the SAVED pointer rather than the sidebar picker's draft, so an
unsaved change can't make the tab report against a system the project isn't
using.
Scope is recorded code, per the operator: snippets are what Scribe holds, and a
repository's own sources are checked where they live, by that project's CI.
Step 3 of milestone #274.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -200,6 +200,14 @@ export interface SnippetCheck {
|
|||||||
findings: SnippetFinding[];
|
findings: SnippetFinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which recorded snippets disagree with this design system's sheet. */
|
/** Which recorded snippets disagree with this design system's sheet.
|
||||||
export const checkSnippets = (id: number) =>
|
*
|
||||||
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
* `projectId` narrows to the snippets one project owns — which is how a
|
||||||
|
* project asks about its OWN code. Omit it to check every project, which is
|
||||||
|
* the right default from the system's side: a component recorded elsewhere
|
||||||
|
* still has to use the same tags. */
|
||||||
|
export const checkSnippets = (id: number, projectId?: number) =>
|
||||||
|
apiGet<SnippetCheck>(
|
||||||
|
`/api/design-systems/${id}/snippet-check`
|
||||||
|
+ (projectId ? `?project_id=${projectId}` : ""),
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* A project's own code, checked against the design system it is bound to (#2432).
|
||||||
|
*
|
||||||
|
* This is what the design surface is FOR: a project's recorded components
|
||||||
|
* measured against the sheet they are supposed to use. The check itself is not
|
||||||
|
* new — `check_snippets_against_system` has taken a project id since it was
|
||||||
|
* written, and the route has always read `?project_id=`. Nothing on this side
|
||||||
|
* ever passed one, so the capability shipped and stayed unreachable.
|
||||||
|
*
|
||||||
|
* The finding that matters most is the quiet one. `local_definitions` is a
|
||||||
|
* snippet minting its own custom property instead of reaching for the shared
|
||||||
|
* one — the codebase re-solving a solved problem, one component at a time.
|
||||||
|
* Nothing breaks, no test fails, and the duplication only becomes visible when
|
||||||
|
* someone changes the shared value and half the components don't move.
|
||||||
|
*
|
||||||
|
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
|
||||||
|
* snippets — because that is the code Scribe holds. A repository's own sources
|
||||||
|
* are checked where they live, by that project's CI.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
|
||||||
|
|
||||||
|
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
|
||||||
|
|
||||||
|
const check = ref<SnippetCheck | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
check.value = null;
|
||||||
|
failed.value = false;
|
||||||
|
if (props.designSystemId === null) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
check.value = await checkSnippets(props.designSystemId, props.projectId);
|
||||||
|
} catch {
|
||||||
|
// Said out loud rather than rendered as an empty result. "Couldn't check"
|
||||||
|
// and "nothing to report" look identical if you let them, and that is how
|
||||||
|
// a check comes to sit dead without anyone noticing (#2419).
|
||||||
|
failed.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(run);
|
||||||
|
watch(() => [props.projectId, props.designSystemId], run);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pdt">
|
||||||
|
<div v-if="designSystemId === null" class="pdt-note">
|
||||||
|
<strong>No design system for this project.</strong>
|
||||||
|
<p>
|
||||||
|
Bind one in the sidebar and this tab reports where the project's recorded
|
||||||
|
components disagree with it — references to tokens the system doesn't
|
||||||
|
have, literals it says to stop writing, and properties a component mints
|
||||||
|
for itself instead of reusing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets…</p>
|
||||||
|
|
||||||
|
<div v-else-if="failed" class="pdt-note">
|
||||||
|
<strong>The check couldn't run.</strong>
|
||||||
|
<p>Nothing was compared — this is a failure, not a clean result.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="check">
|
||||||
|
<p v-if="!check.checked" class="pdt-muted">
|
||||||
|
This project has no recorded snippets, so nothing was checked. Record the
|
||||||
|
components you reuse and they get measured against the sheet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="!check.findings.length" class="pdt-clean">
|
||||||
|
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
|
||||||
|
every reference resolves, and none mints a property of its own.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="pdt-summary">
|
||||||
|
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
|
||||||
|
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="pdt-list">
|
||||||
|
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
|
||||||
|
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
|
||||||
|
{{ f.title || "Untitled snippet" }}
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<!-- Renders as nothing at all: no error, no failing test, just an
|
||||||
|
element that quietly isn't styled. Leads for that reason. -->
|
||||||
|
<div v-if="f.unknown.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag unknown">no such token</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="f.local_definitions.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag local">defines its own</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="f.superseded_literals.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag superseded">write the token</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
|
||||||
|
<code>{{ s.literal }}</code> → <code>{{ s.use_instead }}</code>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pdt {
|
||||||
|
padding: var(--fs-space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-note {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 3px solid var(--color-warning);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
padding: var(--fs-space-3) var(--fs-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-note p {
|
||||||
|
margin: var(--fs-space-2) 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
line-height: var(--fs-leading-body);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-muted,
|
||||||
|
.pdt-clean,
|
||||||
|
.pdt-summary {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
margin: 0 0 var(--fs-space-3);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-clean {
|
||||||
|
color: var(--color-status-done);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-summary {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--fs-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-finding {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
padding: var(--fs-space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-title {
|
||||||
|
display: block;
|
||||||
|
font-weight: var(--fs-weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: var(--fs-space-2);
|
||||||
|
}
|
||||||
|
.pdt-title:hover { color: var(--color-primary-solid); }
|
||||||
|
|
||||||
|
.pdt-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--fs-tracking-tiny);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.unknown {
|
||||||
|
background: var(--color-priority-high-bg);
|
||||||
|
color: var(--color-priority-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.local {
|
||||||
|
background: var(--color-priority-medium-bg);
|
||||||
|
color: var(--color-priority-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.superseded {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
font-size: var(--fs-size-code);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-swap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,7 @@ import { useTasksStore } from "@/stores/tasks";
|
|||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
import { relativeTime } from "@/composables/useRelativeTime";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import ShareDialog from "@/components/ShareDialog.vue";
|
import ShareDialog from "@/components/ShareDialog.vue";
|
||||||
|
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import SystemsSection from "@/components/SystemsSection.vue";
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
import {
|
import {
|
||||||
@@ -108,7 +109,7 @@ async function confirmStartPlanning() {
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
|
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
|
||||||
|
|
||||||
const tasks = ref<NoteItem[]>([]);
|
const tasks = ref<NoteItem[]>([]);
|
||||||
const notes = ref<NoteItem[]>([]);
|
const notes = ref<NoteItem[]>([]);
|
||||||
@@ -570,6 +571,9 @@ async function confirmDelete() {
|
|||||||
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
||||||
Rules
|
Rules
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
|
||||||
|
Design
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tasks tab — milestone-grouped kanban -->
|
<!-- Tasks tab — milestone-grouped kanban -->
|
||||||
@@ -784,6 +788,16 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
<!-- Rules tab -->
|
<!-- Rules tab -->
|
||||||
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
||||||
|
|
||||||
|
<!-- Design tab: this project's recorded components against its sheet.
|
||||||
|
Bound to the SAVED pointer rather than the picker's draft value,
|
||||||
|
so an unsaved change in the sidebar can't make the tab report on
|
||||||
|
a system this project isn't using. -->
|
||||||
|
<ProjectDesignTab
|
||||||
|
v-if="activeTab === 'design'"
|
||||||
|
:project-id="projectId"
|
||||||
|
:design-system-id="project.design_system_id ?? null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user