docs: rewrite context-as-code as reviewing-context guide

Move the page from Concepts to Guides and rebuild around an interactive
review-loop diagram. Extract pan/zoom + fit-view controls into a shared
FlowCanvas wrapper and adopt it across all three docs diagrams.
This commit is contained in:
Andrey Avtomonov 2026-05-21 15:38:36 +02:00
parent a1cfb03d73
commit bb455ed5ce
13 changed files with 1059 additions and 285 deletions

View file

@ -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
*/

View file

@ -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<SourcesNodeData, "sources">;
type IngestNode = Node<IngestNodeData, "ingest">;
type DiffNode = Node<DiffNodeData, "diff">;
type ReviewNode = Node<ReviewNodeData, "review">;
type MergedNode = Node<MergedNodeData, "merged">;
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 (
<span
className={`inline-flex items-center gap-1.5 rounded-sm border px-2 font-mono text-[13px] font-semibold uppercase tracking-[0.06em] ${cls}`}
style={{ lineHeight: "20px", paddingBlock: 0 }}
>
{children}
</span>
);
}
function FlowHandles({ noTop = false }: { noTop?: boolean }) {
return (
<>
{!noTop ? (
<Handle
id="top"
type="target"
position={Position.Top}
className="!opacity-0"
/>
) : null}
<Handle
id="bottom"
type="source"
position={Position.Bottom}
className="!opacity-0"
/>
<Handle
id="right"
type="source"
position={Position.Right}
className="!opacity-0"
/>
<Handle
id="right-target"
type="target"
position={Position.Right}
className="!opacity-0"
/>
</>
);
}
function SourcesHandles() {
return (
<>
<Handle
id="bottom"
type="source"
position={Position.Bottom}
className="!opacity-0"
/>
<Handle
id="right"
type="target"
position={Position.Right}
className="!opacity-0"
/>
</>
);
}
function SourcesNodeView({ data }: NodeProps<SourcesNode>) {
return (
<div
style={{ width: NODE_W, height: STD_H }}
className="flex flex-col rounded-md border border-fd-border bg-fd-card px-4 py-3.5 shadow-sm"
>
<SourcesHandles />
<BadgePill tone="neutral">{data.badge}</BadgePill>
<p className="mt-2 text-[19px] font-semibold leading-7 text-fd-foreground">
{data.title}
</p>
<p className="mt-1.5 text-[15px] leading-6 text-fd-muted-foreground">
{data.caption}
</p>
<div className="mt-auto flex flex-wrap gap-1.5 pt-2.5">
{data.items.map((item) => (
<span
key={item.label}
className="inline-flex items-center gap-1.5 rounded border border-fd-border bg-fd-background px-2 py-0.5 text-[13px] leading-5 text-fd-muted-foreground"
>
<span
aria-hidden="true"
className="h-1.5 w-1.5 flex-none rounded-full"
style={{ background: item.color }}
/>
{item.label}
</span>
))}
</div>
</div>
);
}
function IngestNodeView({ data }: NodeProps<IngestNode>) {
return (
<div
style={{ width: NODE_W, height: STD_H }}
className="relative flex flex-col rounded-md border border-cyan-300/40 bg-[#0f1f23] px-4 py-3.5 text-white shadow-sm dark:bg-[#0b181b]"
>
<span
aria-hidden="true"
className="absolute inset-y-0 left-0 w-[3px] rounded-l-md"
style={{ background: FORWARD_STROKE }}
/>
<FlowHandles />
<div className="flex items-center gap-2.5">
<BadgePill tone="primary">{data.badge}</BadgePill>
<p className="text-[19px] font-semibold leading-7 text-white">
{data.title}
</p>
</div>
<span
className="mt-2 inline-flex w-fit items-center whitespace-pre rounded-sm border border-cyan-100/15 bg-white/[0.08] px-2 py-0.5 font-mono text-[14px] leading-5 text-cyan-100"
style={{
fontVariantLigatures: "none",
fontFeatureSettings: '"liga" 0, "calt" 0',
}}
>
{`$ ${data.command}`}
</span>
<p className="mt-2 text-[15px] leading-6 text-cyan-50/80">
{data.caption}
</p>
</div>
);
}
function DiffLine({ line }: { line: DiffFileLine }) {
if (line.kind === "hunk") {
return (
<div className="bg-fd-muted/40 px-2.5 py-1 font-mono text-[12px] uppercase tracking-[0.06em] text-fd-muted-foreground">
{line.text}
</div>
);
}
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 (
<div
className={`flex gap-1.5 px-2.5 py-px font-mono text-[13px] leading-5 ${cls}`}
>
<span aria-hidden="true" className="w-2.5 flex-none text-center opacity-70">
{symbol}
</span>
<span className="min-w-0 truncate">{line.text}</span>
</div>
);
}
function DiffFileBlock({ file }: { file: DiffFile }) {
return (
<div className="overflow-hidden rounded-sm border border-fd-border bg-fd-background">
<div
className="flex items-center gap-2 border-b border-fd-border px-2.5 py-1.5"
style={{ borderTop: `2px solid ${file.accent}` }}
>
<span
className="truncate font-mono text-[14px] font-semibold tracking-tight"
style={{ color: file.accent }}
>
{file.path}
</span>
<span className="ml-auto flex-none font-mono text-[12px] tabular-nums text-emerald-600 dark:text-emerald-400">
+{file.added}
</span>
{file.removed > 0 ? (
<span className="flex-none font-mono text-[12px] tabular-nums text-rose-600 dark:text-rose-400">
-{file.removed}
</span>
) : null}
</div>
<div className="py-1">
{file.lines.map((line, idx) => (
<DiffLine key={idx} line={line} />
))}
</div>
</div>
);
}
function DiffNodeView({ data }: NodeProps<DiffNode>) {
return (
<div
style={{ width: NODE_W, height: DIFF_H }}
className="flex flex-col rounded-md border-2 border-fd-primary/45 bg-fd-card px-4 py-3.5 shadow-md"
>
<FlowHandles />
<div className="flex items-center gap-2.5">
<BadgePill tone="primary">{data.badge}</BadgePill>
<p className="text-[19px] font-semibold leading-7 text-fd-foreground">
{data.title}
</p>
<span
className="ml-auto inline-flex w-fit items-center gap-1.5 whitespace-nowrap rounded border border-fd-border bg-fd-background px-2 py-0.5 font-mono text-[13px] leading-5 text-fd-muted-foreground"
>
<svg
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="6" cy="6" r="2.25" />
<circle cx="6" cy="18" r="2.25" />
<circle cx="18" cy="6" r="2.25" />
<path d="M6 8.5v7" />
<path d="M6 18c0-6 12-6 12-9.5" />
</svg>
{data.branch}
</span>
</div>
<p className="mt-1.5 text-[15px] leading-6 text-fd-muted-foreground">
{data.caption}
</p>
<div className="mt-2.5 flex min-h-0 flex-1 flex-col gap-2">
{data.files.map((file) => (
<DiffFileBlock key={file.path} file={file} />
))}
</div>
</div>
);
}
function ReviewNodeView({ data }: NodeProps<ReviewNode>) {
return (
<div
style={{ width: NODE_W, height: STD_H }}
className="flex flex-col rounded-md border border-fd-border bg-fd-card px-4 py-3.5 shadow-sm"
>
<FlowHandles />
<div className="flex items-center gap-2.5">
<BadgePill tone="review">{data.badge}</BadgePill>
<p className="text-[19px] font-semibold leading-7 text-fd-foreground">
{data.title}
</p>
</div>
<p className="mt-1.5 text-[15px] leading-6 text-fd-muted-foreground">
{data.caption}
</p>
<ul className="mt-auto flex flex-wrap gap-x-4 gap-y-1.5 pt-2.5">
{data.checks.map((check) => (
<li
key={check}
className="inline-flex items-center gap-1.5 text-[13px] leading-5 text-fd-muted-foreground"
>
<svg
aria-hidden="true"
className="h-3.5 w-3.5 flex-none text-fuchsia-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 12 10 18 20 6" />
</svg>
<span>{check}</span>
</li>
))}
</ul>
</div>
);
}
function MergedNodeView({ data }: NodeProps<MergedNode>) {
return (
<div
style={{ width: NODE_W, height: STD_H }}
className="flex flex-col rounded-md border border-emerald-300/55 bg-fd-card px-4 py-3.5 shadow-sm"
>
<FlowHandles />
<div className="flex items-center gap-2.5">
<BadgePill tone="merged">{data.badge}</BadgePill>
<p className="text-[19px] font-semibold leading-7 text-fd-foreground">
{data.title}
</p>
</div>
<p className="mt-1.5 text-[15px] leading-6 text-fd-muted-foreground">
{data.caption}
</p>
<div className="mt-auto flex flex-wrap gap-4 pt-2.5">
{data.paths.map((path) => (
<span
key={path.label}
className="inline-flex items-center gap-1.5 font-mono text-[15px] font-semibold tracking-tight"
style={{ color: path.color }}
>
<span
aria-hidden="true"
className="h-2 w-2 flex-none rounded-full"
style={{ background: path.color }}
/>
{path.label}
</span>
))}
</div>
</div>
);
}
const nodeTypes = {
sources: SourcesNodeView,
ingest: IngestNodeView,
diff: DiffNodeView,
review: ReviewNodeView,
merged: MergedNodeView,
};
export function ContextReviewLoop() {
return (
<section
id="review-loop"
className="not-prose my-10 w-full max-w-full min-w-0 scroll-mt-24"
aria-labelledby="review-loop-title"
>
<article
className="max-w-full min-w-0 overflow-hidden rounded-lg border border-fd-border bg-fd-card shadow-sm"
aria-label="The ktx context review loop"
>
<div className="border-b border-fd-border bg-fd-muted/35 px-5 py-4">
<a
href="#review-loop"
className="group/anchor inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-fd-primary transition-colors hover:text-fd-primary/80"
>
The review loop
<span
aria-hidden="true"
className="opacity-0 transition-opacity duration-150 group-hover/anchor:opacity-100 group-focus-visible/anchor:opacity-100"
>
#
</span>
</a>
<h3
id="review-loop-title"
className="mt-1 text-base font-semibold tracking-normal text-fd-foreground sm:text-lg"
style={{ fontFamily: "var(--font-display)" }}
>
Every ingest is a diff you can refuse
</h3>
<p className="mt-2 max-w-3xl text-xs leading-5 text-fd-muted-foreground">
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.
</p>
<p className="mt-2 inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.06em] text-fd-muted-foreground">
<span
aria-hidden="true"
className="inline-block h-px w-6"
style={{
borderTop: `1.5px dashed ${FEEDBACK_STROKE}`,
}}
/>
dashed line: merged files feed the next ingest
</p>
</div>
<FlowCanvas
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
canvasStyle={{
height: "min(1080px, 160vw)",
minHeight: 760,
}}
translateExtent={[
[-160, -120],
[CENTER_X + NODE_W + 320, CANVAS_BOTTOM + 120],
]}
ariaLabel="ktx context review loop diagram"
/>
</article>
</section>
);
}

View file

@ -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<TNode extends Node, TEdge extends Edge> = {
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<TNode>;
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<TNode extends Node, TEdge extends Edge>({
nodes,
edges,
nodeTypes,
edgeTypes,
canvasStyle,
className,
fitViewOptions = DEFAULT_FIT_VIEW,
maxZoom = 1.5,
translateExtent,
ariaLabel,
}: FlowCanvasProps<TNode, TEdge>) {
const [minZoom, setMinZoom] = useState(0.15);
const handleInit = useCallback<OnInit<TNode, TEdge>>(
(instance) => {
requestAnimationFrame(() => {
void instance.fitView(fitViewOptions).then(() => {
setMinZoom(instance.getZoom());
});
});
},
[fitViewOptions],
);
return (
<div
className={`flow-canvas relative bg-fd-background ${className ?? ""}`}
style={canvasStyle}
aria-label={ariaLabel}
>
<div className="pointer-events-none absolute right-2.5 top-2.5 z-10 rounded border border-fd-border/50 bg-white/30 px-1.5 py-px font-mono text-[9.5px] font-medium uppercase tracking-[0.06em] text-fd-muted-foreground shadow-sm backdrop-blur-sm dark:bg-white/10">
Drag to pan · /Ctrl + scroll to zoom
</div>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onInit={handleInit}
nodesDraggable={false}
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
panOnDrag
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch
zoomOnDoubleClick
preventScrolling={false}
minZoom={minZoom}
maxZoom={maxZoom}
translateExtent={translateExtent}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="var(--color-fd-border)"
/>
<Controls
showInteractive={false}
position="bottom-right"
aria-label="Zoom and fit-view controls"
/>
</ReactFlow>
</div>
);
}

View file

@ -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() {
</p>
</div>
<div
className="mechanics-canvas bg-fd-background"
style={{
<FlowCanvas
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
canvasStyle={{
height: "min(1240px, 170vw)",
minHeight: 720,
}}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.04 }}
nodesDraggable={false}
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
panOnDrag={false}
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
preventScrolling={false}
minZoom={0.2}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="var(--color-fd-border)"
/>
</ReactFlow>
</div>
className="mechanics-canvas"
fitViewOptions={{ padding: 0.04 }}
ariaLabel="ktx ingestion flow diagram"
/>
</article>
<style>{`
.mechanics-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: auto;
pointer-events: all !important;
}
.mechanics-canvas .react-flow__node > * {
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
}
.mechanics-canvas .react-flow__node.selected,
.mechanics-canvas .react-flow__node:focus,
.mechanics-canvas .react-flow__node:focus-visible {
outline: none;
box-shadow: none;
}
.mechanics-canvas .react-flow__pane {
cursor: default;
}
.mechanics-canvas .react-flow__handle {
width: 1px;
height: 1px;
min-width: 0;
min-height: 0;
background: transparent;
border: 0;
pointer-events: none;
}
.mechanics-canvas .mechanics-particle {
pointer-events: none;
filter: drop-shadow(0 0 6px currentColor);

View file

@ -2,17 +2,14 @@
import { useCallback, useState } from "react";
import {
Background,
BackgroundVariant,
Handle,
MarkerType,
type Node,
type NodeProps,
type OnInit,
Position,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { FlowCanvas } from "./flow-canvas";
type LaneVariant = "manual" | "ktx";
@ -472,8 +469,6 @@ const edges = [
},
];
type FlowEdge = (typeof edges)[number];
function AgentNodeView({ data }: NodeProps<AgentNode>) {
return (
<div
@ -980,15 +975,6 @@ const nodeTypes = {
};
export function SemanticLayerFlow() {
const [minZoom, setMinZoom] = useState(0.2);
const handleFlowInit = useCallback<OnInit<FlowNode, FlowEdge>>((instance) => {
requestAnimationFrame(() => {
void instance.fitView(FIT_VIEW_OPTIONS).then(() => {
setMinZoom(instance.getZoom());
});
});
}, []);
return (
<section
id="imperative-vs-declarative"
@ -1027,85 +1013,20 @@ export function SemanticLayerFlow() {
</p>
</div>
<div
className="sl-flow-canvas relative bg-fd-background"
style={{
<FlowCanvas
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
canvasStyle={{
height: "min(2340px, 290vw)",
minHeight: 1780,
}}
>
<div className="pointer-events-none absolute right-2.5 top-2.5 z-10 rounded border border-fd-border/50 bg-white/30 px-1.5 py-px font-mono text-[9.5px] font-medium uppercase tracking-[0.06em] text-fd-muted-foreground shadow-sm backdrop-blur-sm dark:bg-white/10">
Drag to pan /Ctrl + scroll to zoom
</div>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={handleFlowInit}
nodesDraggable={false}
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
panOnDrag
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch
zoomOnDoubleClick
preventScrolling={false}
minZoom={minZoom}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="var(--color-fd-border)"
/>
</ReactFlow>
</div>
className="sl-flow-canvas"
fitViewOptions={FIT_VIEW_OPTIONS}
ariaLabel="Semantic query to SQL flow diagram"
/>
</article>
<style>{`
.sl-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: auto;
pointer-events: all !important;
}
.sl-flow-canvas .react-flow__node > * {
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
}
.sl-flow-canvas .react-flow__node.selected,
.sl-flow-canvas .react-flow__node:focus,
.sl-flow-canvas .react-flow__node:focus-visible {
outline: none;
box-shadow: none;
}
.sl-flow-canvas .react-flow__pane {
cursor: grab;
}
.sl-flow-canvas .react-flow__pane:active {
cursor: grabbing;
}
.sl-flow-canvas .react-flow__handle {
width: 1px;
height: 1px;
min-width: 0;
min-height: 0;
background: transparent;
border: 0;
pointer-events: none;
}
.sl-flow-canvas pre {
font-size: 11.5px !important;
line-height: 17.5px !important;

View file

@ -1,114 +0,0 @@
---
title: Context as Code
description: Treat analytics context like code - version it, review it, merge it.
---
## The idea
dbt moved analytics transformations into git. **ktx** applies the same pattern to
analytics context: metric definitions, joins, business rules, wiki pages, and
ingest decisions become files that can be reviewed, merged, and audited.
| Before | With **ktx** |
|--------|----------|
| Context scattered across BI tools, chats, docs, and analyst memory | Context lives in YAML and Markdown |
| Agent changes are hard to inspect | Agent changes are git diffs |
| Imports overwrite local judgment | Ingest reconciles with existing files |
| History depends on tool logs | History lives in commits and transcripts |
## Auto-ingestion
Most context already exists in dbt manifests, LookML, MetricFlow, Metabase,
Notion, warehouse metadata, and analyst notes. **ktx** reads those inputs through
connectors, then reconciles them into local files.
```text
context sources -> connectors -> reconciliation agent -> YAML + Markdown diffs
```
| Step | What happens | Output |
|------|--------------|--------|
| **Extract** | Connectors read models, metrics, questions, schemas, and docs | Structured metadata |
| **Reconcile** | The agent compares incoming facts with existing context | Create, update, skip, or flag |
| **Write** | **ktx** saves changed semantic sources and wiki pages | Reviewable project files |
Reconciliation is the key difference from a sync. **ktx** preserves accepted local
edits, fills gaps, and surfaces conflicts instead of blindly overwriting files.
## The git workflow
Run ingestion on a branch, review the changed YAML and Markdown, then merge the
accepted context the same way you merge dbt or application code.
```text
dbt / BI / docs / warehouse
|
v
ktx ingest --all
|
v
branch: ingest/nightly
|
v
semantic diff in PR
|
v
approve and merge
|
v
agents read updated files
```
Typical review checklist:
- new sources match the warehouse and source-tool evidence;
- joins have the right relationship direction;
- generated measures match business definitions;
- wiki pages capture caveats without duplicating YAML;
- `.ktx/` runtime state stays out of git unless your team intentionally reviews
a report or transcript.
Teams often run ingestion on demand during setup, then schedule
`ktx ingest --all --no-input` on an ingest branch once the source is stable.
## Feedback loops
Context improves when human corrections and agent signals flow back into the
same reviewed files.
| Signal | Example | Where it lands |
|--------|---------|----------------|
| Analyst correction | A measure excludes test accounts | `semantic-layer/**/*.yaml` |
| Business clarification | ARR changed definition this quarter | `wiki/**/*.md` |
| Agent query issue | A filter returns no rows unexpectedly | Wiki caveat or tighter source filter |
| Join problem | A path duplicates order-level measures | Relationship metadata or grain fix |
Accepted corrections become input to the next ingest run. That makes the
context layer converge toward the team's current source of truth.
## Deterministic replay
Every ingestion session records the connector inputs, tool calls, LLM responses,
write decisions, and reasoning behind each change.
| Use case | What replay gives you |
|----------|-----------------------|
| **Debugging** | Trace a bad source, join, or measure back to the input that produced it |
| **Trust** | Show where a definition came from and who reviewed the resulting diff |
| **Reproducibility** | Compare old and new ingest behavior after config or model changes |
Commit the YAML and Markdown changes. Commit reports or transcripts only when
they are part of your team's review workflow.
## Agent usage notes
Use this page when an agent needs to explain review workflows, ingestion diffs,
replayability, or why **ktx** writes YAML and Markdown instead of hiding context in
a hosted service.
| Agent task | Relevant section | Next page |
|------------|------------------|-----------|
| Explain how generated context should be reviewed | The git workflow | [Building Context](/docs/guides/building-context) |
| Diagnose why ingestion changed a semantic source | Auto-ingestion / Deterministic replay | [ktx ingest](/docs/cli-reference/ktx-ingest) |
| Explain how context improves over time | Feedback loops | [Building Context](/docs/guides/building-context) |
| Tell a user what to commit | The git workflow | [Writing Context](/docs/guides/writing-context) |

View file

@ -1,5 +1,5 @@
{
"title": "Concepts",
"defaultOpen": true,
"pages": ["the-context-layer", "semantic-layer-internals", "wiki-retrieval", "context-as-code"]
"pages": ["the-context-layer", "semantic-layer-internals", "wiki-retrieval"]
}

View file

@ -337,4 +337,4 @@ different from what the agent first proposed.
| Describe what the planner does between query and SQL | What the planner does | [ktx sl](/docs/cli-reference/ktx-sl) |
| Explain why **ktx** asks for grain and relationship types | The join graph | [Writing context](/docs/guides/writing-context) |
| Diagnose duplicated measures after a join | Fan-out and aggregate locality | [ktx sl](/docs/cli-reference/ktx-sl) |
| Describe how semantic context stays current | Building and maintaining the graph | [Context as code](/docs/concepts/context-as-code) |
| Describe how semantic context stays current | Building and maintaining the graph | [Reviewing Context](/docs/guides/reviewing-context) |

View file

@ -123,7 +123,7 @@ caveat stays anchored to the definition it explains.
<span className="font-medium text-fd-foreground">{"Behind the scenes. "}</span>
<strong className="font-medium text-fd-foreground">{"ktx"}</strong>
{" also keeps scan snapshots and a per-run event log locally so every committed change is traceable to its evidence. You don't read or edit these files yourself - see "}
<a href="/docs/concepts/context-as-code" className="font-medium underline">{"Context as Code"}</a>
<a href="/docs/guides/reviewing-context" className="font-medium underline">{"Reviewing Context"}</a>
{" for how that audit trail flows into review."}
</figcaption>
</figure>
@ -282,4 +282,4 @@ layers.
| Explain why a data agent wrote a plausible but wrong query | Database access isn't enough | [Writing Context](/docs/guides/writing-context) |
| Decide whether a fact belongs in YAML or Markdown | Semantic sources / Wiki pages | [Writing Context](/docs/guides/writing-context) |
| Compare **ktx** to another semantic layer | How ktx compares | [Primary Sources](/docs/integrations/primary-sources) |
| Explain reviewability and source of truth | A ktx project on disk | [Context as Code](/docs/concepts/context-as-code) |
| Explain reviewability and source of truth | A ktx project on disk | [Reviewing Context](/docs/guides/reviewing-context) |

View file

@ -277,4 +277,4 @@ stays in step with the semantic layer.
| Decide whether to add a `refs` or `sl_refs` entry | The page graph | [Writing Context](/docs/guides/writing-context) |
| Repair a wiki write rejected for missing references | Keeping the graph live | [Writing Context](/docs/guides/writing-context) |
| Describe how historic SQL becomes a wiki page | Where the pages come from | [Building Context](/docs/guides/building-context) |
| Explain raw-source provenance comments | Where the pages come from | [Context as Code](/docs/concepts/context-as-code) |
| Explain raw-source provenance comments | Where the pages come from | [Reviewing Context](/docs/guides/reviewing-context) |

View file

@ -1,5 +1,5 @@
{
"title": "Guides",
"defaultOpen": true,
"pages": ["building-context", "writing-context", "serving-agents", "llm-configuration"]
"pages": ["building-context", "writing-context", "reviewing-context", "serving-agents", "llm-configuration"]
}

View file

@ -0,0 +1,164 @@
---
title: Reviewing Context
description: Treat ktx changes like code - review what each ingest writes, fix what's wrong, and merge the rest.
---
import { ContextReviewLoop } from "@/components/context-review-loop";
When dbt put analytics transformations into git, it gave teams a way to argue
about SQL before it ran in production. **ktx** does the same thing for the layer
above transformations: metric definitions, joins, business rules, wiki pages,
and the decisions an ingest agent makes all land as files you can read, diff,
and merge.
This page covers the workflow:
- What `ktx ingest` writes to disk, and what it leaves alone.
- The branch-and-PR loop you use to ship those changes.
- The kinds of decisions you'll see in a diff.
- How analyst fixes flow back into the next ingest.
- How replay and provenance keep changes traceable.
## Why context belongs in git
A context layer that hides in a hosted UI is hard to audit. Agents write
plausible YAML; analysts write quiet overrides; nobody can tell what changed
between Tuesday and Wednesday. The fix is to put context where engineering
teams already argue about code.
| Without context as code | With **ktx** |
|--------|----------|
| Context lives in BI tools, chats, docs, and analyst memory | Context lives in YAML and Markdown next to the warehouse code |
| Agent changes appear without explanation | Agent changes appear as git diffs with provenance |
| Imports overwrite analyst judgment | Ingest reconciles new evidence with accepted files |
| History depends on tool logs | History lives in commits and ingest transcripts |
<ContextReviewLoop />
The loop closes on itself: every accepted edit becomes evidence the next ingest
must respect. That's what makes **ktx** different from a one-way sync - it
reads the layer before it writes to it.
## What's committed, what stays local
A **ktx** project keeps two surfaces under version control and one on disk for
runtime use. The split matters at review time: only the first two belong in a
PR, and the third is what you reach for when something looks off.
| Path | In git? | Purpose |
|------|---------|---------|
| `semantic-layer/<connection-id>/*.yaml` | Yes | Sources, joins, grain, measures, dimensions, and segments the compiler reads |
| `wiki/global/*.md` | Yes | Definitions, policies, caveats, and metric provenance agents search |
| `wiki/user/<user-id>/*.md` | Yes | Per-user scratch context that shadows global pages |
| `.ktx/ingest-transcripts/<job>/` | No - local | Tool calls, LLM responses, and write decisions for one run |
| `.ktx/ingest-evidence/<source>/<run>/` | No - local | Raw evidence snapshots used during reconciliation |
| `.ktx/ingest-report.json` | No - local | Per-run summary with work units, diff stats, and the head commit |
Commit only the YAML and Markdown. The `.ktx/` runtime state is for debugging
and replay; it belongs in `.gitignore`. If your team wants a record of *why* a
change happened, link the transcript path in the PR description rather than
committing the file.
## A typical review session
The loop above describes the shape. In practice, one review session looks like
this:
```bash
# 1. Run ingest on a branch
git checkout -b ingest/2026-05-21
ktx ingest --all
# 2. See what changed
git status --short
git diff -- semantic-layer wiki
# 3. Validate the semantic-layer changes against the warehouse
ktx sl validate orders --connection-id warehouse
# 4. Compile a representative query before agents do
ktx sl query \
--connection-id warehouse \
--measure orders.net_revenue \
--dimension orders.month \
--format sql
# 5. Open a PR, request review, merge when approved
```
Teams typically run interactive ingest during setup, then schedule
`ktx ingest --all --no-input` on a dedicated ingest branch once the
sources are stable. The PR template tends to mirror what you actually
look at in a diff:
- New sources match the warehouse, and their grain looks right.
- Joins have the correct relationship direction.
- Generated measures match business definitions.
- Wiki pages cite evidence and don't duplicate YAML.
- Nothing in `.ktx/` snuck into the commit.
## What changes ktx makes in a diff
Every line in a ktx diff is one of seven actions. The action is recorded in
`.ktx/ingest-report.json` and shows up in the agent's reasoning, so you can
trace any change back to the decision that produced it.
| Action | What it means | Where you see it in the diff |
|--------|---------------|------------------------------|
| `source_created` | A new table got a semantic source | New YAML file under `semantic-layer/<connection>/` |
| `measure_added` | A new measure on an existing source | New entry under `measures:` in an existing YAML |
| `join_added` | A new relationship between two sources | New entry under `joins:` |
| `merged` | Multiple candidates were reconciled into one | Updated YAML or wiki page with combined fields |
| `subsumed` | A duplicate was absorbed into an existing definition | One file removed; another updated |
| `wiki_written` | Business context got captured | New or updated `.md` file under `wiki/` |
| `skipped` | The candidate was already covered or out of scope | No file change; appears only in the report |
If a diff line surprises you, the action label is the fastest way to figure
out what the ingest agent thought it was doing.
## Feedback loops
The accepted state of `semantic-layer/` and `wiki/` is input to the next
ingest, not output. That makes corrections compound: a fix you ship today
becomes the baseline tomorrow.
| Signal | Example | Where it lands |
|--------|---------|----------------|
| Analyst correction | "Net revenue excludes test accounts" | `semantic-layer/**/*.yaml` |
| Business clarification | "ARR definition changed this quarter" | `wiki/**/*.md` |
| Agent query issue | A filter returns no rows unexpectedly | Wiki caveat or tighter source filter |
| Join problem | A path duplicates order-level measures | Updated `relationship` or `grain` metadata |
| Mid-stream note | "Onboarding fees don't count toward ARR" | `ktx ingest --text "..."` writes to `wiki/global/` |
Capture context as soon as it's said. The next ingest will treat it as
accepted truth.
## Replay and provenance
Every ingest writes a transcript next to the report. Together, they let you
walk back through any decision after the fact - useful both for debugging a
bad measure and for showing a stakeholder where a definition came from.
| Use case | What replay gives you |
|----------|-----------------------|
| Debugging | Trace a wrong source, join, or measure back to the evidence and tool calls that produced it |
| Trust | Show which YAML and Markdown lines came from which dbt model, dashboard, or query history sample |
| Reproducibility | Re-run the same evidence against a new model or config and compare diffs |
The artifacts live under `.ktx/ingest-transcripts/<jobId>/` and
`.ktx/ingest-evidence/<source>/<runId>/`. Don't commit them - link to them
from a PR or copy a span into a review comment when it explains a change.
## Agent usage notes
Use this page when an agent needs to explain review workflows, ingestion
diffs, how corrections feed back into the layer, or why **ktx** writes YAML and
Markdown instead of hiding context in a hosted service.
| Agent task | Relevant section | Next page |
|------------|------------------|-----------|
| Explain how generated context should be reviewed | A typical review session | [Building Context](/docs/guides/building-context) |
| Explain what a specific diff line means | What changes ktx makes in a diff | [Writing Context](/docs/guides/writing-context) |
| Diagnose why ingestion changed a semantic source | Replay and provenance | [ktx ingest](/docs/cli-reference/ktx-ingest) |
| Describe how context improves over time | Feedback loops | [Building Context](/docs/guides/building-context) |
| Tell a user what to commit | What's committed, what stays local | [Writing Context](/docs/guides/writing-context) |

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.