@swarmvaultai/viewer 0.7.30 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>SwarmVault Graph</title>
7
- <script type="module" crossorigin src="/assets/index-DxKn2KOc.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BHjjw4rU.css">
7
+ <script type="module" crossorigin src="/assets/index-QQ74kUX8.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-CRcCxyS8.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/dist/lib.d.ts CHANGED
@@ -172,6 +172,29 @@ type ViewerApprovalSummary = {
172
172
  acceptedCount: number;
173
173
  rejectedCount: number;
174
174
  };
175
+ type ViewerApprovalDiffLine = {
176
+ type: "add" | "remove" | "context";
177
+ value: string;
178
+ };
179
+ type ViewerApprovalDiffHunk = {
180
+ oldStart: number;
181
+ oldLines: number;
182
+ newStart: number;
183
+ newLines: number;
184
+ lines: ViewerApprovalDiffLine[];
185
+ };
186
+ type ViewerApprovalFrontmatterChange = {
187
+ key: string;
188
+ before?: unknown;
189
+ after?: unknown;
190
+ protected: boolean;
191
+ };
192
+ type ViewerApprovalStructuredDiff = {
193
+ hunks: ViewerApprovalDiffHunk[];
194
+ addedLines: number;
195
+ removedLines: number;
196
+ frontmatterChanges: ViewerApprovalFrontmatterChange[];
197
+ };
175
198
  type ViewerApprovalEntry = {
176
199
  pageId: string;
177
200
  title: string;
@@ -183,6 +206,9 @@ type ViewerApprovalEntry = {
183
206
  previousPath?: string;
184
207
  currentContent?: string;
185
208
  stagedContent?: string;
209
+ diff?: string;
210
+ structuredDiff?: ViewerApprovalStructuredDiff;
211
+ warnings?: string[];
186
212
  };
187
213
  type ViewerApprovalDetail = ViewerApprovalSummary & {
188
214
  entries: ViewerApprovalEntry[];
@@ -199,6 +225,26 @@ type ViewerCandidateRecord = {
199
225
  sourceIds: string[];
200
226
  createdAt: string;
201
227
  updatedAt: string;
228
+ score?: number;
229
+ scoreBreakdown?: Record<string, number>;
230
+ };
231
+ type ViewerLintFinding = {
232
+ id: string;
233
+ severity: "error" | "warning" | "info";
234
+ category: string;
235
+ message: string;
236
+ pageId?: string;
237
+ pagePath?: string;
238
+ nodeId?: string;
239
+ detectedAt?: string;
240
+ };
241
+ type ViewerWorkspaceBundle = {
242
+ graph: ViewerGraphArtifact;
243
+ approvals: ViewerApprovalSummary[];
244
+ candidates: ViewerCandidateRecord[];
245
+ watchStatus: ViewerWatchStatus;
246
+ graphReport: ViewerGraphReport | null;
247
+ lintFindings: ViewerLintFinding[];
202
248
  };
203
249
  type ViewerWatchStatus = {
204
250
  generatedAt: string;
@@ -327,5 +373,16 @@ declare function applyReviewAction(approvalId: string, action: "accept" | "rejec
327
373
  declare function fetchCandidates(): Promise<ViewerCandidateRecord[]>;
328
374
  declare function applyCandidateAction(target: string, action: "promote" | "archive"): Promise<ViewerCandidateRecord>;
329
375
  declare function fetchWatchStatus(): Promise<ViewerWatchStatus>;
376
+ declare function fetchLintFindings(): Promise<ViewerLintFinding[]>;
377
+ declare function fetchWorkspaceBundle(): Promise<ViewerWorkspaceBundle | null>;
378
+ type SubgraphExportPayload = {
379
+ generatedAt: string;
380
+ rootNodeId?: string;
381
+ nodes: ViewerGraphNode[];
382
+ edges: ViewerGraphEdge[];
383
+ };
384
+ declare function buildSubgraphExport(graph: ViewerGraphArtifact, nodeIds: string[]): SubgraphExportPayload;
385
+ declare function downloadDataUrl(filename: string, dataUrl: string): void;
386
+ declare function downloadText(filename: string, text: string, mime?: string): void;
330
387
 
331
- export { type ViewerApprovalDetail, type ViewerApprovalEntry, type ViewerApprovalSummary, type ViewerCandidateRecord, type ViewerGraphArtifact, type ViewerGraphEdge, type ViewerGraphExplainResult, type ViewerGraphHyperedge, type ViewerGraphNode, type ViewerGraphPage, type ViewerGraphPathResult, type ViewerGraphQueryResult, type ViewerGraphReport, type ViewerOutputAsset, type ViewerPagePayload, type ViewerReviewActionResult, type ViewerSearchOptions, type ViewerSearchResult, type ViewerWatchStatus, applyCandidateAction, applyReviewAction, fetchApprovalDetail, fetchApprovals, fetchCandidates, fetchGraphArtifact, fetchGraphExplain, fetchGraphPath, fetchGraphQuery, fetchGraphReport, fetchViewerPage, fetchWatchStatus, searchViewerPages };
388
+ export { type SubgraphExportPayload, type ViewerApprovalDetail, type ViewerApprovalDiffHunk, type ViewerApprovalDiffLine, type ViewerApprovalEntry, type ViewerApprovalFrontmatterChange, type ViewerApprovalStructuredDiff, type ViewerApprovalSummary, type ViewerCandidateRecord, type ViewerGraphArtifact, type ViewerGraphEdge, type ViewerGraphExplainResult, type ViewerGraphHyperedge, type ViewerGraphNode, type ViewerGraphPage, type ViewerGraphPathResult, type ViewerGraphQueryResult, type ViewerGraphReport, type ViewerLintFinding, type ViewerOutputAsset, type ViewerPagePayload, type ViewerReviewActionResult, type ViewerSearchOptions, type ViewerSearchResult, type ViewerWatchStatus, type ViewerWorkspaceBundle, applyCandidateAction, applyReviewAction, buildSubgraphExport, downloadDataUrl, downloadText, fetchApprovalDetail, fetchApprovals, fetchCandidates, fetchGraphArtifact, fetchGraphExplain, fetchGraphPath, fetchGraphQuery, fetchGraphReport, fetchLintFindings, fetchViewerPage, fetchWatchStatus, fetchWorkspaceBundle, searchViewerPages };
package/dist/lib.js CHANGED
@@ -172,6 +172,142 @@ function explainEmbeddedGraphTarget(graph, target) {
172
172
  ].join("\n")
173
173
  };
174
174
  }
175
+ function uniqueByKey(items, key) {
176
+ const seen = /* @__PURE__ */ new Set();
177
+ const result = [];
178
+ for (const item of items) {
179
+ const k = key(item);
180
+ if (seen.has(k)) continue;
181
+ seen.add(k);
182
+ result.push(item);
183
+ }
184
+ return result;
185
+ }
186
+ function embeddedScoreMatch(query, candidate) {
187
+ const q = query.trim().toLowerCase();
188
+ const c = candidate.trim().toLowerCase();
189
+ if (!q || !c) return 0;
190
+ if (c === q) return 100;
191
+ if (c.startsWith(q)) return 80;
192
+ if (c.includes(q)) return 60;
193
+ const qTokens = q.split(/\s+/).filter(Boolean);
194
+ const cTokens = new Set(c.split(/\s+/).filter(Boolean));
195
+ const overlap = qTokens.filter((token) => cTokens.has(token)).length;
196
+ return overlap ? overlap * 10 : 0;
197
+ }
198
+ function embeddedGraphQuery(graph, question, searchResults, options) {
199
+ const traversal = options?.traversal ?? "bfs";
200
+ const budget = Math.max(3, Math.min(options?.budget ?? 12, 50));
201
+ const pagesById = new Map((graph.pages ?? []).map((page) => [page.id, page]));
202
+ const pageMatchesRaw = searchResults.map((result) => {
203
+ const page = pagesById.get(result.pageId);
204
+ const score = Math.max(embeddedScoreMatch(question, result.title), embeddedScoreMatch(question, result.path));
205
+ if (!page || score <= 0) return null;
206
+ return { type: "page", id: page.id, label: page.title, score };
207
+ });
208
+ const pageMatches = pageMatchesRaw.filter(
209
+ (match) => match !== null
210
+ );
211
+ const nodeMatches = graph.nodes.map((node) => ({
212
+ type: "node",
213
+ id: node.id,
214
+ label: node.label,
215
+ score: Math.max(embeddedScoreMatch(question, node.label), embeddedScoreMatch(question, node.id))
216
+ })).filter((match) => match.score > 0);
217
+ const hyperedgeMatches = (graph.hyperedges ?? []).map((hyperedge) => ({
218
+ type: "hyperedge",
219
+ id: hyperedge.id,
220
+ label: hyperedge.label,
221
+ score: Math.max(
222
+ embeddedScoreMatch(question, hyperedge.label),
223
+ embeddedScoreMatch(question, hyperedge.why),
224
+ embeddedScoreMatch(question, hyperedge.relation)
225
+ )
226
+ })).filter((match) => match.score > 0);
227
+ const matches = uniqueByKey([...pageMatches, ...nodeMatches, ...hyperedgeMatches], (match) => `${match.type}:${match.id}`).sort((left, right) => right.score - left.score || left.label.localeCompare(right.label)).slice(0, 12);
228
+ const seeds = uniqueByKey(
229
+ [
230
+ ...searchResults.flatMap((result) => pagesById.get(result.pageId)?.nodeIds ?? []),
231
+ ...matches.filter((match) => match.type === "page").flatMap((match) => pagesById.get(match.id)?.nodeIds ?? []),
232
+ ...matches.filter((match) => match.type === "node").map((match) => match.id),
233
+ ...matches.filter((match) => match.type === "hyperedge").flatMap((match) => (graph.hyperedges ?? []).find((hyperedge) => hyperedge.id === match.id)?.nodeIds ?? [])
234
+ ],
235
+ (item) => item
236
+ ).filter(Boolean);
237
+ const adjacency = /* @__PURE__ */ new Map();
238
+ const pushNeighbor = (nodeId, neighbor) => {
239
+ if (!adjacency.has(nodeId)) adjacency.set(nodeId, []);
240
+ adjacency.get(nodeId)?.push(neighbor);
241
+ };
242
+ for (const edge of graph.edges) {
243
+ pushNeighbor(edge.source, { edge, nodeId: edge.target, direction: "outgoing" });
244
+ pushNeighbor(edge.target, { edge, nodeId: edge.source, direction: "incoming" });
245
+ }
246
+ for (const [nodeId, items] of adjacency.entries()) {
247
+ items.sort(
248
+ (left, right) => (right.edge.confidence ?? 0) - (left.edge.confidence ?? 0) || left.edge.relation.localeCompare(right.edge.relation)
249
+ );
250
+ adjacency.set(nodeId, items);
251
+ }
252
+ const visitedNodeIds = [];
253
+ const visitedEdgeIds = /* @__PURE__ */ new Set();
254
+ const seen = /* @__PURE__ */ new Set();
255
+ const frontier = [...seeds];
256
+ while (frontier.length && visitedNodeIds.length < budget) {
257
+ const current = traversal === "dfs" ? frontier.pop() : frontier.shift();
258
+ if (!current || seen.has(current)) continue;
259
+ seen.add(current);
260
+ visitedNodeIds.push(current);
261
+ for (const neighbor of adjacency.get(current) ?? []) {
262
+ visitedEdgeIds.add(neighbor.edge.id);
263
+ if (!seen.has(neighbor.nodeId)) frontier.push(neighbor.nodeId);
264
+ if (visitedNodeIds.length + frontier.length >= budget * 2) break;
265
+ }
266
+ }
267
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
268
+ const pageIds = uniqueByKey(
269
+ [
270
+ ...searchResults.map((result) => result.pageId),
271
+ ...matches.filter((match) => match.type === "page").map((match) => match.id),
272
+ ...visitedNodeIds.flatMap((nodeId) => {
273
+ const node = nodesById.get(nodeId);
274
+ return node?.pageId ? [node.pageId] : [];
275
+ })
276
+ ],
277
+ (item) => item
278
+ );
279
+ const communities = uniqueByKey(
280
+ visitedNodeIds.map((nodeId) => nodesById.get(nodeId)?.communityId).filter((communityId) => Boolean(communityId)),
281
+ (item) => item
282
+ );
283
+ const hyperedgeIds = uniqueByKey(
284
+ (graph.hyperedges ?? []).filter((hyperedge) => hyperedge.nodeIds.some((nodeId) => visitedNodeIds.includes(nodeId))).map((hyperedge) => hyperedge.id),
285
+ (item) => item
286
+ );
287
+ return {
288
+ question,
289
+ traversal,
290
+ seedNodeIds: seeds,
291
+ seedPageIds: uniqueByKey(
292
+ [...searchResults.map((result) => result.pageId), ...matches.filter((match) => match.type === "page").map((match) => match.id)],
293
+ (item) => item
294
+ ),
295
+ visitedNodeIds,
296
+ visitedEdgeIds: [...visitedEdgeIds],
297
+ hyperedgeIds,
298
+ pageIds,
299
+ communities,
300
+ matches,
301
+ summary: [
302
+ `Seeds: ${seeds.join(", ") || "none"}`,
303
+ `Visited nodes: ${visitedNodeIds.length}`,
304
+ `Visited edges: ${visitedEdgeIds.size}`,
305
+ `Touched group patterns: ${hyperedgeIds.length}`,
306
+ `Communities: ${communities.join(", ") || "none"}`,
307
+ `Pages: ${pageIds.join(", ") || "none"}`
308
+ ].join("\n")
309
+ };
310
+ }
175
311
  function normalizeSnippet(content, query) {
176
312
  const normalized = content.replace(/\s+/g, " ").trim();
177
313
  if (!query) {
@@ -273,6 +409,11 @@ async function fetchViewerPage(path) {
273
409
  return response.json();
274
410
  }
275
411
  async function fetchGraphQuery(question, options = {}) {
412
+ const embedded = embeddedData();
413
+ if (embedded) {
414
+ const searchResults = await searchViewerPages(question, { limit: 10 });
415
+ return embeddedGraphQuery(embedded.graph, question, searchResults, options);
416
+ }
276
417
  const params = new URLSearchParams({
277
418
  q: question,
278
419
  traversal: options.traversal ?? "bfs",
@@ -396,9 +537,62 @@ async function fetchWatchStatus() {
396
537
  }
397
538
  return response.json();
398
539
  }
540
+ async function fetchLintFindings() {
541
+ if (embeddedData()) {
542
+ return [];
543
+ }
544
+ const response = await fetch("/api/lint");
545
+ if (response.status === 404) {
546
+ return [];
547
+ }
548
+ if (!response.ok) {
549
+ throw new Error(`Failed to load lint findings: ${response.status} ${response.statusText}`);
550
+ }
551
+ return response.json();
552
+ }
553
+ async function fetchWorkspaceBundle() {
554
+ if (embeddedData()) {
555
+ return null;
556
+ }
557
+ const response = await fetch("/api/workspace");
558
+ if (response.status === 404) return null;
559
+ if (!response.ok) {
560
+ throw new Error(`Failed to load workspace bundle: ${response.status} ${response.statusText}`);
561
+ }
562
+ return response.json();
563
+ }
564
+ function buildSubgraphExport(graph, nodeIds) {
565
+ const nodeSet = new Set(nodeIds);
566
+ const nodes = graph.nodes.filter((node) => nodeSet.has(node.id));
567
+ const edges = graph.edges.filter((edge) => nodeSet.has(edge.source) && nodeSet.has(edge.target));
568
+ return {
569
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
570
+ rootNodeId: nodeIds[0],
571
+ nodes,
572
+ edges
573
+ };
574
+ }
575
+ function downloadDataUrl(filename, dataUrl) {
576
+ if (typeof document === "undefined") return;
577
+ const link = document.createElement("a");
578
+ link.href = dataUrl;
579
+ link.download = filename;
580
+ document.body.appendChild(link);
581
+ link.click();
582
+ document.body.removeChild(link);
583
+ }
584
+ function downloadText(filename, text, mime = "text/plain") {
585
+ const blob = new Blob([text], { type: mime });
586
+ const url = URL.createObjectURL(blob);
587
+ downloadDataUrl(filename, url);
588
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
589
+ }
399
590
  export {
400
591
  applyCandidateAction,
401
592
  applyReviewAction,
593
+ buildSubgraphExport,
594
+ downloadDataUrl,
595
+ downloadText,
402
596
  fetchApprovalDetail,
403
597
  fetchApprovals,
404
598
  fetchCandidates,
@@ -407,7 +601,9 @@ export {
407
601
  fetchGraphPath,
408
602
  fetchGraphQuery,
409
603
  fetchGraphReport,
604
+ fetchLintFindings,
410
605
  fetchViewerPage,
411
606
  fetchWatchStatus,
607
+ fetchWorkspaceBundle,
412
608
  searchViewerPages
413
609
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmvaultai/viewer",
3
- "version": "0.7.30",
3
+ "version": "0.8.0",
4
4
  "description": "Graph viewer package for SwarmVault graph artifacts.",
5
5
  "type": "module",
6
6
  "main": "dist/lib.js",
@@ -37,20 +37,21 @@
37
37
  "engines": {
38
38
  "node": ">=24.0.0"
39
39
  },
40
- "scripts": {
41
- "build": "pnpm run build:lib && pnpm run build:app",
42
- "build:lib": "tsup src/lib.ts --format esm --dts --out-dir dist --clean",
43
- "build:app": "vite build",
44
- "test": "vitest run",
45
- "typecheck": "tsc --noEmit",
46
- "prepublishOnly": "node ../../scripts/check-release-sync.mjs && node ../../scripts/check-published-manifests.mjs"
47
- },
48
40
  "dependencies": {
49
41
  "cytoscape": "^3.33.1",
42
+ "highlight.js": "^11.10.0",
50
43
  "react": "^19.1.1",
51
- "react-dom": "^19.1.1"
44
+ "react-dom": "^19.1.1",
45
+ "react-markdown": "^9.0.1",
46
+ "rehype-highlight": "^7.0.0",
47
+ "rehype-slug": "^6.0.0",
48
+ "remark-gfm": "^4.0.0"
52
49
  },
53
50
  "devDependencies": {
51
+ "@testing-library/dom": "^10.4.0",
52
+ "@testing-library/jest-dom": "^6.5.0",
53
+ "@testing-library/react": "^16.0.1",
54
+ "@testing-library/user-event": "^14.5.2",
54
55
  "@types/node": "^24.6.0",
55
56
  "@types/react": "^19.1.13",
56
57
  "@types/react-dom": "^19.1.9",
@@ -60,5 +61,12 @@
60
61
  "typescript": "^5.9.2",
61
62
  "vite": "^7.1.7",
62
63
  "vitest": "^3.2.4"
64
+ },
65
+ "scripts": {
66
+ "build": "pnpm run build:lib && pnpm run build:app",
67
+ "build:lib": "tsup src/lib.ts --format esm --dts --out-dir dist --clean",
68
+ "build:app": "vite build",
69
+ "test": "vitest run",
70
+ "typecheck": "tsc --noEmit"
63
71
  }
64
- }
72
+ }
@@ -1 +0,0 @@
1
- :root{--c-bg-base: #020617;--c-bg-surface: #0c1222;--c-bg-elevated: rgba(15, 23, 42, .88);--c-bg-inset: rgba(2, 6, 23, .82);--c-bg-input: #070d1a;--c-border: rgba(148, 163, 184, .1);--c-border-subtle: rgba(148, 163, 184, .06);--c-border-focus: rgba(125, 211, 252, .28);--c-border-danger: rgba(248, 113, 113, .24);--c-border-warning: rgba(251, 191, 36, .2);--c-text-primary: #e2e8f0;--c-text-secondary: #94a3b8;--c-text-muted: #64748b;--c-text-accent: #7dd3fc;--c-text-error: #f87171;--c-text-warning: #fbbf24;--c-accent-bg: rgba(14, 165, 233, .08);--c-danger-bg: rgba(127, 29, 29, .18);--font-sans: "Inter", "Avenir Next", "Segoe UI", system-ui, sans-serif;--font-mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;--text-2xs: .6875rem;--text-xs: .75rem;--text-sm: .8125rem;--text-base: .9375rem;--text-lg: 1.0625rem;--text-xl: 1.25rem;--sp-1: 4px;--sp-2: 8px;--sp-3: 12px;--sp-4: 16px;--sp-5: 20px;--sp-6: 24px;--radius-sm: 4px;--radius-md: 6px;--radius-lg: 10px;--sidebar-width: 220px;--rail-width: 400px;--bar-height: 42px;color-scheme:dark;font-family:var(--font-sans);background:var(--c-bg-base);color:var(--c-text-primary)}*,*:before,*:after{box-sizing:border-box}body{margin:0;height:100vh;overflow:hidden;font-size:var(--text-sm);line-height:1.5;-webkit-font-smoothing:antialiased}#root{height:100vh;overflow:hidden}.app-shell{display:grid;grid-template-columns:var(--sidebar-width) minmax(0,1fr) var(--rail-width);grid-template-rows:var(--bar-height) minmax(0,1fr);grid-template-areas:"bar bar bar" "sidebar center rail";height:100vh;overflow:hidden}.app-bar{grid-area:bar;display:flex;align-items:center;gap:var(--sp-3);padding:0 var(--sp-5);background:var(--c-bg-surface);border-bottom:1px solid var(--c-border);z-index:10}.app-bar-title{font-size:var(--text-sm);font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--c-text-accent)}.app-bar-subtitle{font-size:var(--text-xs);color:var(--c-text-muted);letter-spacing:.04em}.sidebar{grid-area:sidebar;overflow-y:auto;padding:var(--sp-3);background:var(--c-bg-surface);border-right:1px solid var(--c-border);display:flex;flex-direction:column;gap:var(--sp-3);scrollbar-width:thin;scrollbar-color:var(--c-text-muted) transparent}.sidebar-section{display:flex;flex-direction:column;gap:var(--sp-2)}.sidebar-heading{font-size:var(--text-2xs);font-weight:600;text-transform:uppercase;letter-spacing:.1em;color:var(--c-text-muted);padding:var(--sp-1) 0;border-bottom:1px solid var(--c-border-subtle);margin-bottom:var(--sp-1)}.sidebar-section-toggle{all:unset;display:flex;align-items:center;gap:var(--sp-2);width:100%;cursor:pointer;font-size:var(--text-2xs);font-weight:600;text-transform:uppercase;letter-spacing:.1em;color:var(--c-text-muted);padding:var(--sp-1) 0;border-bottom:1px solid var(--c-border-subtle);margin-bottom:var(--sp-1);transition:color .15s}.sidebar-section-toggle:hover{color:var(--c-text-secondary)}.sidebar-section-toggle:before{content:"▸";font-size:.6em;transition:transform .2s ease;display:inline-block}.sidebar-section-toggle.is-expanded:before{transform:rotate(90deg)}.sidebar-section-toggle .filter-badge{margin-left:auto;font-family:var(--font-mono);font-size:var(--text-2xs);color:var(--c-text-accent);font-weight:500}.sidebar-section-body{display:grid;gap:var(--sp-2);max-height:0;overflow:hidden;transition:max-height .25s ease,opacity .2s ease;opacity:0}.sidebar-section-body.is-expanded{max-height:600px;opacity:1}.filter-group{display:flex;flex-direction:column;gap:3px}.filter-label{font-size:var(--text-2xs);text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted)}.center-area{grid-area:center;display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden}.stats-strip{display:flex;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-bottom:1px solid var(--c-border-subtle);background:var(--c-bg-surface);align-items:center}.stats-group{display:flex;gap:var(--sp-3);align-items:baseline}.stats-divider{width:1px;height:14px;background:var(--c-border);align-self:center;flex-shrink:0}.stat{display:flex;align-items:baseline;gap:var(--sp-1)}.stat-label{font-size:var(--text-2xs);color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em}.stat-value{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--c-text-primary);font-weight:500}.overview-banner{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-bottom:1px solid var(--c-border-subtle);background:#fbbf2414;color:var(--c-text-secondary);font-size:var(--text-xs);letter-spacing:.02em}.overview-banner code{font-family:var(--font-mono);font-size:inherit;color:var(--c-text-warning)}.canvas{min-height:0;background:radial-gradient(ellipse at 50% 40%,rgba(14,165,233,.025),transparent 65%),radial-gradient(circle at 1px 1px,rgba(148,163,184,.045) 1px,transparent 0),var(--c-bg-base);background-size:100% 100%,28px 28px,100% 100%}.canvas-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-3)}.loading-text{font-size:var(--text-base);color:var(--c-text-muted);letter-spacing:.06em;font-weight:400;animation:pulse-fade 2s ease-in-out infinite}@keyframes pulse-fade{0%,to{opacity:.35}50%{opacity:.85}}.canvas-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-2);color:var(--c-text-muted)}.canvas-empty-icon{font-size:2rem;opacity:.4}.report-tabs{border-top:1px solid var(--c-border);background:var(--c-bg-surface);max-height:360px;overflow:hidden}.report-tabs .tabs{border:none;border-radius:0;background:transparent}.report-tabs .tab-panel{max-height:310px;overflow-y:auto}.report-tabs-empty{padding:var(--sp-3);text-align:center;color:var(--c-text-muted);font-size:var(--text-sm)}.report-stats{display:flex;gap:var(--sp-4);font-size:var(--text-sm);color:var(--c-text-secondary)}.report-stats strong{font-family:var(--font-mono);color:var(--c-text-primary);margin-left:var(--sp-1)}.surprise-row{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap}.detail-rail{grid-area:rail;overflow-y:auto;padding:var(--sp-3);background:var(--c-bg-surface);border-left:1px solid var(--c-border);display:flex;flex-direction:column;gap:var(--sp-3);scrollbar-width:thin;scrollbar-color:var(--c-text-muted) transparent}.tabs{border-radius:var(--radius-lg);border:1px solid var(--c-border);background:var(--c-bg-elevated);overflow:hidden}.tab-bar{display:flex;border-bottom:1px solid var(--c-border);padding:0 var(--sp-1);gap:0}.tab-btn{all:unset;padding:var(--sp-2) var(--sp-3);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted);cursor:pointer;border-bottom:2px solid transparent;transition:color .12s,border-color .12s;white-space:nowrap}.tab-btn.is-active{color:var(--c-text-primary);border-bottom-color:var(--c-text-accent)}.tab-btn:hover:not(.is-active){color:var(--c-text-secondary)}.tab-btn:focus-visible{outline:2px solid var(--c-border-focus);outline-offset:-2px;border-radius:var(--radius-sm)}.tab-count{margin-left:var(--sp-1);font-family:var(--font-mono);font-size:var(--text-2xs);color:var(--c-text-accent)}.tab-panel{padding:var(--sp-3);max-height:320px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--c-text-muted) transparent}.panel{background:var(--c-bg-elevated);border:1px solid var(--c-border);border-radius:var(--radius-lg);padding:var(--sp-3);transition:border-color .15s}.panel-heading{margin:0 0 var(--sp-2);font-size:var(--text-base);font-weight:700;text-transform:none;letter-spacing:.01em;color:var(--c-text-primary)}.page-panel{min-height:120px}.card-list{display:grid;gap:var(--sp-2)}.card{background:var(--c-bg-inset);border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--sp-2) var(--sp-3);display:flex;flex-direction:column;gap:4px}.card>.input+.input,.card>.input+.btn,.card>.btn+.input{margin-top:2px}.card-warning{border-color:var(--c-border-warning)}.card-title{font-size:var(--text-sm);font-weight:600;color:var(--c-text-primary);margin:0;line-height:1.3}.result-card{all:unset;display:flex;flex-direction:column;gap:3px;background:var(--c-bg-inset);border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--sp-2) var(--sp-3);cursor:pointer;transition:border-color .12s,background .12s}.result-card:hover{border-color:var(--c-border-focus);background:#0ea5e908}.result-card.is-active{border-color:var(--c-border-focus);background:#0ea5e90f}.input{background:var(--c-bg-input);color:var(--c-text-primary);border:1px solid var(--c-border);border-radius:var(--radius-sm);padding:6px var(--sp-2);font-family:var(--font-sans);font-size:var(--text-sm);width:100%;outline:none;transition:border-color .12s}.input:focus{border-color:var(--c-border-focus)}.input::placeholder{color:var(--c-text-muted)}select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2364748b'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:24px;text-overflow:ellipsis}.btn{all:unset;display:inline-flex;align-items:center;gap:var(--sp-1);height:28px;padding:0 var(--sp-3);font-size:var(--text-xs);font-family:var(--font-sans);border-radius:var(--radius-sm);cursor:pointer;border:1px solid var(--c-border);background:transparent;color:var(--c-text-primary);transition:border-color .12s,background .12s;white-space:nowrap}.btn:hover{border-color:var(--c-border-focus);background:var(--c-accent-bg)}.btn:focus-visible{outline:2px solid var(--c-border-focus);outline-offset:1px}.btn:disabled{opacity:.35;cursor:default;pointer-events:none}.btn-primary{border-color:var(--c-border-focus);background:var(--c-accent-bg)}.btn-danger{border-color:var(--c-border-danger);background:var(--c-danger-bg);color:var(--c-text-error)}.btn-ghost{border:none;background:transparent;color:var(--c-text-accent);padding:0 var(--sp-1);height:auto;font-size:var(--text-xs)}.btn-ghost:hover{text-decoration:underline;border:none;background:transparent}.action-row{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-1)}.chip-row{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-1)}.label{font-size:var(--text-2xs);text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted);font-weight:500}.meta-grid{display:grid;grid-template-columns:auto 1fr;gap:3px var(--sp-3);font-size:var(--text-xs);align-items:baseline}.meta-label{color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.04em;font-size:var(--text-2xs);white-space:nowrap}.meta-value{color:var(--c-text-primary);overflow-wrap:break-word;word-break:break-word}.meta-value-truncate{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block}.meta-value.mono,.text-mono{font-family:var(--font-mono)}.linked-section{margin-top:var(--sp-2);padding-top:var(--sp-2);border-top:1px solid var(--c-border-subtle)}.asset-preview{display:grid;gap:var(--sp-2);margin-top:var(--sp-2)}.asset-card{margin:0;display:grid;gap:var(--sp-1)}.asset-card img{width:100%;max-height:200px;object-fit:contain;border-radius:var(--radius-md);border:1px solid var(--c-border);background:var(--c-bg-inset)}.asset-card figcaption{color:var(--c-text-muted);font-size:var(--text-2xs)}.content-pre{margin:var(--sp-2) 0 0;white-space:pre-wrap;overflow:auto;max-height:340px;padding:var(--sp-3);border-radius:var(--radius-md);background:var(--c-bg-inset);border:1px solid var(--c-border);font-family:var(--font-mono);font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.55;scrollbar-width:thin;scrollbar-color:var(--c-text-muted) transparent}.content-truncation-note{margin-top:var(--sp-1);font-size:var(--text-2xs);color:var(--c-text-muted);font-style:italic}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--sp-5) var(--sp-3);color:var(--c-text-muted);text-align:center;min-height:80px;gap:var(--sp-2)}.empty-state-icon{font-size:1.5rem;opacity:.4;line-height:1}mark{background:#7dd3fc26;color:var(--c-text-accent);border-radius:2px;padding:0 2px}.text-sm{font-size:var(--text-sm)}.text-muted{color:var(--c-text-muted)}.text-secondary{color:var(--c-text-secondary)}.text-error{color:var(--c-text-error);font-size:var(--text-xs)}code{font-family:var(--font-mono);font-size:var(--text-xs)}p,h3,h4{margin:0}a{color:var(--c-text-accent);text-decoration:none}a:hover{text-decoration:underline}@media(max-width:900px){.app-shell{grid-template-columns:1fr;grid-template-rows:var(--bar-height) auto minmax(300px,1fr) auto;grid-template-areas:"bar" "sidebar" "center" "rail";height:auto;min-height:100vh;overflow:auto}.sidebar{border-right:none;border-bottom:1px solid var(--c-border);max-height:260px}.center-area{grid-template-rows:auto minmax(50vh,1fr) auto}.detail-rail{border-left:none;border-top:1px solid var(--c-border)}.stats-strip{flex-wrap:wrap;gap:var(--sp-2)}}