6db791965f
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / integration (push) Successful in 36s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 44s
#231's premise was unifying reusable things already scattered as one-offs. The create gate PREVENTS a new duplicate and merge_snippets CURES one you point it at, but nothing FOUND the duplicates already in the record — someone had to notice them by hand, which is the exact failure the Drafter exists to remove. One indexed self-join over note_embeddings, not an N² Python scan: pgvector's cosine distance is the same operator semantic search uses, so a similarity floor is a distance ceiling and the work stays in Postgres. `left.note_id < right.note_id` yields each unordered pair once and drops the self-pair that would otherwise dominate the ranking. Pairs are collapsed into merge SETS by connected components. Transitive on purpose: A~B plus B~C puts all three together even when A and C don't directly clear the bar, which is what merge actually does (it folds every source into one survivor). The cost is that a chain of mild resemblances can rope in a member that isn't really alike — so the UI presents a set as a proposal, shows the members, and never merges without a confirm. Two scope decisions worth naming: - OWN snippets only. merge_snippets requires one owner across the set, so surfacing someone else's would propose a merge that cannot be performed. The report is bounded by what the operator can act on, not what they can see. - Threshold defaults to 0.82, LOOSER than the write gate's 0.90, and is a setting rather than a constant (rule #25). The gate blocks a create and has to be unforgiving of noise; this only suggests a merge under review, so it must reach further or it would never surface the pairs the gate already let through — which are precisely the ones that accumulated. Fixes a real bug in the merge flow while wiring the UI: selectedList filtered the selection against the CURRENT PAGE, and doMerge derives its source ids from that list. A corpus-wide suggested group with off-page members would have rendered incomplete and silently merged only the visible subset. A group under review is now the authority for that list. Refs #2088 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
1043 lines
30 KiB
Vue
1043 lines
30 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import {
|
||
findDuplicateSnippets,
|
||
listSnippets,
|
||
mergeSnippets,
|
||
type DuplicateGroup,
|
||
type SnippetListItem,
|
||
} from "@/api/snippets";
|
||
import { useToastStore } from "@/stores/toast";
|
||
|
||
const router = useRouter();
|
||
const toast = useToastStore();
|
||
|
||
const snippets = ref<SnippetListItem[]>([]);
|
||
const loading = ref(false);
|
||
const error = ref<string | null>(null);
|
||
const search = ref("");
|
||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||
|
||
// Reverse lookup — "what already lives here?" All three must match the same
|
||
// recorded location; path also matches anything beneath it.
|
||
const showLocationFilter = ref(false);
|
||
const locRepo = ref("");
|
||
const locPath = ref("");
|
||
const locSymbol = ref("");
|
||
const locationActive = computed(
|
||
() => !!(locRepo.value.trim() || locPath.value.trim() || locSymbol.value.trim()),
|
||
);
|
||
function clearLocation() {
|
||
locRepo.value = "";
|
||
locPath.value = "";
|
||
locSymbol.value = "";
|
||
loadSnippets();
|
||
}
|
||
function toggleLocationFilter() {
|
||
showLocationFilter.value = !showLocationFilter.value;
|
||
// Collapsing while filtered would hide why the list is short.
|
||
if (!showLocationFilter.value && locationActive.value) clearLocation();
|
||
}
|
||
|
||
// Drift check (#2086) — "attention" is everything whose recorded location or
|
||
// code no longer checks out, plus everything whose verdict expired because the
|
||
// snippet was edited since it was checked.
|
||
const needsAttentionOnly = ref(false);
|
||
function toggleAttention() {
|
||
needsAttentionOnly.value = !needsAttentionOnly.value;
|
||
loadSnippets();
|
||
}
|
||
|
||
// Multi-select → merge
|
||
const selectMode = ref(false);
|
||
const selectedIds = ref<Set<number>>(new Set());
|
||
const showMergeModal = ref(false);
|
||
const canonicalId = ref<number | null>(null);
|
||
const merging = ref(false);
|
||
|
||
// Near-duplicate report (#2088). Loaded on demand, not with the list: it's a
|
||
// pairwise scan and most visits to this page aren't a tidy-up.
|
||
const duplicateGroups = ref<DuplicateGroup[]>([]);
|
||
const dupLoading = ref(false);
|
||
const dupChecked = ref(false);
|
||
// Set while merging a SUGGESTED group. The report reaches the whole corpus, so
|
||
// its members need not all be on the current page — see selectedList.
|
||
const reviewingGroup = ref<DuplicateGroup | null>(null);
|
||
|
||
/** The records the merge modal acts on.
|
||
*
|
||
* Normally that's the selection filtered against what's on screen. But a
|
||
* suggested group is corpus-wide: filtering it by the current page would render
|
||
* an incomplete set AND silently narrow what doMerge folds in, since it derives
|
||
* its source ids from this list. When a group is under review it is the
|
||
* authority. */
|
||
const selectedList = computed<{ id: number; title: string }[]>(() => {
|
||
if (reviewingGroup.value) return reviewingGroup.value.snippets;
|
||
return snippets.value.filter((s) => selectedIds.value.has(s.id));
|
||
});
|
||
|
||
async function loadDuplicates() {
|
||
dupLoading.value = true;
|
||
try {
|
||
const data = await findDuplicateSnippets();
|
||
duplicateGroups.value = data.groups;
|
||
dupChecked.value = true;
|
||
} catch {
|
||
toast.show("Couldn't check for duplicates", "error");
|
||
} finally {
|
||
dupLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/** Hand a suggested group to the existing merge flow, pre-selected. The operator
|
||
* still picks which record survives and confirms — the report proposes, it
|
||
* never merges. */
|
||
function reviewGroup(group: DuplicateGroup) {
|
||
reviewingGroup.value = group;
|
||
selectedIds.value = new Set(group.note_ids);
|
||
canonicalId.value = group.note_ids[0] ?? null;
|
||
showMergeModal.value = true;
|
||
}
|
||
|
||
function exitSelectMode() {
|
||
selectMode.value = false;
|
||
selectedIds.value = new Set();
|
||
reviewingGroup.value = null;
|
||
}
|
||
function toggleSelectMode() {
|
||
if (selectMode.value) exitSelectMode();
|
||
else selectMode.value = true;
|
||
}
|
||
function toggleSelect(id: number) {
|
||
const next = new Set(selectedIds.value);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
selectedIds.value = next;
|
||
}
|
||
function onCardActivate(s: SnippetListItem) {
|
||
if (selectMode.value) toggleSelect(s.id);
|
||
else router.push(`/snippets/${s.id}`);
|
||
}
|
||
function openMerge() {
|
||
if (selectedIds.value.size < 2) return;
|
||
canonicalId.value = selectedList.value[0]?.id ?? null;
|
||
showMergeModal.value = true;
|
||
}
|
||
/** Dismiss the modal. Clears the reviewed group too — leaving it set would keep
|
||
* selectedList pinned to a corpus-wide set the operator has walked away from. */
|
||
function closeMerge() {
|
||
showMergeModal.value = false;
|
||
if (reviewingGroup.value) {
|
||
reviewingGroup.value = null;
|
||
selectedIds.value = new Set();
|
||
}
|
||
}
|
||
async function doMerge() {
|
||
const target = canonicalId.value;
|
||
if (target == null) return;
|
||
const sources = selectedList.value.map((s) => s.id).filter((id) => id !== target);
|
||
if (!sources.length) return;
|
||
merging.value = true;
|
||
try {
|
||
await mergeSnippets(target, sources);
|
||
toast.show(`Merged ${sources.length} snippet${sources.length > 1 ? "s" : ""} in`);
|
||
showMergeModal.value = false;
|
||
const wasSuggested = reviewingGroup.value !== null;
|
||
exitSelectMode();
|
||
await loadSnippets();
|
||
// The merged-away records are gone, so a stale report would keep offering
|
||
// them. Re-run it rather than clearing, so the operator can work through
|
||
// several groups without re-triggering the scan each time.
|
||
if (wasSuggested && dupChecked.value) await loadDuplicates();
|
||
} catch {
|
||
toast.show("Failed to merge snippets", "error");
|
||
} finally {
|
||
merging.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadSnippets() {
|
||
loading.value = true;
|
||
error.value = null;
|
||
try {
|
||
const data = await listSnippets({
|
||
q: search.value.trim() || undefined,
|
||
repo: locRepo.value.trim() || undefined,
|
||
path: locPath.value.trim() || undefined,
|
||
symbol: locSymbol.value.trim() || undefined,
|
||
verification: needsAttentionOnly.value ? "attention" : undefined,
|
||
});
|
||
snippets.value = data.snippets;
|
||
} catch {
|
||
error.value = "Couldn't load your snippets.";
|
||
toast.show("Failed to load snippets", "error");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function onSearchInput() {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(loadSnippets, 300);
|
||
}
|
||
// Same debounce for the location fields — typing a path shouldn't fire a query
|
||
// per keystroke.
|
||
const onLocationInput = onSearchInput;
|
||
|
||
onMounted(loadSnippets);
|
||
|
||
/** Titles are stored as "name — when to reach for it"; split for display. */
|
||
function splitTitle(title: string): { name: string; when: string } {
|
||
const idx = title.indexOf(" — ");
|
||
if (idx === -1) return { name: title, when: "" };
|
||
return { name: title.slice(0, idx), when: title.slice(idx + 3) };
|
||
}
|
||
|
||
function languageOf(tags: string[]): string {
|
||
return tags.find((t) => t && t !== "snippet") ?? "";
|
||
}
|
||
|
||
/** A snippet that has been offered repeatedly and never opened. The threshold
|
||
* is 3 rather than 1 because one or two surfacings is noise — the record may
|
||
* simply not have come up in a relevant context yet. */
|
||
function isDeadWeight(s: SnippetListItem): boolean {
|
||
const u = s.usage;
|
||
return !!u && u.pull_count === 0 && u.surfaced_count >= 3;
|
||
}
|
||
|
||
/** Short badge text, or "" to render nothing. A record nobody has surfaced yet
|
||
* gets no badge at all: "0 / 0" would read as a verdict when it's an absence
|
||
* of evidence. */
|
||
function usageBadge(s: SnippetListItem): string {
|
||
const u = s.usage;
|
||
if (!u || u.surfaced_count === 0) return "";
|
||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||
}
|
||
|
||
/** Short label for the drift verdict, or "" when there's nothing to say.
|
||
* An expired verdict is reported as "unchecked" whatever it used to say —
|
||
* it was about code that is no longer in the record. */
|
||
function driftBadge(s: SnippetListItem): string {
|
||
const v = s.verification;
|
||
if (!v) return "";
|
||
if (!v.current) return "unchecked since edit";
|
||
// Record<string, string>, not an object literal: `status` is a union that
|
||
// includes "ok" and "unverified", and a literal would have to enumerate every
|
||
// member just to say "nothing to show for these".
|
||
const labels: Record<string, string> = {
|
||
missing: "path gone",
|
||
moved: "symbol moved",
|
||
changed: "code drifted",
|
||
};
|
||
return labels[v.status] ?? "";
|
||
}
|
||
|
||
function driftTitle(s: SnippetListItem): string {
|
||
const v = s.verification;
|
||
if (!v) return "";
|
||
const when = v.checked_at
|
||
? `Checked ${new Date(v.checked_at).toLocaleDateString()}`
|
||
: "Checked";
|
||
if (!v.current) {
|
||
return (
|
||
`${when}, but the snippet has been edited since — that verdict was about ` +
|
||
`code this record no longer holds. Re-verify it.`
|
||
);
|
||
}
|
||
const reasons: Record<string, string> = {
|
||
missing: "the recorded path no longer exists",
|
||
moved: "the file is there but the symbol isn't in it",
|
||
changed: "the source no longer matches the recorded code",
|
||
};
|
||
const what = reasons[v.status] ?? "";
|
||
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||
}
|
||
|
||
function usageTitle(s: SnippetListItem): string {
|
||
const u = s.usage;
|
||
if (!u) return "";
|
||
const last = u.last_pulled_at
|
||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||
: "Never opened.";
|
||
const verdict = isDeadWeight(s)
|
||
? " Offered repeatedly without ever being opened — consider rewriting its" +
|
||
" “when to reach for it” so it says when, or deleting it. It takes a slot" +
|
||
" in every future auto-inject menu."
|
||
: "";
|
||
return (
|
||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||
`${u.pull_count}×. ${last}${verdict}`
|
||
);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<main class="snippets-list">
|
||
<div class="page-header">
|
||
<h1>Snippets</h1>
|
||
<div class="header-actions">
|
||
<button v-if="snippets.length" class="btn-ghost" @click="toggleSelectMode">
|
||
{{ selectMode ? "Cancel" : "Select" }}
|
||
</button>
|
||
<button class="btn-primary" @click="router.push('/snippets/new')">
|
||
+ New snippet
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="page-sub">
|
||
Reusable functions and components, recorded once so they surface before
|
||
they're rewritten as a one-off.
|
||
</p>
|
||
|
||
<div class="search-row">
|
||
<input
|
||
v-model="search"
|
||
type="search"
|
||
class="search-input"
|
||
placeholder="Search snippets…"
|
||
aria-label="Search snippets"
|
||
@input="onSearchInput"
|
||
@keydown.enter="loadSnippets"
|
||
/>
|
||
<button
|
||
class="btn-ghost"
|
||
:class="{ 'filter-on': locationActive }"
|
||
:aria-expanded="showLocationFilter"
|
||
aria-controls="location-filter"
|
||
@click="toggleLocationFilter"
|
||
>
|
||
<!-- Say it in the label, not only in the accent — the state has to reach
|
||
a screen reader too. -->
|
||
{{ locationActive ? "Location · filtering" : "Location" }}
|
||
</button>
|
||
<button
|
||
class="btn-ghost"
|
||
:class="{ 'filter-on': needsAttentionOnly }"
|
||
:aria-pressed="needsAttentionOnly"
|
||
title="Show only snippets whose recorded location or code no longer checks out — including ones edited since they were last verified"
|
||
@click="toggleAttention"
|
||
>
|
||
{{ needsAttentionOnly ? "Needs attention · filtering" : "Needs attention" }}
|
||
</button>
|
||
<button
|
||
class="btn-ghost"
|
||
:disabled="dupLoading"
|
||
title="Look for snippets already recorded that resemble each other closely enough to be worth merging"
|
||
@click="loadDuplicates"
|
||
>
|
||
{{ dupLoading ? "Checking…" : "Find duplicates" }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Near-duplicate report. Only ever a proposal — merging is a separate,
|
||
confirmed act, and the operator chooses which record survives. -->
|
||
<div v-if="dupChecked && !dupLoading" class="dup-panel">
|
||
<p v-if="!duplicateGroups.length" class="dup-empty">
|
||
No near-duplicates found. Nothing recorded resembles anything else closely
|
||
enough to be worth merging.
|
||
</p>
|
||
<template v-else>
|
||
<p class="dup-head">
|
||
{{ duplicateGroups.length }} possible duplicate{{ duplicateGroups.length > 1 ? " sets" : " set" }}
|
||
— review each before merging; a set is a suggestion, not a verdict.
|
||
</p>
|
||
<div v-for="(g, i) in duplicateGroups" :key="i" class="dup-group">
|
||
<div class="dup-members">
|
||
<span v-for="s in g.snippets" :key="s.id" class="dup-member">
|
||
{{ splitTitle(s.title).name }}
|
||
</span>
|
||
</div>
|
||
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
||
<button class="btn-ghost dup-action" @click="reviewGroup(g)">Review & merge</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
|
||
<div v-if="showLocationFilter" id="location-filter" class="location-row">
|
||
<input
|
||
v-model="locRepo"
|
||
class="loc-input"
|
||
placeholder="repo"
|
||
aria-label="Filter by repo"
|
||
@input="onLocationInput"
|
||
@keydown.enter="loadSnippets"
|
||
/>
|
||
<input
|
||
v-model="locPath"
|
||
class="loc-input loc-input-wide"
|
||
placeholder="path — matches the file or anything under it"
|
||
aria-label="Filter by path"
|
||
@input="onLocationInput"
|
||
@keydown.enter="loadSnippets"
|
||
/>
|
||
<input
|
||
v-model="locSymbol"
|
||
class="loc-input"
|
||
placeholder="symbol"
|
||
aria-label="Filter by symbol"
|
||
@input="onLocationInput"
|
||
@keydown.enter="loadSnippets"
|
||
/>
|
||
<button v-if="locationActive" class="loc-clear" @click="clearLocation">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="loading" class="skeleton-grid">
|
||
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
|
||
</div>
|
||
|
||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||
|
||
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
||
<div class="empty-icon">❭_</div>
|
||
<p class="empty-title">
|
||
{{ needsAttentionOnly
|
||
? "Everything checks out"
|
||
: locationActive
|
||
? "Nothing kept at that location yet"
|
||
: search.trim()
|
||
? "No snippets match your search"
|
||
: "No snippets kept yet" }}
|
||
</p>
|
||
<p class="empty-sub">
|
||
{{ needsAttentionOnly
|
||
? "No snippet has drifted from its recorded location or code — as far as anything has been checked. Snippets nobody has verified yet don't appear here."
|
||
: locationActive
|
||
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
|
||
: search.trim()
|
||
? "Try a different term, or clear the search."
|
||
: "Record a reusable function or component and it will be offered back to you later." }}
|
||
</p>
|
||
<button v-if="needsAttentionOnly" class="empty-action" @click="toggleAttention">
|
||
Show all snippets
|
||
</button>
|
||
<button v-else-if="locationActive" class="empty-action" @click="clearLocation">
|
||
Clear location filter
|
||
</button>
|
||
<button
|
||
v-else-if="!search.trim()"
|
||
class="empty-action"
|
||
@click="router.push('/snippets/new')"
|
||
>
|
||
New snippet →
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else class="snippets-grid">
|
||
<div
|
||
v-for="s in snippets"
|
||
:key="s.id"
|
||
class="snippet-card"
|
||
:class="{ selected: selectedIds.has(s.id) }"
|
||
role="button"
|
||
tabindex="0"
|
||
:aria-pressed="selectMode ? selectedIds.has(s.id) : undefined"
|
||
@click="onCardActivate(s)"
|
||
@keydown.enter="onCardActivate(s)"
|
||
>
|
||
<div class="card-header">
|
||
<span
|
||
v-if="selectMode"
|
||
class="select-box"
|
||
:class="{ on: selectedIds.has(s.id) }"
|
||
aria-hidden="true"
|
||
></span>
|
||
<span class="snippet-name">{{ splitTitle(s.title).name }}</span>
|
||
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
|
||
</div>
|
||
<p v-if="splitTitle(s.title).when" class="snippet-when">
|
||
{{ splitTitle(s.title).when }}
|
||
</p>
|
||
<div class="card-footer">
|
||
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
||
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||
{{ driftBadge(s) }}
|
||
</span>
|
||
<span
|
||
v-if="usageBadge(s)"
|
||
class="usage-tag"
|
||
:class="{ 'usage-dead': isDeadWeight(s) }"
|
||
:title="usageTitle(s)"
|
||
>
|
||
{{ usageBadge(s) }}
|
||
</span>
|
||
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
|
||
by {{ s.owner ?? "another user" }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Select action bar -->
|
||
<div v-if="selectMode" class="select-bar">
|
||
<span class="select-count">{{ selectedIds.size }} selected</span>
|
||
<span class="select-hint">Pick two or more to unify into one canonical snippet.</span>
|
||
<button class="btn-primary" :disabled="selectedIds.size < 2" @click="openMerge">
|
||
Merge…
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Merge modal -->
|
||
<teleport to="body">
|
||
<div v-if="showMergeModal" class="modal-overlay" @click.self="closeMerge">
|
||
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Merge snippets">
|
||
<h3 class="modal-title">Merge snippets</h3>
|
||
<p class="modal-desc">
|
||
Keep one as the canonical record — the others are folded into it (their
|
||
locations are added) and moved to the trash, where they can be restored.
|
||
</p>
|
||
<div class="merge-choices">
|
||
<label
|
||
v-for="s in selectedList"
|
||
:key="s.id"
|
||
class="merge-choice"
|
||
:class="{ chosen: canonicalId === s.id }"
|
||
>
|
||
<input type="radio" name="canonical" :value="s.id" v-model="canonicalId" />
|
||
<span class="merge-choice-name">{{ splitTitle(s.title).name }}</span>
|
||
<span class="merge-choice-tag">{{ canonicalId === s.id ? "keep" : "fold in" }}</span>
|
||
</label>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="modal-btn" @click="closeMerge">Cancel</button>
|
||
<button
|
||
class="modal-btn modal-btn-primary"
|
||
:disabled="merging || canonicalId == null"
|
||
@click="doMerge"
|
||
>
|
||
{{ merging ? "Merging…" : "Merge" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</teleport>
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.snippets-list {
|
||
max-width: var(--page-max-width);
|
||
margin: 2rem auto;
|
||
padding: 0 var(--page-padding-x);
|
||
overflow-x: clip;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.35rem;
|
||
}
|
||
.page-header h1 {
|
||
margin: 0;
|
||
}
|
||
.page-sub {
|
||
margin: 0 0 1.25rem;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.9rem;
|
||
line-height: 1.5;
|
||
max-width: 60ch;
|
||
}
|
||
|
||
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
|
||
.btn-primary {
|
||
padding: 0.45rem 1rem;
|
||
background: var(--color-action-primary);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
font-family: inherit;
|
||
transition: background 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.btn-primary:hover {
|
||
background: var(--color-action-primary-hover);
|
||
}
|
||
|
||
.search-row {
|
||
margin-bottom: 1.25rem;
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
/* Filter is engaged — the accent marks "you are here", per the design system. */
|
||
.filter-on {
|
||
border-color: var(--color-primary);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.location-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin: -0.5rem 0 1.25rem;
|
||
}
|
||
.loc-input {
|
||
flex: 1 1 8rem;
|
||
min-width: 0;
|
||
max-width: 12rem;
|
||
padding: 0.4rem 0.65rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
font-size: 0.85rem;
|
||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||
box-sizing: border-box;
|
||
}
|
||
.loc-input-wide {
|
||
flex: 2 1 16rem;
|
||
max-width: 26rem;
|
||
}
|
||
.loc-input::placeholder {
|
||
font-family: inherit;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.loc-input:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: var(--focus-ring);
|
||
}
|
||
.loc-clear {
|
||
padding: 0.4rem 0.65rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.85rem;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
}
|
||
.loc-clear:hover {
|
||
color: var(--color-text);
|
||
}
|
||
.search-input {
|
||
width: 100%;
|
||
max-width: 420px;
|
||
padding: 0.5rem 0.8rem;
|
||
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;
|
||
}
|
||
.search-input:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: var(--focus-ring);
|
||
}
|
||
|
||
.error-msg {
|
||
color: var(--color-danger);
|
||
font-size: 0.9rem;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.empty-state-rich {
|
||
text-align: center;
|
||
padding: 3rem 1rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.empty-icon {
|
||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||
font-size: 2rem;
|
||
margin-bottom: 0.75rem;
|
||
opacity: 0.35;
|
||
}
|
||
.empty-title {
|
||
font-size: 1rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-secondary);
|
||
margin: 0 0 0.35rem;
|
||
}
|
||
.empty-sub {
|
||
font-size: 0.85rem;
|
||
margin: 0 0 1rem;
|
||
max-width: 44ch;
|
||
margin-inline: auto;
|
||
line-height: 1.5;
|
||
}
|
||
.empty-action {
|
||
display: inline-block;
|
||
padding: 0.4rem 1rem;
|
||
border: 1px solid var(--color-primary);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--color-primary);
|
||
background: none;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
.empty-action:hover {
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
|
||
.skeleton-grid,
|
||
.snippets-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||
gap: 1rem;
|
||
}
|
||
.skeleton-card {
|
||
height: 96px;
|
||
border-radius: var(--radius-md);
|
||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||
background-size: 200% 100%;
|
||
animation: skeleton-shimmer 1.4s ease infinite;
|
||
}
|
||
@keyframes skeleton-shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
|
||
.snippet-card {
|
||
background: var(--color-bg-card);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
padding: 0.9rem 1rem;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.snippet-card:hover {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 2px 8px var(--color-shadow);
|
||
transform: translateY(-2px);
|
||
}
|
||
.snippet-card:focus-visible {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
box-shadow: var(--focus-ring);
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 0.5rem;
|
||
}
|
||
.snippet-name {
|
||
font-size: 0.95rem;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
min-width: 0;
|
||
flex: 1;
|
||
word-break: break-word;
|
||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||
}
|
||
|
||
/* Language tag — accent pill per the design system's tag treatment. */
|
||
.lang-pill {
|
||
font-size: 0.68rem;
|
||
font-weight: 500;
|
||
padding: 0.12rem 0.45rem;
|
||
border-radius: 999px;
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.snippet-when {
|
||
font-size: 0.83rem;
|
||
color: var(--color-text-secondary);
|
||
margin: 0;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.card-footer {
|
||
margin-top: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.5rem;
|
||
}
|
||
.meta-date {
|
||
font-size: 0.73rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
/* Marks a record someone else owns. Present only on shared rows, so an
|
||
unmarked card is unambiguously the operator's own. */
|
||
.shared-tag {
|
||
font-size: 0.7rem;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 4px;
|
||
white-space: nowrap;
|
||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
/* Near-duplicate report */
|
||
.dup-panel {
|
||
margin-bottom: 1.25rem;
|
||
padding: 0.85rem 1rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
background: var(--color-surface-alt, var(--color-surface));
|
||
}
|
||
|
||
.dup-empty,
|
||
.dup-head {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.85rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.dup-empty {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.dup-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
flex-wrap: wrap;
|
||
padding: 0.5rem 0;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.dup-members {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
flex-wrap: wrap;
|
||
flex: 1 1 20rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.dup-member {
|
||
font-size: 0.8rem;
|
||
padding: 0.1rem 0.45rem;
|
||
border-radius: 4px;
|
||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||
/* Long snippet names must not push the row into a horizontal scroll. */
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.dup-score {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.dup-action {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Drift is a stronger signal than dead weight: the record may be actively
|
||
misleading, not merely unused. Danger tone, and it sits first in the footer. */
|
||
.drift-tag {
|
||
font-size: 0.7rem;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 4px;
|
||
white-space: nowrap;
|
||
background: color-mix(in srgb, var(--color-danger, #b91c1c) 15%, transparent);
|
||
color: var(--color-danger, #b91c1c);
|
||
}
|
||
|
||
.usage-tag {
|
||
font-size: 0.7rem;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 4px;
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||
than the danger one, because the record isn't broken, just unearned. */
|
||
.usage-tag.usage-dead {
|
||
background: color-mix(in srgb, var(--color-warning, #b45309) 18%, transparent);
|
||
color: var(--color-warning, #b45309);
|
||
}
|
||
|
||
/* Header + select-mode */
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
.btn-ghost {
|
||
padding: 0.4rem 0.85rem;
|
||
border: 1px solid var(--color-border);
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
font-family: inherit;
|
||
transition: border-color 0.15s, color 0.15s;
|
||
}
|
||
.btn-ghost:hover {
|
||
border-color: var(--color-primary);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
/* Selected card = 2px accent border per the design system (featured/active). */
|
||
.snippet-card.selected {
|
||
border-color: var(--color-primary);
|
||
border-width: 2px;
|
||
padding: calc(0.9rem - 1px) calc(1rem - 1px);
|
||
}
|
||
.select-box {
|
||
width: 16px;
|
||
height: 16px;
|
||
flex-shrink: 0;
|
||
margin-top: 0.15rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
transition: background 0.12s, border-color 0.12s;
|
||
}
|
||
.select-box.on {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.select-bar {
|
||
position: sticky;
|
||
bottom: 1rem;
|
||
margin-top: 1.5rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.6rem 0.9rem;
|
||
background: var(--color-bg-card);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: 0 4px 16px var(--color-shadow);
|
||
}
|
||
.select-count {
|
||
font-weight: 500;
|
||
font-size: 0.9rem;
|
||
color: var(--color-text);
|
||
}
|
||
.select-hint {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-muted);
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.select-bar .btn-primary:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
|
||
/* Merge modal */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 200;
|
||
}
|
||
.modal-card {
|
||
background: var(--color-bg-card);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
padding: 1.5rem;
|
||
width: 100%;
|
||
max-width: 460px;
|
||
box-shadow: 0 8px 32px var(--color-shadow);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
.modal-title {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
}
|
||
.modal-desc {
|
||
margin: 0;
|
||
font-size: 0.85rem;
|
||
color: var(--color-text-secondary);
|
||
line-height: 1.5;
|
||
}
|
||
.merge-choices {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.merge-choice {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
padding: 0.5rem 0.7rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
}
|
||
.merge-choice.chosen {
|
||
border-color: var(--color-primary);
|
||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||
}
|
||
.merge-choice-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||
font-size: 0.85rem;
|
||
word-break: break-word;
|
||
}
|
||
.merge-choice-tag {
|
||
font-size: 0.68rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--color-text-muted);
|
||
flex-shrink: 0;
|
||
}
|
||
.modal-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
}
|
||
.modal-btn {
|
||
padding: 0.4rem 0.9rem;
|
||
border: 1px solid var(--color-border);
|
||
background: var(--color-bg-secondary);
|
||
color: var(--color-text);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
}
|
||
.modal-btn:hover {
|
||
background: var(--color-bg);
|
||
}
|
||
.modal-btn-primary {
|
||
background: var(--color-action-primary);
|
||
border-color: var(--color-action-primary);
|
||
color: #fff;
|
||
}
|
||
.modal-btn-primary:hover:not(:disabled) {
|
||
background: var(--color-action-primary-hover);
|
||
}
|
||
.modal-btn-primary:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.snippets-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.select-hint {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|