diff --git a/docs-site/app/global.css b/docs-site/app/global.css
index b9124304..a4cebc55 100644
--- a/docs-site/app/global.css
+++ b/docs-site/app/global.css
@@ -1035,6 +1035,49 @@ body::after {
51%, 100% { opacity: 0; }
}
+/* ═══════════════════════════════════════════
+ Flow canvas (shared ReactFlow diagram styles)
+ ═══════════════════════════════════════════ */
+.flow-canvas .react-flow__node {
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ padding: 0;
+ border-radius: 0;
+ width: auto;
+ text-align: left;
+ user-select: text;
+ -webkit-user-select: text;
+ cursor: default;
+ pointer-events: all !important;
+}
+.flow-canvas .react-flow__node > * {
+ pointer-events: auto;
+ user-select: text;
+ -webkit-user-select: text;
+}
+.flow-canvas .react-flow__node.selected,
+.flow-canvas .react-flow__node:focus,
+.flow-canvas .react-flow__node:focus-visible {
+ outline: none;
+ box-shadow: none;
+}
+.flow-canvas .react-flow__pane {
+ cursor: grab;
+}
+.flow-canvas .react-flow__pane:active {
+ cursor: grabbing;
+}
+.flow-canvas .react-flow__handle {
+ width: 1px;
+ height: 1px;
+ min-width: 0;
+ min-height: 0;
+ background: transparent;
+ border: 0;
+ pointer-events: none;
+}
+
/* ═══════════════════════════════════════════
Reduced motion
═══════════════════════════════════════════ */
diff --git a/docs-site/components/context-review-loop.tsx b/docs-site/components/context-review-loop.tsx
new file mode 100644
index 00000000..38814026
--- /dev/null
+++ b/docs-site/components/context-review-loop.tsx
@@ -0,0 +1,704 @@
+"use client";
+
+import {
+ Handle,
+ MarkerType,
+ type Node,
+ type NodeProps,
+ Position,
+} from "@xyflow/react";
+
+import { FlowCanvas } from "./flow-canvas";
+
+type SourcesNodeData = {
+ variant: "sources";
+ badge: string;
+ title: string;
+ caption: string;
+ items: Array<{ label: string; color: string }>;
+};
+
+type IngestNodeData = {
+ variant: "ingest";
+ badge: string;
+ title: string;
+ command: string;
+ caption: string;
+};
+
+type DiffFileLine = { kind: "add" | "del" | "ctx" | "hunk"; text: string };
+type DiffFile = {
+ path: string;
+ accent: string;
+ added: number;
+ removed: number;
+ lines: DiffFileLine[];
+};
+
+type DiffNodeData = {
+ variant: "diff";
+ badge: string;
+ title: string;
+ caption: string;
+ branch: string;
+ files: DiffFile[];
+};
+
+type ReviewNodeData = {
+ variant: "review";
+ badge: string;
+ title: string;
+ caption: string;
+ checks: string[];
+};
+
+type MergedNodeData = {
+ variant: "merged";
+ badge: string;
+ title: string;
+ caption: string;
+ paths: Array<{ label: string; color: string }>;
+};
+
+type SourcesNode = Node;
+type IngestNode = Node;
+type DiffNode = Node;
+type ReviewNode = Node;
+type MergedNode = Node;
+type FlowNode = SourcesNode | IngestNode | DiffNode | ReviewNode | MergedNode;
+
+const NODE_W = 420;
+const STD_H = 196;
+const DIFF_H = 472;
+
+const CENTER_X = 220;
+const GAP = 72;
+
+const SOURCES_Y = 16;
+const INGEST_Y = SOURCES_Y + STD_H + GAP;
+const DIFF_Y = INGEST_Y + STD_H + GAP;
+const REVIEW_Y = DIFF_Y + DIFF_H + GAP;
+const MERGED_Y = REVIEW_Y + STD_H + GAP;
+const CANVAS_BOTTOM = MERGED_Y + STD_H + 16;
+
+const FORWARD_STROKE = "#0891b2";
+const FEEDBACK_STROKE = "#94a3b8";
+
+const sourcesNode: SourcesNode = {
+ id: "sources",
+ type: "sources",
+ position: { x: CENTER_X, y: SOURCES_Y },
+ data: {
+ variant: "sources",
+ badge: "1 · evidence",
+ title: "Data stack",
+ caption: "Connectors scan warehouses, modeling code, BI tools, and notes.",
+ items: [
+ { label: "warehouse", color: "#3b82f6" },
+ { label: "dbt", color: "#f59e0b" },
+ { label: "Metabase", color: "#f97316" },
+ { label: "Notion", color: "#10b981" },
+ ],
+ },
+ draggable: false,
+ selectable: false,
+};
+
+const ingestNode: IngestNode = {
+ id: "ingest",
+ type: "ingest",
+ position: { x: CENTER_X, y: INGEST_Y },
+ data: {
+ variant: "ingest",
+ badge: "2 · run",
+ title: "ktx ingest",
+ command: "ktx ingest --all",
+ caption:
+ "Reconciles new evidence with the accepted YAML and Markdown already on disk.",
+ },
+ draggable: false,
+ selectable: false,
+};
+
+const diffNode: DiffNode = {
+ id: "diff",
+ type: "diff",
+ position: { x: CENTER_X, y: DIFF_Y },
+ data: {
+ variant: "diff",
+ badge: "3 · diff",
+ title: "Branch diff",
+ caption: "Every decision lands as a YAML or Markdown line.",
+ branch: "ingest/nightly",
+ files: [
+ {
+ path: "semantic-layer/warehouse/orders.yaml",
+ accent: "#3b82f6",
+ added: 4,
+ removed: 1,
+ lines: [
+ { kind: "hunk", text: "@@ measures @@" },
+ { kind: "ctx", text: " - name: revenue" },
+ { kind: "del", text: " expr: sum(amount)" },
+ { kind: "add", text: " expr: sum(amount - refund_amount)" },
+ { kind: "add", text: " - name: net_orders" },
+ { kind: "add", text: " expr: count(distinct id)" },
+ ],
+ },
+ {
+ path: "wiki/global/revenue.md",
+ accent: "#10b981",
+ added: 2,
+ removed: 0,
+ lines: [
+ { kind: "hunk", text: "@@ Net revenue @@" },
+ { kind: "add", text: "Excludes refunds and test accounts." },
+ { kind: "add", text: "sl_refs: [warehouse.orders]" },
+ ],
+ },
+ ],
+ },
+ draggable: false,
+ selectable: false,
+};
+
+const reviewNode: ReviewNode = {
+ id: "review",
+ type: "review",
+ position: { x: CENTER_X, y: REVIEW_Y },
+ data: {
+ variant: "review",
+ badge: "4 · review",
+ title: "PR review",
+ caption: "Analysts approve, edit, or reject like any pull request.",
+ checks: ["joins are safe", "measures match policy", "wiki cites evidence"],
+ },
+ draggable: false,
+ selectable: false,
+};
+
+const mergedNode: MergedNode = {
+ id: "merged",
+ type: "merged",
+ position: { x: CENTER_X, y: MERGED_Y },
+ data: {
+ variant: "merged",
+ badge: "5 · merged",
+ title: "Accepted context",
+ caption: "Merged files become the trusted layer agents read at runtime.",
+ paths: [
+ { label: "semantic-layer/", color: "#3b82f6" },
+ { label: "wiki/", color: "#10b981" },
+ ],
+ },
+ draggable: false,
+ selectable: false,
+};
+
+const nodes: FlowNode[] = [
+ sourcesNode,
+ ingestNode,
+ diffNode,
+ reviewNode,
+ mergedNode,
+];
+
+const arrowMarker = (color: string) => ({
+ type: MarkerType.ArrowClosed,
+ color,
+ width: 14,
+ height: 14,
+});
+
+const forwardLabelStyle = {
+ fontSize: 11,
+ fontWeight: 600,
+ fill: "var(--color-fd-muted-foreground)",
+ letterSpacing: "0.02em",
+} as const;
+
+const forwardLabelBg = {
+ fill: "var(--color-fd-background)",
+ stroke: "var(--color-fd-border)",
+ strokeWidth: 1,
+} as const;
+
+const edges = [
+ {
+ id: "sources-ingest",
+ source: "sources",
+ sourceHandle: "bottom",
+ target: "ingest",
+ targetHandle: "top",
+ type: "straight" as const,
+ label: "scan",
+ labelBgPadding: [6, 3] as [number, number],
+ labelBgBorderRadius: 4,
+ labelStyle: forwardLabelStyle,
+ labelBgStyle: forwardLabelBg,
+ style: { stroke: FORWARD_STROKE, strokeWidth: 1.75 },
+ markerEnd: arrowMarker(FORWARD_STROKE),
+ },
+ {
+ id: "ingest-diff",
+ source: "ingest",
+ sourceHandle: "bottom",
+ target: "diff",
+ targetHandle: "top",
+ type: "straight" as const,
+ label: "propose files",
+ labelBgPadding: [6, 3] as [number, number],
+ labelBgBorderRadius: 4,
+ labelStyle: forwardLabelStyle,
+ labelBgStyle: forwardLabelBg,
+ style: { stroke: FORWARD_STROKE, strokeWidth: 1.75 },
+ markerEnd: arrowMarker(FORWARD_STROKE),
+ },
+ {
+ id: "diff-review",
+ source: "diff",
+ sourceHandle: "bottom",
+ target: "review",
+ targetHandle: "top",
+ type: "straight" as const,
+ label: "open PR",
+ labelBgPadding: [6, 3] as [number, number],
+ labelBgBorderRadius: 4,
+ labelStyle: forwardLabelStyle,
+ labelBgStyle: forwardLabelBg,
+ style: { stroke: FORWARD_STROKE, strokeWidth: 1.75 },
+ markerEnd: arrowMarker(FORWARD_STROKE),
+ },
+ {
+ id: "review-merged",
+ source: "review",
+ sourceHandle: "bottom",
+ target: "merged",
+ targetHandle: "top",
+ type: "straight" as const,
+ label: "merge",
+ labelBgPadding: [6, 3] as [number, number],
+ labelBgBorderRadius: 4,
+ labelStyle: forwardLabelStyle,
+ labelBgStyle: forwardLabelBg,
+ style: { stroke: FORWARD_STROKE, strokeWidth: 1.75 },
+ markerEnd: arrowMarker(FORWARD_STROKE),
+ },
+ {
+ id: "merged-sources",
+ source: "merged",
+ sourceHandle: "right",
+ target: "sources",
+ targetHandle: "right",
+ type: "smoothstep" as const,
+ pathOptions: { offset: 64, borderRadius: 18 },
+ style: {
+ stroke: FEEDBACK_STROKE,
+ strokeWidth: 1.5,
+ strokeDasharray: "5 5",
+ },
+ markerEnd: arrowMarker(FEEDBACK_STROKE),
+ },
+];
+
+function BadgePill({
+ tone,
+ children,
+}: {
+ tone: "neutral" | "primary" | "review" | "merged";
+ children: React.ReactNode;
+}) {
+ const cls =
+ tone === "primary"
+ ? "border-cyan-300/70 bg-cyan-50 text-cyan-800 dark:border-cyan-400/40 dark:bg-cyan-400/15 dark:text-cyan-100"
+ : tone === "review"
+ ? "border-fuchsia-300/70 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-400/40 dark:bg-fuchsia-400/15 dark:text-fuchsia-100"
+ : tone === "merged"
+ ? "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
+ : "border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-600/60 dark:bg-slate-700/40 dark:text-slate-200";
+ return (
+
+ {children}
+
+ );
+}
+
+function FlowHandles({ noTop = false }: { noTop?: boolean }) {
+ return (
+ <>
+ {!noTop ? (
+
+ ) : null}
+
+
+
+ >
+ );
+}
+
+function SourcesHandles() {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function SourcesNodeView({ data }: NodeProps) {
+ return (
+
+
+
{data.badge}
+
+ {data.title}
+
+
+ {data.caption}
+
+
+ {data.items.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+
+ );
+}
+
+function IngestNodeView({ data }: NodeProps) {
+ return (
+
+
+
+
+
{data.badge}
+
+ {data.title}
+
+
+
+ {`$ ${data.command}`}
+
+
+ {data.caption}
+
+
+ );
+}
+
+function DiffLine({ line }: { line: DiffFileLine }) {
+ if (line.kind === "hunk") {
+ return (
+
+ {line.text}
+
+ );
+ }
+ const symbol = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " ";
+ const cls =
+ line.kind === "add"
+ ? "bg-emerald-500/8 text-emerald-700 dark:text-emerald-300"
+ : line.kind === "del"
+ ? "bg-rose-500/8 text-rose-700 dark:text-rose-300"
+ : "text-fd-muted-foreground";
+ return (
+
+
+ {symbol}
+
+ {line.text}
+
+ );
+}
+
+function DiffFileBlock({ file }: { file: DiffFile }) {
+ return (
+
+
+
+ {file.path}
+
+
+ +{file.added}
+
+ {file.removed > 0 ? (
+
+ -{file.removed}
+
+ ) : null}
+
+
+ {file.lines.map((line, idx) => (
+
+ ))}
+
+
+ );
+}
+
+function DiffNodeView({ data }: NodeProps) {
+ return (
+
+
+
+
{data.badge}
+
+ {data.title}
+
+
+
+
+
+
+
+
+
+ {data.branch}
+
+
+
+ {data.caption}
+
+
+ {data.files.map((file) => (
+
+ ))}
+
+
+ );
+}
+
+function ReviewNodeView({ data }: NodeProps) {
+ return (
+
+
+
+
{data.badge}
+
+ {data.title}
+
+
+
+ {data.caption}
+
+
+ {data.checks.map((check) => (
+
+
+
+
+ {check}
+
+ ))}
+
+
+ );
+}
+
+function MergedNodeView({ data }: NodeProps) {
+ return (
+
+
+
+
{data.badge}
+
+ {data.title}
+
+
+
+ {data.caption}
+
+
+ {data.paths.map((path) => (
+
+
+ {path.label}
+
+ ))}
+
+
+ );
+}
+
+const nodeTypes = {
+ sources: SourcesNodeView,
+ ingest: IngestNodeView,
+ diff: DiffNodeView,
+ review: ReviewNodeView,
+ merged: MergedNodeView,
+};
+
+export function ContextReviewLoop() {
+ return (
+
+
+
+
+ The review loop
+
+ #
+
+
+
+ Every ingest is a diff you can refuse
+
+
+ Evidence becomes file changes. File changes become a PR. The PR
+ merges into the layer agents will read tomorrow, and what you
+ merged today becomes the baseline for the next run.
+
+
+
+ dashed line: merged files feed the next ingest
+
+
+
+
+
+
+ );
+}
diff --git a/docs-site/components/flow-canvas.tsx b/docs-site/components/flow-canvas.tsx
new file mode 100644
index 00000000..cc39f9e5
--- /dev/null
+++ b/docs-site/components/flow-canvas.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import {
+ Background,
+ BackgroundVariant,
+ Controls,
+ type Edge,
+ type EdgeTypes,
+ type FitViewOptions,
+ type Node,
+ type NodeTypes,
+ type OnInit,
+ ReactFlow,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+
+type FlowCanvasProps = {
+ nodes: TNode[];
+ edges: TEdge[];
+ nodeTypes?: NodeTypes;
+ edgeTypes?: EdgeTypes;
+ /** Inline style for the canvas wrapper (height, minHeight, etc.). */
+ canvasStyle: React.CSSProperties;
+ /** Extra class on the canvas wrapper (the `flow-canvas` class is always
+ * applied). Use it to scope per-diagram styles. */
+ className?: string;
+ fitViewOptions?: FitViewOptions;
+ maxZoom?: number;
+ translateExtent?: [[number, number], [number, number]];
+ ariaLabel?: string;
+};
+
+const DEFAULT_FIT_VIEW = { padding: 0.05 } satisfies FitViewOptions;
+
+/**
+ * Shared ReactFlow wrapper for docs diagrams.
+ *
+ * Behavior:
+ * - Drag-to-pan, pinch-to-zoom, double-click-to-zoom.
+ * - Scroll wheel passes through to the page (zoomOnScroll/panOnScroll off).
+ * - On mount, the view is fitted and `minZoom` is locked to the fitted zoom
+ * so the user can zoom in but not out beyond the initial framing.
+ * - Nodes are non-draggable, non-selectable, non-focusable — the diagram is
+ * a static read-only artifact.
+ * - Common CSS lives in `global.css` under the `.flow-canvas` selector; the
+ * per-diagram `className` adds anything else specific to that diagram.
+ */
+export function FlowCanvas({
+ nodes,
+ edges,
+ nodeTypes,
+ edgeTypes,
+ canvasStyle,
+ className,
+ fitViewOptions = DEFAULT_FIT_VIEW,
+ maxZoom = 1.5,
+ translateExtent,
+ ariaLabel,
+}: FlowCanvasProps) {
+ const [minZoom, setMinZoom] = useState(0.15);
+ const handleInit = useCallback>(
+ (instance) => {
+ requestAnimationFrame(() => {
+ void instance.fitView(fitViewOptions).then(() => {
+ setMinZoom(instance.getZoom());
+ });
+ });
+ },
+ [fitViewOptions],
+ );
+
+ return (
+
+
+ Drag to pan · ⌘/Ctrl + scroll to zoom
+
+
+
+
+
+
+ );
+}
diff --git a/docs-site/components/product-mechanics.tsx b/docs-site/components/product-mechanics.tsx
index cabc149f..642e1107 100644
--- a/docs-site/components/product-mechanics.tsx
+++ b/docs-site/components/product-mechanics.tsx
@@ -1,8 +1,6 @@
"use client";
import {
- Background,
- BackgroundVariant,
type Edge,
type EdgeProps,
getSmoothStepPath,
@@ -11,11 +9,11 @@ import {
type Node,
type NodeProps,
Position,
- ReactFlow,
} from "@xyflow/react";
-import "@xyflow/react/dist/style.css";
import { useEffect, useMemo, useState } from "react";
+import { FlowCanvas } from "./flow-canvas";
+
type SourceNodeData = {
accent: string;
body: string;
@@ -538,81 +536,21 @@ export function ProductMechanics() {
-
-
-
-
-
+ className="mechanics-canvas"
+ fitViewOptions={{ padding: 0.04 }}
+ ariaLabel="ktx ingestion flow diagram"
+ />