CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 33s
Milestone 333 step 5, and rule 27 — the counter had a tuning point from step 4 and no operator-facing one until now. The task said to reuse the snippet badge's classes rather than mint a parallel set, citing the eight duplicated CSS families the ledger already carries (#3207). `.usage-tag` lived in SnippetListView's SCOPED block, so "reuse" was not available: copying it into the rule pane would have been the ninth family, and importing it is not a thing a scoped block permits. So it was promoted rather than copied. Three pieces, each of which existed once and now exists once: - `components.css` gains `.usage-tag` / `.usage-dead`, geometry and colour only, with the scoped original deleted rather than left behind. - `UsageBadge.vue` holds the logic the two lists would otherwise duplicate — the >=3 dead-weight threshold, the empty-string-renders-nothing rule, the tooltip. - `types/usage.ts` holds `RecordUsage`, one client type over two tables. `SnippetUsage` becomes an alias, so no existing consumer changes. THE ADVICE IS A PROP, and that is the substance rather than the plumbing. The counts read identically for every kind; the remedy does not. A snippet offered and never opened should probably be rewritten or deleted — one action. A rule in the same position has TWO possible causes and the operator has to pick: its trigger may fire on the wrong work, in which case `when_to_apply` wants rewording, or it may genuinely not be wanted. Baking "delete it" into the component would give the wrong nudge half the time on the surface where being wrong is most expensive, since a deleted rule stops binding behaviour. The route zero-fills every row through `usage_for_rules`, one aggregate per page — per-row would be N+1 by construction. That matters more here than for snippets: every rule on every existing install predates `rule_usage_events`, so the zero-filled shape IS the common case for a while, and a route that attached the key only where it found events would leave the badge reading undefined on almost every row. `usage_for_rules` had no test at all — step 1 covered the write path and the zero shape and left the aggregate uncovered, which only became load-bearing when a list started rendering it. It now has an integration test over real Postgres, including that a rule with no events comes back zero-filled rather than absent. Recorded as snippet #3460, per the design system's own instruction that the component layer lives as snippets rather than as prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
840 lines
25 KiB
Vue
840 lines
25 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";
|
|
import UsageBadge from "@/components/UsageBadge.vue";
|
|
|
|
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") ?? "";
|
|
}
|
|
|
|
/** 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}.`;
|
|
}
|
|
|
|
/** The dead-weight nudge for a SNIPPET, passed to the shared badge. Kept here
|
|
* rather than inside the component because the remedy is kind-specific — a
|
|
* rule in the same position gets different advice (milestone 333 step 5). */
|
|
const SNIPPET_DEAD_WEIGHT =
|
|
"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.";
|
|
</script>
|
|
|
|
<template>
|
|
<main class="page-container">
|
|
<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>
|
|
<UsageBadge :usage="s.usage" :dead-weight-advice="SNIPPET_DEAD_WEIGHT" />
|
|
<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 src="@/assets/dup-report.css" />
|
|
<style scoped>
|
|
.page-header {
|
|
margin-bottom: 0.35rem; /* tighter than the shared recipe: .page-sub follows */
|
|
}
|
|
.page-sub {
|
|
margin: 0 0 1.25rem;
|
|
color: var(--fs-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(--fs-accent);
|
|
color: var(--fs-accent);
|
|
}
|
|
|
|
.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(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
background: var(--fs-surface-page);
|
|
color: var(--fs-text-primary);
|
|
font-size: 0.85rem;
|
|
font-family: var(--fs-font-mono);
|
|
box-sizing: border-box;
|
|
}
|
|
.loc-input-wide {
|
|
flex: 2 1 16rem;
|
|
max-width: 26rem;
|
|
}
|
|
.loc-input::placeholder {
|
|
font-family: inherit;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.loc-input:focus {
|
|
outline: none;
|
|
border-color: var(--fs-accent);
|
|
box-shadow: var(--fs-focus-ring);
|
|
}
|
|
.loc-clear {
|
|
padding: 0.4rem 0.65rem;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--fs-text-secondary);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
.loc-clear:hover {
|
|
color: var(--fs-text-primary);
|
|
}
|
|
.search-input {
|
|
width: 100%;
|
|
max-width: 420px;
|
|
padding: 0.5rem 0.8rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
background: var(--fs-surface-page);
|
|
color: var(--fs-text-primary);
|
|
font-size: 0.9rem;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
}
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: var(--fs-accent);
|
|
box-shadow: var(--fs-focus-ring);
|
|
}
|
|
|
|
.error-msg {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.empty-state-rich {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.empty-icon {
|
|
font-family: var(--fs-font-mono);
|
|
font-size: 2rem;
|
|
margin-bottom: 0.75rem;
|
|
opacity: 0.35;
|
|
}
|
|
.empty-sub {
|
|
max-width: 44ch;
|
|
margin-inline: auto;
|
|
line-height: 1.5;
|
|
}
|
|
.empty-action {
|
|
display: inline-block;
|
|
padding: 0.4rem 1rem;
|
|
border: 1px solid var(--fs-action-primary);
|
|
border-radius: var(--fs-radius-sm);
|
|
color: var(--fs-action-primary);
|
|
background: none;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.empty-action:hover {
|
|
background: var(--fs-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(--fs-radius-lg);
|
|
background: linear-gradient(90deg, var(--fs-surface-raised) 25%, var(--fs-border-color) 50%, var(--fs-surface-raised) 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(--fs-surface-raised);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
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(--fs-accent);
|
|
box-shadow: 0 2px 8px var(--color-shadow);
|
|
transform: translateY(-2px);
|
|
}
|
|
.snippet-card:focus-visible {
|
|
outline: none;
|
|
border-color: var(--fs-accent);
|
|
box-shadow: var(--fs-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(--fs-text-primary);
|
|
min-width: 0;
|
|
flex: 1;
|
|
word-break: break-word;
|
|
font-family: var(--fs-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(--fs-accent) 15%, transparent);
|
|
color: var(--fs-accent-fg);
|
|
}
|
|
|
|
.snippet-when {
|
|
font-size: 0.83rem;
|
|
color: var(--fs-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(--fs-text-tertiary);
|
|
}
|
|
/* 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(--fs-text-tertiary) 15%, transparent);
|
|
color: var(--fs-text-tertiary-fg);
|
|
}
|
|
|
|
.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(--fs-error) 15%, transparent);
|
|
color: var(--fs-error-fg);
|
|
}
|
|
|
|
/* 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(--fs-accent);
|
|
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(--fs-border-color);
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
transition: background 0.12s, border-color 0.12s;
|
|
}
|
|
.select-box.on {
|
|
background: var(--fs-accent);
|
|
border-color: var(--fs-accent);
|
|
}
|
|
|
|
.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(--fs-surface-raised);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
box-shadow: 0 4px 16px var(--color-shadow);
|
|
}
|
|
.select-count {
|
|
font-weight: 500;
|
|
font-size: 0.9rem;
|
|
color: var(--fs-text-primary);
|
|
}
|
|
.select-hint {
|
|
font-size: 0.8rem;
|
|
color: var(--fs-text-tertiary);
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Merge modal */
|
|
.modal-card {
|
|
max-width: 460px;
|
|
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(--fs-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(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
.merge-choice.chosen {
|
|
border-color: var(--fs-accent);
|
|
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
|
}
|
|
.merge-choice-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-family: var(--fs-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(--fs-text-tertiary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.snippets-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.select-hint {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|