Files
FabledScribe/frontend/src/views/GraphView.vue
T
bvandeusen 4ba544e2af
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
refactor(theme): retire the --color-* shim — the sweep it promised, run
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.

73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.

One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.

Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.

Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).

Refs #2533
2026-08-08 22:42:37 -04:00

949 lines
26 KiB
Vue

<script setup lang="ts">
import { X } from "lucide-vue-next";
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
import { useRouter } from "vue-router";
import { forceSimulation, forceLink, forceManyBody, forceX, forceY, forceCollide, SimulationNodeDatum, SimulationLinkDatum, Simulation } from "d3-force";
import { zoom, zoomIdentity } from "d3-zoom";
import { drag } from "d3-drag";
import { select } from "d3-selection";
import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
const router = useRouter();
interface GraphNode extends SimulationNodeDatum {
id: number | string;
title: string;
type: "note" | "task" | "tag";
tags: string[];
project_id: number | null;
project_color: string | null;
// computed
degree?: number;
radius?: number;
}
interface GraphEdge {
source: number | string | GraphNode;
target: number | string | GraphNode;
type: "wikilink" | "tag";
}
interface Project {
id: number;
title: string;
color: string | null;
}
const svgRef = ref<SVGSVGElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const nodes = ref<GraphNode[]>([]);
const edges = ref<GraphEdge[]>([]);
const projects = ref<Project[]>([]);
const projectFilter = ref<number | null>(null);
const showSharedTags = ref(true);
const hideOrphans = ref(false);
// Physics tuning knobs
const showPhysics = ref(false);
const repulsion = ref(110); // displayed as positive; applied as negative charge
const linkDist = ref(80);
const linkStrength = ref(0.50);
const hubPull = ref(0.05);
const gravity = ref(0.03); // forceX/Y strength — 0 = no pull, ~0.1 = strong
// tooltip state
const tooltip = ref<{ visible: boolean; x: number; y: number; node: GraphNode | null }>({
visible: false,
x: 0,
y: 0,
node: null,
});
// peek panel state
const peekNode = ref<GraphNode | null>(null);
const peekBody = ref("");
const peekLoading = ref(false);
const peekLinkedNodes = computed<GraphNode[]>(() => {
if (!peekNode.value) return [];
const pid = String(peekNode.value.id);
const linkedIds = new Set<string>();
for (const e of edges.value) {
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
if (sid === pid) linkedIds.add(tid);
if (tid === pid) linkedIds.add(sid);
}
return nodes.value.filter(
(n) => linkedIds.has(String(n.id)) && n.type !== "tag"
);
});
async function openPeek(node: GraphNode) {
peekNode.value = node;
peekBody.value = "";
peekLoading.value = true;
try {
const endpoint = node.type === "task" ? `/api/tasks/${node.id}` : `/api/notes/${node.id}`;
const data = await apiGet<{ body?: string }>(endpoint);
peekBody.value = data.body ?? "";
} catch {
peekBody.value = "";
} finally {
peekLoading.value = false;
}
}
function closePeek() {
peekNode.value = null;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && peekNode.value) {
closePeek();
e.stopPropagation();
}
}
let simulation: Simulation<GraphNode, undefined> | null = null;
let resizeObserver: ResizeObserver | null = null;
const statsText = computed(() => {
if (!nodes.value.length) return "";
const orphanCount = nodes.value.filter((n) => (n.degree ?? 0) === 0).length;
const visibleNodes = hideOrphans.value ? nodes.value.length - orphanCount : nodes.value.length;
return `${visibleNodes} nodes · ${edges.value.length} edges`;
});
async function fetchData() {
loading.value = true;
error.value = null;
try {
const params = new URLSearchParams();
if (projectFilter.value !== null) params.set("project_id", String(projectFilter.value));
if (showSharedTags.value) params.set("shared_tags", "true");
const qs = params.toString() ? "?" + params.toString() : "";
const data = await apiGet<{ nodes: GraphNode[]; edges: GraphEdge[] }>(`/api/notes/graph${qs}`);
nodes.value = data.nodes;
edges.value = data.edges;
} catch (e: any) {
error.value = e?.message ?? "Failed to load graph";
} finally {
loading.value = false;
}
}
async function fetchProjects() {
try {
const data = await apiGet<{ projects: Project[] }>("/api/projects");
projects.value = data.projects ?? [];
} catch {
// non-fatal
}
}
function initGraph() {
if (!svgRef.value || !containerRef.value) return;
const svg = select(svgRef.value);
svg.selectAll("*").remove();
const w = containerRef.value.clientWidth || 800;
const h = containerRef.value.clientHeight || 600;
// Compute degrees
const degreeMap = new Map<string, number>();
for (const n of nodes.value) degreeMap.set(String(n.id), 0);
for (const e of edges.value) {
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
degreeMap.set(sid, (degreeMap.get(sid) ?? 0) + 1);
degreeMap.set(tid, (degreeMap.get(tid) ?? 0) + 1);
}
const simNodes: GraphNode[] = nodes.value.map((n) => {
const deg = degreeMap.get(String(n.id)) ?? 0;
const isTag = n.type === "tag";
return {
...n,
degree: deg,
radius: isTag ? 7 : Math.max(6, Math.min(18, 6 + deg * 1.5)),
x: w / 2 + (Math.random() - 0.5) * 200,
y: h / 2 + (Math.random() - 0.5) * 200,
};
});
// Apply orphan filter (tag nodes always have edges so never filtered)
const visibleNodes = hideOrphans.value
? simNodes.filter((n) => (n.degree ?? 0) > 0)
: simNodes;
const visibleIds = new Set(visibleNodes.map((n) => String(n.id)));
const simEdges: SimulationLinkDatum<GraphNode>[] = edges.value
.filter((e) => {
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
return visibleIds.has(sid) && visibleIds.has(tid);
})
.map((e) => ({
source: String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source),
target: String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target),
type: (e as GraphEdge).type,
})) as SimulationLinkDatum<GraphNode>[] & { type?: string }[];
// Defs — arrowhead marker
const defs = svg.append("defs");
defs.append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -4 8 8")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-4L8,0L0,4")
.attr("fill", "var(--fs-accent)")
.attr("opacity", "0.6");
// Zoom layer
const zoomG = svg.append("g").attr("class", "zoom-layer");
const zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 8])
.on("zoom", (event) => {
zoomG.attr("transform", event.transform);
});
svg
.attr("width", w)
.attr("height", h)
.call(zoomBehavior)
.on("dblclick.zoom", null);
// Reset zoom to fit on initial load
svg.call(zoomBehavior.transform, zoomIdentity.translate(0, 0).scale(1));
// Edges
const linkSel = zoomG
.selectAll("line.graph-edge")
.data(simEdges)
.enter()
.append("line")
.attr("class", "graph-edge")
.attr("stroke", (d: any) =>
d.type === "wikilink" ? "var(--fs-accent)" : "var(--fs-text-tertiary)"
)
.attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
.attr("stroke-dasharray", null)
.attr("stroke-width", (d: any) => (d.type === "wikilink" ? 1.5 : 1))
.attr("marker-end", (d: any) => (d.type === "wikilink" ? "url(#arrow)" : null));
// Node groups
const nodeSel = zoomG
.selectAll("g.graph-node")
.data(visibleNodes)
.enter()
.append("g")
.attr("class", "graph-node")
.style("cursor", "pointer")
.on("click", (_event: MouseEvent, d: GraphNode) => {
if (d.type === "tag") {
router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
} else {
openPeek(d);
}
})
.on("mouseover", function(event: MouseEvent, d: GraphNode) {
const rect = containerRef.value!.getBoundingClientRect();
tooltip.value = {
visible: true,
x: event.clientX - rect.left + 12,
y: event.clientY - rect.top - 8,
node: d,
};
select(this as SVGGElement).select("circle")
.transition().duration(150)
.attr("r", (d.radius ?? 8) * 1.3);
select(this as SVGGElement).select("text")
.transition().duration(150)
.style("font-weight", "600")
.style("font-size", "11px");
})
.on("mousemove", (event: MouseEvent) => {
const rect = containerRef.value!.getBoundingClientRect();
tooltip.value.x = event.clientX - rect.left + 12;
tooltip.value.y = event.clientY - rect.top - 8;
})
.on("mouseout", function(_event: MouseEvent, d: GraphNode) {
tooltip.value.visible = false;
select(this as SVGGElement).select("circle")
.transition().duration(150)
.attr("r", d.radius ?? 8);
select(this as SVGGElement).select("text")
.transition().duration(150)
.style("font-weight", null)
.style("font-size", null);
});
nodeSel
.append("circle")
.attr("r", (d: GraphNode) => d.radius ?? 8)
.attr("fill", (d: GraphNode) => {
if (d.type === "tag") return "color-mix(in srgb, var(--fs-accent) 20%, transparent)";
return d.project_color ?? "var(--fs-surface-raised)";
})
.attr("stroke", (d: GraphNode) =>
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-border-color)"
)
.attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
.attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
.attr("opacity", (d: GraphNode) =>
!hideOrphans.value && (d.degree ?? 0) === 0 ? 0.35 : 1
);
nodeSel
.append("text")
.text((d: GraphNode) => {
const label = d.type === "tag" ? `#${d.title}` : d.title;
return label.length > 20 ? label.slice(0, 20) + "…" : label;
})
.attr("text-anchor", "middle")
.attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
.attr("font-size", "10px")
.attr("fill", (d: GraphNode) =>
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-text-secondary)"
)
.attr("pointer-events", "none");
// Drag
const dragBehavior = drag<SVGGElement, GraphNode>()
.on("start", (event, d) => {
if (!event.active) simulation?.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) simulation?.alphaTarget(0);
d.fx = null;
d.fy = null;
});
nodeSel.call(dragBehavior as any);
// Invisible project hub nodes — one per project, attract all member nodes inward
const projectHubs = new Map<number, GraphNode>();
for (const n of visibleNodes) {
if (n.project_id !== null && !projectHubs.has(n.project_id)) {
projectHubs.set(n.project_id, {
id: `hub:${n.project_id}`,
title: "",
type: "tag" as any, // unused — hubs are never rendered
tags: [],
project_id: n.project_id,
project_color: null,
degree: 0,
radius: 0,
x: w / 2 + (Math.random() - 0.5) * 100,
y: h / 2 + (Math.random() - 0.5) * 100,
});
}
}
const hubNodes = [...projectHubs.values()];
const allSimNodes = [...visibleNodes, ...hubNodes];
const hubEdges: SimulationLinkDatum<GraphNode>[] = [];
for (const n of visibleNodes) {
if (n.project_id !== null && projectHubs.has(n.project_id)) {
hubEdges.push({
source: String(n.id),
target: `hub:${n.project_id}`,
type: "hub",
} as any);
}
}
const allSimEdges = [...simEdges, ...hubEdges];
// Simulation
simulation = forceSimulation<GraphNode>(allSimNodes)
.force(
"link",
forceLink<GraphNode, SimulationLinkDatum<GraphNode>>(allSimEdges)
.id((d) => String(d.id))
.distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
.strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value)
)
.force("charge", forceManyBody<GraphNode>().strength((d: any) =>
String(d.id).startsWith("hub:") ? 0 : -repulsion.value
))
.force("gravityX", forceX<GraphNode>(w / 2).strength(gravity.value))
.force("gravityY", forceY<GraphNode>(h / 2).strength(gravity.value))
.force("collide", forceCollide<GraphNode>().radius((d) => (d.radius ?? 8) + 4))
.on("tick", () => {
linkSel
.attr("x1", (d: any) => (d.source as GraphNode).x ?? 0)
.attr("y1", (d: any) => (d.source as GraphNode).y ?? 0)
.attr("x2", (d: any) => {
const s = d.source as GraphNode;
const t = d.target as GraphNode;
const dx = (t.x ?? 0) - (s.x ?? 0);
const dy = (t.y ?? 0) - (s.y ?? 0);
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const r = (t.radius ?? 8) + 6;
return (t.x ?? 0) - (dx / dist) * r;
})
.attr("y2", (d: any) => {
const s = d.source as GraphNode;
const t = d.target as GraphNode;
const dx = (t.x ?? 0) - (s.x ?? 0);
const dy = (t.y ?? 0) - (s.y ?? 0);
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const r = (t.radius ?? 8) + 6;
return (t.y ?? 0) - (dy / dist) * r;
});
nodeSel.attr("transform", (d: GraphNode) => `translate(${d.x ?? 0},${d.y ?? 0})`);
});
}
function applyPhysics() {
if (!simulation) return;
const r = repulsion.value;
(simulation.force("charge") as any)?.strength((d: any) =>
String(d.id).startsWith("hub:") ? 0 : -r
);
const lf = simulation.force("link") as any;
if (lf) {
lf.distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
.strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value);
}
(simulation.force("gravityX") as any)?.strength(gravity.value);
(simulation.force("gravityY") as any)?.strength(gravity.value);
simulation.alpha(0.3).restart();
}
watch([nodes, edges, hideOrphans], () => {
initGraph();
});
watch([repulsion, linkDist, linkStrength, hubPull, gravity], () => {
applyPhysics();
});
watch([projectFilter, showSharedTags], () => {
fetchData();
});
function handleResize() {
initGraph();
}
onMounted(async () => {
await Promise.all([fetchData(), fetchProjects()]);
initGraph();
resizeObserver = new ResizeObserver(handleResize);
if (containerRef.value) resizeObserver.observe(containerRef.value);
window.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
simulation?.stop();
resizeObserver?.disconnect();
window.removeEventListener("keydown", handleKeyDown);
});
</script>
<template>
<main class="graph-page">
<div class="graph-controls">
<select
v-model="projectFilter"
class="graph-select"
aria-label="Filter by project"
>
<option :value="null">All projects</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
<label class="graph-toggle">
<input type="checkbox" v-model="showSharedTags" />
Show tags
</label>
<label class="graph-toggle">
<input type="checkbox" v-model="hideOrphans" />
Hide orphans
</label>
<button class="graph-physics-btn" :class="{ active: showPhysics }" @click="showPhysics = !showPhysics">
Physics
</button>
<span v-if="statsText" class="graph-stats">{{ statsText }}</span>
</div>
<div v-if="showPhysics" class="graph-physics-panel">
<label class="physics-knob">
<span class="knob-label">Repulsion <em>{{ repulsion }}</em></span>
<input type="range" v-model.number="repulsion" min="10" max="400" step="5" />
</label>
<label class="physics-knob">
<span class="knob-label">Link distance <em>{{ linkDist }}</em></span>
<input type="range" v-model.number="linkDist" min="20" max="200" step="5" />
</label>
<label class="physics-knob">
<span class="knob-label">Link strength <em>{{ linkStrength.toFixed(2) }}</em></span>
<input type="range" v-model.number="linkStrength" min="0.05" max="1" step="0.05" />
</label>
<label class="physics-knob">
<span class="knob-label">Project pull <em>{{ hubPull.toFixed(2) }}</em></span>
<input type="range" v-model.number="hubPull" min="0" max="1" step="0.05" />
</label>
<label class="physics-knob">
<span class="knob-label">Gravity <em>{{ gravity.toFixed(2) }}</em></span>
<input type="range" v-model.number="gravity" min="0" max="0.2" step="0.01" />
</label>
</div>
<div class="graph-wrap" ref="containerRef">
<svg ref="svgRef" class="graph-svg" />
<div v-if="loading" class="graph-overlay graph-loading">
<span class="spinner"></span>
</div>
<div v-else-if="error" class="graph-overlay graph-empty">
<p>{{ error }}</p>
</div>
<div v-else-if="!loading && nodes.length === 0" class="graph-overlay graph-empty">
<p>No notes to display. Create some notes to see the graph.</p>
</div>
<!-- Peek panel -->
<Transition name="peek-slide">
<div v-if="peekNode" class="graph-peek-panel">
<div class="peek-header">
<span :class="['peek-type-badge', `peek-type-${peekNode.type}`]">{{ peekNode.type }}</span>
<div class="peek-actions">
<router-link
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}` : `/notes/${peekNode.id}`"
class="peek-link"
>Open</router-link>
<router-link
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
class="peek-link"
>Edit</router-link>
<button class="peek-close" @click="closePeek" title="Close (Esc)"><X :size="16" /></button>
</div>
</div>
<h2 class="peek-title">{{ peekNode.title }}</h2>
<div v-if="peekNode.tags?.length" class="peek-tags">
<span v-for="tag in peekNode.tags" :key="tag" class="tag-chip">#{{ tag }}</span>
</div>
<div class="peek-body-wrap">
<div v-if="peekLoading" class="peek-loading">Loading...</div>
<div
v-else-if="peekBody"
class="prose peek-prose"
v-html="renderMarkdown(peekBody)"
/>
<div v-else class="peek-empty">No content.</div>
</div>
<div v-if="peekLinkedNodes.length" class="peek-linked">
<div class="peek-linked-label">Linked notes</div>
<ul class="peek-linked-list">
<li
v-for="n in peekLinkedNodes"
:key="n.id"
class="peek-linked-item"
@click="openPeek(n)"
>
<span :class="['peek-linked-type', `peek-type-${n.type}`]">{{ n.type[0].toUpperCase() }}</span>
{{ n.title }}
</li>
</ul>
</div>
</div>
</Transition>
<div
v-if="tooltip.visible && tooltip.node"
class="graph-tooltip"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
>
<div class="tooltip-title">{{ tooltip.node.type === 'tag' ? '#' + tooltip.node.title : tooltip.node.title }}</div>
<div v-if="tooltip.node.tags.length" class="tooltip-tags">
<span v-for="tag in tooltip.node.tags" :key="tag" class="tag-chip">{{ tag }}</span>
</div>
<div class="tooltip-meta">{{ tooltip.node.type === 'tag' ? 'tag' : tooltip.node.type }}</div>
</div>
</div>
</main>
</template>
<style scoped>
.graph-page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--fs-layout-header));
overflow: hidden;
}
.graph-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
flex-wrap: wrap;
}
.graph-select {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary);
font-size: 0.875rem;
padding: 0.3rem 0.6rem;
cursor: pointer;
font-family: inherit;
}
.graph-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
color: var(--fs-text-secondary);
cursor: pointer;
user-select: none;
}
.graph-toggle input {
cursor: pointer;
accent-color: var(--fs-accent);
}
.graph-stats {
margin-left: auto;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.graph-physics-btn {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-secondary);
font-size: 0.875rem;
font-family: inherit;
padding: 0.3rem 0.7rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.graph-physics-btn:hover {
border-color: var(--fs-accent);
color: var(--fs-text-primary);
}
.graph-physics-btn.active {
border-color: var(--fs-accent);
color: var(--fs-accent);
}
.graph-physics-panel {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
padding: 0.6rem 1rem;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
.physics-knob {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 140px;
flex: 1;
max-width: 220px;
}
.knob-label {
font-size: 0.75rem;
color: var(--fs-text-secondary);
display: flex;
justify-content: space-between;
}
.knob-label em {
font-style: normal;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
.physics-knob input[type="range"] {
width: 100%;
accent-color: var(--fs-accent);
cursor: pointer;
}
.graph-wrap {
flex: 1;
position: relative;
overflow: hidden;
background: var(--fs-surface-page);
}
.graph-svg {
width: 100%;
height: 100%;
display: block;
}
.graph-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.graph-empty p {
color: var(--fs-text-tertiary);
font-size: 0.95rem;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--fs-border-color);
border-top-color: var(--fs-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.graph-tooltip {
position: absolute;
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);
padding: 0.5rem 0.75rem;
pointer-events: none;
z-index: 10;
max-width: 240px;
}
.tooltip-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--fs-text-primary);
margin-bottom: 0.25rem;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
.tag-chip {
font-size: 0.7rem;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
border-radius: 999px;
padding: 0.1rem 0.4rem;
}
.tooltip-meta {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
text-transform: capitalize;
}
/* ── Peek panel ── */
.graph-peek-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 340px;
background: var(--fs-surface-raised);
border-left: 1px solid var(--fs-border-color);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 20;
}
.peek-slide-enter-active,
.peek-slide-leave-active {
transition: transform 0.2s ease;
}
.peek-slide-enter-from,
.peek-slide-leave-to {
transform: translateX(100%);
}
.peek-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
flex-shrink: 0;
}
.peek-type-badge {
font-size: 0.68rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.45rem;
border-radius: 10px;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-tertiary);
}
.peek-type-task { border-color: var(--fs-accent); color: var(--fs-accent); }
.peek-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.peek-link {
font-size: 0.78rem;
color: var(--fs-accent);
text-decoration: none;
}
.peek-link:hover { text-decoration: underline; }
.peek-close {
background: none;
border: none;
color: var(--fs-text-tertiary);
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.peek-close:hover { color: var(--fs-text-primary); }
.peek-title {
margin: 0;
padding: 0.75rem 0.75rem 0.4rem;
font-size: 1rem;
font-weight: 500;
color: var(--fs-text-primary);
flex-shrink: 0;
}
.peek-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0 0.75rem 0.5rem;
flex-shrink: 0;
}
.peek-body-wrap {
flex: 1;
overflow-y: auto;
padding: 0 0.75rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.peek-prose {
font-size: 0.85rem;
}
.peek-loading,
.peek-empty {
font-size: 0.82rem;
color: var(--fs-text-tertiary);
padding-top: 0.5rem;
}
.peek-linked {
flex-shrink: 0;
padding: 0.5rem 0.75rem 0.75rem;
}
.peek-linked-label {
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
margin-bottom: 0.4rem;
}
.peek-linked-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 160px;
overflow-y: auto;
}
.peek-linked-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--fs-text-primary);
cursor: pointer;
padding: 0.25rem 0.4rem;
border-radius: 4px;
}
.peek-linked-item:hover {
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
color: var(--fs-accent);
}
.peek-linked-type {
font-size: 0.65rem;
font-weight: 500;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--fs-text-tertiary);
}
</style>