/** * Stigmergic frontend — island-centric SPA. * * The knowledge graph is a set of islands (connected components). * Each island is a cluster of linked URLs discovered via * network.cosmik.connection records. Strata analysis runs on an * island and produces an org.latha.strata record. */ // ── Types ────────────────────────────────────────────────────────────────── interface VertexMeta { title: string; description: string; type: string; } interface IslandEdge { uri: string; did: string; source: string; target: string; connectionType: string; note: string; handle?: string | null; } interface Island { id: string; lexmin?: string; vertices?: string[]; edges?: IslandEdge[]; vertexMeta?: Record; summary: string | null; title?: string | null; strata: StrataData | null; recordUri?: string | null; } interface StrataData { prose: string; themes: string[]; highlights: string[]; tensions: string[]; open_questions: string[]; synthesis: string; } // ── State ────────────────────────────────────────────────────────────────── let session: { did: string; handle: string } | null = null; let currentPage: "explore" | "island" | "strata" | "connect" = "explore"; let islands: Island[] = []; // ── XRPC helpers ─────────────────────────────────────────────────────────── async function apiGet(path: string): Promise { const res = await fetch(path); if (!res.ok) { const err = await res.text(); throw new Error(`API ${path} failed: ${res.status} ${err}`); } return res.json(); } // ── Force-directed graph renderer ────────────────────────────────────────── interface GraphNode { id: string; label: string; x: number; y: number; vx: number; vy: number; type: string; } interface GraphEdge { source: string; target: string; label: string; color: string; } const TYPE_COLORS: Record = { url: "#58a6ff", citation: "#bc8cff", note: "#3fb950", card: "#3fb950", collection: "#d29922", unknown: "#8b949e", }; const EDGE_COLORS: Record = { "related": "#8b949e", "relates": "#8b949e", "leads to": "#58a6ff", "supplement": "#bc8cff", "supplements": "#bc8cff", "opposes": "#f85149", "supports": "#3fb950", "addresses": "#d29922", "explainer": "#79c0ff", "helpful": "#56d364", "contains": "#58a6ff", "cites": "#bc8cff", "inspired": "#d29922", "contradicts": "#f85149", "extends": "#3fb950", "forks": "#79c0ff", "annotates": "#8b949e", }; class ForceGraph { nodes: GraphNode[] = []; edges: GraphEdge[] = []; nodeMap = new Map(); width: number; height: number; alpha = 1; private rafId = 0; private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private hoverNode: string | null = null; private dragNode: string | null = null; private dragOffset = { x: 0, y: 0 }; constructor(canvas: HTMLCanvasElement, island: Island) { this.canvas = canvas; this.ctx = canvas.getContext("2d")!; const rect = canvas.getBoundingClientRect(); this.width = rect.width; this.height = rect.height; canvas.width = rect.width * devicePixelRatio; canvas.height = rect.height * devicePixelRatio; this.ctx.scale(devicePixelRatio, devicePixelRatio); // Build nodes const cx = this.width / 2; const cy = this.height / 2; const r = Math.min(this.width, this.height) * 0.35; for (const uri of island.vertices!) { const meta = island.vertexMeta![uri]; const angle = Math.random() * Math.PI * 2; const dist = Math.random() * r; const node: GraphNode = { id: uri, label: meta?.title || truncateUri(uri), x: cx + Math.cos(angle) * dist, y: cy + Math.sin(angle) * dist, vx: 0, vy: 0, type: meta?.type || "unknown", }; this.nodes.push(node); this.nodeMap.set(uri, node); } // Build edges for (const e of island.edges!) { const sNode = this.nodeMap.get(e.source); const tNode = this.nodeMap.get(e.target); if (!sNode || !tNode) continue; const label = connectionTypeLabel(e.connectionType); this.edges.push({ source: e.source, target: e.target, label, color: EDGE_COLORS[label] || EDGE_COLORS[connectionTypeLabel(e.connectionType)] || "#8b949e", }); } // Interactions canvas.addEventListener("mousemove", this.onMouseMove); canvas.addEventListener("mousedown", this.onMouseDown); canvas.addEventListener("mouseup", this.onMouseUp); canvas.addEventListener("mouseleave", this.onMouseLeave); canvas.addEventListener("dblclick", this.onDblClick); // Warm up the simulation for (let i = 0; i < 200; i++) this.tick(); this.draw(); } destroy() { cancelAnimationFrame(this.rafId); this.canvas.removeEventListener("mousemove", this.onMouseMove); this.canvas.removeEventListener("mousedown", this.onMouseDown); this.canvas.removeEventListener("mouseup", this.onMouseUp); this.canvas.removeEventListener("mouseleave", this.onMouseLeave); this.canvas.removeEventListener("dblclick", this.onDblClick); } tick() { if (this.alpha < 0.001) return; this.alpha *= 0.99; const cx = this.width / 2; const cy = this.height / 2; // Center gravity for (const n of this.nodes) { n.vx += (cx - n.x) * 0.01 * this.alpha; n.vy += (cy - n.y) * 0.01 * this.alpha; } // Repulsion (all pairs) for (let i = 0; i < this.nodes.length; i++) { for (let j = i + 1; j < this.nodes.length; j++) { const a = this.nodes[i]; const b = this.nodes[j]; let dx = b.x - a.x; let dy = b.y - a.y; let dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = -500 * this.alpha / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } // Attraction (edges) for (const e of this.edges) { const a = this.nodeMap.get(e.source)!; const b = this.nodeMap.get(e.target)!; let dx = b.x - a.x; let dy = b.y - a.y; let dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = (dist - 80) * 0.05 * this.alpha; const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } // Apply velocity with damping for (const n of this.nodes) { if (n.id === this.dragNode) continue; n.vx *= 0.6; n.vy *= 0.6; n.x += n.vx; n.y += n.vy; // Keep in bounds const pad = 20; n.x = Math.max(pad, Math.min(this.width - pad, n.x)); n.y = Math.max(pad, Math.min(this.height - pad, n.y)); } } draw() { const ctx = this.ctx; ctx.clearRect(0, 0, this.width, this.height); // Edges for (const e of this.edges) { const a = this.nodeMap.get(e.source)!; const b = this.nodeMap.get(e.target)!; const isHovered = this.hoverNode === e.source || this.hoverNode === e.target; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle = isHovered ? e.color : `${e.color}44`; ctx.lineWidth = isHovered ? 2 : 1; ctx.stroke(); // Arrow const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const arrowLen = 8; const arrowX = b.x - (dx / dist) * 16; const arrowY = b.y - (dy / dist) * 16; const angle = Math.atan2(dy, dx); ctx.beginPath(); ctx.moveTo(arrowX, arrowY); ctx.lineTo(arrowX - arrowLen * Math.cos(angle - 0.4), arrowY - arrowLen * Math.sin(angle - 0.4)); ctx.lineTo(arrowX - arrowLen * Math.cos(angle + 0.4), arrowY - arrowLen * Math.sin(angle + 0.4)); ctx.closePath(); ctx.fillStyle = isHovered ? e.color : `${e.color}44`; ctx.fill(); } // Nodes for (const n of this.nodes) { const isHovered = this.hoverNode === n.id; const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown; const radius = isHovered ? 7 : 5; ctx.beginPath(); ctx.arc(n.x, n.y, radius, 0, Math.PI * 2); ctx.fillStyle = isHovered ? color : `${color}cc`; ctx.fill(); ctx.strokeStyle = isHovered ? "#fff" : color; ctx.lineWidth = isHovered ? 2 : 1; ctx.stroke(); // Label if (isHovered || this.nodes.length <= 12) { ctx.font = `${isHovered ? "11" : "10"}px -apple-system, sans-serif`; ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e"; ctx.textAlign = "center"; ctx.fillText(n.label.slice(0, 30), n.x, n.y - radius - 4); } } } private getMousePos = (e: MouseEvent) => { const rect = this.canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; private findNode(x: number, y: number): string | null { for (const n of this.nodes) { const dx = n.x - x; const dy = n.y - y; if (dx * dx + dy * dy < 100) return n.id; } return null; } private onMouseMove = (e: MouseEvent) => { const pos = this.getMousePos(e); if (this.dragNode) { const n = this.nodeMap.get(this.dragNode)!; n.x = pos.x; n.y = pos.y; n.vx = 0; n.vy = 0; this.alpha = 0.3; this.tick(); this.draw(); return; } const prev = this.hoverNode; this.hoverNode = this.findNode(pos.x, pos.y); if (this.hoverNode !== prev) this.draw(); this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; }; private onMouseDown = (e: MouseEvent) => { const pos = this.getMousePos(e); const id = this.findNode(pos.x, pos.y); if (id) { this.dragNode = id; this.canvas.style.cursor = "grabbing"; } }; private onMouseUp = () => { this.dragNode = null; this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; }; private onMouseLeave = () => { this.hoverNode = null; this.dragNode = null; this.canvas.style.cursor = "default"; this.draw(); }; private onDblClick = (e: MouseEvent) => { const pos = this.getMousePos(e); const id = this.findNode(pos.x, pos.y); if (!id) return; if (id.startsWith("http")) { window.open(id, "_blank"); } }; } // ── Rendering helpers ────────────────────────────────────────────────────── function escHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function truncateUri(uri: string): string { if (uri.startsWith("http")) { try { const u = new URL(uri); const path = u.pathname === "/" ? "" : u.pathname.slice(0, 40); return u.hostname + path; } catch { return uri.slice(0, 60); } } if (uri.startsWith("at://")) { const parts = uri.split("/"); return parts.slice(3).join("/") || uri.slice(0, 60); } return uri.slice(0, 60); } function connectionTypeLabel(t: string): string { if (!t) return "relates"; // Handle both "network.cosmik.connection#RELATES" and raw "RELATES" const raw = t.includes("#") ? t.split("#").pop()! : t; const labels: Record = { RELATED: "related", LEADS_TO: "leads to", SUPPLEMENT: "supplement", SUPPLEMENTS: "supplements", OPPOSES: "opposes", SUPPORTS: "supports", ADDRESSES: "addresses", EXPLAINER: "explainer", HELPFUL: "helpful", CONTAINS: "contains", CITES: "cites", INSPIRED: "inspired", CONTRADICTS: "contradicts", EXTENDS: "extends", FORKS: "forks", ANNOTATES: "annotates", RELATES: "relates", }; return labels[raw] || raw.toLowerCase().replace(/_/g, " "); } function connectionTypeIcon(t: string): string { const label = connectionTypeLabel(t); const icons: Record = { "leads to": "->", "cites": "<-", "related": "<->", "relates": "<->", "inspired": "*", "opposes": "X", "contradicts": "X", "extends": "=>", "forks": "<=>", "annotates": "~", "supplement": "+", "supplements": "+", "supports": "v", "addresses": "*", "explainer": "?", "helpful": "+", "contains": ">", }; return icons[label] || "\u2194"; } // ── Explore page: island cards ───────────────────────────────────────────── function renderExplore(): void { const content = document.getElementById("page-content"); if (!content) return; if (islands.length === 0) { content.innerHTML = '
No islands found in the connection graph. Save some connections to see clusters!
'; return; } content.innerHTML = `
${islands.map(island => renderIslandCard(island)).join("")}
`; // Initialize graphs (only for islands with full data) document.querySelectorAll("canvas.island-graph").forEach(canvas => { const islandId = (canvas as HTMLElement).dataset.islandId; const island = islands.find(i => i.id === islandId); if (island && island.vertices && island.edges) { new ForceGraph(canvas as HTMLCanvasElement, island as any); } }); // Wire up Strata buttons on island cards — go to strata page document.querySelectorAll(".island-strata-btn").forEach(btn => { btn.addEventListener("click", (e) => { const target = e.currentTarget as HTMLElement; const islandId = target.dataset.islandId; if (!islandId) return; const island = islands.find(i => i.id === islandId); window.location.href = `/strata?id=${encodeURIComponent(island?.recordUri || islandId)}`; }); }); } function summarizeIsland(island: Island): string { if (island.title) return island.title; if (island.summary) return island.summary; // Fallback: build from metadata (only if we have full data) const vertices = island.vertices; if (!vertices || vertices.length === 0) return "Island"; const meta = island.vertexMeta || {}; const domains = new Map(); for (const v of vertices) { if (v.startsWith("http")) { try { const host = new URL(v).hostname.replace(/^www\./, ""); domains.set(host, (domains.get(host) || 0) + 1); } catch {} } } const topDomains = [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(d => d[0]); const titles = vertices .map(v => meta[v]?.title || "") .filter(t => t.length > 10 && t.length < 120) .slice(0, 2); const parts: string[] = []; if (topDomains.length > 0) parts.push(topDomains.join(", ")); if (titles.length > 0) parts.push(titles.join("; ")); return parts.join(" -- ") || `${vertices.length} connected nodes`; } function renderIslandCard(island: Island): string { const summary = summarizeIsland(island); const hasStrata = !!island.strata; const strataBtnLabel = hasStrata ? "View Strata" : "Strata"; const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn"; const hasFullData = !!(island.vertices && island.edges); const sizeLabel = hasFullData ? `${island.vertices!.length} nodes · ${island.edges!.length} edges` : ""; const graphOrTitle = hasFullData ? `` : `
${escHtml(summary)}
`; return `
${graphOrTitle}
`; } // ── Island detail page ───────────────────────────────────────────────────── async function showIsland(islandId: string): Promise { currentPage = "island"; const content = document.getElementById("page-content"); if (!content) return; let island = islands.find(i => i.id === islandId); if (!island) { content.innerHTML = '
Island not found.
'; return; } // If vertexMeta is missing (summary mode), fetch full data if (!island.vertexMeta || Object.keys(island.vertexMeta).length === 0) { try { const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands?limit=50"); const full = data.islands?.find(i => i.id === islandId); if (full) { island = full; // Update cached island too const idx = islands.findIndex(i => i.id === islandId); if (idx >= 0) islands[idx] = full; } } catch {} } const edgeList = island.edges!.map(e => { const icon = connectionTypeIcon(e.connectionType); const type = connectionTypeLabel(e.connectionType); const sourceMeta = island.vertexMeta![e.source]; const targetMeta = island.vertexMeta![e.target]; const sourceTitle = sourceMeta?.title || truncateUri(e.source); const targetTitle = targetMeta?.title || truncateUri(e.target); return `
${escHtml(sourceTitle)} ${icon} ${escHtml(type)} ${escHtml(targetTitle)} ${e.note ? `${escHtml(e.note)}` : ""} ${e.handle ? `${escHtml(e.handle)}` : ""}
`; }).join(""); content.innerHTML = `
← Explore

Island

${island.vertices!.length} vertices · ${island.edges!.length} edges${island.recordUri ? ` · View on PDS ↗` : ""}

Connections

${edgeList}
`; // Initialize the detail graph const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement; if (canvas) { new ForceGraph(canvas, island); } document.getElementById("island-strata-btn")?.addEventListener("click", async () => { window.location.href = `/strata?id=${encodeURIComponent(island.recordUri || islandId)}`; }); } // ── Strata page — prose with arrows ─────────────────────────────────────── async function showStrata(islandId: string): Promise { currentPage = "strata"; const content = document.getElementById("page-content"); if (!content) return; let island: Island | null = null; // If the ID is an AT URI, fetch from the strata record endpoint if (islandId.startsWith("at://")) { try { const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.strata.getRecord?uri=${encodeURIComponent(islandId)}`); island = data.island; island.recordUri = data.recordUri; } catch { content.innerHTML = '
Strata record not found.
'; return; } } else { // Legacy: sha256 ID — find from loaded islands island = islands.find(i => i.id === islandId) || null; // If not in memory, fetch from API if (!island) { try { const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); island = data.islands.find(i => i.id === islandId) || null; if (island) islands = data.islands; } catch {} } } if (!island) { content.innerHTML = '
Island not found.
'; return; } if (!island.strata) { content.innerHTML = `
← Explore

Strata

No strata analysis yet for this island. Run scripts/strata-analyze.py to generate.
`; return; } renderStrata(island); } function renderStrata(island: Island): void { const content = document.getElementById("page-content"); if (!content) return; const s = island.strata!; const summary = island.summary || summarizeIsland(island); // Build editable strata page content.innerHTML = `
← Explore

${escHtml(summary)}

${island.vertices!.length} vertices · ${island.edges!.length} edges${island.recordUri ? ` · View on PDS ↗` : ""}

Synthesis

${escHtml(s.synthesis)}

Themes

${s.themes.map(t => `${escHtml(t)}×`).join("")} +

Tensions

${s.tensions.map(t => `
${escHtml(t)}
`).join("")}
+ Add tension

Open Questions

${s.open_questions.map(q => `
${escHtml(q)}
`).join("")}
+ Add question
${s.highlights.length > 0 ? `

Highlights ${s.highlights.length} hot edges

${island.edges!.filter(e => s.highlights.includes(e.uri)).map(e => { const sourceMeta = island.vertexMeta![e.source]; const targetMeta = island.vertexMeta![e.target]; const sourceTitle = sourceMeta?.title || truncateUri(e.source); const targetTitle = targetMeta?.title || truncateUri(e.target); const type = connectionTypeLabel(e.connectionType); const icon = connectionTypeIcon(e.connectionType); const seemsUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : ""; return ` ${escHtml(sourceTitle)} ${icon} ${escHtml(type)} ${escHtml(targetTitle)} ${e.note ? `${escHtml(e.note)}` : ""} ${e.handle ? `@${escHtml(e.handle)}` : ""} `; }).join("")}
` : ""}

Edges

${island.edges!.map(e => { const sourceMeta = island.vertexMeta![e.source]; const targetMeta = island.vertexMeta![e.target]; const sourceTitle = sourceMeta?.title || truncateUri(e.source); const targetTitle = targetMeta?.title || truncateUri(e.target); const type = connectionTypeLabel(e.connectionType); const icon = connectionTypeIcon(e.connectionType); const seemsUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : ""; return ` ${escHtml(sourceTitle)} ${icon} ${escHtml(type)} ${escHtml(targetTitle)} ${e.note ? `${escHtml(e.note)}` : ""} ${e.handle ? `@${escHtml(e.handle)}` : ""} `; }).join("")}
`; // Initialize the force graph const canvas = document.getElementById("strata-graph") as HTMLCanvasElement; if (canvas) { new ForceGraph(canvas, island); } // ── Edit tracking ────────────────────────────────────────────── let dirty = false; const saveBtn = document.getElementById("save-btn"); const saveStatus = document.getElementById("save-status"); function markDirty() { dirty = true; saveBtn?.classList.remove("hidden"); saveStatus!.textContent = ""; } // Track contenteditable changes const editContainer = content!; editContainer.querySelectorAll("[contenteditable]").forEach(el => { el.addEventListener("input", markDirty); }); // Theme tag removal editContainer.querySelectorAll(".theme-remove").forEach(btn => { btn.addEventListener("click", (e) => { (e.currentTarget as HTMLElement).parentElement!.remove(); markDirty(); }); }); // Theme tag add editContainer.querySelectorAll(".theme-add").forEach(btn => { btn.addEventListener("click", () => { const tag = document.createElement("span"); tag.className = "theme-tag"; tag.dataset.value = "new theme"; tag.innerHTML = 'new theme×'; tag.setAttribute("contenteditable", "true"); tag.addEventListener("input", () => { tag.dataset.value = tag.textContent?.replace("×", "").trim() || ""; markDirty(); }); tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); }); (btn as Element as ChildNode).before(tag); tag.focus(); markDirty(); }); }); // List item add editContainer.querySelectorAll(".list-add").forEach(btn => { btn.addEventListener("click", () => { const field = (btn as HTMLElement).dataset.field!; const div = document.createElement("div"); div.className = field === "tensions" ? "analysis-tension editable" : "analysis-question editable"; div.setAttribute("contenteditable", "true"); div.textContent = "New item..."; div.addEventListener("input", markDirty); (btn as Element as ChildNode).before(div); div.focus(); markDirty(); }); }); // ── Collect current analysis state ───────────────────────────── function collectAnalysis(): Record { const titleEl = editContainer.querySelector('[data-field="title"]'); const synthesisEl = editContainer.querySelector('[data-field="synthesis"]'); const themeTags = editContainer.querySelectorAll('.theme-tag'); const tensionEls = editContainer.querySelectorAll('[data-field="tensions"] .editable'); const questionEls = editContainer.querySelectorAll('[data-field="openQuestions"] .editable'); return { title: titleEl?.textContent?.trim() || "", synthesis: synthesisEl?.textContent?.trim() || "", themes: Array.from(themeTags).map(t => t.getAttribute("data-value") || t.textContent?.replace("×", "").trim() || ""), tensions: Array.from(tensionEls).map(t => t.textContent?.trim() || "").filter(Boolean), openQuestions: Array.from(questionEls).map(q => q.textContent?.trim() || "").filter(Boolean), highlights: island.strata?.highlights || [], }; } // ── Save to PDS ──────────────────────────────────────────────── saveBtn?.addEventListener("click", async () => { if (!island.recordUri) return; saveBtn.textContent = "Saving..."; saveBtn.setAttribute("disabled", "true"); try { const analysis = collectAnalysis(); const res = await fetch("/xrpc/org.latha.strata.updateRecord", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uri: island.recordUri, analysis }), }); if (!res.ok) { const err = (await res.json()) as { message?: string }; throw new Error(err.message || "Save failed"); } dirty = false; saveBtn.classList.add("hidden"); saveStatus!.textContent = "Saved ✓"; setTimeout(() => { saveStatus!.textContent = ""; }, 3000); } catch (e: any) { saveStatus!.textContent = `Error: ${e.message}`; } finally { saveBtn.textContent = "Save to PDS"; saveBtn.removeAttribute("disabled"); } }); // ── Regenerate ───────────────────────────────────────────────── const regenBtn = document.getElementById("regenerate-btn"); regenBtn?.addEventListener("click", async () => { regenBtn.textContent = "Generating..."; regenBtn.setAttribute("disabled", "true"); try { const res = await fetch("/xrpc/org.latha.strata.analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ island: { vertices: island.vertices, edges: island.edges } }), }); if (!res.ok) throw new Error("Analysis failed"); const result = await res.json(); // Diff-highlight: mark changed fields const oldAnalysis = collectAnalysis(); const newAnalysis = result as Record; // Update fields with animation const titleEl = editContainer.querySelector('[data-field="title"]') as HTMLElement; const synthesisEl = editContainer.querySelector('[data-field="synthesis"]') as HTMLElement; if (titleEl && newAnalysis.title !== oldAnalysis.title) { titleEl.textContent = newAnalysis.title; titleEl.classList.add("field-changed"); setTimeout(() => titleEl.classList.remove("field-changed"), 2000); } if (synthesisEl && newAnalysis.synthesis !== oldAnalysis.synthesis) { synthesisEl.textContent = newAnalysis.synthesis; synthesisEl.classList.add("field-changed"); setTimeout(() => synthesisEl.classList.remove("field-changed"), 2000); } // Update themes const themesContainer = editContainer.querySelector('[data-field="themes"]'); if (themesContainer) { themesContainer.querySelectorAll(".theme-tag").forEach(t => t.remove()); const addBtn = themesContainer.querySelector(".theme-add"); for (const theme of newAnalysis.themes || []) { const tag = document.createElement("span"); tag.className = "theme-tag field-changed"; tag.dataset.value = theme; tag.innerHTML = `${escHtml(theme)}×`; tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); }); (addBtn as ChildNode)?.before(tag); setTimeout(() => tag.classList.remove("field-changed"), 2000); } } // Update tensions const tensionsContainer = editContainer.querySelector('[data-field="tensions"]'); if (tensionsContainer) { tensionsContainer.querySelectorAll(".editable").forEach(t => t.remove()); const addBtn = tensionsContainer.querySelector(".list-add"); for (const t of newAnalysis.tensions || []) { const div = document.createElement("div"); div.className = "analysis-tension editable field-changed"; div.setAttribute("contenteditable", "true"); div.textContent = t; div.addEventListener("input", markDirty); (addBtn as ChildNode)?.before(div); setTimeout(() => div.classList.remove("field-changed"), 2000); } } // Update questions const questionsContainer = editContainer.querySelector('[data-field="openQuestions"]'); if (questionsContainer) { questionsContainer.querySelectorAll(".editable").forEach(q => q.remove()); const addBtn = questionsContainer.querySelector(".list-add"); for (const q of newAnalysis.openQuestions || []) { const div = document.createElement("div"); div.className = "analysis-question editable field-changed"; div.setAttribute("contenteditable", "true"); div.textContent = q; div.addEventListener("input", markDirty); (addBtn as ChildNode)?.before(div); setTimeout(() => div.classList.remove("field-changed"), 2000); } } markDirty(); // Regenerated = needs save } catch (e: any) { saveStatus!.textContent = `Regenerate error: ${e.message}`; } finally { regenBtn.textContent = "Regenerate"; regenBtn.removeAttribute("disabled"); } }); } // ── Connect page ─────────────────────────────────────────────────────────── function renderConnectForm(sourceUri?: string): void { const content = document.getElementById("page-content"); if (!content) return; content.innerHTML = `
← Explore

Create Connection

A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.

`; document.getElementById("connect-form")?.addEventListener("submit", async (e) => { e.preventDefault(); const result = document.getElementById("connect-result"); if (!result) return; result.innerHTML = '
Creating connection on your PDS...
'; const source = (document.getElementById("source") as HTMLInputElement).value; const target = (document.getElementById("target") as HTMLInputElement).value; const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value; const note = (document.getElementById("note") as HTMLTextAreaElement).value; const record = { $type: "network.cosmik.connection", source, target, connectionType, note: note || undefined, createdAt: new Date().toISOString(), }; result.innerHTML = `

Record to create on your PDS:

${escHtml(JSON.stringify(record, null, 2))}

OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.

`; }); } // ── Pages ────────────────────────────────────────────────────────────────── async function showExplore(): Promise { currentPage = "explore"; // Try API first, fall back to server-rendered data // Use summary mode — skip vertexMeta (saves ~250KB) try { const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands?summary=true"); islands = data.islands || []; } catch { const serverData = (window as any).__ISLANDS__; if (serverData) islands = serverData; } renderExplore(); } // ── Routing ─────────────────────────────────────────────────────────────── function getRoute(): { page: string; params: Record } { const url = new URL(window.location.href); const path = url.pathname; if (path === "/island") { return { page: "island", params: { id: url.searchParams.get("id") || "" } }; } if (path === "/strata") { return { page: "strata", params: { id: url.searchParams.get("id") || "" } }; } if (path === "/connect") { return { page: "connect", params: { source: url.searchParams.get("source") || "" } }; } return { page: "explore", params: {} }; } async function route(): Promise { const { page, params } = getRoute(); if (page === "island" && params.id) { await showIsland(params.id); } else if (page === "strata" && params.id) { await showStrata(params.id); } else if (page === "connect") { renderConnectForm(params.source); } else { await showExplore(); } } // ── Auth ─────────────────────────────────────────────────────────────────── async function initAuth(): Promise { try { const res = await fetch("/xrpc/org.latha.strata.getProfile", { headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") }, }); if (res.ok) { const data: any = await res.json(); session = { did: data.did, handle: data.handle }; updateAuthUI(); return; } } catch {} updateAuthUI(); } function updateAuthUI(): void { const status = document.getElementById("auth-status"); const loginBtn = document.getElementById("login-btn"); if (!status || !loginBtn) return; if (session) { status.textContent = session.handle; status.classList.add("visible"); loginBtn.classList.add("hidden"); } else { status.classList.remove("visible"); loginBtn.classList.remove("hidden"); } } // ── Init ─────────────────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", () => { route(); }); window.addEventListener("popstate", route);