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[];
|
||||
}
|
||||
|
||||
/** Which recorded snippets disagree with this design system's sheet. */
|
||||
export const checkSnippets = (id: number) =>
|
||||
apiGet<SnippetCheck>(`/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<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 { 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<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 notes = ref<NoteItem[]>([]);
|
||||
@@ -570,6 +571,9 @@ async function confirmDelete() {
|
||||
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
||||
Rules
|
||||
</button>
|
||||
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
|
||||
Design
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tasks tab — milestone-grouped kanban -->
|
||||
@@ -784,6 +788,16 @@ async function confirmDelete() {
|
||||
|
||||
<!-- Rules tab -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user