CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 43s
#2277 counted ~150 "raw colour literals bypassing the tokens". Measuring them told a different story: 184 sat in `var(--token, #fallback)` position, and a check against theme.css shows every one of those tokens IS declared. So the fallbacks could not render. Not drift — vestigial. They were also not this palette. The most common were Tailwind and Flat-UI defaults — #6366f1 indigo, #22c55e green, #f59e0b amber, #3b82f6 blue, #e74c3c and #27ae60 — a second, unsanctioned colour scheme sitting in the codebase looking like the app's colours to anyone reading it. Removing them is not tidying. #2319's lesson is that a fallback is WORSE than a missing token: a missing token renders as nothing and someone eventually notices, while a fallback renders something plausible forever. These 184 were one token rename away from silently repainting the app in Tailwind. The design token check would catch the rename — but the fallback is precisely the thing that would make it invisible if the check were ever bypassed. Literal count 152 -> 45, which matters beyond the number: a report that is mostly unreachable noise is one people stop reading, and then it stops working while still passing. What remains should be genuinely worth looking at. Done with a paren-aware transform, not a regex — `var(--x, rgba(0,0,0,.5))` nests parens and `[^)]+` would cut at the first one and leave `))` behind. Verified after: every changed line is a fallback strip and nothing else, and every var() reference still resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
583 lines
15 KiB
Vue
583 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, nextTick, watch } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import {
|
||
getSnippet,
|
||
createSnippet,
|
||
updateSnippet,
|
||
type SnippetInput,
|
||
type SnippetLocation,
|
||
} from "@/api/snippets";
|
||
import { ApiError } from "@/api/client";
|
||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||
import { useSystemsStore } from "@/stores/systems";
|
||
import { useToastStore } from "@/stores/toast";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const toast = useToastStore();
|
||
const systemsStore = useSystemsStore();
|
||
|
||
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||
const isEditing = computed(() => editId.value !== null);
|
||
|
||
// All-string scalar state (tags/locations handled separately) so v-model and
|
||
// .trim() never meet an undefined.
|
||
interface FormState {
|
||
name: string;
|
||
code: string;
|
||
language: string;
|
||
signature: string;
|
||
when_to_use: string;
|
||
}
|
||
|
||
const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" });
|
||
|
||
const form = ref<FormState>({
|
||
name: "",
|
||
code: "",
|
||
language: "",
|
||
signature: "",
|
||
when_to_use: "",
|
||
});
|
||
// A snippet that unified several one-offs carries several locations; a fresh one
|
||
// starts with a single blank row.
|
||
const locations = ref<SnippetLocation[]>([blankLocation()]);
|
||
|
||
function addLocation() {
|
||
locations.value.push(blankLocation());
|
||
}
|
||
function removeLocation(i: number) {
|
||
locations.value.splice(i, 1);
|
||
}
|
||
const tagsText = ref("");
|
||
const projectId = ref<number | null>(null);
|
||
const systemIds = ref<number[]>([]);
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const loadError = ref<string | null>(null);
|
||
const nameRef = ref<HTMLInputElement | null>(null);
|
||
|
||
// Systems belong to a project, so the picker only has anything to offer once
|
||
// one is chosen — and it repopulates when the project changes.
|
||
const projectSystems = computed(() =>
|
||
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
|
||
);
|
||
|
||
async function loadSystems() {
|
||
if (!projectId.value) return;
|
||
try {
|
||
await systemsStore.fetchSystems(projectId.value);
|
||
} catch {
|
||
/* non-fatal — the systems picker just won't populate */
|
||
}
|
||
}
|
||
|
||
watch(projectId, (next, prev) => {
|
||
// Moving to another project invalidates system ids from the old one.
|
||
if (prev !== null && next !== prev) systemIds.value = [];
|
||
loadSystems();
|
||
});
|
||
|
||
const canSave = computed(
|
||
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
|
||
);
|
||
|
||
// A near-duplicate blocked on create: the same reusable thing is already
|
||
// recorded. Recording it twice is what merge then has to undo, so the editor
|
||
// offers the existing record before it offers to save anyway.
|
||
interface DuplicateHit {
|
||
existing_id: number;
|
||
existing_title: string;
|
||
}
|
||
const duplicate = ref<DuplicateHit | null>(null);
|
||
const forceCreate = ref(false);
|
||
|
||
function duplicateFrom(err: unknown): DuplicateHit | null {
|
||
if (!(err instanceof ApiError) || err.status !== 409) return null;
|
||
const body = err.body as Record<string, unknown>;
|
||
if (!body.duplicate) return null;
|
||
return {
|
||
existing_id: Number(body.existing_id),
|
||
existing_title: String(body.existing_title ?? "an existing snippet"),
|
||
};
|
||
}
|
||
|
||
async function load() {
|
||
if (!isEditing.value) {
|
||
// Recording from a project page carries the project through, so the snippet
|
||
// lands where the work is instead of unfiled.
|
||
if (route.query.projectId) {
|
||
projectId.value = Number(route.query.projectId);
|
||
await loadSystems();
|
||
}
|
||
await nextTick();
|
||
nameRef.value?.focus();
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
loadError.value = null;
|
||
try {
|
||
const s = await getSnippet(editId.value!);
|
||
const f = s.snippet;
|
||
form.value = {
|
||
name: f.name,
|
||
code: f.code,
|
||
language: f.language,
|
||
signature: f.signature,
|
||
when_to_use: f.when_to_use,
|
||
};
|
||
locations.value = f.locations?.length
|
||
? f.locations.map((l) => ({ ...l }))
|
||
: [blankLocation()];
|
||
// The stored tag list carries language + "snippet" markers, which the
|
||
// backend re-derives on save — show only the caller's extra tags here.
|
||
tagsText.value = s.tags
|
||
.filter((t) => t !== "snippet" && t !== f.language)
|
||
.join(", ");
|
||
projectId.value = s.project_id ?? null;
|
||
systemIds.value = (s.systems ?? []).map((sys) => sys.id);
|
||
await loadSystems();
|
||
} catch {
|
||
loadError.value = "Couldn't load this snippet to edit.";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(load);
|
||
|
||
function parseTags(): string[] {
|
||
return tagsText.value
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function cleanLocations(): SnippetLocation[] {
|
||
return locations.value
|
||
.map((l) => ({ repo: l.repo.trim(), path: l.path.trim(), symbol: l.symbol.trim() }))
|
||
.filter((l) => l.repo || l.path || l.symbol);
|
||
}
|
||
|
||
async function save() {
|
||
if (!canSave.value) return;
|
||
saving.value = true;
|
||
const payload: SnippetInput = {
|
||
name: form.value.name.trim(),
|
||
code: form.value.code,
|
||
language: form.value.language.trim(),
|
||
signature: form.value.signature.trim(),
|
||
when_to_use: form.value.when_to_use.trim(),
|
||
locations: cleanLocations(),
|
||
tags: parseTags(),
|
||
project_id: projectId.value,
|
||
system_ids: systemIds.value,
|
||
};
|
||
if (forceCreate.value) payload.force = true;
|
||
try {
|
||
const result = isEditing.value
|
||
? await updateSnippet(editId.value!, payload)
|
||
: await createSnippet(payload);
|
||
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
|
||
router.push(`/snippets/${result.id}`);
|
||
} catch (err) {
|
||
// A blocked near-duplicate isn't a failure — it's the record telling you
|
||
// this already exists. Offer the existing one rather than a red toast.
|
||
const dup = duplicateFrom(err);
|
||
if (dup) {
|
||
duplicate.value = dup;
|
||
saving.value = false;
|
||
return;
|
||
}
|
||
toast.show("Failed to save snippet", "error");
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveAnyway() {
|
||
duplicate.value = null;
|
||
forceCreate.value = true;
|
||
await save();
|
||
}
|
||
|
||
function cancel() {
|
||
if (isEditing.value) router.push(`/snippets/${editId.value}`);
|
||
else router.push("/snippets");
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<main class="snippet-editor" @keydown.ctrl.s.prevent="save" @keydown.meta.s.prevent="save">
|
||
<router-link :to="isEditing ? `/snippets/${editId}` : '/snippets'" class="back-link">
|
||
← {{ isEditing ? "Back to snippet" : "Snippets" }}
|
||
</router-link>
|
||
|
||
<h1>{{ isEditing ? "Edit snippet" : "New snippet" }}</h1>
|
||
|
||
<div v-if="loading" class="state-msg">Loading…</div>
|
||
<p v-else-if="loadError" class="error-msg">{{ loadError }}</p>
|
||
|
||
<form v-else class="form" @submit.prevent="save">
|
||
<div class="field">
|
||
<label for="sn-name">Name <span class="required">*</span></label>
|
||
<input
|
||
id="sn-name"
|
||
ref="nameRef"
|
||
v-model="form.name"
|
||
type="text"
|
||
class="input mono"
|
||
placeholder="useDebouncedRef"
|
||
@keydown.escape="cancel"
|
||
/>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="sn-when">When to reach for it</label>
|
||
<input
|
||
id="sn-when"
|
||
v-model="form.when_to_use"
|
||
type="text"
|
||
class="input"
|
||
placeholder="Debounce a reactive ref that updates too often"
|
||
@keydown.escape="cancel"
|
||
/>
|
||
<p class="hint">Shown in the recall menu — keep it sharp.</p>
|
||
</div>
|
||
|
||
<div class="field-row">
|
||
<div class="field">
|
||
<label for="sn-lang">Language</label>
|
||
<input
|
||
id="sn-lang"
|
||
v-model="form.language"
|
||
type="text"
|
||
class="input"
|
||
placeholder="typescript"
|
||
@keydown.escape="cancel"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label for="sn-sig">Signature</label>
|
||
<input
|
||
id="sn-sig"
|
||
v-model="form.signature"
|
||
type="text"
|
||
class="input mono"
|
||
placeholder="useDebouncedRef(value, ms)"
|
||
@keydown.escape="cancel"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<fieldset class="location-set">
|
||
<legend>
|
||
Locations
|
||
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||
</legend>
|
||
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
||
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||
<button
|
||
type="button"
|
||
class="loc-remove"
|
||
aria-label="Remove location"
|
||
title="Remove location"
|
||
@click="removeLocation(i)"
|
||
>×</button>
|
||
</div>
|
||
<button type="button" class="loc-add" @click="addLocation">+ Add location</button>
|
||
</fieldset>
|
||
|
||
<div class="field">
|
||
<label for="sn-code">Code <span class="required">*</span></label>
|
||
<textarea
|
||
id="sn-code"
|
||
v-model="form.code"
|
||
class="input mono code-area"
|
||
rows="14"
|
||
spellcheck="false"
|
||
placeholder="Paste the reusable implementation…"
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="sn-tags">Tags</label>
|
||
<input
|
||
id="sn-tags"
|
||
v-model="tagsText"
|
||
type="text"
|
||
class="input"
|
||
placeholder="composable, ui (comma-separated)"
|
||
@keydown.escape="cancel"
|
||
/>
|
||
<p class="hint">Extra tags. Language and “snippet” are added automatically.</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="sn-project">Project</label>
|
||
<ProjectSelector id="sn-project" v-model="projectId" />
|
||
<p class="hint">
|
||
Snippets surface proactively within their project; search reaches across all of them.
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="projectId" class="field">
|
||
<span class="field-label">Systems</span>
|
||
<div v-if="projectSystems.length" class="systems">
|
||
<label v-for="s in projectSystems" :key="s.id" class="system-opt">
|
||
<input type="checkbox" :value="s.id" v-model="systemIds" />
|
||
<span>{{ s.name }}</span>
|
||
</label>
|
||
</div>
|
||
<p v-else class="hint">No systems in this project yet.</p>
|
||
</div>
|
||
|
||
<div v-if="duplicate" class="duplicate" role="alert">
|
||
<p class="duplicate-title">This may already be recorded</p>
|
||
<p class="duplicate-body">
|
||
A similar snippet exists —
|
||
<router-link :to="`/snippets/${duplicate.existing_id}`">
|
||
{{ duplicate.existing_title }}
|
||
</router-link
|
||
>. Reuse or edit that one rather than keeping two copies of the same thing.
|
||
</p>
|
||
<div class="duplicate-actions">
|
||
<button type="button" class="btn-secondary" @click="saveAnyway">
|
||
Record it anyway
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
|
||
<button type="submit" class="btn-primary" :disabled="!canSave">
|
||
{{ saving ? "Saving…" : isEditing ? "Save changes" : "Create snippet" }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.snippet-editor {
|
||
max-width: 760px;
|
||
margin: 2rem auto;
|
||
padding: 0 var(--page-padding-x);
|
||
overflow-x: clip;
|
||
}
|
||
.snippet-editor h1 {
|
||
margin: 0 0 1.25rem;
|
||
}
|
||
|
||
.back-link {
|
||
display: inline-block;
|
||
margin-bottom: 1rem;
|
||
font-size: 0.85rem;
|
||
color: var(--color-text-secondary);
|
||
text-decoration: none;
|
||
}
|
||
.back-link:hover {
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.state-msg {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
}
|
||
.error-msg {
|
||
color: var(--color-danger);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1.1rem;
|
||
}
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
min-width: 0;
|
||
}
|
||
.field-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
}
|
||
.field-row.three {
|
||
grid-template-columns: 1fr 1.4fr 1fr;
|
||
}
|
||
.field label,
|
||
.location-set legend {
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
.required {
|
||
color: var(--color-danger);
|
||
}
|
||
.hint {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
margin: 0.1rem 0 0;
|
||
}
|
||
.hint-inline {
|
||
font-weight: 400;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.input {
|
||
padding: 0.5rem 0.7rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
font-size: 0.9rem;
|
||
font-family: inherit;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
}
|
||
.input:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: var(--focus-ring);
|
||
}
|
||
.mono {
|
||
font-family: var(--font-mono);
|
||
}
|
||
.code-area {
|
||
resize: vertical;
|
||
line-height: 1.6;
|
||
white-space: pre;
|
||
overflow-wrap: normal;
|
||
overflow-x: auto;
|
||
tab-size: 2;
|
||
}
|
||
|
||
.location-set {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
padding: 0.85rem 1rem 1rem;
|
||
margin: 0;
|
||
}
|
||
.location-set legend {
|
||
padding: 0 0.4rem;
|
||
}
|
||
.loc-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1.4fr 1fr auto;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.loc-remove {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border: 1px solid var(--color-border);
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 1.1rem;
|
||
line-height: 1;
|
||
}
|
||
.loc-remove:hover {
|
||
border-color: var(--color-danger);
|
||
color: var(--color-danger);
|
||
}
|
||
.loc-add {
|
||
margin-top: 0.15rem;
|
||
padding: 0.35rem 0.7rem;
|
||
border: 1px dashed var(--color-border);
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
font-family: inherit;
|
||
}
|
||
.loc-add:hover {
|
||
border-color: var(--color-primary);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.loc-row {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
.field-label {
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
.systems {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
}
|
||
.system-opt {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
font-size: 0.85rem;
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
}
|
||
.system-opt input {
|
||
accent-color: var(--color-primary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.duplicate {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
padding: 0.85rem 1rem;
|
||
border: 1px solid var(--color-border);
|
||
border-left: 3px solid var(--color-warning);
|
||
border-radius: 8px;
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.duplicate-title {
|
||
margin: 0;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
.duplicate-body {
|
||
margin: 0;
|
||
font-size: 0.8rem;
|
||
line-height: 1.5;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.duplicate-body a {
|
||
color: var(--color-primary);
|
||
}
|
||
.duplicate-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.field-row,
|
||
.field-row.three {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|