Files
FabledScribe/frontend/src/views/SnippetListView.vue
T
bvandeusenandClaude Opus 5 bd60d679d9
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
refactor(theme): remove 184 var() fallbacks — every one was unreachable
#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
2026-08-03 11:52:04 -04:00

1009 lines
29 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. */
.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);
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);
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-action-primary);
border-radius: var(--radius-sm);
color: var(--color-action-primary);
background: none;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
.empty-action:hover {
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.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);
}
/* 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);
}
.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) 15%, transparent);
color: var(--color-danger);
}
.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) 18%, transparent);
color: var(--color-warning);
}
/* Header + select-mode */
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* 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;
}
/* Merge modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay);
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);
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: var(--fs-text-on-action);
}
.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>