Files
FabledScribe/frontend/src/views/SnippetListView.vue
T
bvandeusen 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
feat(snippets): near-duplicate finder — surface the sets worth merging
#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
2026-07-28 18:32:40 -04:00

1043 lines
30 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 } 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 &amp; 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>