Files
FabledScribe/frontend/src/views/SnippetEditorView.vue
T
bvandeusenandClaude Opus 5 1a959b1db0
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 39s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 1m12s
refactor(ui): one button definition, aligned to the design system
`.btn-primary` was defined five times in five scoped stylesheets and all five
had drifted: three paddings, three font sizes, three disabled opacities — and
ProjectListView had no disabled style at all, so a disabled button there looked
enabled. Nothing detected any of it. A scoped duplicate is not a rule
violation, not a broken reference, and not a recorded snippet, so no existing
check could see it (#2273).

assets/components.css is now the single definition of the core four —
primary, secondary, ghost, danger — plus the small modifier, in design-system
tokens throughout. Operator's call to align to the system rather than to the
majority of current values, so buttons move to the 8px radius and 12px label
the system specifies, from the app's 4px/14.4px.

Class names are unchanged, so there are no template edits: the existing surface
is repurposed, not rebuilt.

STAGING PROPERTY that makes this safe to land ahead of the rest: a Vue
`<style scoped>` rule compiles to `.btn-primary[data-v-…]` (specificity 0,2,0)
and beats a plain global selector (0,1,0). So the shared sheet changes nothing
for a view still carrying its own copy, and every intermediate state of the
remaining migration is coherent rather than half-applied.

Two divergences corrected on the way, both worth naming:

- SnippetEditorView's `.btn-secondary` was a GHOST in disguise — outline
  styling under the secondary name, while the house style says secondary is
  filled bronze. It now looks like what it is called.
- SnippetDetailView and SnippetListView tinted a ghost button's label with the
  ACCENT on hover. The house style reserves the accent for identity and active
  state, never general chrome.

DesignView reported "no shared button exists" as an honest gap and declined to
draw a look-alike. That gap is closed, so it now renders the app's real
classes — the specimens cannot drift from the app without drifting the app.

Net -177 lines. Follows: the ~20 semantic one-offs (.btn-save, .btn-delete,
.btn-toggle …) and the compound overrides in ProjectView, one of which puts the
accent on a primary action button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 19:52:35 -04:00

583 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, ui-monospace, "JetBrains Mono", monospace);
}
.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, var(--color-primary));
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>