Files
FabledGrimoire/frontend/src/views/SnippetListView.vue
T
bvandeusenandClaude Opus 5 f491b6d7b9
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 47s
refactor(ui): action buttons stop wearing the accent
21 buttons filled with --color-primary, which resolves to Scribe's violet
accent: every auth Submit (Login, Register, Invite, Forgot, Reset), plus
Invite, Add, Confirm, Restore, Generate, Log-save, Subtask-confirm,
Toggle-open, the modal primary, the version-restore, the inline-assist button,
the milestone-plan actions, the task-advance hover, and both empty-state CTAs.

The house style is explicit that the accent never appears on an action button:
action colours are universal across the family precisely so a Save button looks
identical in every app, while the accent carries identity. Doing both makes the
accent mean two things and neither clearly.

Operator's call, and the reasoning is worth keeping: the violet-on-Scribe-
actions treatment was a deliberate early choice to give the web UI its own
personality, made when much more of the app was user-facing. That is no longer
true, so consistency is now worth more than the distinction it was buying.

Found in two passes, which is the part worth noting. The first scan looked for
`.btn-*` and found 13. Seven more were the same thing under different names —
.modal-btn-primary, .vh-btn-restore, .inline-assist-btn, .empty-action,
.task-advance-btn — plus .ms-plan-actions .btn-primary, a compound override
flagged in the previous commit. Searching by naming convention finds what was
named consistently, which is never the whole set.

Deliberately NOT changed: progress-bar fills, active tab / page / selection
states, tag-pill hover, the duration badge, the skip link, the assist pulse.
Those are identity and active-state, which is exactly where the accent belongs.
After this the accent appears only there, which is what makes it read as
identity rather than as decoration.

Colour swaps only — 25 lines changed, no geometry, no structure, no templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-01 22:13:58 -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, 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-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, 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;
}
/* 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, 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: 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>