feat: README architecture diagrams + React Flow diagram studio (#245)

Replace the tall portrait README ingestion SVG with two landscape
diagrams — "1 · Ingestion" (build the context layer) and "2 · Serving"
(agents query it through MCP) — wired in as transparent 2x PNGs that
read on GitHub light and dark.

Add docs-site/diagram-studio: a static React Flow page with custom
themed nodes and the inlined ktx mascot that renders both diagrams and
exports them to PNG via html-to-image (the diagrams' reproducible
source). Remove the superseded ingestion-flow SVGs.
This commit is contained in:
Andrey Avtomonov 2026-06-01 12:06:27 +02:00 committed by GitHub
parent d320d54ab2
commit ba5bb92ab7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1147 additions and 211 deletions

View file

@ -34,9 +34,14 @@ business knowledge it builds and maintains for you.
> No extra usage billing from **ktx**.
<p align="center">
<img src="docs-site/public/images/ingestion-flow-transparent.svg" alt="ktx ingestion flow from source systems through validation to wiki and semantic-layer outputs" width="900" />
<img src="docs-site/public/images/ingestion-flow.png" alt="Ingestion: ktx ingests databases, BI tools, modeling code, and docs through its context engine (source connectors, context builder, reconciliation, validation) into wiki Markdown and semantic-layer YAML" width="900" />
</p>
<p align="center">
<img src="docs-site/public/images/mcp-runtime-flow.png" alt="Serving: an agent queries ktx through MCP, which searches the wiki and semantic layer, returns approved metrics, and compiles them into read-only SQL run against the warehouse" width="900" />
</p>
## Why ktx
General-purpose agents struggle on data tasks. They re-explore your warehouse

View file

@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { DiagramStudio } from "@/components/diagram-studio/studio";
export const metadata: Metadata = {
title: "Diagram studio",
robots: { index: false, follow: false },
};
export default function DiagramStudioPage() {
return <DiagramStudio />;
}

View file

@ -0,0 +1,328 @@
import { type Edge, MarkerType, type Node } from "@xyflow/react";
import { C } from "./nodes";
const EDGE_COLOR = "#b3bcc4";
const MARKER_COLOR = "#9aa6ad";
const labelStyle = {
fontFamily: "var(--font-inter), system-ui, sans-serif",
fontSize: 15,
fontWeight: 600,
fill: C.inkMuted,
};
const labelBgStyle = { fill: "#ffffff", stroke: C.chipBorder, strokeWidth: 1 };
const labelBg = {
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 6,
labelStyle,
labelBgStyle,
};
const marker = { type: MarkerType.ArrowClosed, color: MARKER_COLOR, width: 16, height: 16 };
const edgeStyle = { stroke: EDGE_COLOR, strokeWidth: 2 };
/* ============================== INGESTION =============================== */
const SRC_W = 300;
const SRC_H = 138;
const SRC_GAP = 24;
const srcY = (i: number) => i * (SRC_H + SRC_GAP);
export const ingestionNodes: Node[] = [
{
id: "title",
type: "title",
position: { x: 0, y: -96 },
data: {
width: 560,
eyebrow: "1 · Ingestion",
title: "ktx builds your context layer",
},
},
{
id: "db",
type: "card",
position: { x: 0, y: srcY(0) },
data: {
width: SRC_W,
height: SRC_H,
accent: C.teal,
rows: [
{ kind: "title", text: "Databases" },
{ kind: "desc", text: "Schemas, keys, query history" },
{ kind: "muted", text: "Postgres · Snowflake · BigQuery · …" },
],
handles: [{ side: "right", type: "source", id: "out" }],
},
},
{
id: "bi",
type: "card",
position: { x: 0, y: srcY(1) },
data: {
width: SRC_W,
height: SRC_H,
accent: C.orange,
rows: [
{ kind: "title", text: "BI tools" },
{ kind: "desc", text: "Dashboards, explores, usage" },
{ kind: "muted", text: "Metabase · Looker · …" },
],
handles: [{ side: "right", type: "source", id: "out" }],
},
},
{
id: "model",
type: "card",
position: { x: 0, y: srcY(2) },
data: {
width: SRC_W,
height: SRC_H,
accent: C.amber,
rows: [
{ kind: "title", text: "Modeling code" },
{ kind: "desc", text: "Metrics, models, joins, entities" },
{ kind: "muted", text: "dbt · LookML · MetricFlow · …" },
],
handles: [{ side: "right", type: "source", id: "out" }],
},
},
{
id: "docs",
type: "card",
position: { x: 0, y: srcY(3) },
data: {
width: SRC_W,
height: SRC_H,
accent: C.emerald,
rows: [
{ kind: "title", text: "Docs & notes" },
{ kind: "desc", text: "Policies, definitions, notes" },
{ kind: "muted", text: "Notion · any text · …" },
],
handles: [{ side: "right", type: "source", id: "out" }],
},
},
{
id: "engine",
type: "engine",
position: { x: 420, y: 52 },
data: {
width: 380,
height: 520,
steps: [
{ n: 1, title: "Source connectors", desc: "Read each source in its shape" },
{ n: 2, title: "Context builder", desc: "Evidence into proposed updates" },
{ n: 3, title: "Reconciliation", desc: "Merge with existing context" },
{ n: 4, title: "Validation", desc: "Check references & semantics" },
],
handles: [
{ side: "left", type: "target", id: "in" },
{ side: "right", type: "source", id: "out" },
],
},
},
{
id: "wiki",
type: "card",
position: { x: 900, y: 66 },
data: {
width: 320,
height: 220,
accent: C.emerald,
rows: [
{ kind: "mono", text: "wiki/*.md", color: C.emerald },
{ kind: "title", text: "Wiki" },
{ kind: "chips", items: ["free-form", "auto-maintained"] },
{ kind: "desc", text: "Definitions, caveats, policies," },
{ kind: "desc", text: "and notes agents can search." },
],
handles: [{ side: "left", type: "target", id: "in" }],
},
},
{
id: "sl",
type: "card",
position: { x: 900, y: 338 },
data: {
width: 320,
height: 220,
accent: C.teal,
rows: [
{ kind: "mono", text: "semantic-layer/*.yaml", color: C.teal },
{ kind: "title", text: "Semantic layer" },
{ kind: "chips", items: ["executable", "auto-maintained"] },
{ kind: "desc", text: "Metrics, joins, dimensions, and" },
{ kind: "desc", text: "filters ktx compiles into SQL." },
],
handles: [{ side: "left", type: "target", id: "in" }],
},
},
];
const ingestEdge = (source: string, target: string): Edge => ({
id: `${source}-${target}`,
source,
target,
sourceHandle: "out",
targetHandle: "in",
type: "default",
style: edgeStyle,
markerEnd: marker,
});
export const ingestionEdges: Edge[] = [
ingestEdge("db", "engine"),
ingestEdge("bi", "engine"),
ingestEdge("model", "engine"),
ingestEdge("docs", "engine"),
ingestEdge("engine", "wiki"),
ingestEdge("engine", "sl"),
];
/* =============================== RUNTIME ================================ */
export const runtimeNodes: Node[] = [
{
id: "title",
type: "title",
position: { x: 0, y: -84 },
data: {
width: 560,
eyebrow: "2 · Serving",
title: "agents query it through MCP",
},
},
{
id: "agent",
type: "card",
position: { x: 0, y: 115 },
data: {
width: 280,
height: 190,
accent: C.neutral,
align: "center",
rows: [
{ kind: "title", text: "Your agent" },
{ kind: "muted", text: "Claude Code · Cursor" },
{ kind: "muted", text: "Codex · OpenCode" },
],
handles: [
{ side: "right", type: "source", id: "ask", top: "42%" },
{ side: "right", type: "target", id: "answer", top: "62%" },
],
},
},
{
id: "hub",
type: "hub",
position: { x: 420, y: 85 },
data: {
width: 360,
height: 250,
rows: [
"Search wiki + semantic layer",
"Return approved metrics",
"Compile metrics → SQL",
],
handles: [
{ side: "left", type: "target", id: "ask", top: "42%" },
{ side: "left", type: "source", id: "answer", top: "62%" },
{ side: "right", type: "source", id: "to-context", top: "30%" },
{ side: "right", type: "source", id: "to-warehouse", top: "72%" },
],
},
},
{
id: "context",
type: "card",
position: { x: 920, y: 15 },
data: {
width: 300,
height: 150,
accent: C.teal,
rows: [
{ kind: "title", text: "Context layer" },
{ kind: "mono", text: "wiki/*.md", color: C.emerald },
{ kind: "mono", text: "semantic-layer/*.yaml", color: C.teal },
],
handles: [{ side: "left", type: "target", id: "in" }],
},
},
{
id: "warehouse",
type: "card",
position: { x: 920, y: 255 },
data: {
width: 300,
height: 150,
accent: C.slate,
rows: [
{ kind: "title", text: "Warehouse" },
{
kind: "badge",
text: "read-only",
bg: "#ecf6f8",
border: "#bfe3ea",
color: C.teal,
},
{ kind: "desc", text: "Runs the compiled SQL" },
],
handles: [{ side: "left", type: "target", id: "in" }],
},
},
];
export const runtimeEdges: Edge[] = [
{
id: "ask",
source: "agent",
sourceHandle: "ask",
target: "hub",
targetHandle: "ask",
type: "default",
label: "ask",
...labelBg,
style: edgeStyle,
markerEnd: marker,
},
{
id: "answer",
source: "hub",
sourceHandle: "answer",
target: "agent",
targetHandle: "answer",
type: "default",
label: "answer",
...labelBg,
style: edgeStyle,
markerEnd: marker,
},
{
id: "search",
source: "hub",
sourceHandle: "to-context",
target: "context",
targetHandle: "in",
type: "default",
label: "search",
...labelBg,
style: edgeStyle,
markerStart: marker,
markerEnd: marker,
},
{
id: "readonly",
source: "hub",
sourceHandle: "to-warehouse",
target: "warehouse",
targetHandle: "in",
type: "default",
label: "read-only",
...labelBg,
style: edgeStyle,
markerStart: marker,
markerEnd: marker,
},
];

View file

@ -0,0 +1,57 @@
/**
* Inlined ktx mascot, ported from assets/ktx-mascot.svg.
*
* - `light` renders the dark-bodied mascot for light surfaces.
* - `dark` renders the cream-bodied mascot for dark surfaces (e.g. the ktx
* hub panel), mirroring brand/ktx-mascot-dark.svg.
*/
export function KtxMascot({
variant = "light",
size = 56,
}: {
variant?: "light" | "dark";
size?: number;
}) {
const body = variant === "dark" ? "#F5F1EA" : "#1B3139";
const eye = variant === "dark" ? "#1B3139" : "#F5F1EA";
return (
<svg
viewBox="0 0 200 200"
width={size}
height={size}
role="img"
aria-label="ktx mascot"
>
<g fill="none" stroke={body} strokeWidth="16" strokeLinecap="round">
<path d="M 62 110 Q 32 130 44 152" />
<path d="M 88 116 Q 80 152 70 174" />
<path d="M 112 116 Q 120 152 130 174" />
</g>
<path
d="M 134 108 C 162 116, 172 96, 162 78 C 154 64, 168 56, 178 60"
fill="none"
stroke="#FF8A4C"
strokeWidth="16"
strokeLinecap="round"
/>
<path
d="M 48 102 C 48 56, 78 30, 100 30 C 122 30, 152 56, 152 102 C 152 116, 132 120, 100 120 C 68 120, 48 116, 48 102 Z"
fill={body}
/>
<path
d="M 80 84 Q 86 77 92 84"
fill="none"
stroke={eye}
strokeWidth="3.5"
strokeLinecap="round"
/>
<path
d="M 108 84 Q 114 77 120 84"
fill="none"
stroke={eye}
strokeWidth="3.5"
strokeLinecap="round"
/>
</svg>
);
}

View file

@ -0,0 +1,493 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { KtxMascot } from "./mascot";
/** Fixed palette mirrored from the approved SVG diagrams so the exported PNG
* is theme-independent (one image that reads on light and dark GitHub). */
export const C = {
ink: "#1b1b18",
inkSoft: "#57534e",
inkMuted: "#8c857f",
cardBorder: "#e2dfd9",
engineBg: "#15323a",
engineBorder: "#23474f",
cyan: "#55dced",
stepNum: "#06262c",
stepTitle: "#f3f1ec",
stepDesc: "#9fb6bc",
hubRow: "#eef4f5",
chipBg: "#faf9f6",
chipBorder: "#e7e5e4",
teal: "#0e7490",
emerald: "#059669",
orange: "#f97316",
amber: "#d97706",
slate: "#334155",
neutral: "#94a3b8",
} as const;
const DISPLAY = "var(--font-display), system-ui, sans-serif";
const BODY = "var(--font-inter), system-ui, sans-serif";
const MONO = "var(--font-mono), ui-monospace, monospace";
const CARD_SHADOW = "0 3px 12px rgba(27, 49, 57, 0.10)";
const ENGINE_SHADOW = "0 6px 22px rgba(2, 12, 15, 0.30)";
/** ktx logo mascot size, shared by the engine and hub headers. */
const LOGO_SIZE = 56;
type HandleSpec = {
side: "left" | "right";
type: "source" | "target";
id: string;
top?: string;
};
function Handles({ specs }: { specs?: HandleSpec[] }) {
if (!specs) return null;
return (
<>
{specs.map((h) => (
<Handle
key={`${h.type}-${h.id}`}
id={h.id}
type={h.type}
position={h.side === "left" ? Position.Left : Position.Right}
isConnectable={false}
style={{
opacity: 0,
border: 0,
background: "transparent",
...(h.top ? { top: h.top } : {}),
}}
/>
))}
</>
);
}
/* ------------------------------- Card node ------------------------------- */
type CardRow =
| { kind: "title"; text: string }
| { kind: "mono"; text: string; color: string }
| { kind: "desc"; text: string }
| { kind: "muted"; text: string }
| { kind: "chips"; items: string[] }
| { kind: "badge"; text: string; bg: string; border: string; color: string };
type CardData = {
width: number;
height: number;
accent: string;
align?: "center";
rows: CardRow[];
handles?: HandleSpec[];
};
function gapFor(kind: CardRow["kind"], prev?: CardRow["kind"]): number {
if (!prev) return 0;
if (kind === "desc" && prev === "desc") return 3;
if (kind === "mono" && prev === "mono") return 2;
if (kind === "title") return 6;
return 10;
}
function CardRowView({ row }: { row: CardRow }) {
switch (row.kind) {
case "title":
return (
<span
style={{
fontFamily: DISPLAY,
fontWeight: 700,
fontSize: 26,
lineHeight: 1.15,
color: C.ink,
}}
>
{row.text}
</span>
);
case "mono":
return (
<span
style={{
fontFamily: MONO,
fontWeight: 700,
fontSize: 18,
lineHeight: 1.4,
color: row.color,
}}
>
{row.text}
</span>
);
case "desc":
return (
<span
style={{
fontFamily: BODY,
fontWeight: 500,
fontSize: 17,
lineHeight: 1.45,
color: C.inkSoft,
}}
>
{row.text}
</span>
);
case "muted":
return (
<span
style={{
fontFamily: BODY,
fontWeight: 500,
fontSize: 14,
lineHeight: 1.4,
color: C.inkMuted,
}}
>
{row.text}
</span>
);
case "chips":
return (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{row.items.map((c) => (
<span
key={c}
style={{
fontFamily: BODY,
fontWeight: 600,
fontSize: 14,
color: C.inkSoft,
background: C.chipBg,
border: `1px solid ${C.chipBorder}`,
borderRadius: 6,
padding: "4px 10px",
}}
>
{c}
</span>
))}
</div>
);
case "badge":
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
borderRadius: 14,
padding: "3px 12px",
fontFamily: BODY,
fontWeight: 700,
fontSize: 14,
background: row.bg,
border: `1px solid ${row.border}`,
color: row.color,
}}
>
{row.text}
</span>
);
}
}
function CardNode({ data }: NodeProps<Node<CardData>>) {
const center = data.align === "center";
return (
<div
style={{
width: data.width,
height: data.height,
position: "relative",
background: "#ffffff",
border: `1px solid ${C.cardBorder}`,
borderRadius: 10,
boxShadow: CARD_SHADOW,
padding: "18px 20px",
display: "flex",
flexDirection: "column",
alignItems: center ? "center" : "flex-start",
justifyContent: center ? "center" : "flex-start",
textAlign: center ? "center" : "left",
overflow: "hidden",
}}
>
<span
style={{
position: "absolute",
top: 0,
left: 2,
right: 2,
height: 4,
borderRadius: 2,
background: data.accent,
}}
/>
<Handles specs={data.handles} />
{data.rows.map((row, i) => (
<div
key={i}
style={{ marginTop: gapFor(row.kind, data.rows[i - 1]?.kind) }}
>
<CardRowView row={row} />
</div>
))}
</div>
);
}
/* ------------------------------ Engine node ------------------------------ */
type EngineStep = { n: number; title: string; desc: string };
type EngineData = {
width: number;
height: number;
steps: EngineStep[];
handles?: HandleSpec[];
};
function EngineNode({ data }: NodeProps<Node<EngineData>>) {
return (
<div
style={{
width: data.width,
height: data.height,
position: "relative",
background: C.engineBg,
border: `1px solid ${C.engineBorder}`,
borderRadius: 14,
boxShadow: ENGINE_SHADOW,
padding: "24px 24px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<span
style={{
position: "absolute",
top: 0,
left: 2,
right: 2,
height: 4,
borderRadius: 2,
background: C.cyan,
}}
/>
<Handles specs={data.handles} />
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<KtxMascot variant="dark" size={LOGO_SIZE} />
<span
style={{
fontFamily: DISPLAY,
fontWeight: 700,
fontSize: 30,
color: C.stepTitle,
}}
>
ktx
</span>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
justifyContent: "space-around",
marginTop: 6,
}}
>
{data.steps.map((s) => (
<div
key={s.n}
style={{ display: "flex", alignItems: "center", gap: 18 }}
>
<span
style={{
flex: "none",
width: 44,
height: 44,
borderRadius: "50%",
background: C.cyan,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: DISPLAY,
fontWeight: 800,
fontSize: 22,
color: C.stepNum,
}}
>
{s.n}
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<span
style={{
fontFamily: DISPLAY,
fontWeight: 700,
fontSize: 24,
lineHeight: 1.1,
color: C.stepTitle,
}}
>
{s.title}
</span>
<span
style={{
fontFamily: BODY,
fontWeight: 500,
fontSize: 16,
lineHeight: 1.3,
color: C.stepDesc,
}}
>
{s.desc}
</span>
</div>
</div>
))}
</div>
</div>
);
}
/* -------------------------------- Hub node ------------------------------- */
type HubData = {
width: number;
height: number;
rows: string[];
handles?: HandleSpec[];
};
function HubNode({ data }: NodeProps<Node<HubData>>) {
return (
<div
style={{
width: data.width,
height: data.height,
position: "relative",
background: C.engineBg,
border: `1px solid ${C.engineBorder}`,
borderRadius: 14,
boxShadow: ENGINE_SHADOW,
padding: "24px 24px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<span
style={{
position: "absolute",
top: 0,
left: 2,
right: 2,
height: 4,
borderRadius: 2,
background: C.cyan,
}}
/>
<Handles specs={data.handles} />
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<KtxMascot variant="dark" size={LOGO_SIZE} />
<span
style={{
fontFamily: DISPLAY,
fontWeight: 700,
fontSize: 30,
color: C.stepTitle,
}}
>
ktx
</span>
</div>
<div
style={{
marginTop: 22,
display: "flex",
flexDirection: "column",
gap: 18,
}}
>
{data.rows.map((r) => (
<div key={r} style={{ display: "flex", alignItems: "center", gap: 14 }}>
<span
style={{
flex: "none",
width: 10,
height: 10,
borderRadius: "50%",
background: C.cyan,
}}
/>
<span
style={{
fontFamily: BODY,
fontWeight: 600,
fontSize: 19,
color: C.hubRow,
}}
>
{r}
</span>
</div>
))}
</div>
</div>
);
}
/* ------------------------------- Title node ------------------------------ */
type TitleData = { width: number; eyebrow: string; title: string };
function TitleNode({ data }: NodeProps<Node<TitleData>>) {
return (
<div
style={{
width: data.width,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<span
style={{
fontFamily: BODY,
fontSize: 19,
fontWeight: 800,
letterSpacing: 2,
textTransform: "uppercase",
color: C.teal,
}}
>
{data.eyebrow}
</span>
<span
style={{
fontFamily: DISPLAY,
fontSize: 24,
fontWeight: 600,
color: C.inkMuted,
}}
>
{data.title}
</span>
</div>
);
}
export const nodeTypes = {
card: CardNode,
engine: EngineNode,
hub: HubNode,
title: TitleNode,
};

View file

@ -0,0 +1,242 @@
"use client";
import "@xyflow/react/dist/style.css";
import { useCallback, useRef, useState } from "react";
import {
Background,
BackgroundVariant,
type Edge,
getNodesBounds,
type Node,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
} from "@xyflow/react";
import { toPng } from "html-to-image";
import {
ingestionEdges,
ingestionNodes,
runtimeEdges,
runtimeNodes,
} from "./flows";
import { nodeTypes } from "./nodes";
const EXPORT_PADDING = 48;
const EXPORT_PIXEL_RATIO = 2;
function DiagramCanvasInner({
initialNodes,
initialEdges,
fileName,
height,
dark,
}: {
initialNodes: Node[];
initialEdges: Edge[];
fileName: string;
height: number;
dark: boolean;
}) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [nodes, , onNodesChange] = useNodesState(initialNodes);
const [edges, , onEdgesChange] = useEdgesState(initialEdges);
const { getNodes } = useReactFlow();
const [busy, setBusy] = useState(false);
const download = useCallback(async () => {
const viewport = wrapperRef.current?.querySelector<HTMLElement>(
".react-flow__viewport",
);
if (!viewport) return;
setBusy(true);
try {
await document.fonts.ready;
const bounds = getNodesBounds(getNodes());
const outW = Math.ceil(bounds.width + EXPORT_PADDING * 2);
const outH = Math.ceil(bounds.height + EXPORT_PADDING * 2);
const tx = EXPORT_PADDING - bounds.x;
const ty = EXPORT_PADDING - bounds.y;
const dataUrl = await toPng(viewport, {
width: outW,
height: outH,
pixelRatio: EXPORT_PIXEL_RATIO,
// transparent background so one PNG works on light and dark GitHub
style: {
width: `${outW}px`,
height: `${outH}px`,
transform: `translate(${tx}px, ${ty}px) scale(1)`,
},
});
const link = document.createElement("a");
link.download = fileName;
link.href = dataUrl;
link.click();
} finally {
setBusy(false);
}
}, [fileName, getNodes]);
return (
<div>
<div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
<button
type="button"
onClick={download}
disabled={busy}
style={btnStyle(busy)}
>
{busy ? "Exporting…" : "Download PNG"}
</button>
</div>
<div
ref={wrapperRef}
style={{
height,
borderRadius: 12,
border: "1px solid rgba(127,127,127,0.2)",
background: dark ? "#0d1117" : "#ffffff",
}}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
fitView
fitViewOptions={{ padding: 0.08 }}
nodesDraggable={false}
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
panOnDrag={false}
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
preventScrolling={false}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color={dark ? "#1f2a30" : "#e6e2db"}
/>
</ReactFlow>
</div>
</div>
);
}
function btnStyle(disabled: boolean): React.CSSProperties {
return {
fontFamily: "var(--font-inter), system-ui, sans-serif",
fontSize: 13,
fontWeight: 600,
padding: "7px 14px",
borderRadius: 8,
border: "1px solid #0e7490",
background: disabled ? "#9bbdc6" : "#0e7490",
color: "#ffffff",
cursor: disabled ? "default" : "pointer",
};
}
function DiagramCanvas(props: {
initialNodes: Node[];
initialEdges: Edge[];
fileName: string;
height: number;
dark: boolean;
}) {
return (
<ReactFlowProvider>
<DiagramCanvasInner {...props} />
</ReactFlowProvider>
);
}
export function DiagramStudio() {
const [dark, setDark] = useState(false);
return (
<main
style={{
maxWidth: 1320,
margin: "0 auto",
padding: "32px 24px 80px",
fontFamily: "var(--font-inter), system-ui, sans-serif",
}}
>
<header style={{ marginBottom: 24 }}>
<h1
style={{
fontFamily: "var(--font-display), system-ui, sans-serif",
fontSize: 30,
fontWeight: 700,
color: "#1b1b18",
margin: 0,
}}
>
ktx diagram studio
</h1>
<p style={{ color: "#6b6560", marginTop: 6, fontSize: 15 }}>
Static diagrams. Export is a transparent 2× PNG framed to the node
bounds the dark-background toggle is only for previewing.
</p>
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
marginTop: 12,
fontSize: 14,
color: "#57534e",
}}
>
<input
type="checkbox"
checked={dark}
onChange={(e) => setDark(e.target.checked)}
/>
Preview on dark background
</label>
</header>
<section style={{ marginBottom: 40 }}>
<h2 style={sectionTitle}>1 · Ingestion building the context layer</h2>
<DiagramCanvas
initialNodes={ingestionNodes}
initialEdges={ingestionEdges}
fileName="ingestion-flow.png"
height={560}
dark={dark}
/>
</section>
<section>
<h2 style={sectionTitle}>2 · Serving answering agents at runtime</h2>
<DiagramCanvas
initialNodes={runtimeNodes}
initialEdges={runtimeEdges}
fileName="mcp-runtime-flow.png"
height={480}
dark={dark}
/>
</section>
</main>
);
}
const sectionTitle: React.CSSProperties = {
fontFamily: "var(--font-display), system-ui, sans-serif",
fontSize: 18,
fontWeight: 600,
color: "#1b1b18",
marginBottom: 12,
};

View file

@ -14,6 +14,7 @@
"fumadocs-core": "16.8.10",
"fumadocs-mdx": "15.0.7",
"fumadocs-ui": "16.8.10",
"html-to-image": "1.11.11",
"next": "^16",
"react": "19.2.6",
"react-dom": "19.2.6"

View file

@ -1,210 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1346" height="1710" viewBox="0 0 1346 1710" role="img" aria-labelledby="title desc">
<title id="title">ktx ingestion flow</title>
<desc id="desc">Source systems flow through source connectors, context builder, reconciliation, and validation to create wiki Markdown and semantic-layer YAML outputs.</desc>
<defs>
<filter id="card-shadow" x="-12%" y="-12%" width="124%" height="124%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#0f172a" flood-opacity="0.14"/>
</filter>
<filter id="dark-shadow" x="-12%" y="-12%" width="124%" height="124%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#020617" flood-opacity="0.22"/>
</filter>
<filter id="glow-blue" x="-160%" y="-160%" width="420%" height="420%">
<feGaussianBlur stdDeviation="7" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<marker id="arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#94a3b8"/>
</marker>
<style>
.card { fill: #ffffff; stroke: #e2e8f0; stroke-width: 1.4; filter: url(#card-shadow); }
.stage { fill: #0b1f23; stroke: #17343a; stroke-width: 1.2; filter: url(#dark-shadow); }
.title { fill: #24272d; font: 700 28px Inter, Arial, sans-serif; }
.body { fill: #666b73; font: 500 18px Inter, Arial, sans-serif; }
.tag { fill: #6b7280; font: 500 16px Inter, Arial, sans-serif; }
.mono { font: 700 20px "SFMono-Regular", Consolas, monospace; }
.stage-title { fill: #f8fafc; font: 700 28px Inter, Arial, sans-serif; }
.stage-body { fill: #b8c6ca; font: 500 20px Inter, Arial, sans-serif; }
.index { fill: #07313a; font: 700 22px Inter, Arial, sans-serif; text-anchor: middle; dominant-baseline: middle; }
.edge { fill: none; stroke: #94a3b8; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.dash { fill: none; stroke: #64748b; stroke-width: 1.8; stroke-dasharray: 5 8; stroke-linecap: round; }
</style>
</defs>
<g id="source-cards">
<g transform="translate(24 39)">
<rect class="card" x="0" y="0" width="298" height="285" rx="4"/>
<rect x="0" y="0" width="298" height="4" rx="2" fill="#3b82f6"/>
<text class="title" x="22" y="52">Databases</text>
<text class="body" x="22" y="92">Schemas, columns, keys,</text>
<text class="body" x="22" y="120">row counts, and query</text>
<text class="body" x="22" y="148">history.</text>
<g transform="translate(22 180)">
<rect x="0" y="0" width="112" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="24">PostgreSQL</text>
<rect x="120" y="0" width="100" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="132" y="24">Snowflake</text>
<rect x="0" y="46" width="92" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="70">BigQuery</text>
<rect x="100" y="46" width="74" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="112" y="70">SQLite</text>
</g>
</g>
<g transform="translate(358 39)">
<rect class="card" x="0" y="0" width="298" height="285" rx="4"/>
<rect x="0" y="0" width="298" height="4" rx="2" fill="#f97316"/>
<text class="title" x="22" y="52">BI tools</text>
<text class="body" x="22" y="92">Dashboards, questions,</text>
<text class="body" x="22" y="120">explores, usage, and trusted</text>
<text class="body" x="22" y="148">examples.</text>
<g transform="translate(22 180)">
<rect x="0" y="0" width="96" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="24">Metabase</text>
<rect x="104" y="0" width="74" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="116" y="24">Looker</text>
</g>
</g>
<g transform="translate(692 39)">
<rect class="card" x="0" y="0" width="298" height="285" rx="4"/>
<rect x="0" y="0" width="298" height="4" rx="2" fill="#f59e0b"/>
<text class="title" x="22" y="52">Modeling code</text>
<text class="body" x="22" y="92">Existing metrics, dimensions,</text>
<text class="body" x="22" y="120">models, joins, and entities.</text>
<g transform="translate(22 152)">
<rect x="0" y="0" width="48" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="24">dbt</text>
<rect x="56" y="0" width="82" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="68" y="24">LookML</text>
<rect x="0" y="46" width="102" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="70">MetricFlow</text>
</g>
</g>
<g transform="translate(1026 39)">
<rect class="card" x="0" y="0" width="298" height="285" rx="4"/>
<rect x="0" y="0" width="298" height="4" rx="2" fill="#10b981"/>
<text class="title" x="22" y="52">Docs and notes</text>
<text class="body" x="22" y="92">Policies, caveats, team</text>
<text class="body" x="22" y="120">definitions, and analyst</text>
<text class="body" x="22" y="148">context.</text>
<g transform="translate(22 180)">
<rect x="0" y="0" width="72" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="24">Notion</text>
<rect x="80" y="0" width="84" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="92" y="24">Any text</text>
</g>
</g>
</g>
<g id="edges">
<path class="edge" d="M172 324 V380 Q172 394 186 394 H507 Q507 394 507 380 V324"/>
<path class="edge" d="M841 324 V380 Q841 394 827 394 H507"/>
<path class="edge" d="M1175 324 V380 Q1175 394 1161 394 H673 Q673 394 673 408 V433" marker-end="url(#arrow)"/>
<path class="edge" d="M507 394 H673"/>
<path class="edge" d="M673 618 V651" marker-end="url(#arrow)"/>
<path class="edge" d="M673 833 V866" marker-end="url(#arrow)"/>
<path class="edge" d="M673 1048 V1081" marker-end="url(#arrow)"/>
<path class="edge" d="M673 1262 V1310 Q673 1325 656 1325 H305 Q291 1325 291 1339 V1364" marker-end="url(#arrow)"/>
<path class="edge" d="M673 1262 V1310 Q673 1325 690 1325 H1043 Q1057 1325 1057 1339 V1364" marker-end="url(#arrow)"/>
<path class="dash" d="M546 1523 H800"/>
<path d="M546 1523 l9 -6 v12 z" fill="#64748b"/>
<path d="M800 1523 l-9 -6 v12 z" fill="#64748b"/>
</g>
<g id="particles">
<circle cx="256" cy="394" r="18" fill="#3b82f6" opacity="0.18" filter="url(#glow-blue)"/>
<circle cx="256" cy="394" r="6" fill="#3b82f6" opacity="0.9"/>
<circle cx="632" cy="394" r="18" fill="#f97316" opacity="0.18" filter="url(#glow-blue)"/>
<circle cx="632" cy="394" r="6" fill="#f97316" opacity="0.9"/>
<circle cx="830" cy="394" r="18" fill="#10b981" opacity="0.18" filter="url(#glow-blue)"/>
<circle cx="830" cy="394" r="6" fill="#10b981" opacity="0.9"/>
<circle cx="673" cy="635" r="17" fill="#10b981" opacity="0.18" filter="url(#glow-blue)"/>
<circle cx="673" cy="635" r="6" fill="#10b981" opacity="0.9"/>
<circle cx="673" cy="1065" r="17" fill="#f59e0b" opacity="0.18" filter="url(#glow-blue)"/>
<circle cx="673" cy="1065" r="6" fill="#f59e0b" opacity="0.9"/>
<circle cx="573" cy="1322" r="17" fill="#3b82f6" opacity="0.18" filter="url(#glow-blue)"/>
<circle cx="573" cy="1322" r="6" fill="#3b82f6" opacity="0.9"/>
</g>
<g id="stages">
<g transform="translate(464 438)">
<rect class="stage" x="0" y="0" width="420" height="180" rx="4"/>
<circle cx="52" cy="90" r="26" fill="#55dced"/>
<text class="index" x="52" y="90">1</text>
<text class="stage-title" x="98" y="72">Source connectors</text>
<text class="stage-body" x="98" y="110">Read each configured system in</text>
<text class="stage-body" x="98" y="140">its native shape.</text>
</g>
<g transform="translate(464 653)">
<rect class="stage" x="0" y="0" width="420" height="180" rx="4"/>
<circle cx="52" cy="90" r="26" fill="#55dced"/>
<text class="index" x="52" y="90">2</text>
<text class="stage-title" x="98" y="72">Context builder</text>
<text class="stage-body" x="98" y="110">Turn source evidence into</text>
<text class="stage-body" x="98" y="140">proposed context updates.</text>
</g>
<g transform="translate(464 868)">
<rect class="stage" x="0" y="0" width="420" height="180" rx="4"/>
<circle cx="52" cy="90" r="26" fill="#55dced"/>
<text class="index" x="52" y="90">3</text>
<text class="stage-title" x="98" y="72">Reconciliation</text>
<text class="stage-body" x="98" y="110">Merge new evidence with the</text>
<text class="stage-body" x="98" y="140">context that already exists.</text>
</g>
<g transform="translate(464 1082)">
<rect class="stage" x="0" y="0" width="420" height="180" rx="4"/>
<circle cx="52" cy="90" r="26" fill="#55dced"/>
<text class="index" x="52" y="90">4</text>
<text class="stage-title" x="98" y="72">Validation</text>
<text class="stage-body" x="98" y="110">Check references and semantics</text>
<text class="stage-body" x="98" y="140">before agents rely on them.</text>
</g>
</g>
<g id="outputs">
<g transform="translate(60 1373)">
<rect class="card" x="0" y="0" width="485" height="329" rx="4"/>
<rect x="0" y="0" width="485" height="4" rx="2" fill="#10b981"/>
<text class="mono" x="24" y="52" fill="#10b981">wiki/*.md</text>
<text class="title" x="24" y="100">Wiki</text>
<g transform="translate(24 122)">
<rect x="0" y="0" width="90" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="24">free-form</text>
<rect x="98" y="0" width="140" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="110" y="24">auto-maintained</text>
</g>
<text class="body" x="24" y="194">Definitions, caveats, policies, analyst notes, and</text>
<text class="body" x="24" y="222">business language that agents can search.</text>
</g>
<g transform="translate(803 1373)">
<rect class="card" x="0" y="0" width="485" height="329" rx="4"/>
<rect x="0" y="0" width="485" height="4" rx="2" fill="#3b82f6"/>
<text class="mono" x="24" y="52" fill="#3b82f6">semantic-layer/*.yaml</text>
<text class="title" x="24" y="100">Semantic layer</text>
<g transform="translate(24 122)">
<rect x="0" y="0" width="96" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="12" y="24">structured</text>
<rect x="104" y="0" width="104" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="116" y="24">executable</text>
<rect x="216" y="0" width="140" height="36" rx="4" fill="#fbfaf8" stroke="#e5e1dc"/>
<text class="tag" x="228" y="24">auto-maintained</text>
</g>
<text class="body" x="24" y="194">Metrics, joins, tables, dimensions, filters, and</text>
<text class="body" x="24" y="222">segments that ktx can validate and compile into</text>
<text class="body" x="24" y="250">SQL.</text>
</g>
<g transform="translate(622 1505)">
<rect x="0" y="0" width="102" height="36" rx="4" fill="#ffffff" stroke="#e5e1dc"/>
<text class="tag" x="13" y="24">references</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

8
pnpm-lock.yaml generated
View file

@ -85,6 +85,9 @@ importers:
fumadocs-ui:
specifier: 16.8.10
version: 16.8.10(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.8.10(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.16.0(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0)
html-to-image:
specifier: 1.11.11
version: 1.11.11
next:
specifier: ^16
version: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@ -3722,6 +3725,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-to-image@1.11.11:
resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@ -9623,6 +9629,8 @@ snapshots:
html-escaper@2.0.2: {}
html-to-image@1.11.11: {}
html-void-elements@3.0.0: {}
http-errors@2.0.1: