feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)

* feat(web): graph search fly-to + collection/status/role filters (TASK-1735)

Toolbar grows a type-ahead search (ref/title over the post-filter node
list; ArrowUp/Down + Enter picks, Escape closes without stealing the
page's deselect) that routes through the existing selectNode() — same
camera fly-to, highlight, and detail card as a click. Client-side
filters subset the rendered graph: collection chips with palette dots,
status chips, and a role select (hidden when no node carries a role;
the graph endpoint now emits the assigned agent-role slug per node).
Edges survive only when both endpoints do; counts read "X of Y" while
filtered. Workspace switch resets filters; show-completed doesn't.
Filter changes deselect so a vanished node can't strand focus mode.

New GraphToolbar.svelte owns the presentational toolbar; the page owns
authoritative filter state (CONVE-1688 discipline unchanged).

Parent: PLAN-1730.

* fix(web): close graph search dropdown on blur per Codex review (round 1)

The dropdown opened on focus/input but only closed on pick or Escape,
leaving stale results floating over the canvas after clicking away.
The result buttons already pick on mousedown+preventDefault, so the
input never blurs mid-pick — a plain onblur close is safe.

* fix(web): gate search Escape on dropdown visibility per Codex review (round 2)

Escape in a focused-but-empty search now falls through to the
page-level deselect instead of being swallowed by the searchOpen flag.
This commit is contained in:
xarmian
2026-06-05 19:14:45 -04:00
committed by GitHub
parent 1c3db435a3
commit 77dcd07ecd
4 changed files with 695 additions and 59 deletions
+4
View File
@@ -20,6 +20,9 @@ type GraphNode struct {
IsTerminal bool `json:"is_terminal"`
ChildCount int `json:"child_count"`
UpdatedAt time.Time `json:"updated_at"`
// Role is the assigned agent-role slug, when set. Feeds the graph
// view's role filter (TASK-1735).
Role string `json:"role,omitempty"`
}
// GraphEdge is one typed relationship between two graph nodes. Source
@@ -130,6 +133,7 @@ func (s *Server) handleGetWorkspaceGraph(w http.ResponseWriter, r *http.Request)
Status: status,
IsTerminal: terminal,
UpdatedAt: item.UpdatedAt,
Role: item.AgentRoleSlug,
})
}
+2
View File
@@ -985,6 +985,8 @@ export interface GraphNode {
/** number of child items (parent + implements links pointing here) */
child_count: number;
updated_at: string;
/** assigned agent-role slug, when set — feeds the graph view's role filter */
role?: string;
}
/**
@@ -14,6 +14,7 @@
import type { NodeObject, LinkObject } from '3d-force-graph';
import type { GraphResponse, Item } from '$lib/types';
import DetailCard from './DetailCard.svelte';
import GraphToolbar from './GraphToolbar.svelte';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
@@ -66,9 +67,87 @@
// commits so a fast re-select can't be clobbered by an older response.
let selectSeq = 0;
// Node-count / edge-count readout for the toolbar.
const nodeCount = $derived(graphData?.nodes.length ?? 0);
const edgeCount = $derived(graphData?.edges.length ?? 0);
// ── Filters (PLAN-1730 / TASK-1735) ──────────────────────────────────────────
// Client-side filters over the loaded payload. All three are reactive $state so
// the renderer-sync effect (which reads `filteredData`, derived from these) re-
// runs on any change. Empty collection/status selection === no filter on that
// axis (matches the insights page semantics); a null role === no role filter.
// Per CONVE-1688: these are written only from event handlers (the toolbar
// callbacks) and a deselect-on-change effect that writes deselect()'s state but
// never reads graphData — never read+written by the same effect.
let filterCollections = $state<string[]>([]);
let filterStatuses = $state<string[]>([]);
let filterRole = $state<string | null>(null);
// Distinct option lists for the toolbar controls, in first-seen order so the
// chips stay stable within a payload.
const distinctCollections = $derived.by<string[]>(() => {
const seen = new Set<string>();
const out: string[] = [];
for (const n of graphData?.nodes ?? []) {
if (!seen.has(n.collection)) {
seen.add(n.collection);
out.push(n.collection);
}
}
return out;
});
const distinctStatuses = $derived.by<string[]>(() => {
const seen = new Set<string>();
const out: string[] = [];
for (const n of graphData?.nodes ?? []) {
if (n.status && !seen.has(n.status)) {
seen.add(n.status);
out.push(n.status);
}
}
return out;
});
const distinctRoles = $derived.by<string[]>(() => {
const seen = new Set<string>();
const out: string[] = [];
for (const n of graphData?.nodes ?? []) {
if (n.role && !seen.has(n.role)) {
seen.add(n.role);
out.push(n.role);
}
}
return out;
});
// Any filter active? Drives the "X of Y" vs "X" count readout.
const filtersActive = $derived(
filterCollections.length > 0 || filterStatuses.length > 0 || filterRole !== null
);
// The filtered subset fed to the renderer: nodes matching every active axis, and
// edges where BOTH endpoints survive. The force layout re-settles on change —
// expected and fine. When no filter is active this is graphData verbatim.
const filteredData = $derived.by<GraphResponse | null>(() => {
if (!graphData) return null;
if (!filtersActive) return graphData;
const collSet = new Set(filterCollections);
const statusSet = new Set(filterStatuses);
const nodes = graphData.nodes.filter((n) => {
if (collSet.size > 0 && !collSet.has(n.collection)) return false;
if (statusSet.size > 0 && (!n.status || !statusSet.has(n.status))) return false;
if (filterRole !== null && n.role !== filterRole) return false;
return true;
});
const surviving = new Set(nodes.map((n) => n.ref));
const edges = graphData.edges.filter(
(e) => surviving.has(e.source) && surviving.has(e.target)
);
return { nodes, edges };
});
// Node/edge counts: FILTERED for the live readout, total for the "X of Y" form.
const nodeCount = $derived(filteredData?.nodes.length ?? 0);
const edgeCount = $derived(filteredData?.edges.length ?? 0);
const totalNodeCount = $derived(graphData?.nodes.length ?? 0);
const totalEdgeCount = $derived(graphData?.edges.length ?? 0);
// Empty state keys off the unfiltered payload — a filter that hides everything is
// the user's doing, not an empty workspace.
const isEmpty = $derived(graphData !== null && graphData.nodes.length === 0);
// ── Color palette ────────────────────────────────────────────────────────────
@@ -231,7 +310,9 @@
// from `graphData.edges` (the source of truth, untouched by the renderer).
function computeNeighbors(ref: string): Set<string> {
const set = new Set<string>([ref]);
const edges = graphData?.edges ?? [];
// Use the FILTERED edges so the neighborhood matches what's actually rendered —
// a filtered-out neighbor isn't on screen to highlight anyway.
const edges = filteredData?.edges ?? [];
for (const e of edges) {
if (e.source === ref) set.add(e.target);
else if (e.target === ref) set.add(e.source);
@@ -266,6 +347,37 @@
void loadSelectedItem(node.ref);
}
// ── Filter toggles (TASK-1735) ───────────────────────────────────────────────
// Event-handler writes to the filter $state (CONVE-1688: no effect reads+writes
// these). The deselect-on-filter-change effect handles clearing a now-hidden
// selection; the filteredData derived + sync effect handle the re-render.
function toggleCollectionFilter(slug: string) {
filterCollections = filterCollections.includes(slug)
? filterCollections.filter((s) => s !== slug)
: [...filterCollections, slug];
}
function toggleStatusFilter(status: string) {
filterStatuses = filterStatuses.includes(status)
? filterStatuses.filter((s) => s !== status)
: [...filterStatuses, status];
}
function selectRoleFilter(role: string | null) {
filterRole = role;
}
// Search fly-to (TASK-1735). The toolbar emits the chosen ref; resolve it to the
// LIVE renderer node — `graph.graphData().nodes` carry the current x/y/z the
// camera math in selectNode() needs (our static GraphResponse nodes don't). If
// the node isn't found (shouldn't happen — search lists POST-filter nodes that
// are by definition in the renderer), no-op gracefully.
function flyToRef(ref: string) {
if (!graph) return;
const nodes = (graph.graphData()?.nodes ?? []) as NodeObject[];
const match = nodes.find((n) => asNode(n).ref === ref);
if (!match) return;
selectNode(asNode(match));
}
async function loadSelectedItem(ref: string) {
const seq = ++selectSeq;
selectedItem = null;
@@ -332,12 +444,37 @@
// Drop the previous workspace's graph so it doesn't linger under the new
// URL while the fetch is in flight — `loading` covers the gap.
graphData = null;
// A workspace switch brings a different collection/status/role universe, so
// any prior selection would over-filter (none of B's slugs match A's). Reset
// to no-filter. NOT done on a show-completed toggle: that's the SAME
// workspace with a superset payload, where keeping filters is the right call.
// These writes are filter $state this effect doesn't read — and the
// filteredData/sync effects don't write them — so it stays CONVE-1688-clean.
filterCollections = [];
filterStatuses = [];
filterRole = null;
}
if (slug) {
void loadGraph(slug, withTerminal);
}
});
// Deselect whenever the filters change: the selected node may have just been
// filtered out, in which case the dim/highlight + detail card would reference a
// node that's no longer rendered. Reads the three filter $states (reactive) and
// calls deselect() (which writes selection $state but never reads graphData or
// the filter states) — so this effect never read+writes the same $state, keeping
// it CONVE-1688-clean. Separate from the renderer-sync effect on purpose: that
// one must NOT call deselect (it reads filteredData, and deselect writes selection
// state — mixing them risks a read/write cycle on shared state).
$effect(() => {
// Track the filter axes.
filterCollections;
filterStatuses;
filterRole;
deselect();
});
// Push freshly-loaded data into the renderer once both are ready. Reads
// graphData (reactive) + rendererReady (reactive); writes only the imperative
// `graph` handle and the plain `collectionColors` map, never a tracked $state.
@@ -345,10 +482,19 @@
// canvas too — otherwise the previous workspace's nodes linger behind the
// loading overlay (Codex round-1 finding #1).
$effect(() => {
const data = graphData;
// Feed the FILTERED subset (TASK-1735) — not the raw payload. filteredData is
// graphData verbatim when no filter is active, so the unfiltered path is
// unchanged. Reads filteredData (reactive, derived from graphData + the filter
// $states); writes only the imperative `graph` handle + the plain
// collectionColors map, never a tracked $state — CONVE-1688-clean.
const data = filteredData;
if (!rendererReady || !graph) return;
// Reset color assignment so collection→color stays stable per payload.
// Reset color assignment so collection→color stays stable per payload. Built
// from the FULL node list (not the filtered subset) so a collection keeps its
// hue whether or not the current filter happens to include it — the filter
// chips and the rendered nodes must agree on color.
collectionColors = {};
for (const n of graphData?.nodes ?? []) colorForCollection(n.collection);
graph.graphData({
nodes: data ? data.nodes.map((n) => ({ ...n, id: n.ref, name: n.title })) : [],
links: data
@@ -402,6 +548,7 @@
title: string;
collection: string;
status?: string;
role?: string;
is_terminal: boolean;
child_count: number;
updated_at: string;
@@ -425,18 +572,27 @@
<svelte:window onkeydown={onKeydown} />
<div class="graph-page">
<!-- Controls overlay (top-left) -->
<div class="toolbar">
<label class="toggle">
<input type="checkbox" bind:checked={showCompleted} />
<span>Show completed</span>
</label>
<span class="counts">
<span class="count">{nodeCount} node{nodeCount === 1 ? '' : 's'}</span>
<span class="count-sep">·</span>
<span class="count">{edgeCount} edge{edgeCount === 1 ? '' : 's'}</span>
</span>
</div>
<!-- Controls overlay (top-left): toggle + filters + search fly-to. -->
<GraphToolbar
bind:showCompleted
{nodeCount}
{edgeCount}
{totalNodeCount}
{totalEdgeCount}
filtered={filtersActive}
collections={distinctCollections}
statuses={distinctStatuses}
roles={distinctRoles}
selectedCollections={filterCollections}
selectedStatuses={filterStatuses}
selectedRole={filterRole}
searchNodes={filteredData?.nodes ?? []}
{colorForCollection}
ontogglecollection={toggleCollectionFilter}
ontogglestatus={toggleStatusFilter}
onselectrole={selectRoleFilter}
onsearchpick={flyToRef}
/>
<!-- The renderer mounts here; it owns its own canvas. -->
<div class="canvas" bind:this={containerEl}></div>
@@ -499,47 +655,6 @@
inset: 0;
}
/* ── Toolbar ──────────────────────────────────────────────────────────────── */
.toolbar {
position: absolute;
top: var(--space-4);
left: var(--space-4);
z-index: 10;
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-2) var(--space-4);
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(6px);
}
.toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.82em;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.toggle input {
cursor: pointer;
}
.counts {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.78em;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.count-sep {
opacity: 0.5;
}
/* ── State overlays ───────────────────────────────────────────────────────── */
.overlay {
position: absolute;
@@ -0,0 +1,515 @@
<script lang="ts">
// Toolbar for the 3D workspace graph (PLAN-1730 / TASK-1735).
//
// Owns the show-completed toggle, the filter controls (collection chips, status
// chips, optional role dropdown), the filtered node/edge readout, and the search
// fly-to box. It is PURE PRESENTATION + LOCAL UI STATE: the page owns the
// authoritative filter $state (read by its renderer-sync effect) and the
// selection machinery. This component receives the current filter values + the
// distinct option lists, and emits callbacks the page applies.
//
// Styling mirrors the page's old inline .toolbar (backdrop-blur, color-mix
// surfaces) so the control layer reads as the same UI.
// Minimal node shape the search box needs — a structural subset of the page's
// GraphNode3D so the page can pass its mapped/filtered nodes straight through.
interface SearchNode {
ref: string;
title: string;
collection: string;
}
let {
// show-completed toggle (two-way bound to the page).
showCompleted = $bindable(),
// Filtered vs total counts for the readout.
nodeCount,
edgeCount,
totalNodeCount,
totalEdgeCount,
filtered,
// Distinct option lists, derived by the page from the loaded payload.
collections,
statuses,
roles,
// Current filter selections (page-owned $state, passed down read-only).
selectedCollections,
selectedStatuses,
selectedRole,
// POST-filter nodes, for the search type-ahead.
searchNodes,
// Palette accessor — shared with the renderer so chips match node colors.
colorForCollection,
// Filter callbacks.
ontogglecollection,
ontogglestatus,
onselectrole,
// Search fly-to: page resolves the ref to a live renderer node + selects it.
onsearchpick
}: {
showCompleted: boolean;
nodeCount: number;
edgeCount: number;
totalNodeCount: number;
totalEdgeCount: number;
filtered: boolean;
collections: string[];
statuses: string[];
roles: string[];
selectedCollections: string[];
selectedStatuses: string[];
selectedRole: string | null;
searchNodes: SearchNode[];
colorForCollection: (slug: string) => string;
ontogglecollection: (slug: string) => void;
ontogglestatus: (status: string) => void;
onselectrole: (role: string | null) => void;
onsearchpick: (ref: string) => void;
} = $props();
// ── Filters disclosure ──────────────────────────────────────────────────────
// Collapsed by default to keep the toolbar tidy; a count badge hints at active
// filters without expanding.
let filtersOpen = $state(false);
const activeFilterCount = $derived(
selectedCollections.length + selectedStatuses.length + (selectedRole ? 1 : 0)
);
// ── Search box (local UI state only) ─────────────────────────────────────────
let query = $state('');
let searchOpen = $state(false);
// Active descendant in the type-ahead list, -1 when none. Reset on every fresh
// match set so the highlight never points past the list.
let highlight = $state(-1);
// Case-insensitive match on ref or title, capped at 8 results. $derived so the
// list tracks both the query and any payload/filter change underneath it.
const MAX_RESULTS = 8;
const matches = $derived.by<SearchNode[]>(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
const out: SearchNode[] = [];
for (const n of searchNodes) {
if (n.ref.toLowerCase().includes(q) || n.title.toLowerCase().includes(q)) {
out.push(n);
if (out.length >= MAX_RESULTS) break;
}
}
return out;
});
// Dropdown is visible only when focused AND there are matches to show.
const dropdownVisible = $derived(searchOpen && matches.length > 0);
function pick(ref: string) {
onsearchpick(ref);
query = '';
searchOpen = false;
highlight = -1;
}
function onInput() {
searchOpen = true;
// Reset the active row whenever the query changes; the match set just shifted.
highlight = matches.length > 0 ? 0 : -1;
}
// Keyboard nav. Escape closes the dropdown WITHOUT bubbling to the page's
// window-level Escape-deselect handler — but only when the dropdown is open, so
// a stray Escape on an empty/closed box still reaches the page (CONVE-639 spirit).
function onSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
if (!dropdownVisible) return;
e.preventDefault();
highlight = (highlight + 1) % matches.length;
} else if (e.key === 'ArrowUp') {
if (!dropdownVisible) return;
e.preventDefault();
highlight = (highlight - 1 + matches.length) % matches.length;
} else if (e.key === 'Enter') {
if (!dropdownVisible) return;
e.preventDefault();
const target = highlight >= 0 ? matches[highlight] : matches[0];
if (target) pick(target.ref);
} else if (e.key === 'Escape') {
// Gate on the dropdown actually being VISIBLE (not just searchOpen):
// Escape in a focused-but-empty search should fall through to the
// page-level deselect handler (Codex PR #703 round 2).
if (dropdownVisible) {
e.preventDefault();
e.stopPropagation();
searchOpen = false;
highlight = -1;
}
}
}
</script>
<div class="toolbar">
<div class="row primary">
<label class="toggle">
<input type="checkbox" bind:checked={showCompleted} />
<span>Show completed</span>
</label>
<button
type="button"
class="filters-btn"
class:open={filtersOpen}
aria-expanded={filtersOpen}
onclick={() => (filtersOpen = !filtersOpen)}
>
Filters{#if activeFilterCount > 0}<span class="badge">{activeFilterCount}</span>{/if}
</button>
<span class="counts">
{#if filtered}
<span class="count">{nodeCount} of {totalNodeCount} nodes</span>
<span class="count-sep">·</span>
<span class="count">{edgeCount} of {totalEdgeCount} edges</span>
{:else}
<span class="count">{nodeCount} node{nodeCount === 1 ? '' : 's'}</span>
<span class="count-sep">·</span>
<span class="count">{edgeCount} edge{edgeCount === 1 ? '' : 's'}</span>
{/if}
</span>
</div>
<!-- Search fly-to. The wrapper is relative so the type-ahead anchors to it. -->
<div class="row search-row">
<div class="search">
<input
type="text"
class="search-input"
placeholder="Search items…"
bind:value={query}
oninput={onInput}
onfocus={() => (searchOpen = true)}
onblur={() => (searchOpen = false)}
onkeydown={onSearchKeydown}
role="combobox"
aria-expanded={dropdownVisible}
aria-controls="graph-search-list"
aria-autocomplete="list"
/>
{#if dropdownVisible}
<ul class="search-results" id="graph-search-list" role="listbox">
{#each matches as m, i (m.ref)}
<li role="option" aria-selected={i === highlight}>
<button
type="button"
class="result"
class:active={i === highlight}
onmousedown={(e) => {
// mousedown (not click) so the pick fires before the input's
// blur closes the dropdown out from under it.
e.preventDefault();
pick(m.ref);
}}
onmouseenter={() => (highlight = i)}
>
<span
class="dot"
style:background-color={colorForCollection(m.collection)}
aria-hidden="true"
></span>
<span class="result-ref">{m.ref}</span>
<span class="result-title">{m.title}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if filtersOpen}
<div class="row filters">
{#if collections.length > 0}
<div class="filter-group" role="group" aria-label="Filter by collection">
<span class="filter-label">Collection</span>
<div class="chips">
{#each collections as slug (slug)}
<button
type="button"
class="chip"
class:active={selectedCollections.includes(slug)}
aria-pressed={selectedCollections.includes(slug)}
onclick={() => ontogglecollection(slug)}
>
<span
class="dot"
style:background-color={colorForCollection(slug)}
aria-hidden="true"
></span>
{slug}
</button>
{/each}
</div>
</div>
{/if}
{#if statuses.length > 0}
<div class="filter-group" role="group" aria-label="Filter by status">
<span class="filter-label">Status</span>
<div class="chips">
{#each statuses as status (status)}
<button
type="button"
class="chip"
class:active={selectedStatuses.includes(status)}
aria-pressed={selectedStatuses.includes(status)}
onclick={() => ontogglestatus(status)}
>
{status}
</button>
{/each}
</div>
</div>
{/if}
{#if roles.length > 0}
<div class="filter-group">
<span class="filter-label" id="graph-role-label">Role</span>
<select
class="role-select"
aria-labelledby="graph-role-label"
value={selectedRole ?? ''}
onchange={(e) => onselectrole(e.currentTarget.value || null)}
>
<option value="">All roles</option>
{#each roles as role (role)}
<option value={role}>{role}</option>
{/each}
</select>
</div>
{/if}
</div>
{/if}
</div>
<style>
.toolbar {
position: absolute;
top: var(--space-4);
left: var(--space-4);
z-index: 10;
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: min(28rem, calc(100% - var(--space-8)));
padding: var(--space-2) var(--space-4);
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(6px);
}
.row {
display: flex;
align-items: center;
gap: var(--space-4);
}
.row.primary {
flex-wrap: wrap;
}
.toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.82em;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.toggle input {
cursor: pointer;
}
.filters-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: var(--space-1) var(--space-3);
font-size: 0.8em;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.filters-btn:hover,
.filters-btn.open {
border-color: var(--text-muted);
color: var(--text-primary);
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
padding: 0 0.3rem;
font-size: 0.85em;
font-variant-numeric: tabular-nums;
color: var(--btn-primary-text, #fff);
background: var(--accent, #6366f1);
border-radius: 999px;
}
.counts {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.78em;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.count-sep {
opacity: 0.5;
}
/* ── Search ───────────────────────────────────────────────────────────────── */
.search-row {
align-items: stretch;
}
.search {
position: relative;
flex: 1;
}
.search-input {
width: 100%;
padding: var(--space-1) var(--space-3);
font-size: 0.82em;
color: var(--text-primary);
background: var(--bg-primary, #0a0a1a);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-input:focus {
outline: none;
border-color: var(--accent, #6366f1);
}
.search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 11;
margin: 0;
padding: var(--space-1);
list-style: none;
max-height: 16rem;
overflow-y: auto;
background: color-mix(in srgb, var(--bg-secondary) 96%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px);
}
.result {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-1) var(--space-2);
text-align: left;
background: transparent;
border: none;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
}
.result.active {
background: color-mix(in srgb, var(--accent, #6366f1) 18%, transparent);
}
.result-ref {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72em;
font-weight: 600;
color: var(--text-secondary);
flex: 0 0 auto;
}
.result-title {
font-size: 0.78em;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Filters ──────────────────────────────────────────────────────────────── */
.filters {
flex-direction: column;
align-items: stretch;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--border);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.filter-label {
font-size: 0.7em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: var(--space-1) var(--space-3);
font-size: 0.78em;
font-weight: 500;
text-transform: capitalize;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chip:hover {
border-color: var(--text-muted);
color: var(--text-primary);
}
.chip.active {
background: color-mix(in srgb, var(--accent, #6366f1) 15%, transparent);
border-color: var(--accent, #6366f1);
color: var(--text-primary);
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
flex: 0 0 auto;
}
.role-select {
padding: var(--space-1) var(--space-2);
font-size: 0.8em;
color: var(--text-primary);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
}
</style>