diff --git a/apps/website/app/(home)/layout.tsx b/apps/website/app/(home)/layout.tsx
index b85e43b70..c66c545ad 100644
--- a/apps/website/app/(home)/layout.tsx
+++ b/apps/website/app/(home)/layout.tsx
@@ -41,6 +41,7 @@ const HomeLayout = async ({
}): Promise => {
const hasUpdates = !!(await getAllBlogs()).length;
const navigationItems = [
+ { href: "/try", label: "Try it" },
{ href: "/#about", label: "About" },
{ href: "/#plugins", label: "Plugins" },
{ href: "/#resources", label: "Resources" },
diff --git a/apps/website/app/(home)/page.tsx b/apps/website/app/(home)/page.tsx
index 90752b135..51e7da1b7 100644
--- a/apps/website/app/(home)/page.tsx
+++ b/apps/website/app/(home)/page.tsx
@@ -299,22 +299,22 @@ const Home = async (): Promise => {
build on.
+
+ Try a Discourse Graph
+
+
{/* Use hard navigation across the marketing/docs boundary because client-side transitions can leak docs CSS. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
Open docs
-
- Explore plugins
-
-
(
+
{children}
+);
+
+export default PrototypeLayout;
diff --git a/apps/website/app/(prototype)/try/DiscourseGraphPrototype.tsx b/apps/website/app/(prototype)/try/DiscourseGraphPrototype.tsx
new file mode 100644
index 000000000..1037d3172
--- /dev/null
+++ b/apps/website/app/(prototype)/try/DiscourseGraphPrototype.tsx
@@ -0,0 +1,941 @@
+"use client";
+
+import { useMemo, useState, type ChangeEvent, type ReactElement } from "react";
+import Link from "next/link";
+import {
+ ArrowLeft,
+ BookOpen,
+ Check,
+ ChevronDown,
+ ChevronRight,
+ CircleHelp,
+ ExternalLink,
+ FileSearch,
+ FlaskConical,
+ Link2,
+ Network,
+ Plus,
+ RotateCcw,
+ Search,
+ Trash2,
+ Unlink,
+ X,
+ type LucideIcon,
+} from "lucide-react";
+import {
+ NODE_TYPES,
+ RELATIONSHIP_TYPES,
+ addRelationship,
+ deleteNode,
+ getSeedNodes,
+ removeRelationship,
+ type DiscourseLink,
+ type DiscourseNode,
+ type NodeType,
+ type RelationshipType,
+} from "./model";
+
+type NodeTypeConfig = {
+ border: string;
+ color: string;
+ icon: LucideIcon;
+ label: string;
+ surface: string;
+};
+
+const NODE_TYPE_CONFIG: Record
= {
+ question: {
+ label: "Question",
+ icon: CircleHelp,
+ color: "text-violet-700",
+ surface: "bg-violet-50",
+ border: "border-violet-200",
+ },
+ claim: {
+ label: "Claim",
+ icon: Check,
+ color: "text-orange-700",
+ surface: "bg-orange-50",
+ border: "border-orange-200",
+ },
+ evidence: {
+ label: "Evidence",
+ icon: FlaskConical,
+ color: "text-emerald-700",
+ surface: "bg-emerald-50",
+ border: "border-emerald-200",
+ },
+ source: {
+ label: "Source",
+ icon: BookOpen,
+ color: "text-sky-700",
+ surface: "bg-sky-50",
+ border: "border-sky-200",
+ },
+};
+
+const RELATIONSHIP_LABELS: Record = {
+ supports: "supports",
+ challenges: "challenges",
+ cites: "cites",
+ relates_to: "relates to",
+};
+
+type TypeBadgeProps = {
+ compact?: boolean;
+ type: NodeType;
+};
+
+const TypeBadge = ({ compact = false, type }: TypeBadgeProps): ReactElement => {
+ const config = NODE_TYPE_CONFIG[type];
+ const Icon = config.icon;
+
+ return (
+
+
+ {config.label}
+
+ );
+};
+
+type OutlineNodeProps = {
+ depth: number;
+ expandedIds: Set;
+ forceExpanded: boolean;
+ node: DiscourseNode;
+ nodes: DiscourseNode[];
+ onSelect: (nodeId: string) => void;
+ onToggle: (nodeId: string) => void;
+ selectedId: string;
+ visibleIds: Set;
+};
+
+const OutlineNode = ({
+ depth,
+ expandedIds,
+ forceExpanded,
+ node,
+ nodes,
+ onSelect,
+ onToggle,
+ selectedId,
+ visibleIds,
+}: OutlineNodeProps): ReactElement => {
+ const children = nodes.filter(
+ (candidate) =>
+ candidate.parentId === node.id && visibleIds.has(candidate.id),
+ );
+ const isExpanded = forceExpanded || expandedIds.has(node.id);
+ const isSelected = selectedId === node.id;
+
+ return (
+
+
+ onToggle(node.id)}
+ aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.text}`}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ onSelect(node.id)}
+ >
+
+
+
+ {node.text}
+
+
+ {node.links.length > 0 && (
+
+ {node.links.slice(0, 2).map((link) => {
+ const target = nodes.find(
+ (candidate) => candidate.id === link.targetId,
+ );
+ if (!target) return null;
+
+ return (
+
+
+ {RELATIONSHIP_LABELS[link.type]} ·{" "}
+ {target.text.slice(0, 34)}
+ {target.text.length > 34 ? "…" : ""}
+
+ );
+ })}
+
+ )}
+
+
+
+ {children.length > 0 && isExpanded && (
+
+ {children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+type ConnectionRowProps = {
+ direction: "incoming" | "outgoing";
+ link: DiscourseLink;
+ node: DiscourseNode;
+ onRemove?: () => void;
+ onSelect: (nodeId: string) => void;
+};
+
+const ConnectionRow = ({
+ direction,
+ link,
+ node,
+ onRemove,
+ onSelect,
+}: ConnectionRowProps): ReactElement => (
+
+ onSelect(node.id)}
+ >
+
+ {direction === "incoming"
+ ? "Referenced by"
+ : RELATIONSHIP_LABELS[link.type]}
+ {direction === "incoming" && (
+
+ · {RELATIONSHIP_LABELS[link.type]}
+
+ )}
+
+
+ {node.text}
+
+
+ {onRemove && (
+
+
+
+ )}
+
+);
+
+type NodeComposerProps = {
+ nodes: DiscourseNode[];
+ onClose: () => void;
+ onCreate: ({
+ parentId,
+ text,
+ type,
+ }: {
+ parentId: string | null;
+ text: string;
+ type: NodeType;
+ }) => void;
+ suggestedParentId: string | null;
+};
+
+const NodeComposer = ({
+ nodes,
+ onClose,
+ onCreate,
+ suggestedParentId,
+}: NodeComposerProps): ReactElement => {
+ const [text, setText] = useState("");
+ const [type, setType] = useState("claim");
+ const [parentId, setParentId] = useState(suggestedParentId);
+
+ const handleSubmit = (): void => {
+ const trimmedText = text.trim();
+ if (!trimmedText) return;
+ onCreate({ parentId, text: trimmedText, type });
+ };
+
+ return (
+ {
+ if (event.currentTarget === event.target) onClose();
+ }}
+ >
+
+
+
+
+ Extend the graph
+
+
+ Add a discourse node
+
+
+
+
+
+
+
+
+
+ Node type
+ ) =>
+ setType(event.target.value as NodeType)
+ }
+ className="h-10 rounded-lg border border-stone-200 bg-white px-3 text-sm text-stone-800 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
+ >
+ {NODE_TYPES.map((nodeType) => (
+
+ {NODE_TYPE_CONFIG[nodeType].label}
+
+ ))}
+
+
+
+
+ Content
+
+
+
+ Place under
+ ) =>
+ setParentId(event.target.value || null)
+ }
+ className="h-10 rounded-lg border border-stone-200 bg-white px-3 text-sm text-stone-800 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
+ >
+ Top level
+ {nodes.map((node) => (
+
+ {NODE_TYPE_CONFIG[node.type].label}: {node.text}
+
+ ))}
+
+
+
+
+
+
+ Ctrl/⌘ + Enter to add
+
+
+
+ Cancel
+
+
+
+ Add node
+
+
+
+
+
+ );
+};
+
+export const DiscourseGraphPrototype = (): ReactElement => {
+ const [nodes, setNodes] = useState(getSeedNodes);
+ const [selectedId, setSelectedId] = useState("question-preregistration");
+ const [expandedIds, setExpandedIds] = useState>(
+ () => new Set(getSeedNodes().map((node) => node.id)),
+ );
+ const [query, setQuery] = useState("");
+ const [typeFilter, setTypeFilter] = useState("all");
+ const [isComposerOpen, setIsComposerOpen] = useState(false);
+ const [composerParentId, setComposerParentId] = useState(null);
+ const [relationshipType, setRelationshipType] =
+ useState("supports");
+ const [relationshipTargetId, setRelationshipTargetId] = useState("");
+ const [isDeleteArmed, setIsDeleteArmed] = useState(false);
+
+ const selectedNode =
+ nodes.find((node) => node.id === selectedId) ?? nodes[0] ?? null;
+
+ const visibleIds = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+ const matches = nodes.filter((node) => {
+ const matchesQuery =
+ !normalizedQuery || node.text.toLowerCase().includes(normalizedQuery);
+ const matchesType = typeFilter === "all" || node.type === typeFilter;
+ return matchesQuery && matchesType;
+ });
+ const ids = new Set(matches.map((node) => node.id));
+
+ matches.forEach((node) => {
+ let parentId = node.parentId;
+ while (parentId) {
+ ids.add(parentId);
+ parentId =
+ nodes.find((candidate) => candidate.id === parentId)?.parentId ??
+ null;
+ }
+ });
+
+ return ids;
+ }, [nodes, query, typeFilter]);
+
+ const rootNodes = nodes.filter(
+ (node) => node.parentId === null && visibleIds.has(node.id),
+ );
+ const forceExpanded = query.trim().length > 0 || typeFilter !== "all";
+ const inboundLinks = selectedNode
+ ? nodes.flatMap((node) =>
+ node.links
+ .filter((link) => link.targetId === selectedNode.id)
+ .map((link) => ({ link, node })),
+ )
+ : [];
+ const availableTargets = selectedNode
+ ? nodes.filter((node) => node.id !== selectedNode.id)
+ : [];
+ const validTargetId = availableTargets.some(
+ (node) => node.id === relationshipTargetId,
+ )
+ ? relationshipTargetId
+ : "";
+
+ const handleSelect = (nodeId: string): void => {
+ setSelectedId(nodeId);
+ setRelationshipTargetId("");
+ setIsDeleteArmed(false);
+ };
+
+ const handleToggle = (nodeId: string): void => {
+ setExpandedIds((current) => {
+ const next = new Set(current);
+ if (next.has(nodeId)) next.delete(nodeId);
+ else next.add(nodeId);
+ return next;
+ });
+ };
+
+ const handleCreate = ({
+ parentId,
+ text,
+ type,
+ }: {
+ parentId: string | null;
+ text: string;
+ type: NodeType;
+ }): void => {
+ const id = `node-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+ const node: DiscourseNode = { id, links: [], parentId, text, type };
+ setNodes((current) => [...current, node]);
+ if (parentId) {
+ setExpandedIds((current) => new Set([...current, parentId]));
+ }
+ setSelectedId(id);
+ setIsComposerOpen(false);
+ setTypeFilter("all");
+ setQuery("");
+ };
+
+ const openComposer = (parentId: string | null): void => {
+ setComposerParentId(parentId);
+ setIsComposerOpen(true);
+ };
+
+ const handleDelete = (): void => {
+ if (!selectedNode) return;
+ if (!isDeleteArmed) {
+ setIsDeleteArmed(true);
+ return;
+ }
+
+ const nextNodes = deleteNode({ nodes, nodeId: selectedNode.id });
+ setNodes(nextNodes);
+ setSelectedId(selectedNode.parentId ?? nextNodes[0]?.id ?? "");
+ setIsDeleteArmed(false);
+ };
+
+ const handleReset = (): void => {
+ const seedNodes = getSeedNodes();
+ setNodes(seedNodes);
+ setSelectedId("question-preregistration");
+ setExpandedIds(new Set(seedNodes.map((node) => node.id)));
+ setQuery("");
+ setTypeFilter("all");
+ setRelationshipTargetId("");
+ setIsDeleteArmed(false);
+ };
+
+ const handleAddRelationship = (): void => {
+ if (!selectedNode || !validTargetId) return;
+ setNodes((current) =>
+ addRelationship({
+ nodes: current,
+ sourceId: selectedNode.id,
+ targetId: validTargetId,
+ type: relationshipType,
+ }),
+ );
+ setRelationshipTargetId("");
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Website
+
+
/
+
Research synthesis demo
+
+
+ Preregistration and research credibility
+
+
+ Follow a question into claims, evidence, and sources—then add your
+ own idea or connect two nodes.
+
+
+
+
+ {nodes.length} nodes
+
+
+
+ {nodes.reduce((total, node) => total + node.links.length, 0)}
+ {" "}
+ links
+
+
+
+
+
+
+
+ Try this
+
+
+ Select the first claim, inspect what supports and challenges it,
+ then use Add relationship to make a new connection.
+
+
+
+
+
+
+
+
+ ) =>
+ setQuery(event.target.value)
+ }
+ placeholder="Search this graph…"
+ className="h-9 w-full rounded-lg border border-stone-200 bg-stone-50 pl-9 pr-3 text-sm outline-none placeholder:text-stone-400 focus:border-primary focus:bg-white focus:ring-2 focus:ring-primary/15"
+ aria-label="Search graph"
+ />
+
+
openComposer(selectedNode?.id ?? null)}
+ className="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg bg-stone-900 px-3.5 text-xs font-semibold text-white transition hover:bg-stone-700"
+ >
+
+ New node
+
+
+
+
+ {(["all", ...NODE_TYPES] as const).map((type) => (
+ setTypeFilter(type)}
+ className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition ${
+ typeFilter === type
+ ? "bg-stone-900 text-white"
+ : "bg-stone-100 text-stone-500 hover:bg-stone-200 hover:text-stone-700"
+ }`}
+ >
+ {type === "all" ? "All nodes" : NODE_TYPE_CONFIG[type].label}
+
+ ))}
+
+
+
+ {rootNodes.length > 0 ? (
+
+ {rootNodes.map((node) => (
+
+ ))}
+
+ ) : (
+
+
+
+ No nodes found
+
+
{
+ setQuery("");
+ setTypeFilter("all");
+ }}
+ className="mt-2 text-xs font-medium text-primary hover:underline"
+ >
+ Clear filters
+
+
+ )}
+
+
+
+
+ {selectedNode ? (
+
+
+
+
+
openComposer(selectedNode.id)}
+ className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-stone-500 hover:bg-stone-100 hover:text-stone-800"
+ >
+
+ Add child
+
+
+
+
+ Node text
+
+
+ {selectedNode.sourceUrl && (
+
+ Open source
+
+
+ )}
+
+
+
+
+
+
+ Connections
+
+
+ {selectedNode.links.length + inboundLinks.length} total
+
+
+
+ {selectedNode.links.map((link) => {
+ const target = nodes.find(
+ (node) => node.id === link.targetId,
+ );
+ if (!target) return null;
+ return (
+
+ setNodes((current) =>
+ removeRelationship({
+ nodes: current,
+ sourceId: selectedNode.id,
+ targetId: target.id,
+ type: link.type,
+ }),
+ )
+ }
+ />
+ );
+ })}
+ {inboundLinks.map(({ link, node }) => (
+
+ ))}
+ {selectedNode.links.length === 0 &&
+ inboundLinks.length === 0 && (
+
+ This node has no explicit links yet. Its outline
+ position still provides context.
+
+ )}
+
+
+
+
+
+ Add relationship
+
+
+ ) =>
+ setRelationshipType(
+ event.target.value as RelationshipType,
+ )
+ }
+ className="h-9 min-w-0 rounded-lg border border-stone-200 bg-white px-2 text-xs text-stone-700 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
+ aria-label="Relationship type"
+ >
+ {RELATIONSHIP_TYPES.map((type) => (
+
+ {RELATIONSHIP_LABELS[type]}
+
+ ))}
+
+ ) =>
+ setRelationshipTargetId(event.target.value)
+ }
+ className="h-9 min-w-0 rounded-lg border border-stone-200 bg-white px-2 text-xs text-stone-700 outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
+ aria-label="Relationship target"
+ >
+ Choose a node…
+ {availableTargets.map((node) => (
+
+ {NODE_TYPE_CONFIG[node.type].label}: {node.text}
+
+ ))}
+
+
+
+
+ Connect nodes
+
+
+
+
+
+
+
+ {isDeleteArmed ? "Confirm delete" : "Delete node"}
+
+ {isDeleteArmed && (
+ setIsDeleteArmed(false)}
+ className="ml-2 rounded-lg px-3 py-2 text-xs font-medium text-stone-500 hover:bg-stone-100"
+ >
+ Cancel
+
+ )}
+
+
+ ) : (
+
+ Select a node to see its context.
+
+ )}
+
+
+
+
+
+
+ {isComposerOpen && (
+
setIsComposerOpen(false)}
+ onCreate={handleCreate}
+ suggestedParentId={composerParentId}
+ />
+ )}
+
+ );
+};
diff --git a/apps/website/app/(prototype)/try/model.test.ts b/apps/website/app/(prototype)/try/model.test.ts
new file mode 100644
index 000000000..cc109d937
--- /dev/null
+++ b/apps/website/app/(prototype)/try/model.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import {
+ addRelationship,
+ deleteNode,
+ getSeedNodes,
+ type DiscourseNode,
+} from "./model";
+
+describe("discourse graph model", () => {
+ it("returns a fresh copy of the demo on every reset", () => {
+ const firstCopy = getSeedNodes();
+ firstCopy[0]?.links.push({
+ targetId: "claim-transparency",
+ type: "relates_to",
+ });
+
+ expect(getSeedNodes()[0]?.links).toEqual([]);
+ });
+
+ it("does not create duplicate relationships", () => {
+ const nodes = getSeedNodes();
+ const updated = addRelationship({
+ nodes,
+ sourceId: "evidence-registered-reports",
+ targetId: "claim-transparency",
+ type: "supports",
+ });
+
+ expect(
+ updated
+ .find((node) => node.id === "evidence-registered-reports")
+ ?.links.filter((link) => link.targetId === "claim-transparency"),
+ ).toHaveLength(1);
+ });
+
+ it("reparents children and removes backlinks when a node is deleted", () => {
+ const nodes: DiscourseNode[] = [
+ {
+ id: "parent",
+ type: "question",
+ text: "Parent",
+ parentId: null,
+ links: [],
+ },
+ {
+ id: "deleted",
+ type: "claim",
+ text: "Deleted",
+ parentId: "parent",
+ links: [],
+ },
+ {
+ id: "child",
+ type: "evidence",
+ text: "Child",
+ parentId: "deleted",
+ links: [{ targetId: "deleted", type: "supports" }],
+ },
+ ];
+
+ const updated = deleteNode({ nodes, nodeId: "deleted" });
+
+ expect(updated.find((node) => node.id === "child")).toMatchObject({
+ parentId: "parent",
+ links: [],
+ });
+ });
+});
diff --git a/apps/website/app/(prototype)/try/model.ts b/apps/website/app/(prototype)/try/model.ts
new file mode 100644
index 000000000..3c4ad5601
--- /dev/null
+++ b/apps/website/app/(prototype)/try/model.ts
@@ -0,0 +1,171 @@
+export const NODE_TYPES = ["question", "claim", "evidence", "source"] as const;
+
+export const RELATIONSHIP_TYPES = [
+ "supports",
+ "challenges",
+ "cites",
+ "relates_to",
+] as const;
+
+export type NodeType = (typeof NODE_TYPES)[number];
+export type RelationshipType = (typeof RELATIONSHIP_TYPES)[number];
+
+export type DiscourseLink = {
+ targetId: string;
+ type: RelationshipType;
+};
+
+export type DiscourseNode = {
+ id: string;
+ links: DiscourseLink[];
+ parentId: string | null;
+ sourceUrl?: string;
+ text: string;
+ type: NodeType;
+};
+
+const SEED_NODES: DiscourseNode[] = [
+ {
+ id: "question-preregistration",
+ type: "question",
+ text: "How does preregistration change the credibility of scientific findings?",
+ parentId: null,
+ links: [],
+ },
+ {
+ id: "claim-transparency",
+ type: "claim",
+ text: "Preregistration makes planned analyses easier to distinguish from choices made after seeing the results.",
+ parentId: "question-preregistration",
+ links: [{ targetId: "question-preregistration", type: "relates_to" }],
+ },
+ {
+ id: "evidence-registered-reports",
+ type: "evidence",
+ text: "Registered reports review the research question and methods before the results are known.",
+ parentId: "claim-transparency",
+ links: [
+ { targetId: "claim-transparency", type: "supports" },
+ { targetId: "source-nosek", type: "cites" },
+ ],
+ },
+ {
+ id: "source-nosek",
+ type: "source",
+ text: "Nosek et al. (2018), The preregistration revolution",
+ parentId: "evidence-registered-reports",
+ sourceUrl: "https://doi.org/10.1073/pnas.1708274114",
+ links: [],
+ },
+ {
+ id: "claim-not-guarantee",
+ type: "claim",
+ text: "Preregistration is a transparency tool, not a guarantee that a study uses strong methods.",
+ parentId: "question-preregistration",
+ links: [
+ { targetId: "question-preregistration", type: "relates_to" },
+ { targetId: "claim-transparency", type: "challenges" },
+ ],
+ },
+ {
+ id: "evidence-specificity",
+ type: "evidence",
+ text: "A plan can be too vague to constrain analysis, while disclosed and justified deviations can still be informative.",
+ parentId: "claim-not-guarantee",
+ links: [
+ { targetId: "claim-not-guarantee", type: "supports" },
+ { targetId: "source-chambers", type: "cites" },
+ ],
+ },
+ {
+ id: "source-chambers",
+ type: "source",
+ text: "Chambers & Tzavella (2022), The past, present and future of Registered Reports",
+ parentId: "evidence-specificity",
+ sourceUrl: "https://doi.org/10.1038/s41562-021-01193-7",
+ links: [],
+ },
+ {
+ id: "question-deviations",
+ type: "question",
+ text: "When should a researcher deviate from a preregistered plan?",
+ parentId: "question-preregistration",
+ links: [
+ { targetId: "claim-not-guarantee", type: "relates_to" },
+ { targetId: "evidence-specificity", type: "relates_to" },
+ ],
+ },
+];
+
+export const getSeedNodes = (): DiscourseNode[] =>
+ SEED_NODES.map((node) => ({
+ ...node,
+ links: node.links.map((link) => ({ ...link })),
+ }));
+
+export const addRelationship = ({
+ nodes,
+ sourceId,
+ targetId,
+ type,
+}: {
+ nodes: DiscourseNode[];
+ sourceId: string;
+ targetId: string;
+ type: RelationshipType;
+}): DiscourseNode[] => {
+ if (sourceId === targetId) return nodes;
+
+ return nodes.map((node) => {
+ if (node.id !== sourceId) return node;
+
+ const alreadyExists = node.links.some(
+ (link) => link.targetId === targetId && link.type === type,
+ );
+
+ return alreadyExists
+ ? node
+ : { ...node, links: [...node.links, { targetId, type }] };
+ });
+};
+
+export const removeRelationship = ({
+ nodes,
+ sourceId,
+ targetId,
+ type,
+}: {
+ nodes: DiscourseNode[];
+ sourceId: string;
+ targetId: string;
+ type: RelationshipType;
+}): DiscourseNode[] =>
+ nodes.map((node) =>
+ node.id === sourceId
+ ? {
+ ...node,
+ links: node.links.filter(
+ (link) => link.targetId !== targetId || link.type !== type,
+ ),
+ }
+ : node,
+ );
+
+export const deleteNode = ({
+ nodeId,
+ nodes,
+}: {
+ nodeId: string;
+ nodes: DiscourseNode[];
+}): DiscourseNode[] => {
+ const deletedNode = nodes.find((node) => node.id === nodeId);
+ if (!deletedNode) return nodes;
+
+ return nodes
+ .filter((node) => node.id !== nodeId)
+ .map((node) => ({
+ ...node,
+ parentId: node.parentId === nodeId ? deletedNode.parentId : node.parentId,
+ links: node.links.filter((link) => link.targetId !== nodeId),
+ }));
+};
diff --git a/apps/website/app/(prototype)/try/page.tsx b/apps/website/app/(prototype)/try/page.tsx
new file mode 100644
index 000000000..8f65c57c0
--- /dev/null
+++ b/apps/website/app/(prototype)/try/page.tsx
@@ -0,0 +1,13 @@
+import type { Metadata } from "next";
+import type { ReactElement } from "react";
+import { DiscourseGraphPrototype } from "./DiscourseGraphPrototype";
+
+export const metadata: Metadata = {
+ title: "Try a Discourse Graph",
+ description:
+ "Explore a small, interactive graph of questions, claims, evidence, and sources.",
+};
+
+const TryDiscourseGraphPage = (): ReactElement => ;
+
+export default TryDiscourseGraphPage;
diff --git a/apps/website/package.json b/apps/website/package.json
index 9c22c558b..0eb299cf7 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -12,6 +12,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"check-types": "tsc --noEmit --skipLibCheck",
+ "test:unit": "vitest run --config vitest.config.mts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:nodb": "vitest run --config vitest.integration.config.ts --tags-filter '!database'"
},
diff --git a/apps/website/vitest.config.mts b/apps/website/vitest.config.mts
new file mode 100644
index 000000000..18db4a2f4
--- /dev/null
+++ b/apps/website/vitest.config.mts
@@ -0,0 +1,14 @@
+import path from "path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["app/**/*.test.ts"],
+ },
+ resolve: {
+ alias: {
+ "~": path.resolve(import.meta.dirname, "app"),
+ },
+ },
+});