use anyhow::Result;
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Deserialize)]
struct SchemaField {
#[serde(rename = "type")]
field_type: String,
required: bool,
}
#[derive(Deserialize)]
struct Schema {
enums: BTreeMap<String, Vec<String>>,
types: BTreeMap<String, BTreeMap<String, SchemaField>>,
components: BTreeMap<String, BTreeMap<String, SchemaField>>,
}
fn load_schema() -> Schema {
serde_json::from_str(include_str!("../schema/components.json")).expect("invalid schema JSON")
}
pub fn emit_typescript() -> Result<()> {
print!("{}", generate_typescript());
Ok(())
}
fn component_defs() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
let schema = load_schema();
schema
.components
.into_iter()
.map(|(tag, fields)| {
let tag: &'static str = Box::leak(tag.into_boxed_str());
let fields: Vec<(&'static str, &'static str)> = fields
.into_iter()
.map(|(name, field)| {
let name_str = if field.required {
name
} else {
format!("{}?", name)
};
let name: &'static str = Box::leak(name_str.into_boxed_str());
let ftype: &'static str = Box::leak(field.field_type.into_boxed_str());
(name, ftype)
})
.collect();
(tag, fields)
})
.collect()
}
fn generate_typescript() -> String {
let mut out = String::with_capacity(8192);
out.push_str("// Auto-generated by `kazam sdk emit` — do not edit\n\n");
let schema = load_schema();
for (name, variants) in &schema.enums {
let variant_refs: Vec<&str> = variants.iter().map(|s| s.as_str()).collect();
emit_enum(&mut out, name, &variant_refs);
}
for (name, fields) in &schema.types {
let field_list: Vec<(String, String)> = fields
.iter()
.map(|(fname, field)| {
let key = if field.required {
fname.clone()
} else {
format!("{}?", fname)
};
(key, field.field_type.clone())
})
.collect();
let field_refs: Vec<(&str, &str)> = field_list
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
emit_interface(&mut out, name, &field_refs);
}
out.push_str("export type Component =\n");
let components = component_defs();
for (i, (tag, fields)) in components.iter().enumerate() {
out.push_str(" | { type: \"");
out.push_str(tag);
out.push('"');
for (name, ts_type) in fields {
out.push_str("; ");
out.push_str(name);
out.push_str(": ");
out.push_str(ts_type);
}
out.push_str(" }");
if i < components.len() - 1 {
out.push('\n');
}
}
out.push_str(";\n\n");
emit_interface(
&mut out,
"SourceOfTruth",
&[("label", "string"), ("href?", "string")],
);
emit_interface(
&mut out,
"RefreshStep",
&[
("run?", "string"),
("prompt?", "string"),
("review?", "string"),
],
);
emit_interface(
&mut out,
"RefreshConfig",
&[("mode?", "RefreshMode"), ("steps?", "RefreshStep[]")],
);
emit_interface(
&mut out,
"Freshness",
&[
("updated?", "string"),
("review_every?", "string"),
("owner?", "string"),
("sources_of_truth?", "(string | SourceOfTruth)[]"),
("expires?", "string"),
("refresh?", "string | RefreshConfig"),
],
);
emit_interface(
&mut out,
"Reference",
&[("url", "string"), ("note?", "string")],
);
emit_interface(
&mut out,
"NavLink",
&[
("label", "string"),
("href?", "string"),
("children?", "NavLink[]"),
("personas?", "string[]"),
("collapsed?", "boolean"),
],
);
emit_interface(
&mut out,
"Voice",
&[
("tone?", "string"),
("reading_level?", "string"),
(
"terminology?",
"{ prefer?: Record<string, string>; avoid?: string[] }",
),
],
);
emit_interface(
&mut out,
"Role",
&[
("id", "string"),
("label", "string"),
("description?", "string"),
("icon?", "string"),
("href?", "string"),
],
);
emit_interface(
&mut out,
"DriftRepo",
&[("prefix", "string"), ("local?", "string")],
);
emit_interface(&mut out, "DriftConfig", &[("repos", "DriftRepo[]")]);
out.push_str("export interface Page {\n");
out.push_str(" title: string;\n");
out.push_str(" shell: Shell;\n");
out.push_str(" eyebrow?: string;\n");
out.push_str(" subtitle?: string;\n");
out.push_str(" components?: Component[];\n");
out.push_str(" slides?: Slide[];\n");
out.push_str(" unlisted?: boolean;\n");
out.push_str(" texture?: Texture;\n");
out.push_str(" glow?: Glow;\n");
out.push_str(" print_flow?: PrintFlow;\n");
out.push_str(" hub?: HubConfig;\n");
out.push_str(" search_terms?: string[];\n");
out.push_str(" freshness?: \"never\" | Freshness;\n");
out.push_str(" owner?: string;\n");
out.push_str(" references?: Reference[];\n");
out.push_str(" personas?: string[];\n");
out.push_str(" archived?: boolean;\n");
out.push_str(" draft?: boolean;\n");
out.push_str(" nav_layout?: NavLayout;\n");
out.push_str(" nav?: NavLink[];\n");
out.push_str("}\n\n");
out.push_str("export interface SiteConfig {\n");
out.push_str(" name: string;\n");
out.push_str(" theme?: string;\n");
out.push_str(" colors?: Record<string, string>;\n");
out.push_str(" nav?: NavLink[];\n");
out.push_str(" favicon?: string | { svg?: string; png?: string; ico?: string; apple_touch_icon?: string };\n");
out.push_str(" logo?: string | { src: string; height?: string; alt?: string };\n");
out.push_str(" view_source?: boolean;\n");
out.push_str(" texture?: Texture;\n");
out.push_str(" glow?: Glow;\n");
out.push_str(" nav_layout?: NavLayout;\n");
out.push_str(" mode?: Mode;\n");
out.push_str(" description?: string;\n");
out.push_str(" url?: string;\n");
out.push_str(" edit_url?: string;\n");
out.push_str(" og_image?: string;\n");
out.push_str(" voice?: Voice;\n");
out.push_str(" roles?: Role[];\n");
out.push_str(" drift?: DriftConfig;\n");
out.push_str("}\n\n");
emit_interface(
&mut out,
"ValidationError",
&[
("file", "string"),
("path", "string"),
("error_type", "string"),
("message", "string"),
("suggestion?", "string"),
],
);
emit_interface(
&mut out,
"Annotation",
&[
("id", "string"),
("text", "string"),
("author", "string"),
("section?", "string"),
("added", "string"),
("status", "AnnotationStatus"),
("source", "AnnotationSource"),
],
);
out
}
pub fn emit_react() -> Result<()> {
print!("{}", generate_react());
Ok(())
}
pub fn emit_schema() -> Result<()> {
print!("{}", generate_schema());
Ok(())
}
fn generate_schema() -> String {
let schema = load_schema();
let mut out = String::with_capacity(16384);
out.push_str("{\n");
let enums: Vec<(&String, &Vec<String>)> = schema.enums.iter().collect();
out.push_str(" \"enums\": {\n");
for (i, (name, variants)) in enums.iter().enumerate() {
out.push_str(" \"");
out.push_str(name);
out.push_str("\": [");
for (j, v) in variants.iter().enumerate() {
if j > 0 {
out.push_str(", ");
}
out.push('"');
out.push_str(v);
out.push('"');
}
out.push(']');
if i < enums.len() - 1 {
out.push(',');
}
out.push('\n');
}
out.push_str(" },\n");
let types: Vec<(&String, &BTreeMap<String, SchemaField>)> = schema.types.iter().collect();
out.push_str(" \"types\": {\n");
for (i, (name, fields)) in types.iter().enumerate() {
out.push_str(" \"");
out.push_str(name);
out.push_str("\": {\n");
let field_list: Vec<(&String, &SchemaField)> = fields.iter().collect();
for (j, (fname, field)) in field_list.iter().enumerate() {
out.push_str(" \"");
out.push_str(fname);
out.push_str("\": { \"type\": \"");
out.push_str(&field.field_type);
out.push_str("\", \"required\": ");
out.push_str(if field.required { "true" } else { "false" });
out.push_str(" }");
if j < field_list.len() - 1 {
out.push(',');
}
out.push('\n');
}
out.push_str(" }");
if i < types.len() - 1 {
out.push(',');
}
out.push('\n');
}
out.push_str(" },\n");
let components_map: Vec<(&String, &BTreeMap<String, SchemaField>)> =
schema.components.iter().collect();
out.push_str(" \"components\": {\n");
for (i, (tag, fields)) in components_map.iter().enumerate() {
out.push_str(" \"");
out.push_str(tag);
out.push_str("\": {\n");
let field_list: Vec<(&String, &SchemaField)> = fields.iter().collect();
for (j, (fname, field)) in field_list.iter().enumerate() {
out.push_str(" \"");
out.push_str(fname);
out.push_str("\": { \"type\": \"");
out.push_str(&field.field_type);
out.push_str("\", \"required\": ");
out.push_str(if field.required { "true" } else { "false" });
out.push_str(" }");
if j < field_list.len() - 1 {
out.push(',');
}
out.push('\n');
}
out.push_str(" }");
if i < components_map.len() - 1 {
out.push(',');
}
out.push('\n');
}
out.push_str(" }\n");
out.push_str("}\n");
out
}
pub fn emit_agents() -> Result<()> {
print!("{}", generate_agents());
Ok(())
}
fn yaml_example_value(field_type: &str, field_name: &str, schema: &Schema) -> String {
if field_type.ends_with("[]") {
return "[]".to_string();
}
match field_type {
"string" => match field_name {
"title" | "heading" | "label" | "term" => "\"Example Title\"",
"body" | "description" | "detail" | "definition" | "summary" => "\"Description text\"",
"value" => "\"$1.2M\"",
"href" | "src" | "link" | "url" => "\"https://example.com\"",
"code" => "\"console.log('hello')\"",
"language" => "\"typescript\"",
"key" => "\"metric\"",
"name" => "\"Example\"",
"date" => "\"2025-01-15\"",
"alt" | "caption" => "\"Image description\"",
"aspect" => "\"16:9\"",
_ => "\"example\"",
}
.to_string(),
"number" => match field_name {
"value" => "42",
"columns" | "max" | "limit" | "min_width" => "3",
"height" | "max_width" => "300",
_ => "0",
}
.to_string(),
"boolean" => "false".to_string(),
_ => {
if let Some(variants) = schema.enums.get(field_type) {
if let Some(first) = variants.first() {
return format!("\"{}\"", first);
}
}
"{}".to_string()
}
}
}
fn generate_agents() -> String {
let schema = load_schema();
let mut out = String::with_capacity(32768);
out.push_str("# kazam component reference\n\n");
out.push_str("Auto-generated from schema. Do not edit.\n\n");
out.push_str("## Enums\n\n");
for (name, variants) in &schema.enums {
out.push_str("### ");
out.push_str(name);
out.push('\n');
out.push_str("Values: ");
for (i, v) in variants.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push('`');
out.push_str(v);
out.push('`');
}
out.push_str("\n\n");
}
out.push_str("## Types\n\n");
for (name, fields) in &schema.types {
out.push_str("### ");
out.push_str(name);
out.push('\n');
out.push_str("| Field | Type | Required |\n");
out.push_str("|-------|------|----------|\n");
for (fname, field) in fields {
out.push_str("| ");
out.push_str(fname);
out.push_str(" | ");
out.push_str(&field.field_type);
out.push_str(" | ");
out.push_str(if field.required { "yes" } else { "no" });
out.push_str(" |\n");
}
out.push('\n');
}
out.push_str("## Components\n\n");
for (tag, fields) in &schema.components {
out.push_str("### ");
out.push_str(tag);
out.push('\n');
out.push_str("| Field | Type | Required |\n");
out.push_str("|-------|------|----------|\n");
for (fname, field) in fields {
out.push_str("| ");
out.push_str(fname);
out.push_str(" | ");
out.push_str(&field.field_type);
out.push_str(" | ");
out.push_str(if field.required { "yes" } else { "no" });
out.push_str(" |\n");
}
out.push('\n');
out.push_str("```yaml\n");
out.push_str("- type: ");
out.push_str(tag);
out.push('\n');
let mut optional_shown = 0usize;
for (fname, field) in fields {
if field.required {
out.push_str(" ");
out.push_str(fname);
out.push_str(": ");
out.push_str(&yaml_example_value(&field.field_type, fname, &schema));
out.push('\n');
} else if optional_shown < 2 {
out.push_str(" ");
out.push_str(fname);
out.push_str(": ");
out.push_str(&yaml_example_value(&field.field_type, fname, &schema));
out.push_str(" # optional\n");
optional_shown += 1;
}
}
out.push_str("```\n\n");
}
out
}
fn generate_react() -> String {
let mut out = String::with_capacity(65536);
out.push_str(RENDERER_RAW);
generate_editor(&mut out);
out
}
const RENDERER_RAW: &str = r####""use client";
// Auto-generated by `kazam sdk emit-react` — do not edit
/* eslint-disable @next/next/no-img-element */
import React from "react";
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
export const DeckControlContext = React.createContext<{
slide?: number;
onSlideChange?: (index: number) => void;
} | null>(null);
interface SlideData {
label: string;
components?: ComponentData[];
title?: string;
eyebrow?: string;
subtitle?: string;
align?: "left" | "center";
valign?: "top" | "center";
cover?: boolean;
hide_label?: boolean;
}
interface FreshnessData {
updated?: string;
review_every?: string;
owner?: string;
expires?: string;
}
interface HubData {
name: string;
eyebrow?: string;
status?: string;
status_color?: string;
pages?: Array<{ label: string; href: string }>;
}
interface PageData {
title: string;
subtitle?: string;
shell?: string;
components?: ComponentData[];
slides?: SlideData[];
freshness?: FreshnessData | "never";
hub?: HubData;
}
interface ComponentData {
type: string;
[key: string]: unknown;
}
interface PageRendererProps {
page: PageData;
renderMarkdown?: (md: string) => string;
renderChart?: (comp: ComponentData) => React.ReactNode;
renderRoleMap?: (comp: ComponentData) => React.ReactNode;
/** Map a hub link href to an environment URL (e.g. slug -> /pages/slug). Defaults to identity. */
resolveHubHref?: (href: string) => string;
/** The current page's href as written in the hub block — drives the active tab. */
activeHubHref?: string;
/** When true, renders for export: hides nav, freshness, wraps in .export-root. */
exportMode?: boolean;
}
function esc(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
const _assetBase = typeof process !== "undefined" && process.env?.NEXT_PUBLIC_BASE_PATH || "";
function assetSrc(src: string): string {
if (_assetBase && src.startsWith("/") && !src.startsWith("//")) return _assetBase + src;
return src;
}
function renderInline(text: string): React.ReactNode[] {
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
const parts: React.ReactNode[] = [];
let last = 0;
let match: RegExpExecArray | null;
let key = 0;
while ((match = pattern.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index));
const token = match[0];
if (token.startsWith("`")) {
parts.push(<code key={key++} className="c-inline-code">{token.slice(1, -1)}</code>);
} else if (token.startsWith("**")) {
parts.push(<strong key={key++}>{token.slice(2, -2)}</strong>);
} else if (token.startsWith("[")) {
const labelEnd = token.indexOf("](");
const label = token.slice(1, labelEnd);
const href = token.slice(labelEnd + 2, -1);
parts.push(<a key={key++} href={href}>{label}</a>);
} else {
parts.push(<em key={key++}>{token.slice(1, -1)}</em>);
}
last = match.index + token.length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
function renderBlock(text: string): React.ReactElement {
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
let key = 0;
let listItems: React.ReactNode[] = [];
let listTag: "ul" | "ol" = "ul";
let paraLines: string[] = [];
let codeLines: string[] | null = null;
let codeLang = "";
let tableLines: string[] = [];
const parseTableRow = (row: string): string[] =>
row.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
const flushTable = () => {
if (tableLines.length < 2) {
tableLines.forEach(l => paraLines.push(l));
tableLines = [];
return;
}
const sepIdx = tableLines.findIndex(l => /^\|?[\s:]*-{2,}[\s:]*(\|[\s:]*-{2,}[\s:]*)+\|?$/.test(l));
if (sepIdx < 1) {
tableLines.forEach(l => paraLines.push(l));
tableLines = [];
return;
}
const headers = parseTableRow(tableLines[sepIdx - 1]);
const bodyRows = tableLines.slice(sepIdx + 1).map(parseTableRow);
elements.push(
<div key={key++} className="c-table-wrap">
<table className="c-table">
<thead>
<tr>{headers.map((h, i) => <th key={i}>{renderInline(h)}</th>)}</tr>
</thead>
<tbody>
{bodyRows.map((cells, ri) => (
<tr key={ri}>
{headers.map((_, ci) => <td key={ci}>{renderInline(cells[ci] || "")}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
tableLines = [];
};
const flushList = () => {
if (listItems.length > 0) {
const List = listTag;
elements.push(<List key={key++} className="c-md-list">{listItems}</List>);
listItems = [];
}
};
const flushPara = () => {
if (paraLines.length > 0) {
elements.push(<p key={key++}>{renderInline(paraLines.join(" "))}</p>);
paraLines = [];
}
};
const flushCode = () => {
if (codeLines !== null) {
const code = codeLines.join("\n");
elements.push(
<pre key={key++} className="c-code" {...(codeLang ? { "data-lang": codeLang } : {})}>
<code>{code}</code>
</pre>
);
codeLines = null;
codeLang = "";
}
};
for (const line of lines) {
const trimmed = line.trimStart();
if (codeLines !== null) {
if (/^`{3,}\s*$/.test(trimmed)) {
flushCode();
} else {
codeLines.push(line);
}
continue;
}
const fenceMatch = trimmed.match(/^`{3,}(.*)$/);
if (fenceMatch) {
flushPara();
flushList();
codeLines = [];
codeLang = fenceMatch[1].trim();
continue;
}
const isTableLine = /^\|(.+\|)+\s*$/.test(trimmed) || /^\|?[\s:]*-{2,}[\s:]*(\|[\s:]*-{2,}[\s:]*)+\|?\s*$/.test(trimmed);
if (isTableLine && (tableLines.length > 0 || /^\|(.+\|){2,}\s*$/.test(trimmed))) {
flushPara();
flushList();
tableLines.push(trimmed);
continue;
}
if (tableLines.length > 0) {
flushTable();
}
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
flushPara();
if (listTag !== "ul") flushList();
listTag = "ul";
listItems.push(<li key={key++}>{renderInline(trimmed.slice(2))}</li>);
} else if (/^\d+\.\s/.test(trimmed)) {
flushPara();
if (listTag !== "ol") flushList();
listTag = "ol";
listItems.push(<li key={key++}>{renderInline(trimmed.replace(/^\d+\.\s/, ""))}</li>);
} else if (trimmed === "") {
flushPara();
flushList();
} else if (trimmed.startsWith("### ")) {
flushPara();
flushList();
elements.push(<h3 key={key++}>{renderInline(trimmed.slice(4))}</h3>);
} else if (trimmed.startsWith("## ")) {
flushPara();
flushList();
elements.push(<h2 key={key++}>{renderInline(trimmed.slice(3))}</h2>);
} else if (trimmed.startsWith("# ")) {
flushPara();
flushList();
elements.push(<h1 key={key++}>{renderInline(trimmed.slice(2))}</h1>);
} else if (/^!\[.*\]\(.*\)$/.test(trimmed)) {
flushPara();
flushList();
const alt = trimmed.slice(2, trimmed.indexOf("]("));
const src = trimmed.slice(trimmed.indexOf("](") + 2, -1);
elements.push(<img key={key++} src={assetSrc(src)} alt={alt} />);
} else if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
flushPara();
flushList();
elements.push(<hr key={key++} />);
} else {
flushList();
paraLines.push(trimmed);
}
}
flushCode();
flushTable();
flushPara();
flushList();
return <>{elements}</>;
}
const semToHex: Record<string, string> = { default: "var(--color-text-muted, #999)", green: "var(--color-green, #4ade80)", yellow: "var(--color-yellow, #facc15)", red: "var(--color-red, #f87171)", teal: "var(--color-teal, #2dd4bf)" };
const getColor = (c?: string, i?: number) => {
if (c && semToHex[c]) return semToHex[c];
const palette = ["var(--color-accent, #8b5cf6)", "var(--color-green, #4ade80)", "var(--color-yellow, #facc15)", "var(--color-red, #f87171)", "var(--color-teal, #2dd4bf)"];
return palette[(i || 0) % palette.length];
};
function ChartSVG({ id, comp }: { id?: string; comp: ComponentData }) {
const [tip, setTip] = React.useState<{ x: number; y: number; text: string } | null>(null);
const title = comp.title as string | undefined;
const kind = (comp.kind as string) || "bar";
const data = (comp.data as Array<{ label: string; value: number; color?: string }>) || [];
const series = (comp.series as Array<{ label: string; color?: string; points: Array<{ label: string; value: number }> }>) || [];
const h = (comp.height as number) || 200;
const orientation = (comp.orientation as string) || "vertical";
const Tip = tip ? (
<g pointerEvents="none">
<rect x={tip.x - tip.text.length * 3.5 - 6} y={tip.y - 24} width={tip.text.length * 7 + 12} height={20} rx={4} fill="var(--color-bg, #111)" stroke="var(--color-text-muted, #666)" strokeWidth="0.5" opacity="0.95" />
<text x={tip.x} y={tip.y - 11} textAnchor="middle" fontSize={11} fontWeight="600" fill="var(--color-text, #eee)">{tip.text}</text>
</g>
) : null;
let chart: React.ReactNode = null;
if (kind === "pie" && data.length > 0) {
const total = data.reduce((s, d) => s + d.value, 0);
const cx = 80, cy = 80, r = 70;
let cumAngle = -Math.PI / 2;
const slices = data.map((d, i) => {
const angle = (d.value / total) * 2 * Math.PI;
const x1 = cx + r * Math.cos(cumAngle);
const y1 = cy + r * Math.sin(cumAngle);
const midAngle = cumAngle + angle / 2;
cumAngle += angle;
const x2 = cx + r * Math.cos(cumAngle);
const y2 = cy + r * Math.sin(cumAngle);
const large = angle > Math.PI ? 1 : 0;
const path = `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${large} 1 ${x2},${y2} Z`;
const pct = Math.round((d.value / total) * 100);
const tx = cx + (r * 0.6) * Math.cos(midAngle);
const ty = cy + (r * 0.6) * Math.sin(midAngle);
return <path key={i} d={path} fill={getColor(d.color, i)} stroke="var(--color-bg, #111)" strokeWidth="1" className="c-chart-slice" onMouseEnter={() => setTip({ x: tx, y: ty, text: `${d.label}: ${d.value} (${pct}%)` })} onMouseLeave={() => setTip(null)} />;
});
const legend = data.map((d, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12 }}>
<span style={{ width: 10, height: 10, borderRadius: "50%", background: getColor(d.color, i), display: "inline-block" }} />
<span>{d.label}: {d.value}</span>
</div>
));
chart = (
<div style={{ display: "flex", alignItems: "center", gap: 24, justifyContent: "center" }}>
<svg viewBox="0 0 160 160" style={{ width: "40%", maxWidth: 200, minWidth: 120, flexShrink: 0, overflow: "visible" }}>{slices}{Tip}</svg>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>{legend}</div>
</div>
);
} else if (kind === "bar" && data.length > 0) {
const maxVal = Math.max(...data.map(d => d.value), 1);
const vw = 600;
const padX = 40;
const padTop = 28;
const gap = 8;
const barW = Math.max(20, (vw - padX * 2 - gap * (data.length - 1)) / data.length);
if (orientation === "horizontal") {
const barH = 24;
const svgH = data.length * (barH + 6) + 20;
chart = (
<svg width="100%" viewBox={`0 0 ${vw} ${svgH}`} preserveAspectRatio="xMidYMid meet" style={{ overflow: "visible" }}>
{data.map((d, i) => {
const w = (d.value / maxVal) * (vw - 120);
const y = i * (barH + 6) + 10;
return <React.Fragment key={i}>
<rect x={80} y={y} width={w} height={barH} fill={getColor(d.color, i)} rx={3} className="c-chart-bar" onMouseEnter={() => setTip({ x: 80 + w / 2, y: y - 4, text: `${d.label}: ${d.value}` })} onMouseLeave={() => setTip(null)} />
<text x={74} y={y + barH / 2 + 4} textAnchor="end" fontSize={11} fill="var(--color-text-muted, #999)">{d.label}</text>
<text x={84 + w} y={y + barH / 2 + 4} fontSize={11} fill="var(--color-text-muted, #999)">{d.value}</text>
</React.Fragment>;
})}
{Tip}
</svg>
);
} else {
const plotH = h - padTop;
chart = (
<svg width="100%" viewBox={`0 0 ${vw} ${h + 30}`} preserveAspectRatio="xMidYMid meet" style={{ overflow: "visible" }}>
<line x1={padX} y1={h} x2={vw - padX} y2={h} stroke="var(--color-text-muted, #999)" strokeWidth="0.5" strokeOpacity="0.3" />
{data.map((d, i) => {
const barH = (d.value / maxVal) * plotH;
const x = padX + i * (barW + gap);
return <React.Fragment key={i}>
<rect x={x} y={h - barH} width={barW} height={barH} fill={getColor(d.color, i)} rx={3} className="c-chart-bar" onMouseEnter={() => setTip({ x: x + barW / 2, y: h - barH - 4, text: `${d.label}: ${d.value}` })} onMouseLeave={() => setTip(null)} />
<text x={x + barW / 2} y={h - barH - 6} textAnchor="middle" fontSize={11} fontWeight="500" fill="var(--color-text, #eee)">{d.value}</text>
<text x={x + barW / 2} y={h + 16} textAnchor="middle" fontSize={11} fill="var(--color-text-muted, #999)">{d.label}</text>
</React.Fragment>;
})}
{Tip}
</svg>
);
}
} else if (kind === "timeseries" && series.length > 0) {
const allPoints = series.flatMap(s => s.points);
const maxVal = Math.max(...allPoints.map(p => p.value), 1);
const vw = 600;
const pad = { l: 50, r: 20, t: 20, b: 24 };
const plotW = vw - pad.l - pad.r;
const plotH = h - pad.t - pad.b;
const n = series[0]?.points.length || 1;
const gridLines = [0, 0.25, 0.5, 0.75, 1].map((frac, gi) => {
const y = pad.t + plotH * (1 - frac);
const val = Math.round(maxVal * frac);
return <React.Fragment key={gi}>
<line x1={pad.l} y1={y} x2={vw - pad.r} y2={y} stroke="var(--color-text-muted, #999)" strokeWidth="0.5" strokeOpacity="0.2" />
<text x={pad.l - 6} y={y + 4} textAnchor="end" fontSize={10} fill="var(--color-text-muted, #999)">{val}</text>
</React.Fragment>;
});
const lines = series.map((s, si) => {
const pts = s.points.map((p, pi) => {
const x = pad.l + (pi / Math.max(n - 1, 1)) * plotW;
const y = pad.t + plotH - (p.value / maxVal) * plotH;
return `${x},${y}`;
}).join(" ");
return <polyline key={si} points={pts} fill="none" stroke={getColor(s.color, si)} strokeWidth="2.5" strokeLinejoin="round" />;
});
const dots = series.map((s, si) =>
s.points.map((p, pi) => {
const x = pad.l + (pi / Math.max(n - 1, 1)) * plotW;
const y = pad.t + plotH - (p.value / maxVal) * plotH;
const label = series.length > 1 ? `${s.label}: ${p.value}` : String(p.value);
return <circle key={`${si}-${pi}`} cx={x} cy={y} r={4} fill={getColor(s.color, si)} className="c-chart-dot" onMouseEnter={() => setTip({ x, y: y - 4, text: label })} onMouseLeave={() => setTip(null)} />;
})
);
const labels = (series[0]?.points || []).map((p, pi) => {
const x = pad.l + (pi / Math.max(n - 1, 1)) * plotW;
return <text key={pi} x={x} y={h - 2} textAnchor="middle" fontSize={10} fill="var(--color-text-muted, #999)">{p.label}</text>;
});
const legend = series.length > 1 ? series.map((s, si) => (
<div key={si} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12 }}>
<span style={{ width: 16, height: 2, background: getColor(s.color, si), display: "inline-block" }} />
<span>{s.label}</span>
</div>
)) : null;
chart = (
<>
<svg width="100%" viewBox={`0 0 ${vw} ${h}`} preserveAspectRatio="xMidYMid meet" style={{ overflow: "visible" }}>
{gridLines}{lines}{dots}{labels}{Tip}
</svg>
{legend && <div style={{ display: "flex", gap: 12, marginTop: 8 }}>{legend}</div>}
</>
);
}
return (
<div id={id} className="c-chart" data-kind={kind}>
{title && <h3 className="c-chart-title">{title}</h3>}
{chart || <div className="c-chart-placeholder">Chart ({kind})</div>}
</div>
);
}
function GraphSVG({ id, comp }: { id?: string; comp: ComponentData }) {
type GNode = { id: string; label: string; group?: string; width?: number; height?: number; shape?: string; color?: string; detail?: string; ports?: Array<{ side: string; label: string }> };
type GEdge = { from: string; to: string; label?: string; style?: string; color?: string };
type GGroup = { id: string; label?: string; parent?: string; color?: string };
const nodes = (comp.nodes as GNode[]) || [];
const edges = (comp.edges as GEdge[]) || [];
const groups = (comp.groups as GGroup[]) || [];
const direction = (comp.direction as string) || "left_to_right";
const title = comp.title as string | undefined;
const compHeight = (comp.height as number) || 400;
const svgData = React.useMemo(() => {
if (nodes.length === 0) return null;
const isLR = direction === "left_to_right" || direction === "LR" || direction === "lr";
const defaultW = 140;
const defaultH = 60;
const pad = 40;
const groupPad = 24;
const nodeGapV = 32;
const hasGroups = groups.length > 0;
const minColGap = hasGroups ? 120 : 80;
const portSpread = 0.85;
const VB_W = 720;
// Per-node dimensions
const dims: Record<string, { w: number; h: number }> = {};
for (const n of nodes) {
const scale = n.shape === "diamond" ? 1.4 : 1;
dims[n.id] = { w: (n.width ?? defaultW) * scale, h: (n.height ?? defaultH) * scale };
}
// Filter out bidirectional edges for topo sort
const edgeSet = new Set(edges.map(e => `${e.from}||${e.to}`));
const forwardEdges = edges.filter(e => !edgeSet.has(`${e.to}||${e.from}`));
// Topological column assignment
const col: Record<string, number> = {};
const inSet = new Set(forwardEdges.map(e => e.to));
for (const n of nodes) {
if (!inSet.has(n.id)) col[n.id] = 0;
}
const maxIters = nodes.length * forwardEdges.length + 1;
let iters = 0;
let changed = true;
while (changed && iters < maxIters) {
changed = false;
iters++;
for (const e of forwardEdges) {
if (col[e.from] !== undefined) {
const sc = col[e.from];
if (col[e.to] === undefined || col[e.to] <= sc) {
col[e.to] = sc + 1;
changed = true;
}
}
}
}
for (const n of nodes) {
if (col[n.id] === undefined) col[n.id] = 0;
}
const minColVal = Math.min(...Object.values(col));
for (const k of Object.keys(col)) col[k] -= minColVal;
const maxColVal = Math.max(...Object.values(col));
const numCols = maxColVal + 1;
// Group nodes by column
const colsNodes: GNode[][] = Array.from({ length: numCols }, () => []);
for (const n of nodes) {
colsNodes[col[n.id] ?? 0].push(n);
}
const colMaxW = colsNodes.map(cn => Math.max(defaultW, ...cn.map(n => dims[n.id]?.w ?? defaultW)));
const colMaxH = colsNodes.map(cn => Math.max(defaultH, ...cn.map(n => dims[n.id]?.h ?? defaultH)));
const maxColHeight = Math.max(1, ...colsNodes.map(cn => cn.length));
const neededW = isLR
? pad * 2 + colMaxW.reduce((a, b) => a + b, 0) + Math.max(0, numCols - 1) * minColGap
: pad * 2 + maxColHeight * defaultW + Math.max(0, maxColHeight - 1) * nodeGapV;
const vbW = Math.max(neededW, VB_W);
const neededH = !isLR
? pad * 2 + colMaxH.reduce((a, b) => a + b, 0) + Math.max(0, numCols - 1) * minColGap
: pad * 2 + maxColHeight * defaultH + Math.max(0, maxColHeight - 1) * nodeGapV;
const h = Math.max(compHeight, neededH);
// Position nodes
const positions: Record<string, { cx: number; cy: number }> = {};
if (isLR) {
const colCx: number[] = [];
let xCursor = pad;
for (let ci = 0; ci < numCols; ci++) {
colCx.push(xCursor + colMaxW[ci] / 2);
xCursor += colMaxW[ci] + minColGap;
}
const totalUsed = xCursor - minColGap + pad;
const xOffset = vbW > totalUsed ? (vbW - totalUsed) / 2 : 0;
for (let ci = 0; ci < numCols; ci++) {
const cn = colsNodes[ci];
const nCount = cn.length;
const totalH = nCount * defaultH + Math.max(0, nCount - 1) * nodeGapV;
const startY = (h - totalH) / 2;
for (let ni = 0; ni < cn.length; ni++) {
positions[cn[ni].id] = {
cx: colCx[ci] + xOffset,
cy: startY + ni * (defaultH + nodeGapV) + defaultH / 2,
};
}
}
} else {
const rowCy: number[] = [];
let yCursor = pad;
for (let ci = 0; ci < numCols; ci++) {
rowCy.push(yCursor + colMaxH[ci] / 2);
yCursor += colMaxH[ci] + minColGap;
}
const totalUsed = yCursor - minColGap + pad;
const yOffset = h > totalUsed ? (h - totalUsed) / 2 : 0;
for (let ci = 0; ci < numCols; ci++) {
const cn = colsNodes[ci];
const nCount = cn.length;
const totalW = nCount * defaultW + Math.max(0, nCount - 1) * nodeGapV;
const startX = (vbW - totalW) / 2;
for (let ni = 0; ni < cn.length; ni++) {
positions[cn[ni].id] = {
cx: startX + ni * (defaultW + nodeGapV) + defaultW / 2,
cy: rowCy[ci] + yOffset,
};
}
}
}
// Bidirectional detection
const isBidir = (from: string, to: string) => edgeSet.has(`${to}||${from}`);
// Group bounding boxes
const groupBounds: Record<string, { x: number; y: number; w: number; h: number }> = {};
for (const grp of groups) {
const members = nodes.filter(n => n.group === grp.id).map(n => ({ pos: positions[n.id], d: dims[n.id] })).filter(m => m.pos && m.d);
if (members.length === 0) continue;
const minX = Math.min(...members.map(m => m.pos.cx - m.d.w / 2));
const minY = Math.min(...members.map(m => m.pos.cy - m.d.h / 2));
const maxX = Math.max(...members.map(m => m.pos.cx + m.d.w / 2));
const maxY = Math.max(...members.map(m => m.pos.cy + m.d.h / 2));
groupBounds[grp.id] = {
x: minX - groupPad,
y: minY - groupPad - 14,
w: maxX - minX + groupPad * 2,
h: maxY - minY + groupPad * 2 + 14,
};
}
// Expand parent groups
for (const grp of groups) {
if (!grp.parent) continue;
const cb = groupBounds[grp.id];
if (!cb) continue;
const cx2 = cb.x + cb.w;
const cy2 = cb.y + cb.h;
let pb = groupBounds[grp.parent];
if (!pb) {
groupBounds[grp.parent] = {
x: cb.x - groupPad,
y: cb.y - groupPad - 14,
w: (cx2 - cb.x) + groupPad * 2,
h: (cy2 - cb.y) + groupPad * 2 + 14,
};
pb = groupBounds[grp.parent];
}
const px2 = pb.x + pb.w;
const py2 = pb.y + pb.h;
const newX = Math.min(pb.x, cb.x - groupPad);
const newY = Math.min(pb.y, cb.y - groupPad - 14);
const newX2 = Math.max(px2, cx2 + groupPad);
const newY2 = Math.max(py2, cy2 + groupPad);
pb.x = newX; pb.y = newY; pb.w = newX2 - newX; pb.h = newY2 - newY;
}
// Per-side port assignment
const edgeSrcSide: number[] = [];
const edgeTgtSide: number[] = [];
const srcGroups: Map<string, Array<[number, number]>> = new Map();
const tgtGroups: Map<string, Array<[number, number]>> = new Map();
for (let ei = 0; ei < edges.length; ei++) {
const e = edges[ei];
const fp = positions[e.from];
const tp = positions[e.to];
if (!fp || !tp) { edgeSrcSide.push(0); edgeTgtSide.push(1); continue; }
const bidir = isBidir(e.from, e.to);
let ss: number, ts: number;
if (isLR) {
const forward = tp.cx >= fp.cx;
if (bidir && !forward) { ss = 2; ts = 2; }
else { ss = forward ? 0 : 1; ts = forward ? 1 : 0; }
} else {
const downward = tp.cy >= fp.cy;
if (bidir && !downward) { ss = 0; ts = 0; }
else { ss = downward ? 3 : 2; ts = downward ? 2 : 3; }
}
edgeSrcSide.push(ss);
edgeTgtSide.push(ts);
const srcSort = (ss === 0 || ss === 1) ? tp.cy : tp.cx;
const tgtSort = (ts === 0 || ts === 1) ? fp.cy : fp.cx;
const skSrc = `${e.from}||${ss}`;
const skTgt = `${e.to}||${ts}`;
if (!srcGroups.has(skSrc)) srcGroups.set(skSrc, []);
srcGroups.get(skSrc)!.push([ei, srcSort]);
if (!tgtGroups.has(skTgt)) tgtGroups.set(skTgt, []);
tgtGroups.get(skTgt)!.push([ei, tgtSort]);
}
for (const arr of srcGroups.values()) arr.sort((a, b) => a[1] - b[1]);
for (const arr of tgtGroups.values()) arr.sort((a, b) => a[1] - b[1]);
const srcSlot: Array<[number, number]> = edges.map(() => [0, 1]);
const tgtSlot: Array<[number, number]> = edges.map(() => [0, 1]);
for (const arr of srcGroups.values()) {
const count = arr.length;
arr.forEach(([ei], slot) => { srcSlot[ei] = [slot, count]; });
}
for (const arr of tgtGroups.values()) {
const count = arr.length;
arr.forEach(([ei], slot) => { tgtSlot[ei] = [slot, count]; });
}
const portPos = (cx: number, cy: number, w: number, h: number, side: number, slot: number, count: number): [number, number] => {
const off = count > 1 ? ((slot + 0.5) / count - 0.5) * portSpread : 0;
if (side === 0) return [cx + w / 2, cy + off * h];
if (side === 1) return [cx - w / 2, cy + off * h];
if (side === 2) return [cx + off * w, cy - h / 2];
return [cx + off * w, cy + h / 2];
};
// Build SVG elements arrays
const groupElems: React.ReactNode[] = [];
const parentGroupIds = new Set(groups.filter(g => !g.parent).map(g => g.id));
const orderedGroups = [...groups.filter(g => !g.parent), ...groups.filter(g => !!g.parent)];
for (let gi = 0; gi < orderedGroups.length; gi++) {
const grp = orderedGroups[gi];
const b = groupBounds[grp.id];
if (!b) continue;
const stroke = grp.color && semToHex[grp.color] ? semToHex[grp.color] : "rgba(var(--text-rgb),0.2)";
groupElems.push(
<React.Fragment key={`grp-${gi}`}>
<rect x={b.x} y={b.y} width={b.w} height={b.h} rx={10} fill="rgba(var(--text-rgb),0.02)" stroke={stroke} strokeWidth={1} strokeDasharray="5 3" />
{grp.label && <text x={b.x + 8} y={b.y + 10} className="c-arch-node-detail" dominantBaseline="middle">{grp.label}</text>}
</React.Fragment>
);
}
const edgeElems: React.ReactNode[] = [];
for (let ei = 0; ei < edges.length; ei++) {
const e = edges[ei];
const fp = positions[e.from];
const tp = positions[e.to];
if (!fp || !tp) continue;
const fd = dims[e.from] ?? { w: defaultW, h: defaultH };
const td = dims[e.to] ?? { w: defaultW, h: defaultH };
const bidir = isBidir(e.from, e.to);
const dx = tp.cx - fp.cx;
const dy = tp.cy - fp.cy;
const len = Math.max(1, Math.sqrt(dx * dx + dy * dy));
const normPerpX = -dy / len;
const normPerpY = dx / len;
const [sSlot, sCount] = srcSlot[ei];
const [tSlot, tCount] = tgtSlot[ei];
const [sx1, sy1] = portPos(fp.cx, fp.cy, fd.w, fd.h, edgeSrcSide[ei], sSlot, sCount);
const [sx2, sy2] = portPos(tp.cx, tp.cy, td.w, td.h, edgeTgtSide[ei], tSlot, tCount);
let routeOffset = 0;
if (!bidir) {
for (const n of nodes) {
if (n.id === e.from || n.id === e.to) continue;
const np = positions[n.id];
if (!np) continue;
const nd = dims[n.id] ?? { w: defaultW, h: defaultH };
const ax = np.cx - fp.cx;
const ay = np.cy - fp.cy;
const proj = (ax * dx + ay * dy) / (len * len);
if (proj < 0.02 || proj > 0.98) continue;
const clX = fp.cx + proj * dx;
const clY = fp.cy + proj * dy;
const dist = Math.sqrt((np.cx - clX) ** 2 + (np.cy - clY) ** 2);
const clearance = Math.max(nd.w / 2 + 30, nd.h / 2 + 30);
if (dist < clearance) {
const cross = ax * dy - ay * dx;
const needed = clearance - dist + 25;
const sign = cross >= 0 ? 1 : -1;
const candidate = sign * needed;
if (Math.abs(candidate) > Math.abs(routeOffset)) routeOffset = candidate;
}
}
}
const needsCurve = bidir || Math.abs(routeOffset) > 1;
const strokeColor = e.color && semToHex[e.color] ? semToHex[e.color] : "rgba(var(--text-rgb),0.3)";
const dashAttr = e.style === "dashed" ? "6 3" : undefined;
let labelX = 0, labelY = 0;
if (needsCurve) {
let cpx: number, cpy: number;
if (bidir) {
const backward = isLR ? tp.cx < fp.cx : tp.cy < fp.cy;
let curveOffset = 40;
if (backward && groups.length > 0) {
const midY = (sy1 + sy2) / 2;
const leftX = Math.min(sx1, sx2);
const rightX = Math.max(sx1, sx2);
const perpYAbs = Math.max(Math.abs(normPerpY), 0.1);
for (const grp of groups) {
const b = groupBounds[grp.id];
if (!b) continue;
const gRight = b.x + b.w;
if (gRight > leftX && b.x < rightX && b.y < midY) {
const needed = 2.0 * (midY - b.y + 15.0) / perpYAbs;
if (needed > curveOffset) curveOffset = needed;
}
}
}
cpx = (sx1 + sx2) / 2 + normPerpX * curveOffset;
cpy = (sy1 + sy2) / 2 + normPerpY * curveOffset;
} else {
cpx = (sx1 + sx2) / 2 + normPerpX * routeOffset;
cpy = (sy1 + sy2) / 2 + normPerpY * routeOffset;
}
const d = `M ${sx1.toFixed(1)},${sy1.toFixed(1)} Q ${cpx.toFixed(1)},${cpy.toFixed(1)} ${sx2.toFixed(1)},${sy2.toFixed(1)}`;
edgeElems.push(
<path key={`e-${ei}`} d={d} fill="none" stroke="rgba(var(--text-rgb),0.3)" strokeWidth={1.5} strokeDasharray={dashAttr} markerEnd="url(#graph-arrow)" className="c-arch-edge" />
);
if (bidir) {
const t = 0.25;
const mt = 1 - t;
labelX = mt * mt * sx1 + 2 * mt * t * cpx + t * t * sx2;
labelY = mt * mt * sy1 + 2 * mt * t * cpy + t * t * sy2;
} else {
labelX = 0.25 * sx1 + 0.5 * cpx + 0.25 * sx2;
labelY = 0.25 * sy1 + 0.5 * cpy + 0.25 * sy2;
}
} else {
edgeElems.push(
<line key={`e-${ei}`} x1={sx1} y1={sy1} x2={sx2} y2={sy2} stroke="rgba(var(--text-rgb),0.3)" strokeWidth={1.5} strokeDasharray={dashAttr} markerEnd="url(#graph-arrow)" className="c-arch-edge" />
);
labelX = (sx1 + sx2) / 2;
labelY = (sy1 + sy2) / 2;
}
if (e.label) {
const lx = labelX;
const ly = labelY - 8;
const lc = strokeColor;
edgeElems.push(
<text key={`el-${ei}`} x={lx} y={ly} textAnchor="middle" dominantBaseline="middle" fontSize={10} fill={lc} style={{ stroke: "var(--bg,#121113)", strokeWidth: 3, paintOrder: "stroke" as const }}>{e.label}</text>
);
}
}
const nodeElems: React.ReactNode[] = [];
for (const n of nodes) {
const pos = positions[n.id];
if (!pos) continue;
const d = dims[n.id] ?? { w: defaultW, h: defaultH };
const nw = d.w;
const nh = d.h;
const rx = pos.cx - nw / 2;
const ry = pos.cy - nh / 2;
const stroke = n.color && semToHex[n.color] ? semToHex[n.color] : "rgba(var(--text-rgb),0.5)";
const shape = n.shape || "box";
if (shape === "diamond") {
const hw = nw / 2;
const hh = nh / 2;
const pts = `${pos.cx.toFixed(1)},${(pos.cy - hh).toFixed(1)} ${(pos.cx + hw).toFixed(1)},${pos.cy.toFixed(1)} ${pos.cx.toFixed(1)},${(pos.cy + hh).toFixed(1)} ${(pos.cx - hw).toFixed(1)},${pos.cy.toFixed(1)}`;
nodeElems.push(<polygon key={`ns-${n.id}`} points={pts} fill="rgba(var(--text-rgb),0.06)" stroke={stroke} strokeWidth={1.5} className="c-arch-node" />);
} else if (shape === "pill") {
nodeElems.push(<rect key={`ns-${n.id}`} x={rx} y={ry} width={nw} height={nh} rx={nh / 2} fill="rgba(var(--text-rgb),0.06)" stroke={stroke} strokeWidth={1.5} className="c-arch-node" />);
} else {
nodeElems.push(<rect key={`ns-${n.id}`} x={rx} y={ry} width={nw} height={nh} rx={8} fill="rgba(var(--text-rgb),0.06)" stroke={stroke} strokeWidth={1.5} className="c-arch-node" />);
}
nodeElems.push(
<text key={`nl-${n.id}`} x={pos.cx} y={n.detail ? pos.cy - 7 : pos.cy} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-label">{n.label}</text>
);
if (n.detail) {
nodeElems.push(
<text key={`nd-${n.id}`} x={pos.cx} y={pos.cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail">{n.detail}</text>
);
}
for (let pi = 0; pi < (n.ports ?? []).length; pi++) {
const port = n.ports![pi];
let px: number, py: number, anchor: "start" | "middle" | "end";
if (port.side === "right") { px = pos.cx + nw / 2 - 4; py = pos.cy; anchor = "end" as const; }
else if (port.side === "left") { px = pos.cx - nw / 2 + 4; py = pos.cy; anchor = "start" as const; }
else if (port.side === "top") { px = pos.cx; py = pos.cy - nh / 2 + 10; anchor = "middle" as const; }
else { px = pos.cx; py = pos.cy + nh / 2 - 4; anchor = "middle" as const; }
nodeElems.push(
<text key={`np-${n.id}-${pi}`} x={px} y={py} textAnchor={anchor} dominantBaseline="middle" className="c-arch-node-detail" fontSize={9} opacity={0.6}>{port.label}</text>
);
}
}
return { vbW, h, groupElems, edgeElems, nodeElems };
}, [nodes, edges, groups, direction, compHeight]);
return (
<div id={id} className="c-chart" data-kind="graph">
{title && <h3 className="c-chart-title">{title}</h3>}
{svgData ? (
<svg viewBox={`0 0 ${svgData.vbW} ${svgData.h}`} preserveAspectRatio="xMidYMid meet" className="c-chart-svg">
<defs>
<marker id="graph-arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="rgba(var(--text-rgb),0.5)" />
</marker>
</defs>
{svgData.groupElems}
{svgData.edgeElems}
{svgData.nodeElems}
</svg>
) : (
<div className="c-chart-placeholder">Graph</div>
)}
</div>
);
}
function OrgNode({ person, depth, autoDepth }: { person: any; depth: number; autoDepth: number }) {
const reports = (person.reports || []) as any[];
const hasReports = reports.length > 0;
const [open, setOpen] = React.useState(depth < autoDepth);
const countAll = (p: any): number => { const r = (p.reports || []) as any[]; return r.reduce((s: number, c: any) => s + 1 + countAll(c), 0); };
const total = countAll(person);
const color = (person.color as string) || "";
const tags = (person.tags || []) as Array<{ label: string; color?: string }>;
const email = person.email as string | undefined;
const linkedin = person.linkedin as string | undefined;
const colorClass = color ? ` c-org-node--${color}` : "";
return (
<div className="c-org-branch">
<div className={`c-org-node${colorClass}${hasReports ? " c-org-node--parent" : ""}${open && hasReports ? " c-org-node--open" : ""}`} onClick={() => hasReports && setOpen(!open)} style={{ cursor: hasReports ? "pointer" : "default" }}>
<div className="c-org-node-name">{person.name}</div>
{person.title && <div className="c-org-node-title">{person.title}</div>}
{tags.length > 0 && (
<div className="c-org-node-tags">
{tags.map((t, ti) => <span key={ti} className={`c-badge c-badge-${t.color || "default"}`}>{t.label}</span>)}
</div>
)}
{(email || linkedin) && (
<div className="c-org-node-contact" onClick={(e) => e.stopPropagation()}>
{email && <a href={`mailto:${email}`} title={email} className="c-org-contact-link">✉</a>}
{linkedin && <a href={linkedin} target="_blank" rel="noopener noreferrer" title="LinkedIn" className="c-org-contact-link">in</a>}
</div>
)}
{!open && hasReports && <div className="c-org-node-count">{total} report{total !== 1 ? "s" : ""}</div>}
</div>
{open && hasReports && (
<div className="c-org-children">
{reports.map((r: any, i: number) => <OrgNode key={r.id || i} person={r} depth={depth + 1} autoDepth={autoDepth} />)}
</div>
)}
</div>
);
}
function OrgChartZoom({ children }: { children: React.ReactNode }) {
const STEPS = [0.5, 0.65, 0.8, 1, 1.2];
const [zi, setZi] = React.useState(3);
const scale = STEPS[zi];
return (
<div className="c-org-zoom-wrap">
<div className="c-org-zoom-controls">
<button type="button" className="c-org-zoom-btn" disabled={zi === 0} onClick={() => setZi(zi - 1)} title="Zoom out">−</button>
<span className="c-org-zoom-label">{Math.round(scale * 100)}%</span>
<button type="button" className="c-org-zoom-btn" disabled={zi === STEPS.length - 1} onClick={() => setZi(zi + 1)} title="Zoom in">+</button>
{zi !== 3 && <button type="button" className="c-org-zoom-btn" onClick={() => setZi(3)} title="Reset zoom">↺</button>}
</div>
<div className="c-org-zoom-scroll">
<div className="c-org-zoom-inner" style={{ transform: `scale(${scale})`, transformOrigin: "top center" }}>
{children}
</div>
</div>
</div>
);
}
function AccordionView({
id,
items,
renderMarkdown,
renderChart,
renderRoleMap,
}: {
id: string;
items: Array<{ title: string; components: ComponentData[] }>;
renderMarkdown?: (md: string) => string;
renderChart?: (comp: ComponentData) => React.ReactNode;
renderRoleMap?: (comp: ComponentData) => React.ReactNode;
}) {
const [openIndex, setOpenIndex] = React.useState<number | null>(null);
return (
<div id={id} className="c-accordion">
{items.map((item, i) => (
<div key={i} className={`c-accordion-item${openIndex === i ? " accordion-open" : ""}`}>
<button className="accordion-head" onClick={() => setOpenIndex(openIndex === i ? null : i)}>
{item.title}<span className="accordion-chevron">›</span>
</button>
{openIndex === i && (
<div className="accordion-body">
{(item.components || []).map((c, ci) => (
<ComponentView key={ci} comp={c} index={ci} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
)}
</div>
))}
</div>
);
}
function ComponentView({
comp,
index,
renderMarkdown,
renderChart,
renderRoleMap,
}: {
comp: ComponentData;
index: number;
renderMarkdown?: (md: string) => string;
renderChart?: (comp: ComponentData) => React.ReactNode;
renderRoleMap?: (comp: ComponentData) => React.ReactNode;
}) {
const id = (comp.id as string) || `c-${index}`;
const md = (s: string) =>
renderMarkdown ? (
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(s) }} />
) : (
renderBlock(s)
);
switch (comp.type) {
case "header": {
const title = (comp.title as string) || "";
const subtitle = comp.subtitle as string | undefined;
const eyebrow = comp.eyebrow as string | undefined;
return (
<div id={id} className="c-header">
{eyebrow && <div className="c-header-eyebrow">{eyebrow}</div>}
<h1 className="c-header-title">{title}</h1>
{subtitle && <p className="c-header-subtitle">{subtitle}</p>}
</div>
);
}
case "markdown": {
const body = (comp.body as string) || "";
return <div id={id} className="c-markdown">{md(body)}</div>;
}
case "callout": {
const variant = (comp.variant as string) || "info";
const title = comp.title as string | undefined;
const body = (comp.body as string) || "";
const links = (comp.links as Array<{ label: string; href: string; variant?: string }>) || [];
return (
<div id={id} className={`c-callout c-callout-${variant}`}>
{title && <div className="c-callout-title">{renderInline(title)}</div>}
<div className="c-callout-body c-markdown">{md(body)}</div>
{links.length > 0 && (
<div className="c-callout-links c-button-group">
{links.map((l, i) => (
<a key={i} href={l.href} className={`c-button c-button-${l.variant || "secondary"}`}>{l.label}</a>
))}
</div>
)}
</div>
);
}
case "card_grid": {
const cards = (comp.cards as Array<{
title: string;
description?: string;
badge?: { label: string; color?: string };
href?: string;
}>) || [];
const connector = comp.connector as string | undefined;
const gridClass = connector === "arrow" ? "c-card-grid c-card-grid-arrow" : "c-card-grid";
return (
<div id={id} className={gridClass}>
{cards.map((card, i) => {
const Tag = card.href ? "a" : "div";
return (
<React.Fragment key={i}>
{connector === "arrow" && i > 0 && <div className="c-card-arrow">→</div>}
<Tag className="c-card c-card-default" {...(card.href ? { href: card.href } : {})}>
<div className="c-card-top">
<h2 className="c-card-title">{card.title}</h2>
{card.badge && (
<span className={`c-badge c-badge-${card.badge.color || "default"}`}>
{card.badge.label}
</span>
)}
</div>
{card.description && <p className="c-card-desc">{renderInline(card.description)}</p>}
</Tag>
</React.Fragment>
);
})}
</div>
);
}
case "stat_grid": {
const stats = (comp.stats as Array<{
label: string;
value: string;
detail?: string;
color?: string;
trend?: string;
previous?: string;
history?: number[];
}>) || [];
const columns = (comp.columns as number) || stats.length;
return (
<div
id={id}
className="c-stat-grid"
style={{ "--stat-cols": columns } as React.CSSProperties}
>
{stats.map((s, i) => {
const trendClass = s.trend === "up" ? "c-stat-trend-up" : s.trend === "down" ? "c-stat-trend-down" : "c-stat-trend-neutral";
const trendArrow = s.trend === "up" ? "▲" : s.trend === "down" ? "▼" : "→";
let sparkline: React.ReactNode = null;
if (s.history && s.history.length > 1) {
const pts = s.history;
const minV = Math.min(...pts), maxV = Math.max(...pts);
const range = maxV - minV || 1;
const w = 80, h = 24, pad = 2;
const xs = pts.map((_, idx) => pad + (idx / (pts.length - 1)) * (w - pad * 2));
const ys = pts.map(v => (h - pad) - ((v - minV) / range) * (h - pad * 2));
const d = xs.map((x, idx) => `${idx === 0 ? "M" : "L"}${x.toFixed(1)},${ys[idx].toFixed(1)}`).join(" ");
sparkline = (
<svg className="c-stat-sparkline" viewBox={`0 0 ${w} ${h}`} width={w} height={h} aria-hidden="true">
<polyline points={xs.map((x, idx) => `${x.toFixed(1)},${ys[idx].toFixed(1)}`).join(" ")} fill="none" strokeWidth={1.5} className="c-stat-spark-line" />
</svg>
);
}
return (
<div key={i} className={`c-stat${s.color && s.color !== "default" ? ` c-stat-${s.color}` : ""}`}>
<div className="c-stat-label">{s.label}</div>
<div className="c-stat-value">{s.value}</div>
{s.detail && <div className="c-stat-detail">{renderInline(s.detail)}</div>}
{s.trend && <span className={`c-stat-trend ${trendClass}`}>{trendArrow}</span>}
{s.previous && <span className="c-stat-previous">was {s.previous}</span>}
{sparkline}
</div>
);
})}
</div>
);
}
case "steps": {
const items = (comp.items as Array<{ title: string; detail?: string }>) || [];
const numbered = comp.numbered !== false;
const Tag = numbered ? "ol" : "ul";
return (
<Tag id={id} className="c-steps">
{items.map((item, i) => (
<li key={i} className="c-step">
{numbered ? (
<div className="c-step-num">{i + 1}</div>
) : (
<div className="c-step-bullet" />
)}
<div>
<div className="c-step-title">{renderInline(item.title)}</div>
{item.detail && <div className="c-step-detail">{renderInline(item.detail)}</div>}
</div>
</li>
))}
</Tag>
);
}
case "code": {
const code = (comp.code as string) || "";
const language = comp.language as string | undefined;
return (
<pre id={id} className="c-code" {...(language ? { "data-lang": language } : {})}>
<code>{code}</code>
</pre>
);
}
case "image": {
const src = (comp.src as string) || "";
const alt = (comp.alt as string) || "";
const caption = comp.caption as string | undefined;
return (
<figure id={id} className="c-image">
<img src={assetSrc(src)} alt={alt} />
{caption && <figcaption>{caption}</figcaption>}
</figure>
);
}
case "divider": {
const label = comp.label as string | undefined;
if (label) {
return (
<div id={id} className="c-divider c-divider-labeled">
<span className="c-divider-line" />
<span className="c-divider-label">{label}</span>
<span className="c-divider-line" />
</div>
);
}
return <hr id={id} className="c-divider" />;
}
case "table": {
const columns = (comp.columns as Array<{ key: string; label: string; color_map?: Record<string, string> }>) || [];
const rows = (comp.rows as Array<Record<string, unknown>>) || [];
const summary = comp.summary as Array<{ label: string; value: number; color?: string }> | undefined;
const summaryTotal = summary ? summary.reduce((acc, item) => acc + item.value, 0) : 0;
return (
<div id={id} className="c-table-wrap">
{summary && summary.length > 0 && (
<div className="c-table-summary">
<div className="c-table-summary-dots">
{summary.map((item, si) => (
<span key={si} className={`c-table-summary-dot c-table-summary-dot-${item.color || "default"}`}>
<span className="c-dot" />
<span className="c-dot-label">{item.label}: {item.value}</span>
</span>
))}
</div>
<div className="c-table-summary-bar">
{summary.map((item, si) => (
<div
key={si}
className={`c-table-summary-seg c-table-summary-seg-${item.color || "default"}`}
style={{ width: `${summaryTotal > 0 ? (item.value / summaryTotal * 100).toFixed(1) : 0}%` } as React.CSSProperties}
title={`${item.label}: ${item.value}`}
/>
))}
</div>
</div>
)}
<table className="c-table">
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{columns.map((col, ci) => {
const cellVal = String(row[col.key] ?? "");
const mappedColor = col.color_map ? col.color_map[cellVal] : undefined;
return (
<td key={ci} className={mappedColor ? `cell-${mappedColor}` : undefined}>{renderInline(cellVal)}</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
case "meta": {
const fields = (comp.fields as Array<{ key: string; value: string }>) || [];
return (
<div id={id} className="c-meta">
{fields.map((f, i) => (
<div key={i} className="c-meta-item">
<span className="c-meta-key">{f.key}</span>
<span className="c-meta-value">{renderInline(f.value)}</span>
</div>
))}
</div>
);
}
case "section": {
const heading = comp.heading as string | undefined;
const eyebrow = comp.eyebrow as string | undefined;
const children = (comp.components as ComponentData[]) || [];
const sectionAlign = comp.align as string | undefined;
return (
<section id={id} className={`c-section${sectionAlign ? ` align-${sectionAlign}` : ""}`}>
{(eyebrow || heading) && (
<div className="c-section-header">
{eyebrow && <div className="c-section-eyebrow">{eyebrow}</div>}
{heading && <h2 className="c-section-heading">{heading}</h2>}
</div>
)}
{children.map((c, i) => (
<ComponentView key={i} comp={c} index={i} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</section>
);
}
case "blockquote": {
const body = (comp.body as string) || "";
const attribution = comp.attribution as string | undefined;
return (
<figure id={id} className="c-blockquote">
<blockquote><p>{renderInline(body)}</p></blockquote>
{attribution && (
<figcaption className="c-blockquote-attribution">— {attribution}</figcaption>
)}
</figure>
);
}
case "badge": {
const label = (comp.label as string) || "";
const color = (comp.color as string) || "default";
return <span id={id} className={`c-badge c-badge-${color}`}>{label}</span>;
}
case "status": {
const label = (comp.label as string) || "";
const color = (comp.color as string) || "default";
return (
<span id={id} className={`c-status c-status-${color}`}>
<span className="c-status-dot" />
<span>{label}</span>
</span>
);
}
case "progress_bar": {
const value = Math.min(100, (comp.value as number) || 0);
const label = comp.label as string | undefined;
const detail = comp.detail as string | undefined;
const target = comp.target as number | undefined;
const thresholds = comp.thresholds as Array<{ at: number; color: string }> | undefined;
let autoColor = (comp.color as string) || "default";
if (thresholds && thresholds.length > 0) {
const sorted = [...thresholds].sort((a, b) => b.at - a.at);
const matched = sorted.find(t => value >= t.at);
if (matched) autoColor = matched.color;
}
const targetPct = target != null ? Math.min(100, Math.max(0, target)) : undefined;
return (
<div id={id} className="c-progress">
{(label || true) && (
<div className="c-progress-labels">
{label ? <span className="c-progress-label">{label}</span> : <span />}
<span className="c-progress-value">{value}%</span>
</div>
)}
<div className="c-progress-track" role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100} style={{ position: "relative" } as React.CSSProperties}>
<div className={`c-progress-fill c-progress-fill-${autoColor}`} style={{ "--progress": `${value}%` } as React.CSSProperties} />
{targetPct != null && (
<div className="c-progress-target" style={{ left: `${targetPct}%` } as React.CSSProperties} aria-label={`Target: ${targetPct}%`} />
)}
</div>
{detail && <div className="c-progress-detail">{detail}</div>}
</div>
);
}
case "embed": {
const src = (comp.src as string) || "";
const title = (comp.title as string) || "Embedded video";
const aspect = (comp.aspect as string) || "16/9";
return (
<div id={id} className="c-embed" style={{ "--embed-ratio": aspect } as React.CSSProperties}>
<iframe src={src} title={title} frameBorder="0" allowFullScreen />
</div>
);
}
case "timeline": {
const items = (comp.items as Array<{ name: string; status: string }>) || [];
return (
<div id={id} className="c-timeline">
{items.map((item, i) => (
<div key={i} className={`c-timeline-phase ${item.status || "upcoming"}`}>
<div className="c-timeline-dot" />
<div className="c-timeline-label">{item.name}</div>
<div className={`c-timeline-bar ${item.status || "upcoming"}`} />
</div>
))}
</div>
);
}
case "split_compare": {
const left = comp.left as { eyebrow?: string; title: string; stats: Array<{ label: string; value: string; color?: string }> };
const right = comp.right as { eyebrow?: string; title: string; stats: Array<{ label: string; value: string; color?: string }> };
const rowSpan = 2 + Math.max(left.stats?.length || 0, right.stats?.length || 0);
return (
<div id={id} className="c-split-compare">
{[left, right].map((panel, pi) => (
<div key={pi} className={`c-sc-panel c-sc-${pi === 0 ? "left" : "right"}`} style={{ "--sc-span": rowSpan } as React.CSSProperties}>
<div className="c-sc-eyebrow">{panel.eyebrow || ""}</div>
<div className="c-sc-title">{panel.title}</div>
{(panel.stats || []).map((s, si) => (
<div key={si} className="c-sc-stat">
<span className="c-sc-label">{s.label}</span>
<span className={`c-sc-value color-${s.color || "default"}`}>{s.value}</span>
</div>
))}
</div>
))}
</div>
);
}
case "before_after": {
const items = (comp.items as Array<{ title: string; before: string; after: string; after_context?: string }>) || [];
const beforeLabel = (comp.before_label as string) || "Before";
const afterLabel = (comp.after_label as string) || "After";
return (
<div id={id} className="c-before-after">
{items.map((item, i) => (
<div key={i} className="c-ba-card">
<div className="c-ba-title">{item.title}</div>
<div className="c-ba-before">{beforeLabel}: {item.before}</div>
<div className="c-ba-after">{afterLabel}: <span className="c-ba-highlight">{item.after}</span>{item.after_context ? ` — ${item.after_context}` : ""}</div>
</div>
))}
</div>
);
}
case "tabs": {
const tabs = (comp.tabs as Array<{ label: string; components: ComponentData[] }>) || [];
const [activeTab, setActiveTab] = React.useState(0);
return (
<div id={id} className="c-tabs">
<div className="c-tab-buttons">
{tabs.map((tab, ti) => (
<button key={ti} className={`tab-btn${ti === activeTab ? " tab-btn-active" : ""}`} onClick={() => setActiveTab(ti)}>{tab.label}</button>
))}
</div>
{tabs.map((tab, ti) => (
<div key={ti} className="tab-panel" style={{ display: ti === activeTab ? "block" : "none" }}>
{(tab.components || []).map((c, ci) => (
<ComponentView key={ci} comp={c} index={ci} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
))}
</div>
);
}
case "columns": {
const cols = (comp.columns as ComponentData[][]) || [];
const eqHeights = comp.equal_heights as boolean;
return (
<div id={id} className={`c-columns${eqHeights ? " c-columns-stretch" : ""}`} style={{ "--cols": cols.length } as React.CSSProperties}>
{cols.map((col, ci) => (
<div key={ci} className="c-column">
{col.map((c, i) => (
<ComponentView key={i} comp={c} index={i} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
))}
</div>
);
}
case "accordion": {
const items = (comp.items as Array<{ title: string; components: ComponentData[] }>) || [];
return <AccordionView id={id} items={items} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />;
}
case "hero_banner": {
const title = (comp.title as string) || "";
const eyebrow = comp.eyebrow as string | undefined;
const subtitle = comp.subtitle as string | undefined;
const buttons = (comp.buttons as Array<{ label: string; href: string; variant?: string; external?: boolean }>) || [];
return (
<div id={id} className="c-hero">
{eyebrow && <div className="c-hero-eyebrow">{eyebrow}</div>}
<h1 className="c-hero-title">{title}</h1>
{subtitle && <p className="c-hero-subtitle">{subtitle}</p>}
{buttons.length > 0 && (
<div className="c-hero-buttons">
{buttons.map((b, i) => (
<a key={i} className={`c-btn c-btn-${b.variant || "primary"}`} href={b.href}>{b.label}</a>
))}
</div>
)}
</div>
);
}
case "event_timeline": {
const rawEvents = (comp.events as Array<{ date: string; title: string; summary?: string; severity?: string; source?: string; link?: string; tags?: string[] }>) || [];
// Most-recent-first, regardless of authored order - a changelog/activity
// feed, not an ordered list an author controls.
const events = [...rawEvents].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
const filterBy = comp.filter_by as string[] | undefined;
const [activeTags, setActiveTags] = React.useState<Set<string>>(new Set());
const handleTagFilter = (tag: string) => {
setActiveTags(prev => {
const next = new Set(prev);
if (next.has(tag)) next.delete(tag); else next.add(tag);
return next;
});
};
const tagCounts: Record<string, number> = {};
if (filterBy) filterBy.forEach(tag => {
tagCounts[tag] = events.filter(e => (e.tags || []).includes(tag)).length;
});
const visibleEvents = activeTags.size === 0 ? events : events.filter(ev => (ev.tags || []).some(t => activeTags.has(t)));
return (
<div id={id} className="c-event-timeline">
{filterBy && filterBy.length > 0 && (
<div className="c-event-tag-filters">
{filterBy.map((tag, ti) => (
<button key={ti} className={`c-event-tag-pill${activeTags.has(tag) ? " active" : ""}`} data-tag={tag} onClick={() => handleTagFilter(tag)}>{tag} <span className="c-event-tag-count">{tagCounts[tag] || 0}</span></button>
))}
</div>
)}
{visibleEvents.map((ev, i) => (
<div key={i} className={`c-event severity-${ev.severity || "minor"}`} data-severity={ev.severity || "minor"} {...(ev.tags && ev.tags.length > 0 ? { "data-event-tags": ev.tags.join(",") } : {})}>
<div className="c-event-rail">
<div className="c-event-dot" />
</div>
<div className="c-event-body">
<div className="c-event-meta">
<span className="c-event-date">{ev.date}</span>
{ev.severity && <span className="c-event-severity">{ev.severity}</span>}
{ev.source && <span className="c-event-source">{ev.source}</span>}
</div>
<div className="c-event-title">{ev.link ? <a href={ev.link}>{ev.title}</a> : ev.title}</div>
{ev.summary && <p className="c-event-summary">{renderInline(ev.summary)}</p>}
{ev.tags && ev.tags.length > 0 && (
<div className="c-event-tags">
{ev.tags.map((tag, ti) => <span key={ti} className="c-event-tag">{tag}</span>)}
</div>
)}
</div>
</div>
))}
</div>
);
}
case "tree": {
const nodes = (comp.nodes as Array<Record<string, unknown>>) || [];
const showFilterToggle = (comp.show_filter_toggle as boolean) ?? true;
const defaultFilter = (comp.default_filter as string) || "all";
const defaultCollapsed = (comp.default_collapsed as boolean) || false;
const defaultDepth = comp.default_depth as number | undefined;
const statusGlyphs: Record<string, string> = { completed: "✓", active: "●", blocked: "✕", priority: "!", upcoming: "○" };
const filters = ["all", "incomplete", "blocked", "priority"];
const filterLabels: Record<string, string> = { all: "All", incomplete: "Incomplete only", blocked: "Blocked only", priority: "Priority only" };
const [activeFilter, setActiveFilter] = React.useState(defaultFilter);
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => {
const set = new Set<string>();
function walk(nodeList: Array<Record<string, unknown>>, depth: number, prefix: string) {
for (let i = 0; i < nodeList.length; i++) {
const path = prefix ? `${prefix}.${i}` : String(i);
const ch = (nodeList[i].children as Array<Record<string, unknown>>) || [];
if (ch.length > 0) {
if (defaultDepth != null ? depth >= defaultDepth : defaultCollapsed) set.add(path);
walk(ch, depth + 1, path);
}
}
}
walk(nodes, 0, "");
return set;
});
const toggleCollapse = React.useCallback((path: string) => {
setCollapsed(prev => {
const next = new Set(prev);
if (next.has(path)) next.delete(path); else next.add(path);
return next;
});
}, []);
const hasBlockedDescendant = (node: Record<string, unknown>): boolean => {
const children = (node.children as Array<Record<string, unknown>>) || [];
if ((node.status as string) === "blocked") return true;
return children.some(hasBlockedDescendant);
};
const hasPriorityDescendant = (node: Record<string, unknown>): boolean => {
const children = (node.children as Array<Record<string, unknown>>) || [];
if ((node.status as string) === "priority") return true;
return children.some(hasPriorityDescendant);
};
const countDescendants = (node: Record<string, unknown>): number => {
const children = (node.children as Array<Record<string, unknown>>) || [];
return children.reduce((acc, c) => acc + 1 + countDescendants(c), 0);
};
const renderNode = (node: Record<string, unknown>, depth: number, path: string): React.JSX.Element => {
const children = (node.children as Array<Record<string, unknown>>) || [];
const status = (node.status as string) || "default";
const hasChildren = children.length > 0;
const isLeaf = !hasChildren;
const isCollapsed = collapsed.has(path);
const blockedDesc = status !== "blocked" && children.some(hasBlockedDescendant);
const priorityDesc = status !== "priority" && children.some(hasPriorityDescendant);
const descCount = countDescendants(node);
return (
<li
className={`c-tree-node status-${status}${isCollapsed && hasChildren ? " collapsed" : ""}`}
data-status={status}
{...(isLeaf ? { "data-leaf": "true" } : {})}
{...(blockedDesc ? { "data-has-blocked-descendant": "true" } : {})}
{...(priorityDesc ? { "data-has-priority-descendant": "true" } : {})}
>
<div className="c-tree-row">
{hasChildren && <span className="c-tree-chevron" aria-hidden="true" onClick={() => toggleCollapse(path)}>▶</span>}
<span className="c-tree-glyph" aria-hidden="true">{statusGlyphs[status] || "·"}</span>
<span className="c-tree-label">{node.label as string}</span>
{node.owner ? <span className="c-tree-owner">{String(node.owner)}</span> : null}
{hasChildren && <span className="c-tree-count">{descCount}</span>}
{node.note ? <span className="c-tree-note">{node.note as string}</span> : null}
</div>
{hasChildren && (
<ul className="c-tree-children">
{children.map((child, i) => {
const childPath = path ? `${path}.${i}` : String(i);
return <React.Fragment key={i}>{renderNode(child, depth + 1, childPath)}</React.Fragment>;
})}
</ul>
)}
</li>
);
};
return (
<div id={id} className={`c-tree filter-${activeFilter}`} data-filter={activeFilter}>
{showFilterToggle && (
<div className="c-tree-filter-toggle">
{filters.map((f) => (
<button key={f} type="button" className={f === activeFilter ? "active" : ""} onClick={() => setActiveFilter(f)}>{filterLabels[f]}</button>
))}
</div>
)}
<ul className="c-tree-root">{nodes.map((n, i) => <React.Fragment key={i}>{renderNode(n, 0, String(i))}</React.Fragment>)}</ul>
</div>
);
}
case "priority_queue": {
const items = (comp.items as Array<Record<string, unknown>>) || [];
const groupBy = (comp.group_by as string) || "urgency";
const showDates = (comp.show_dates as boolean) ?? true;
const showCounts = (comp.show_counts as boolean) ?? true;
const filterable = (comp.filterable as boolean) ?? false;
const title = comp.title as string | undefined;
const today = (() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
})();
const addDays = (dateStr: string, n: number): string => {
const d = new Date(dateStr + "T00:00:00");
d.setDate(d.getDate() + n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
const fmtDate = (d: string): string => {
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const [, m, day] = d.split("-");
return `${months[parseInt(m, 10) - 1]} ${parseInt(day, 10)}`;
};
const twoWeekEnd = addDays(today, 13);
const eightWeekEnd = addDays(today, 55);
const getDateUrgency = (item: Record<string, unknown>): string => {
const status = (item.status as string) || "default";
if (status === "blocked") return "blocked";
const due = item.due as string | undefined;
if (!due) return "none";
if (due < today) {
return status === "completed" ? "soon" : "overdue";
}
if (due <= twoWeekEnd) return "soon";
if (due <= eightWeekEnd) return "track";
return "track";
};
const getUrgency = (item: Record<string, unknown>): string => {
const horizon = item.horizon as string | undefined;
if (horizon) {
if (horizon === "now") return "soon";
return "track";
}
return getDateUrgency(item);
};
const horizonRank: Record<string, number> = { overdue: 0, soon: 1, track: 2, none: 3, blocked: 2 };
const hasDrift = (item: Record<string, unknown>): boolean => {
const horizon = item.horizon as string | undefined;
if (!horizon || !item.due) return false;
const dateUrg = getDateUrgency(item);
const horizonUrg = horizon === "now" ? "soon" : "track";
return (horizonRank[dateUrg] ?? 3) < (horizonRank[horizonUrg] ?? 3);
};
const [query, setQuery] = React.useState("");
const [openOverride, setOpenOverride] = React.useState<Record<string, boolean>>({});
const trimmedQuery = filterable ? query.toLowerCase().trim() : "";
const filteredItems = React.useMemo(() => {
if (!trimmedQuery) return items;
return items.filter((item) => {
const tags = (item.tags as Array<{ label?: string }> | undefined) || [];
const haystack = [
item.label as string | undefined,
item.detail as string | undefined,
item.owner as string | undefined,
item.due as string | undefined,
...tags.map((t) => t.label),
];
return haystack.some((part) => typeof part === "string" && part.toLowerCase().includes(trimmedQuery));
});
}, [items, trimmedQuery]);
const activeItems = React.useMemo(() => filteredItems.filter((item) => (item.status as string) !== "completed"), [filteredItems]);
const doneItems = React.useMemo(() => filteredItems.filter((item) => (item.status as string) === "completed"), [filteredItems]);
type QueueGroup = { key: string; label: string; groupItems: Array<Record<string, unknown>> };
const groups = React.useMemo<QueueGroup[]>(() => {
if (groupBy === "none") return [{ key: "all", label: "", groupItems: activeItems }];
const buckets = new Map<string, Array<Record<string, unknown>>>();
const order: string[] = [];
const pushTo = (key: string, item: Record<string, unknown>) => {
if (!buckets.has(key)) { buckets.set(key, []); order.push(key); }
buckets.get(key)!.push(item);
};
const itemBucket = (item: Record<string, unknown>): string => {
const horizon = item.horizon as string | undefined;
const due = item.due as string | undefined;
const status = (item.status as string) || "default";
if (horizon) {
if (horizon === "now") return "two_weeks";
if (horizon === "next") return "two_to_eight";
return "later";
}
if (!due) return "none";
if (due < today) {
return status === "completed" ? "two_weeks" : "overdue";
}
if (due <= twoWeekEnd) return "two_weeks";
if (due <= eightWeekEnd) return "two_to_eight";
return "later";
};
if (groupBy === "urgency") {
for (const item of activeItems) { pushTo(itemBucket(item), item); }
const fixedOrder = ["overdue", "two_weeks", "two_to_eight", "later", "none"];
const labels: Record<string, string> = { overdue: "Overdue", two_weeks: "Next two weeks", two_to_eight: "2-8 weeks", later: "Later", none: "No date" };
return fixedOrder.filter((k) => buckets.has(k)).map((k) => ({ key: k, label: labels[k], groupItems: buckets.get(k)! }));
}
if (groupBy === "horizon") {
const horizonMap: Record<string, string> = { overdue: "now", two_weeks: "now", two_to_eight: "next", later: "later", none: "none" };
for (const item of activeItems) { pushTo(horizonMap[itemBucket(item)] || "later", item); }
const fixedOrder = ["now", "next", "later", "none"];
const labels: Record<string, string> = { now: "Now", next: "Next", later: "Later", none: "No date" };
return fixedOrder.filter((k) => buckets.has(k)).map((k) => ({ key: k, label: labels[k], groupItems: buckets.get(k)! }));
}
if (groupBy === "owner") {
for (const item of activeItems) {
const owner = (item.owner as string) || "Unassigned";
pushTo(owner, item);
}
return order.map((k) => ({ key: k, label: k, groupItems: buckets.get(k)! }));
}
if (groupBy === "status") {
for (const item of activeItems) {
const status = (item.status as string) || "default";
pushTo(status, item);
}
return order.map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1), groupItems: buckets.get(k)! }));
}
return [{ key: "all", label: "", groupItems: activeItems }];
}, [activeItems, groupBy, today, twoWeekEnd, eightWeekEnd]);
const renderSlip = (item: Record<string, unknown>): React.JSX.Element | null => {
const due = item.due as string | undefined;
const originalDue = item.original_due as string | undefined;
if (!due || !originalDue) return null;
if (due > originalDue) return <span className="c-queue-slip">was {fmtDate(originalDue)}</span>;
if (due < originalDue) return <span className="c-queue-slip pulled">pulled in</span>;
return null;
};
const renderItem = (item: Record<string, unknown>, i: number): React.JSX.Element => {
const status = (item.status as string) || "default";
const urgency = getUrgency(item);
const drift = hasDrift(item);
const label = item.label as string;
const detail = item.detail as string | undefined;
const owner = item.owner as string | undefined;
const href = item.href as string | undefined;
const due = item.due as string | undefined;
const tags = (item.tags as Array<{ label: string; color?: string; emphasis?: boolean }>) || [];
return (
<div key={i} className={`c-queue-row status-${status} urgency-${urgency}${drift ? " drift" : ""}`}>
<div className="c-queue-stripe"></div>
<div className="c-queue-main">
<div className="c-queue-label-line">
{href ? <a className="c-queue-label" href={href}>{label}</a> : <span className="c-queue-label">{label}</span>}
{owner && <span className="c-queue-owner">{owner}</span>}
</div>
{detail && <div className="c-queue-detail">{detail}</div>}
{tags.length > 0 && (
<div className="c-queue-tags">
{tags.map((tag, ti) => (
<span key={ti} className={`c-queue-tag color-${tag.color || "default"}${tag.emphasis ? " emphasis" : ""}`}>{tag.label}</span>
))}
</div>
)}
</div>
{showDates && due && (
<div className="c-queue-date">
<span className="c-queue-due">{fmtDate(due)}</span>
{renderSlip(item)}
</div>
)}
</div>
);
};
const defaultOpen = (groupKey: string, index: number): boolean => {
if (groupBy === "none") return true;
if (groupBy === "urgency") return groupKey === "overdue" || groupKey === "two_weeks";
if (groupBy === "horizon") return groupKey === "now";
return index === 0;
};
const toggleGroup = (groupKey: string, open: boolean) => {
setOpenOverride((prev) => ({ ...prev, [groupKey]: !open }));
};
return (
<div id={id} className="c-queue">
{title && <div className="c-queue-title">{title}</div>}
{filterable && (
<div className="c-queue-search">
<input
type="text"
className="c-queue-search-input"
placeholder="Filter items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
)}
{groups.map((group, gi) => {
const open = trimmedQuery ? true : (openOverride[group.key] ?? defaultOpen(group.key, gi));
return (
<div key={group.key} className={`c-queue-group${open ? "" : " collapsed"}`}>
{groupBy !== "none" && (
<div className="c-queue-group-header" onClick={() => toggleGroup(group.key, open)}>
<span className="c-queue-group-label">{group.label}</span>
{showCounts && <span className="c-queue-group-count">{group.groupItems.length}</span>}
</div>
)}
{group.groupItems.map((item, i) => renderItem(item, i))}
</div>
);
})}
{doneItems.length > 0 && (() => {
const doneOpen = trimmedQuery ? true : (openOverride["__done"] ?? false);
return (
<div className={`c-queue-group c-queue-done${doneOpen ? "" : " collapsed"}`}>
<div className="c-queue-group-header" onClick={() => toggleGroup("__done", doneOpen)}>
<span className="c-queue-group-label">Done</span>
{showCounts && <span className="c-queue-group-count">{doneItems.length}</span>}
</div>
{doneItems.map((item, i) => renderItem(item, i))}
</div>
);
})()}
</div>
);
}
case "selectable_grid": {
const cards = (comp.cards as Array<{ title: string; eyebrow?: string; bullets?: string[]; body?: string }>) || [];
const connector = comp.connector as string | undefined;
const gridClass = connector === "arrow" ? "c-sel-cards c-sel-cards-arrow" : "c-sel-cards";
return (
<div id={id} className="c-selectable-grid">
<div className={gridClass} style={{ "--sel-cols": cards.length } as React.CSSProperties}>
{cards.map((card, i) => (
<div key={i} className="sel-card">
{card.eyebrow && <div className="c-sel-eyebrow">{card.eyebrow}</div>}
<div className="c-sel-title">{card.title}</div>
{card.body && <div className="c-sel-body">{renderInline(card.body)}</div>}
{card.bullets && (
<ul className="c-sel-bullets">
{card.bullets.map((b, bi) => <li key={bi}><span className="c-sel-bullet-dot" /><span>{renderInline(b)}</span></li>)}
</ul>
)}
</div>
))}
</div>
</div>
);
}
case "resources": {
const items = (comp.items as Array<{ title: string; href: string; description?: string; owner?: string }>) || [];
return (
<div id={id} className="c-resources">
{items.map((item, i) => (
<a key={i} className="c-resource" href={item.href}>
<div className="c-resource-title">{item.title}</div>
{item.description && <div className="c-resource-desc">{renderInline(item.description)}</div>}
{item.owner && <div className="c-resource-owner">{item.owner}</div>}
</a>
))}
</div>
);
}
case "button_group": {
const buttons = (comp.buttons as Array<{ label: string; href: string; variant?: string; external?: boolean }>) || [];
return (
<div id={id} className="c-button-group">
{buttons.map((b, i) => (
<a key={i} className={`c-btn c-btn-${b.variant || "primary"}`} href={b.href} {...(b.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>{b.label}</a>
))}
</div>
);
}
case "definition_list": {
const items = (comp.items as Array<{ term: string; definition: string }>) || [];
return (
<dl id={id} className="c-definition-list">
{items.map((item, i) => (
<div key={i} className="c-dl-row">
<dt className="c-dl-term">{item.term}</dt>
<dd className="c-dl-def">{renderInline(item.definition)}</dd>
</div>
))}
</dl>
);
}
case "avatar": {
const name = (comp.name as string) || "";
const src = comp.src as string | undefined;
const subtitle = comp.subtitle as string | undefined;
const sizeClass = (comp.size as string) || "md";
const initials = name.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase();
if (subtitle) {
return (
<div id={id} className="c-avatar-row">
<div className={`c-avatar c-avatar-${sizeClass}`}>
{src ? <img src={assetSrc(src)} alt={name} /> : <span className="c-avatar-initials">{initials}</span>}
</div>
<div className="c-avatar-meta">
<div className="c-avatar-name">{name}</div>
<div className="c-avatar-sub">{subtitle}</div>
</div>
</div>
);
}
return (
<div id={id} className={`c-avatar c-avatar-${sizeClass}`}>
{src ? <img src={assetSrc(src)} alt={name} /> : <span className="c-avatar-initials">{initials}</span>}
</div>
);
}
case "avatar_group": {
const avatars = (comp.avatars as Array<{ name: string; src?: string }>) || [];
const max = (comp.max as number) || 5;
const sizeClass = (comp.size as string) || "md";
const visible = avatars.slice(0, max);
const overflow = avatars.length - max;
return (
<div id={id} className={`c-avatar-group c-avatar-group-${sizeClass}`}>
{visible.map((a, i) => {
const initials = a.name.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase();
return (
<div key={i} className={`c-avatar c-avatar-${sizeClass}`} title={a.name}>
{a.src ? <img src={assetSrc(a.src)} alt={a.name} /> : <span className="c-avatar-initials">{initials}</span>}
</div>
);
})}
{overflow > 0 && <div className={`c-avatar c-avatar-${sizeClass} c-avatar-more`}>+{overflow}</div>}
</div>
);
}
case "breadcrumb": {
const items = (comp.items as Array<{ label: string; href?: string }>) || [];
return (
<nav id={id} className="c-breadcrumb">
{items.map((item, i) => (
<span key={i}>
{i > 0 && <span className="c-breadcrumb-sep">/</span>}
{item.href ? <a href={item.href}>{item.label}</a> : <span>{item.label}</span>}
</span>
))}
</nav>
);
}
case "tag": {
const label = (comp.label as string) || "";
const color = (comp.color as string) || "default";
return <span id={id} className={`c-tag c-tag-${color}`}>{label}</span>;
}
case "kbd": {
const keys = (comp.keys as string[]) || [];
return (
<span id={id} className="c-kbd">
{keys.map((k, i) => (
<span key={i}>
{i > 0 && <span className="c-kbd-sep">+</span>}
<kbd>{k}</kbd>
</span>
))}
</span>
);
}
case "empty_state": {
const title = (comp.title as string) || "";
const body = comp.body as string | undefined;
const action = comp.action as { label: string; href: string } | undefined;
return (
<div id={id} className="c-empty-state">
<h3 className="c-empty-title">{title}</h3>
{body && <p className="c-empty-body">{renderInline(body)}</p>}
{action && <a className="c-btn c-btn-primary" href={action.href}>{action.label}</a>}
</div>
);
}
case "venn": {
const sets = (comp.sets as Array<{ label: string; color?: string }>) || [];
const overlaps = (comp.overlaps as Array<{ sets: number[]; label?: string }>) || [];
const title = comp.title as string | undefined;
return (
<div id={id} className="c-venn">
{title && <h3 className="c-venn-title">{title}</h3>}
<div className="c-venn-diagram">
{sets.map((s, i) => (
<div key={i} className={`c-venn-set c-venn-set-${i} c-venn-color-${s.color || "default"}`}>
<span>{s.label}</span>
</div>
))}
</div>
{overlaps.length > 0 && (
<div className="c-venn-overlaps">
{overlaps.map((o, i) => o.label && (
<div key={i} className="c-venn-overlap">
{sets.filter((_, si) => o.sets.includes(si)).map(s => s.label).join(" ∩ ")}: {o.label}
</div>
))}
</div>
)}
</div>
);
}
case "chart": {
if (renderChart) {
return <div id={id} className="c-chart">{renderChart(comp)}</div>;
}
return <ChartSVG id={id} comp={comp} />;
}
case "chart_group": {
const title = comp.title as string | undefined;
const cols = (comp.columns as number) || 2;
const children = (comp.components as ComponentData[]) || [];
return (
<div id={id} className="c-chart-group" style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 16 }}>
{title && <h3 className="c-chart-group-title" style={{ gridColumn: "1 / -1" }}>{title}</h3>}
{children.map((child, ci) => (
<ComponentView key={ci} comp={child} index={ci} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
);
}
case "role_map": {
const title = comp.title as string | undefined;
if (renderRoleMap) return <div id={id} className="c-role-map">{renderRoleMap(comp)}</div>;
return (
<div id={id} className="c-role-map">
{title && <h3 className="c-role-map-title">{title}</h3>}
<div className="c-role-map-placeholder">Roles configured in site settings</div>
</div>
);
}
case "sankey": {
const flows = (comp.flows as Array<{ source: string; target: string; value: number }>) || [];
const colors = (comp.colors as Record<string, string>) || {};
const title = comp.title as string | undefined;
const h = (comp.height as number) || 400;
const VB_W = 720;
if (flows.length === 0) return <div id={id} className="c-chart c-chart-sankey"><em>No flows</em></div>;
const nodeNames: string[] = [];
for (const f of flows) {
if (!nodeNames.includes(f.source)) nodeNames.push(f.source);
if (!nodeNames.includes(f.target)) nodeNames.push(f.target);
}
const inEdges: Record<string, string[]> = {};
for (const f of flows) { (inEdges[f.target] ||= []).push(f.source); }
const col: Record<string, number> = {};
for (const n of nodeNames) { if (!inEdges[n]) col[n] = 0; }
let changed = true;
while (changed) {
changed = false;
for (const f of flows) {
if (col[f.source] !== undefined) {
if ((col[f.target] ?? 0) <= col[f.source]) {
col[f.target] = col[f.source] + 1;
changed = true;
}
}
}
}
for (const n of nodeNames) { if (col[n] === undefined) col[n] = 0; }
const maxCol = Math.max(...Object.values(col));
const numCols = maxCol + 1;
const nodeIn: Record<string, number> = {};
const nodeOut: Record<string, number> = {};
for (const f of flows) {
nodeOut[f.source] = (nodeOut[f.source] || 0) + f.value;
nodeIn[f.target] = (nodeIn[f.target] || 0) + f.value;
}
const nodeTotal: Record<string, number> = {};
for (const n of nodeNames) { nodeTotal[n] = Math.max(nodeIn[n] || 0, nodeOut[n] || 0); }
const byCol: Record<number, number> = {};
for (const n of nodeNames) { byCol[col[n]] = (byCol[col[n]] || 0) + nodeTotal[n]; }
const totalMax = Math.max(...Object.values(byCol));
if (totalMax <= 0) return <div id={id} className="c-chart c-chart-sankey"><em>No data</em></div>;
const padX = 140, padY = 20, nodeW = 18, nodeGap = 8;
const plotW = VB_W - padX * 2, plotH = h - padY * 2;
const colSpacing = numCols > 1 ? plotW / (numCols - 1) : 0;
const positions: Record<string, { x: number; y: number; h: number }> = {};
for (let c = 0; c < numCols; c++) {
const colNodes = nodeNames.filter(n => col[n] === c);
const colTotal = colNodes.reduce((s, n) => s + nodeTotal[n], 0);
const gaps = colNodes.length > 1 ? (colNodes.length - 1) * nodeGap : 0;
const scale = colTotal > 0 ? (plotH - gaps) / colTotal : 1;
const x = padX + colSpacing * c - nodeW / 2;
let y = padY;
for (const n of colNodes) {
const nh = Math.max(nodeTotal[n] * scale, 2);
positions[n] = { x, y, h: nh };
y += nh + nodeGap;
}
}
const sourceOff: Record<string, number> = {};
const targetOff: Record<string, number> = {};
const fmtNum = (v: number) => v >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "K" : String(v);
const flowPaths = flows.map((f, i) => {
const sp = positions[f.source], tp = positions[f.target];
const sTotal = nodeTotal[f.source], tTotal = nodeTotal[f.target];
const sOff = sourceOff[f.source] || 0;
const linkHS = sTotal > 0 ? (f.value / sTotal) * sp.h : 0;
const sy = sp.y + sOff;
sourceOff[f.source] = sOff + linkHS;
const tOff = targetOff[f.target] || 0;
const linkHT = tTotal > 0 ? (f.value / tTotal) * tp.h : 0;
const ty = tp.y + tOff;
targetOff[f.target] = tOff + linkHT;
const sx = sp.x + nodeW, tx = tp.x, cpx = (sx + tx) / 2;
const fillColor = colors[f.source] || colors[f.target] || ["default", "green", "yellow", "red", "teal"][i % 5];
const fill = semToHex[fillColor] || semToHex.default;
return <path key={i} d={`M ${sx} ${sy} C ${cpx} ${sy}, ${cpx} ${ty}, ${tx} ${ty} L ${tx} ${ty + linkHT} C ${cpx} ${ty + linkHT}, ${cpx} ${sy + linkHS}, ${sx} ${sy + linkHS} Z`} fill={fill} fillOpacity={0.35} className="c-sankey-link"><title>{`${f.source} → ${f.target}: ${fmtNum(f.value)}`}</title></path>;
});
const nodeEls = nodeNames.map((n, i) => {
const pos = positions[n];
const fillColor = colors[n] || ["default", "green", "yellow", "red", "teal"][i % 5];
const fill = semToHex[fillColor] || semToHex.default;
const c = col[n];
const [lx, anchor] = c === 0 ? [pos.x - 6, "end" as const] : c === maxCol ? [pos.x + nodeW + 6, "start" as const] : [pos.x + nodeW / 2, "middle" as const];
const ly = pos.y + pos.h / 2;
return <React.Fragment key={i}>
<rect x={pos.x} y={pos.y} width={nodeW} height={pos.h} fill={fill} rx={2} className="c-sankey-node" />
<text x={lx} y={ly} textAnchor={anchor} dominantBaseline="middle" className="c-sankey-label">{n}</text>
<text x={lx} y={ly + 14} textAnchor={anchor} dominantBaseline="middle" className="c-sankey-value">{fmtNum(nodeTotal[n])}</text>
</React.Fragment>;
});
return (
<figure id={id} className="c-chart c-chart-sankey" role="img" aria-label={title || "Sankey diagram"}>
{title && <figcaption className="c-chart-title">{title}</figcaption>}
<svg viewBox={`0 0 ${VB_W} ${h}`} preserveAspectRatio="xMidYMid meet" className="c-chart-svg">{flowPaths}{nodeEls}</svg>
</figure>
);
}
case "radar": {
const axes = (comp.axes as string[]) || [];
const curves = (comp.curves as Array<{ label: string; values: number[]; color?: string }>) || [];
const title = comp.title as string | undefined;
const h = (comp.height as number) || 360;
const maxVal = Math.max(comp.max as number || 0, ...curves.flatMap(c => c.values), 1);
const VB_W = 720;
const n = axes.length;
if (n < 3 || curves.length === 0) return <div id={id} className="c-chart c-chart-radar"><em>Insufficient data</em></div>;
const cx = VB_W / 2, cy = h / 2, r = (Math.min(VB_W, h) / 2) - 60;
const angleOf = (i: number) => -Math.PI / 2 + i * (2 * Math.PI) / n;
const pointAt = (i: number, val: number) => {
const a = angleOf(i), d = (val / maxVal) * r;
return [cx + d * Math.cos(a), cy + d * Math.sin(a)];
};
const rings = Array.from({ length: 5 }, (_, ri) => {
const frac = (ri + 1) / 5;
const pts = Array.from({ length: n }, (_, i) => pointAt(i, maxVal * frac).join(",")).join(" ");
return <polygon key={ri} points={pts} fill="none" stroke="rgba(128,128,128,0.15)" strokeWidth={1} className="c-radar-ring" />;
});
const spokes = axes.map((label, i) => {
const [ex, ey] = pointAt(i, maxVal);
const a = angleOf(i), ld = r + 16;
const lx = cx + ld * Math.cos(a), ly = cy + ld * Math.sin(a);
const anchor = Math.abs(Math.cos(a)) < 0.1 ? "middle" : Math.cos(a) > 0 ? "start" : "end";
return <React.Fragment key={i}>
<line x1={cx} y1={cy} x2={ex} y2={ey} stroke="rgba(128,128,128,0.15)" strokeWidth={1} className="c-radar-axis" />
<text x={lx} y={ly} textAnchor={anchor} dominantBaseline="middle" className="c-radar-label">{label}</text>
</React.Fragment>;
});
const curveSvg = curves.map((curve, ci) => {
const color = semToHex[curve.color || ""] || getColor(curve.color, ci);
const pts = curve.values.map((v, i) => pointAt(i, Math.min(v, maxVal)).join(",")).join(" ");
const dots = curve.values.map((v, i) => {
const [px, py] = pointAt(i, Math.min(v, maxVal));
return <circle key={i} cx={px} cy={py} r={3.5} fill={color} className="c-chart-dot"><title>{`${curve.label} — ${axes[i]}: ${v}`}</title></circle>;
});
return <React.Fragment key={ci}>
<polygon points={pts} fill={color} fillOpacity={0.18} stroke={color} strokeWidth={2} className="c-radar-curve" />
{dots}
</React.Fragment>;
});
const legend = curves.length > 1 ? (
<ul className="c-chart-legend">
{curves.map((c, i) => {
const color = semToHex[c.color || ""] || getColor(c.color, i);
return <li key={i} className="c-chart-legend-item"><span className="c-chart-swatch" style={{ background: color }} /><span>{c.label}</span></li>;
})}
</ul>
) : null;
return (
<figure id={id} className="c-chart c-chart-radar" role="img" aria-label={title || "Radar chart"}>
{title && <figcaption className="c-chart-title">{title}</figcaption>}
<svg viewBox={`0 0 ${VB_W} ${h}`} preserveAspectRatio="xMidYMid meet" className="c-chart-svg">{rings}{spokes}{curveSvg}</svg>
{legend}
</figure>
);
}
case "quadrant": {
const title = comp.title as string | undefined;
const h = (comp.height as number) || 400;
const xAxis = (comp.x_axis as string) || "";
const yAxis = (comp.y_axis as string) || "";
const quadrants = (comp.quadrants as string[]) || [];
const points = (comp.points as Array<{ label: string; x: number; y: number; color?: string }>) || [];
const VB_W = 720;
if (quadrants.length !== 4 || points.length === 0) return <div id={id} className="c-chart c-chart-quadrant"><em>Insufficient data</em></div>;
const left = 90, right = 20, top = 10, bottom = 40;
const plotW = VB_W - left - right, plotH = h - top - bottom;
const midX = left + plotW / 2, midY = top + plotH / 2;
const quadColors = ["rgba(52,211,153,0.06)", "rgba(251,191,36,0.04)", "rgba(248,113,113,0.06)", "rgba(60,206,206,0.04)"];
const quadRects: [number, number, number, number][] = [
[midX, top, plotW / 2, plotH / 2],
[left, top, plotW / 2, plotH / 2],
[left, midY, plotW / 2, plotH / 2],
[midX, midY, plotW / 2, plotH / 2],
];
const xParts = xAxis.split(" → ");
return (
<figure id={id} className="c-chart c-chart-quadrant" role="img" aria-label={title || "Quadrant chart"}>
{title && <figcaption className="c-chart-title">{title}</figcaption>}
<svg viewBox={`0 0 ${VB_W} ${h}`} preserveAspectRatio="xMidYMid meet" className="c-chart-svg">
{quadRects.map(([qx, qy, qw, qh], i) => (
<React.Fragment key={i}>
<rect x={qx} y={qy} width={qw} height={qh} fill={quadColors[i]} className="c-quadrant-bg" />
<text x={qx + qw / 2} y={qy + qh / 2} textAnchor="middle" dominantBaseline="middle" className="c-quadrant-zone-label">{quadrants[i]}</text>
</React.Fragment>
))}
<line x1={midX} y1={top} x2={midX} y2={top + plotH} className="c-quadrant-cross" stroke="rgba(128,128,128,0.3)" strokeWidth={1} strokeDasharray="4 4" />
<line x1={left} y1={midY} x2={left + plotW} y2={midY} className="c-quadrant-cross" stroke="rgba(128,128,128,0.3)" strokeWidth={1} strokeDasharray="4 4" />
{xParts.length === 2 ? (
<>
<text x={left} y={top + plotH + 24} textAnchor="start" className="c-chart-axis">{xParts[0]}</text>
<text x={left + plotW} y={top + plotH + 24} textAnchor="end" className="c-chart-axis">{xParts[1]}</text>
</>
) : (
<text x={midX} y={top + plotH + 24} textAnchor="middle" className="c-chart-axis">{xAxis}</text>
)}
<text x={left - 50} y={midY} textAnchor="middle" transform={`rotate(-90,${left - 50},${midY})`} className="c-chart-axis">{yAxis}</text>
{points.map((pt, i) => {
const px = left + Math.min(Math.max(pt.x, 0), 1) * plotW;
const py = top + (1 - Math.min(Math.max(pt.y, 0), 1)) * plotH;
const color = semToHex[pt.color || ""] || getColor(pt.color, i);
const labelText = pt.label.length > 35 ? pt.label.slice(0, 32) + "…" : pt.label;
const lx = pt.x > 0.5 ? px - 10 : px + 10;
const anchor = pt.x > 0.5 ? "end" : "start";
return <React.Fragment key={i}>
<circle cx={px} cy={py} r={6} fill={color} className="c-chart-dot"><title>{`${pt.label} (${(pt.x * 100).toFixed(0)}%, ${(pt.y * 100).toFixed(0)}%)`}</title></circle>
<text x={lx} y={py} textAnchor={anchor} dominantBaseline="middle" className="c-quadrant-point-label">{labelText}</text>
</React.Fragment>;
})}
</svg>
</figure>
);
}
case "architecture": {
const title = comp.title as string | undefined;
const h = (comp.height as number) || 300;
const direction = (comp.direction as string) || "left_to_right";
const nodes = (comp.nodes as Array<{ id: string; label: string; detail?: string; color?: string }>) || [];
const connections = (comp.connections as Array<{ from: string; to: string; label?: string }>) || [];
const VB_W = 720;
if (nodes.length === 0) return <div id={id} className="c-chart c-chart-arch"><em>No nodes</em></div>;
const inSet = new Set(connections.map(c => c.to));
const col: Record<string, number> = {};
for (const n of nodes) { if (!inSet.has(n.id)) col[n.id] = 0; }
let changed = true;
while (changed) {
changed = false;
for (const c of connections) {
if (col[c.from] !== undefined) {
if ((col[c.to] ?? 0) <= col[c.from]) {
col[c.to] = col[c.from] + 1;
changed = true;
}
}
}
}
for (const n of nodes) { if (col[n.id] === undefined) col[n.id] = 0; }
const maxCol = Math.max(...Object.values(col));
const numCols = maxCol + 1;
const isLR = direction === "left_to_right" || direction === "LR" || direction === "lr";
const pad = 40, nodeW = 130, nodeH = 56;
const colsNodes: Array<typeof nodes> = Array.from({ length: numCols }, () => []);
for (const n of nodes) colsNodes[col[n.id]].push(n);
const positions: Record<string, { cx: number; cy: number }> = {};
if (isLR) {
const colSpacing = numCols > 1 ? (VB_W - pad * 2 - nodeW) / (numCols - 1) : 0;
for (let ci = 0; ci < numCols; ci++) {
const cn = colsNodes[ci], nCount = cn.length;
const totalH = nCount * nodeH + Math.max(nCount - 1, 0) * 16;
const startY = (h - totalH) / 2;
cn.forEach((node, ni) => { positions[node.id] = { cx: pad + nodeW / 2 + colSpacing * ci, cy: startY + ni * (nodeH + 16) + nodeH / 2 }; });
}
} else {
const rowSpacing = numCols > 1 ? (h - pad * 2 - nodeH) / (numCols - 1) : 0;
for (let ci = 0; ci < numCols; ci++) {
const cn = colsNodes[ci], nCount = cn.length;
const totalW = nCount * nodeW + Math.max(nCount - 1, 0) * 24;
const startX = (VB_W - totalW) / 2;
cn.forEach((node, ni) => { positions[node.id] = { cx: startX + ni * (nodeW + 24) + nodeW / 2, cy: pad + nodeH / 2 + rowSpacing * ci }; });
}
}
const connEls = connections.map((c, i) => {
const fp = positions[c.from], tp = positions[c.to];
if (!fp || !tp) return null;
const [x1, y1, x2, y2] = isLR
? [fp.cx + nodeW / 2, fp.cy, tp.cx - nodeW / 2, tp.cy]
: [fp.cx, fp.cy + nodeH / 2, tp.cx, tp.cy - nodeH / 2];
return <React.Fragment key={i}>
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="rgba(128,128,128,0.4)" strokeWidth={1.5} markerEnd="url(#arch-arrow)" className="c-arch-edge" />
{c.label && <text x={(x1 + x2) / 2} y={(y1 + y2) / 2 - 8} textAnchor="middle" className="c-arch-edge-label">{c.label}</text>}
</React.Fragment>;
});
const nodeEls = nodes.map((n, i) => {
const pos = positions[n.id];
if (!pos) return null;
const rx = pos.cx - nodeW / 2, ry = pos.cy - nodeH / 2;
const stroke = semToHex[n.color || ""] || getColor(n.color, i);
return <React.Fragment key={i}>
<rect x={rx} y={ry} width={nodeW} height={nodeH} rx={8} fill="rgba(128,128,128,0.08)" stroke={stroke} strokeWidth={1.5} className="c-arch-node" />
<text x={pos.cx} y={n.detail ? pos.cy - 7 : pos.cy} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-label">{n.label}</text>
{n.detail && <text x={pos.cx} y={pos.cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail">{n.detail}</text>}
</React.Fragment>;
});
return (
<figure id={id} className="c-chart c-chart-arch" role="img" aria-label={title || "Architecture diagram"}>
{title && <figcaption className="c-chart-title">{title}</figcaption>}
<svg viewBox={`0 0 ${VB_W} ${h}`} preserveAspectRatio="xMidYMid meet" className="c-chart-svg">
<defs><marker id="arch-arrow" viewBox="0 0 10 7" refX={10} refY={3.5} markerWidth={8} markerHeight={6} orient="auto-start-reverse"><path d="M 0 0 L 10 3.5 L 0 7 z" fill="rgba(128,128,128,0.5)" /></marker></defs>
{connEls}{nodeEls}
</svg>
</figure>
);
}
case "pipeline": {
const title = comp.title as string | undefined;
const h = (comp.height as number) || 400;
const inputs = (comp.inputs as Array<{ label: string; detail?: string; color?: string; dim?: boolean }>) || [];
const stages = (comp.stages as Array<{ label: string; detail?: string; capabilities?: Array<{ label: string; detail?: string; dim?: boolean }> }>) || [];
const outputs = (comp.outputs as Array<{ label: string; detail?: string; color?: string; dim?: boolean }>) || [];
const context = (comp.context as Array<{ label: string; detail?: string; color?: string }>) || [];
const VB_W = 720;
const hasContext = context.length > 0;
const mainH = hasContext ? h * 0.78 : h;
const ctxY = mainH + 12;
const pad = 16, itemH = 44, gap = 22, stageGap = 10;
const charLabel = 7.2, charDetail = 5.8, itemPadX = 20, minW = 80, maxW = 160;
const estimateW = (items: Array<{ label: string; detail?: string }>) => {
let widest = 0;
for (const it of items) {
widest = Math.max(widest, it.label.length * charLabel, (it.detail?.length || 0) * charDetail);
}
return Math.min(maxW, Math.max(minW, widest + itemPadX * 2));
};
const inputW = inputs.length ? estimateW(inputs) : minW;
const outputW = outputs.length ? estimateW(outputs) : minW;
const capRowH = 36, capGap = 6, stagePadTop = 40, stagePadBot = 14;
const stageHeights = stages.map(s => {
const n = Math.max((s.capabilities || []).length, 1);
return stagePadTop + n * capRowH + Math.max(n - 1, 0) * capGap + stagePadBot;
});
const totalStagesH = stageHeights.reduce((a, b) => a + b, 0) + Math.max(stages.length - 1, 0) * stageGap;
const stagesW = VB_W - pad - inputW - gap - gap - outputW - pad;
const stagesX = pad + inputW + gap;
const stagesStartY = (mainH - totalStagesH) / 2;
let curY = stagesStartY;
const stageLayouts = stageHeights.map(sh => { const layout = { y: curY, h: sh }; curY += sh + stageGap; return layout; });
const centerMidY = stagesStartY + totalStagesH / 2;
const inputX = pad;
const outputX = stagesX + stagesW + gap;
const inTotal = inputs.length * itemH + Math.max(inputs.length - 1, 0) * 10;
const inStartY = (mainH - inTotal) / 2;
const outTotal = outputs.length * itemH + Math.max(outputs.length - 1, 0) * 10;
const outStartY = (mainH - outTotal) / 2;
return (
<figure id={id} className="c-chart c-chart-pipeline" role="img" aria-label={title || "Pipeline diagram"}>
{title && <figcaption className="c-chart-title">{title}</figcaption>}
<svg viewBox={`0 0 ${VB_W} ${h}`} preserveAspectRatio="xMidYMid meet" className="c-chart-svg">
<defs><marker id="pipe-arrow" viewBox="0 0 10 7" refX={10} refY={3.5} markerWidth={8} markerHeight={6} orient="auto-start-reverse"><path d="M 0 0 L 10 3.5 L 0 7 z" fill="rgba(128,128,128,0.5)" /></marker></defs>
{inputs.map((item, i) => {
const y = inStartY + i * (itemH + 10);
const cx = inputX + inputW / 2;
const cy = y + itemH / 2;
const stroke = semToHex[item.color || ""] || "rgba(128,128,128,0.4)";
return <React.Fragment key={`in-${i}`}>
<g opacity={item.dim ? 0.3 : 1}>
<rect x={inputX} y={y} width={inputW} height={itemH} rx={8} fill="rgba(128,128,128,0.08)" stroke={stroke} strokeWidth={1.5} />
<text x={cx} y={item.detail ? cy - 7 : cy} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-label">{item.label}</text>
{item.detail && <text x={cx} y={cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail">{item.detail}</text>}
</g>
<line x1={inputX + inputW} y1={cy} x2={stagesX - 4} y2={centerMidY} stroke="rgba(128,128,128,0.3)" strokeWidth={1.5} markerEnd="url(#pipe-arrow)" />
</React.Fragment>;
})}
{stages.map((stage, si) => {
const sl = stageLayouts[si];
const stagesCx = stagesX + stagesW / 2;
const caps = stage.capabilities || [];
const capW = stagesW - 24;
const capX = stagesX + 12;
const capStartY = sl.y + stagePadTop;
return <React.Fragment key={`stage-${si}`}>
<rect x={stagesX} y={sl.y} width={stagesW} height={sl.h} rx={12} fill="rgba(128,128,128,0.03)" stroke="rgba(128,128,128,0.25)" strokeWidth={1.5} strokeDasharray="6 3" />
<text x={stagesCx} y={sl.y + 16} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-label" fontSize={13}>{stage.label}</text>
{stage.detail && <text x={stagesCx} y={sl.y + 30} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" fontSize={10}>{stage.detail}</text>}
{caps.map((cap, ci) => {
const cy = capStartY + ci * (capRowH + capGap);
const tcx = capX + capW / 2;
const tcy = cy + capRowH / 2;
return <g key={ci} opacity={cap.dim ? 0.3 : 1}>
<rect x={capX} y={cy} width={capW} height={capRowH} rx={6} fill="rgba(128,128,128,0.05)" stroke="rgba(128,128,128,0.15)" strokeWidth={1} />
<text x={tcx} y={cap.detail ? tcy - 6 : tcy} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-label" fontSize={11}>{cap.label}</text>
{cap.detail && <text x={tcx} y={tcy + 8} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" fontSize={9}>{cap.detail}</text>}
</g>;
})}
</React.Fragment>;
})}
{outputs.map((item, i) => {
const y = outStartY + i * (itemH + 10);
const cx = outputX + outputW / 2;
const cy = y + itemH / 2;
const stroke = semToHex[item.color || ""] || "rgba(128,128,128,0.4)";
return <React.Fragment key={`out-${i}`}>
<g opacity={item.dim ? 0.3 : 1}>
<rect x={outputX} y={y} width={outputW} height={itemH} rx={8} fill="rgba(128,128,128,0.08)" stroke={stroke} strokeWidth={1.5} />
<text x={cx} y={item.detail ? cy - 7 : cy} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-label">{item.label}</text>
{item.detail && <text x={cx} y={cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail">{item.detail}</text>}
</g>
<line x1={stagesX + stagesW} y1={centerMidY} x2={outputX - 4} y2={cy} stroke="rgba(128,128,128,0.3)" strokeWidth={1.5} markerEnd="url(#pipe-arrow)" />
</React.Fragment>;
})}
{hasContext && (() => {
const ctxItemW = Math.max(inputW, outputW) + 20;
const ctxTotalW = context.length * ctxItemW + Math.max(context.length - 1, 0) * 16;
const ctxStartX = (VB_W - ctxTotalW) / 2;
const lastStage = stageLayouts[stageLayouts.length - 1];
const targetY = lastStage ? lastStage.y + lastStage.h + 4 : centerMidY;
return context.map((item, i) => {
const x = ctxStartX + i * (ctxItemW + 16);
const cx = x + ctxItemW / 2;
const cy = ctxY + itemH / 2;
const stroke = semToHex[item.color || ""] || "rgba(128,128,128,0.4)";
return <React.Fragment key={`ctx-${i}`}>
<rect x={x} y={ctxY} width={ctxItemW} height={itemH} rx={8} fill="rgba(128,128,128,0.08)" stroke={stroke} strokeWidth={1.5} />
<text x={cx} y={item.detail ? cy - 7 : cy} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-label">{item.label}</text>
{item.detail && <text x={cx} y={cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail">{item.detail}</text>}
<line x1={cx} y1={ctxY} x2={cx} y2={targetY} stroke="rgba(128,128,128,0.2)" strokeWidth={1} strokeDasharray="4 2" markerEnd="url(#pipe-arrow)" />
</React.Fragment>;
});
})()}
</svg>
</figure>
);
}
case "graph": {
return <GraphSVG id={id} comp={comp} />;
}
case "org_chart": {
const chartTitle = comp.title as string | undefined;
const people = (comp.people || []) as any[];
const defaultDepth = comp.default_open_depth as number | undefined;
const totalPeople = (() => { const count = (arr: any[]): number => arr.reduce((s: number, p: any) => s + 1 + count(p.reports || []), 0); return count(people); })();
const autoDepth = defaultDepth !== undefined ? defaultDepth : totalPeople <= 15 ? 99 : totalPeople <= 40 ? 2 : 1;
const multiRoot = people.length > 1;
return (
<div id={id} className="c-org-chart">
{chartTitle && <div className="c-org-chart-title">{chartTitle}</div>}
<OrgChartZoom>
<div className="c-org-root">
{multiRoot ? (
<div className="c-org-children c-org-children--root">
{people.map((p: any, i: number) => <OrgNode key={p.id || i} person={p} depth={0} autoDepth={autoDepth} />)}
</div>
) : (
people.map((p: any, i: number) => <OrgNode key={p.id || i} person={p} depth={0} autoDepth={autoDepth} />)
)}
</div>
</OrgChartZoom>
</div>
);
}
case "aside": {
const body = (comp.body as string) || "";
return (
<div id={id} className="c-aside">
<div className="c-aside-body">{md(body)}</div>
</div>
);
}
case "rule_list": {
const items = (comp.items as Array<{ label: string; body: string; color?: string }>) || [];
return (
<div id={id} className="c-rule-list">
{items.map((item, i) => (
<div key={i} className={`c-rule-item${item.color && item.color !== "default" ? ` c-rule-item-${item.color}` : ""}`}>
<div className="c-rule-label">{item.label}</div>
<div className="c-rule-body">{renderInline(item.body)}</div>
</div>
))}
</div>
);
}
case "gauge": {
const items = (comp.items as Array<{ label: string; value: number; color?: string }>) || [];
const maxVal = (comp.max as number) || 100;
const cx = 32, cy = 32, r = 26, strokeW = 6;
const circ = 2 * Math.PI * r;
const total = items.reduce((acc, it) => acc + it.value, 0);
const title = comp.title as string | undefined;
let offset = 0;
const arcs = items.map((item, i) => {
const fraction = maxVal > 0 ? Math.min(item.value / maxVal, 1) : 0;
const dash = fraction * circ;
const gap = circ - dash;
const arc = (
<circle
key={i}
cx={cx}
cy={cy}
r={r}
fill="none"
strokeWidth={strokeW}
className={`c-gauge-arc c-gauge-arc-${item.color || "default"}`}
strokeDasharray={`${dash.toFixed(2)} ${gap.toFixed(2)}`}
strokeDashoffset={(-offset * circ / (maxVal > 0 ? maxVal : 1)).toFixed(2)}
transform={`rotate(-90 ${cx} ${cy})`}
>
<title>{item.label}: {item.value}</title>
</circle>
);
offset += item.value;
return arc;
});
return (
<figure id={id} className="c-gauge">
{title && <figcaption className="c-gauge-title">{title}</figcaption>}
<svg viewBox="0 0 64 64" width={64} height={64} className="c-gauge-svg" aria-label={title || "Gauge"}>
<circle cx={cx} cy={cy} r={r} fill="none" strokeWidth={strokeW} className="c-gauge-track" />
{arcs}
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle" className="c-gauge-total">{total}</text>
</svg>
{items.length > 0 && (
<ul className="c-gauge-legend">
{items.map((item, i) => (
<li key={i} className={`c-gauge-legend-item c-gauge-legend-item-${item.color || "default"}`}>
<span className="c-gauge-swatch" />
<span>{item.label}: {item.value}</span>
</li>
))}
</ul>
)}
</figure>
);
}
default:
return (
<div id={id} className="c-unsupported" data-type={comp.type}>
<em>Unsupported component: {comp.type}</em>
</div>
);
}
}
function DeckRenderer({ slides, renderMarkdown, renderChart, renderRoleMap }: { slides: SlideData[]; renderMarkdown?: (md: string) => string; renderChart?: (comp: ComponentData) => React.ReactNode; renderRoleMap?: (comp: ComponentData) => React.ReactNode }) {
const control = React.useContext(DeckControlContext);
const [internalCurrent, setInternalCurrent] = React.useState(() => {
if (control?.slide !== undefined) return control.slide;
if (typeof window === "undefined") return 0;
const p = new URLSearchParams(window.location.search);
const s = parseInt(p.get("slide") ?? "", 10);
return isNaN(s) || s < 1 ? 0 : Math.min(s - 1, slides.length - 1);
});
const controlled = control?.slide !== undefined;
const rawCurrent = controlled ? control.slide! : internalCurrent;
const current = Math.max(0, Math.min(slides.length - 1, rawCurrent));
const [presenting, setPresenting] = React.useState(false);
const [overlayVisible, setOverlayVisible] = React.useState(true);
const trackRef = React.useRef<HTMLDivElement>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
const fadeTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const go = React.useCallback((n: number) => {
const clamped = Math.max(0, Math.min(slides.length - 1, n));
if (control?.onSlideChange) control.onSlideChange(clamped);
else setInternalCurrent(clamped);
}, [slides.length, control]);
const resetFade = React.useCallback(() => {
setOverlayVisible(true);
clearTimeout(fadeTimer.current);
fadeTimer.current = setTimeout(() => setOverlayVisible(false), 3000);
}, []);
const enterPresentation = React.useCallback(() => {
const el = rootRef.current;
if (!el) return;
if (el.requestFullscreen) el.requestFullscreen();
else if ((el as any).webkitRequestFullscreen) (el as any).webkitRequestFullscreen();
}, []);
const exitPresentation = React.useCallback(() => {
if (document.fullscreenElement) document.exitFullscreen();
else if ((document as any).webkitFullscreenElement) (document as any).webkitExitFullscreen();
}, []);
React.useEffect(() => {
const handler = () => {
const isFs = !!(document.fullscreenElement || (document as any).webkitFullscreenElement);
setPresenting(isFs);
if (isFs) {
document.body.classList.add("presenting");
resetFade();
} else {
document.body.classList.remove("presenting");
setOverlayVisible(true);
clearTimeout(fadeTimer.current);
}
};
document.addEventListener("fullscreenchange", handler);
document.addEventListener("webkitfullscreenchange", handler);
return () => {
document.removeEventListener("fullscreenchange", handler);
document.removeEventListener("webkitfullscreenchange", handler);
document.body.classList.remove("presenting");
};
}, [resetFade]);
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") { go(current + 1); if (presenting) resetFade(); }
if (e.key === "ArrowLeft" || e.key === "ArrowUp") { go(current - 1); if (presenting) resetFade(); }
if (e.key === "Home") { go(0); }
if (e.key === "End") { go(slides.length - 1); }
if (e.key === "f" || e.key === "F") { if (!presenting && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) enterPresentation(); }
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [slides.length, presenting, enterPresentation, resetFade, go, current]);
React.useEffect(() => {
if (!presenting) return;
const handler = () => resetFade();
document.addEventListener("mousemove", handler);
return () => document.removeEventListener("mousemove", handler);
}, [presenting, resetFade]);
React.useEffect(() => {
if (controlled && rawCurrent !== current && control?.onSlideChange) {
control.onSlideChange(current);
}
}, [controlled, rawCurrent, current, control]);
React.useEffect(() => {
const p = new URLSearchParams(window.location.search);
p.set("slide", String(current + 1));
try { history.replaceState(history.state, "", window.location.pathname + "?" + p.toString()); } catch {}
}, [current]);
React.useEffect(() => {
document.body.classList.add("shell-deck");
return () => { document.body.classList.remove("shell-deck"); };
}, []);
return (
<div className="deck-root" ref={rootRef}>
<div className="deck-viewport">
<div className="deck-track" ref={trackRef} style={{ transform: `translateX(-${current * 100}%)` }}>
{slides.map((slide, si) => (
<div key={si} className={`deck-slide${slide.cover || slide.align === "center" ? " deck-slide-cover" : ""}${slide.valign === "top" ? " deck-slide-top" : ""}`} data-label={slide.label}>
<div className="deck-inner">
{(slide.eyebrow || slide.title || slide.subtitle) ? (
<div className="deck-slide-header">
{slide.eyebrow && <div className="deck-slide-eyebrow">{slide.eyebrow}</div>}
{slide.title && <h1 className="deck-slide-title">{slide.title}</h1>}
{slide.subtitle && <div className="deck-slide-subtitle">{slide.subtitle}</div>}
</div>
) : (!slide.hide_label && <div className="deck-label">{slide.label}</div>)}
{(slide.components ?? []).map((comp, ci) => (
<ComponentView key={ci} comp={comp} index={ci} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
</div>
))}
</div>
</div>
{presenting ? (
<div className={`deck-present-overlay${overlayVisible ? "" : " deck-overlay-hidden"}`}>
<div className="deck-present-progress"><div className="deck-present-progress-bar" style={{ width: `${((current + 1) / slides.length) * 100}%` }} /></div>
<div className="deck-present-controls">
<button className="deck-present-arrow" onClick={() => go(current - 1)} disabled={current === 0} aria-label="Previous slide">{"←"}</button>
<span className="deck-present-counter">{current + 1} / {slides.length}</span>
<button className="deck-present-arrow" onClick={() => go(current + 1)} disabled={current >= slides.length - 1} aria-label="Next slide">{"→"}</button>
<button className="deck-present-exit" onClick={exitPresentation} aria-label="Exit presentation">ESC</button>
</div>
</div>
) : (
<div className="deck-nav">
<button className="deck-arrow deck-prev" onClick={() => go(current - 1)} disabled={current === 0} aria-label="Previous slide">← {slides[current - 1]?.label}</button>
<span className="deck-nav-label" aria-live="polite">{slides[current]?.label}</span>
<button className="deck-present-btn" onClick={enterPresentation}>Present</button>
<button className="deck-arrow deck-next" onClick={() => go(current + 1)} disabled={current >= slides.length - 1} aria-label="Next slide">{slides[current + 1]?.label} →</button>
</div>
)}
</div>
);
}
function FreshnessBanner({ freshness }: { freshness?: FreshnessData | "never" }) {
if (!freshness || freshness === "never") return null;
const f = freshness as FreshnessData;
if (!f.updated || !f.review_every) return null;
const cadenceMap: Record<string, number> = {
weekly: 7, monthly: 30, quarterly: 90, yearly: 365, annually: 365,
};
let cadenceDays = cadenceMap[f.review_every];
if (!cadenceDays) {
const m = f.review_every.match(/^(\d+)(d|w|m|y)$/);
if (m) {
const n = parseInt(m[1], 10);
cadenceDays = m[2] === "d" ? n : m[2] === "w" ? n * 7 : m[2] === "m" ? n * 30 : n * 365;
}
}
if (!cadenceDays) return null;
const updated = new Date(f.updated);
const now = new Date();
const elapsed = Math.floor((now.getTime() - updated.getTime()) / 86400000);
const dueIn = cadenceDays - elapsed;
let variant: string;
let title: string;
let headline: string;
if (dueIn > 7) return null;
if (dueIn > 0) {
variant = "warn";
title = "Review due soon";
headline = dueIn === 1 ? "Review is due in 1 day." : `Review is due in ${dueIn} days.`;
} else if (dueIn === 0) {
variant = "warn";
title = "Review due soon";
headline = "Review is due today.";
} else {
variant = "danger";
title = "Review overdue";
headline = `Review is ${-dueIn} ${-dueIn === 1 ? "day" : "days"} overdue.`;
}
const meta = [
`Last updated: ${f.updated} (${elapsed} days ago)`,
`Review cadence: every ${f.review_every}`,
f.owner ? `Owner: ${f.owner}` : null,
].filter(Boolean).join(". ");
return (
<div className={`c-callout c-callout-${variant} c-freshness-banner`}>
<div className="c-callout-title">{title}</div>
<div className="c-callout-body">{headline} {meta}.</div>
</div>
);
}
function HubMasthead({ hub, resolveHref, activeHref, exportMode }: { hub: HubData; resolveHref?: (href: string) => string; activeHref?: string; exportMode?: boolean }) {
const links = hub.pages ?? [];
const colorClass = hub.status_color ? ` hub-status-${hub.status_color}` : " hub-status-default";
return (
<div className="hub-masthead">
<div className="hub-masthead-inner">
<div className="hub-id">
{hub.eyebrow && <span className="hub-eyebrow">{hub.eyebrow}</span>}
<span className="hub-name">{hub.name}</span>
{hub.status && <span className={`hub-status${colorClass}`}>{hub.status}</span>}
</div>
{!exportMode && links.length > 0 && (
<nav className="hub-nav" aria-label="Hub pages">
{links.map((l) => {
const active = activeHref !== undefined && l.href === activeHref;
return (
<a
key={l.href}
className={`hub-nav-link${active ? " hub-nav-link--active" : ""}`}
href={resolveHref ? resolveHref(l.href) : l.href}
aria-current={active ? "page" : undefined}
>
{l.label}
</a>
);
})}
</nav>
)}
</div>
</div>
);
}
export function PageRenderer({ page, renderMarkdown, renderChart, renderRoleMap, resolveHubHref, activeHubHref, exportMode }: PageRendererProps) {
if (page.shell === "deck" && page.slides && page.slides.length > 0) {
if (exportMode) {
return (
<div className="deck-export">
{page.slides.map((slide, si) => (
<div key={si} className={`deck-slide${slide.cover || slide.align === "center" ? " deck-slide-cover" : ""}${slide.valign === "top" ? " deck-slide-top" : ""}`} data-label={slide.label}>
<div className="deck-inner">
{(slide.eyebrow || slide.title || slide.subtitle) ? (
<div className="deck-slide-header">
{slide.eyebrow && <div className="deck-slide-eyebrow">{slide.eyebrow}</div>}
{slide.title && <h1 className="deck-slide-title">{slide.title}</h1>}
{slide.subtitle && <div className="deck-slide-subtitle">{slide.subtitle}</div>}
</div>
) : (!slide.hide_label && <div className="deck-label">{slide.label}</div>)}
{(slide.components ?? []).map((comp, ci) => (
<ComponentView key={ci} comp={comp} index={ci} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
</div>
))}
</div>
);
}
return <DeckRenderer slides={page.slides} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />;
}
const components = page.components ?? [];
const body = (
<>
{!exportMode && <FreshnessBanner freshness={page.freshness} />}
{components.map((comp, i) => (
<ComponentView key={i} comp={comp} index={i} renderMarkdown={renderMarkdown} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</>
);
if (page.shell === "hub" && page.hub) {
return (
<div className={`hub-root${exportMode ? " export-root" : ""}`}>
<HubMasthead hub={page.hub} resolveHref={resolveHubHref} activeHref={activeHubHref} exportMode={exportMode} />
<div className="hub-content">{body}</div>
</div>
);
}
return <div className={exportMode ? "export-root" : undefined}>{body}</div>;
}
export { ComponentView, DeckRenderer, HubMasthead, type SlideData, type PageData, type ComponentData, type PageRendererProps, type HubData };
"####;
fn generate_editor(out: &mut String) {
let components = component_defs();
let enums: Vec<(&str, Vec<&str>)> = vec![
("CalloutVariant", vec!["info", "warn", "success", "danger"]),
(
"SemColor",
vec!["default", "green", "yellow", "red", "teal"],
),
("Align", vec!["left", "right", "center"]),
("Connector", vec!["none", "dots_line", "arrow"]),
("TimelineStatus", vec!["completed", "active", "upcoming"]),
("EventSeverity", vec!["major", "minor", "info"]),
("EventFilter", vec!["all", "major"]),
(
"TreeStatus",
vec![
"default",
"completed",
"active",
"blocked",
"priority",
"upcoming",
],
),
(
"TreeFilter",
vec!["all", "incomplete", "blocked", "priority"],
),
("ButtonVariant", vec!["primary", "secondary", "ghost"]),
("IconSize", vec!["xs", "sm", "md", "lg", "xl"]),
("AvatarSize", vec!["sm", "md", "lg", "xl"]),
("ChartKind", vec!["pie", "bar", "timeseries"]),
("ChartOrientation", vec!["vertical", "horizontal"]),
("ArchDirection", vec!["left_to_right", "top_to_bottom"]),
("Interaction", vec!["single_select", "multi_select", "none"]),
];
type InterfaceDef<'a> = (&'a str, Vec<(&'a str, &'a str, bool)>);
let interfaces: Vec<InterfaceDef> = vec![
(
"MetaField",
vec![("key", "string", true), ("value", "string", true)],
),
(
"Stat",
vec![
("label", "string", true),
("value", "string", true),
("detail", "string", false),
("color", "SemColor", false),
],
),
(
"Card",
vec![
("title", "string", true),
("description", "string", false),
("href", "string", false),
("color", "SemColor", false),
],
),
(
"BeforeAfterItem",
vec![
("title", "string", true),
("before", "string", true),
("after", "string", true),
("after_context", "string", false),
],
),
(
"Step",
vec![("title", "string", true), ("detail", "string", false)],
),
(
"TableColumn",
vec![("key", "string", true), ("label", "string", true)],
),
(
"EventItem",
vec![
("date", "string", true),
("title", "string", true),
("summary", "string", false),
("severity", "EventSeverity", false),
("source", "string", false),
],
),
(
"BreadcrumbItem",
vec![("label", "string", true), ("href", "string", false)],
),
(
"ButtonConfig",
vec![
("label", "string", true),
("href", "string", true),
("variant", "ButtonVariant", false),
],
),
(
"DefinitionItem",
vec![("term", "string", true), ("definition", "string", true)],
),
(
"AvatarConfig",
vec![("name", "string", true), ("src", "string", false)],
),
(
"ResourceItem",
vec![
("title", "string", true),
("href", "string", true),
("description", "string", false),
("owner", "string", false),
],
),
(
"ChartPoint",
vec![
("label", "string", true),
("value", "number", true),
("color", "SemColor", false),
],
),
(
"ChartSeries",
vec![("label", "string", true), ("color", "SemColor", false)],
),
(
"VennSet",
vec![("label", "string", true), ("color", "SemColor", false)],
),
(
"TimelineItem",
vec![("name", "string", true), ("status", "TimelineStatus", true)],
),
(
"SelectableCard",
vec![
("title", "string", true),
("eyebrow", "string", false),
("body", "string", false),
("color", "SemColor", false),
],
),
("Tab", vec![("label", "string", true)]),
("AccordionItem", vec![("title", "string", true)]),
(
"SankeyFlow",
vec![
("source", "string", true),
("target", "string", true),
("value", "number", true),
],
),
(
"RadarCurve",
vec![
("label", "string", true),
("values", "number[]", true),
("color", "SemColor", false),
],
),
(
"QuadrantPoint",
vec![
("label", "string", true),
("x", "number", true),
("y", "number", true),
("color", "SemColor", false),
],
),
(
"ArchNode",
vec![
("id", "string", true),
("label", "string", true),
("detail", "string", false),
("icon", "string", false),
("color", "SemColor", false),
],
),
(
"ArchConnection",
vec![
("from", "string", true),
("to", "string", true),
("label", "string", false),
],
),
];
let long_fields = ["body", "code", "summary", "definition", "description"];
let icons: Vec<(&str, &str)> = vec![
("header", "H"),
("markdown", "Aa"),
("callout", "!"),
("card_grid", ":::"),
("stat_grid", "#"),
("steps", "1."),
("code", "</>"),
("image", "img"),
("divider", "---"),
("table", "|||"),
("section", "\u{00a7}"),
("columns", "||"),
("tabs", "\u{2291}"),
("accordion", "\u{2261}"),
("chart", "\u{25d4}"),
("chart_group", "\u{25d4}\u{25d4}"),
("sankey", "\u{21c9}"),
("radar", "\u{25ce}"),
("quadrant", "\u{229e}"),
("architecture", "\u{2b1a}"),
("pipeline", "\u{27a1}"),
("org_chart", "\u{1f465}"),
];
let get_icon = |comp_type: &str| -> String {
for (t, icon) in &icons {
if *t == comp_type {
return (*icon).to_string();
}
}
let first = comp_type.chars().next().unwrap_or('?');
first.to_uppercase().to_string()
};
let make_label = |comp_type: &str| -> String {
comp_type
.replace('_', " ")
.split_whitespace()
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
};
let find_enum = |type_name: &str| -> Option<&Vec<&str>> {
enums.iter().find(|(n, _)| *n == type_name).map(|(_, v)| v)
};
let find_interface = |type_name: &str| -> Option<&Vec<(&str, &str, bool)>> {
interfaces
.iter()
.find(|(n, _)| *n == type_name)
.map(|(_, v)| v)
};
out.push_str("\nconst EDITOR_TYPES: Array<{type: string; label: string; icon: string}> = [\n");
for (comp_type, _) in &components {
let label = make_label(comp_type);
let icon = get_icon(comp_type);
out.push_str(&format!(
" {{ type: \"{}\", label: \"{}\", icon: \"{}\" }},\n",
comp_type, label, icon
));
}
out.push_str("];\n\n");
out.push_str("function newEditorComponent(type: string): ComponentData {\n");
out.push_str(" switch (type) {\n");
for (comp_type, fields) in &components {
out.push_str(&format!(
" case \"{}\": return {{ type: \"{}\"",
comp_type, comp_type
));
for (fname, ftype) in fields {
let is_optional = fname.ends_with('?');
if is_optional {
continue;
} let clean = fname.trim_end_matches('?');
let default_val = if *ftype == "string" {
"\"\"".to_string()
} else if *ftype == "number" {
"0".to_string()
} else if *ftype == "boolean" {
"false".to_string()
} else if ftype.ends_with("[]") || *ftype == "Component[]" || *ftype == "Component[][]"
{
"[]".to_string()
} else if find_enum(ftype).is_some() {
let variants = find_enum(ftype).unwrap();
format!("\"{}\"", variants[0])
} else {
continue;
};
out.push_str(&format!(", {}: {}", clean, default_val));
}
out.push_str(" };\n");
}
out.push_str(" default: return { type } as ComponentData;\n");
out.push_str(" }\n");
out.push_str("}\n\n");
out.push_str("function ComponentFieldEditor({ comp, onChange }: { comp: ComponentData; onChange: (c: ComponentData) => void }) {\n");
out.push_str(" switch (comp.type) {\n");
for (comp_type, fields) in &components {
out.push_str(&format!(" case \"{}\": return (<>\n", comp_type));
for (fname, ftype) in fields {
let is_optional = fname.ends_with('?');
let clean = fname.trim_end_matches('?');
let display_name = make_label(clean);
if *ftype == "string" {
if long_fields.contains(&clean) {
out.push_str(&format!(
" <textarea className=\"pe-textarea\" placeholder=\"{}\" value={{(comp.{} as string) || \"\"}} onChange={{(e) => onChange({{...comp, {}: e.target.value}})}} />\n",
display_name, clean, clean
));
} else {
if is_optional {
out.push_str(&format!(
" <input className=\"pe-input\" placeholder=\"{}\" value={{(comp.{} as string) || \"\"}} onChange={{(e) => onChange({{...comp, {}: e.target.value || undefined}})}} />\n",
display_name, clean, clean
));
} else {
out.push_str(&format!(
" <input className=\"pe-input\" placeholder=\"{}\" value={{(comp.{} as string) || \"\"}} onChange={{(e) => onChange({{...comp, {}: e.target.value}})}} />\n",
display_name, clean, clean
));
}
}
} else if *ftype == "number" {
out.push_str(&format!(
" <input className=\"pe-input\" type=\"number\" placeholder=\"{}\" value={{(comp.{} as number) ?? \"\"}} onChange={{(e) => onChange({{...comp, {}: e.target.value ? Number(e.target.value) : undefined}})}} />\n",
display_name, clean, clean
));
} else if *ftype == "boolean" {
out.push_str(&format!(
" <label className=\"pe-checkbox-label\"><input type=\"checkbox\" checked={{(comp.{} as boolean) || false}} onChange={{(e) => onChange({{...comp, {}: e.target.checked || undefined}})}}/> {}</label>\n",
clean, clean, display_name
));
} else if let Some(variants) = find_enum(ftype) {
out.push_str(&format!(
" <select className=\"pe-select\" value={{(comp.{} as string) || \"\"}} onChange={{(e) => onChange({{...comp, {}: e.target.value || undefined}})}}>\\n <option value=\"\">-- {} --</option>\n",
clean, clean, display_name
));
for v in variants.iter() {
out.push_str(&format!(" <option value=\"{}\">{}</option>\n", v, v));
}
out.push_str(" </select>\n");
} else if *ftype == "Component[]" {
out.push_str(&format!(
" <NestedComponentList components={{(comp.{} as ComponentData[]) || []}} onChange={{(comps) => onChange({{...comp, {}: comps}})}} />\n",
clean, clean
));
} else if *ftype == "Component[][]" {
out.push_str(&format!(
" <div className=\"pe-columns-editor\">{{((comp.{} as ComponentData[][]) || []).map((col, ci) => (<div key={{ci}} className=\"pe-column-wrap\"><div className=\"pe-column-label\">Column {{ci+1}}</div><NestedComponentList components={{col}} onChange={{(comps) => {{ const next = [...((comp.{} as ComponentData[][]) || [])]; next[ci] = comps; onChange({{...comp, {}: next}}); }}}} /></div>))}}</div>\n",
clean, clean, clean
));
} else if let Some(elem_type) = ftype.strip_suffix("[]") {
if elem_type == "string" {
out.push_str(&format!(
" <textarea className=\"pe-textarea\" placeholder=\"{}\" value={{((comp.{} as string[]) || []).join(\", \")}} onChange={{(e) => onChange({{...comp, {}: e.target.value.split(\",\").map((s: string) => s.trim()).filter(Boolean)}})}} />\n",
display_name, clean, clean
));
} else if let Some(iface_fields) = find_interface(elem_type) {
let field_name = clean;
let iface_fields_clone: Vec<(&str, &str, bool)> = iface_fields.to_vec();
out.push_str(&format!(
" <div className=\"pe-list-editor\">\n {{((comp.{} as Array<Record<string, unknown>>) || []).map((item, i) => (\n <div key={{i}} className=\"pe-list-item pe-list-item-row\">\n",
field_name
));
for (ifield, itype, _ireq) in &iface_fields_clone {
let ifield_display = make_label(ifield);
if let Some(ivariants) = find_enum(itype) {
out.push_str(&format!(
" <select className=\"pe-select\" value={{(item.{} as string) || \"\"}} onChange={{(e) => {{ const next = [...((comp.{} as Array<Record<string, unknown>>) || [])]; next[i] = {{...item, {}: e.target.value || undefined}}; onChange({{...comp, {}: next}}); }}}}>\\n <option value=\"\">-- {} --</option>\n",
ifield, field_name, ifield, field_name, ifield_display
));
for iv in ivariants.iter() {
out.push_str(&format!(
" <option value=\"{}\">{}</option>\n",
iv, iv
));
}
out.push_str(" </select>\n");
} else if *itype == "number" {
out.push_str(&format!(
" <input className=\"pe-input pe-input-short\" type=\"number\" placeholder=\"{}\" value={{(item.{} as number) ?? \"\"}} onChange={{(e) => {{ const next = [...((comp.{} as Array<Record<string, unknown>>) || [])]; next[i] = {{...item, {}: e.target.value ? Number(e.target.value) : undefined}}; onChange({{...comp, {}: next}}); }}}} />\n",
ifield_display, ifield, field_name, ifield, field_name
));
} else {
let placeholder = ifield_display.clone();
out.push_str(&format!(
" <input className=\"pe-input pe-input-short\" placeholder=\"{}\" value={{(item.{} as string) || \"\"}} onChange={{(e) => {{ const next = [...((comp.{} as Array<Record<string, unknown>>) || [])]; next[i] = {{...item, {}: e.target.value}}; onChange({{...comp, {}: next}}); }}}} />\n",
placeholder, ifield, field_name, ifield, field_name
));
}
}
let required_defaults: Vec<String> = iface_fields_clone
.iter()
.filter(|(_, _, req)| *req)
.map(|(f, t, _)| {
if *t == "number" {
format!("{}: 0", f)
} else {
format!("{}: \"\"", f)
}
})
.collect();
let default_item = format!("{{{}}}", required_defaults.join(", "));
out.push_str(&format!(
" {{((comp.{} as unknown[]) || []).length > 1 && <button className=\"pe-list-remove\" onClick={{() => onChange({{...comp, {}: ((comp.{} as unknown[]) || []).filter((_: unknown, j: number) => j !== i)}})}}>×</button>}}\n",
field_name, field_name, field_name
));
out.push_str(" </div>\n ))}\n");
out.push_str(&format!(
" <button className=\"pe-list-add\" onClick={{() => onChange({{...comp, {}: [...((comp.{} as unknown[]) || []), {}]}})}}> + Add {}</button>\n",
field_name, field_name, default_item, elem_type
));
out.push_str(" </div>\n");
} else {
out.push_str(&format!(
" <textarea className=\"pe-textarea\" placeholder=\"{}\" value={{yamlStringify(comp.{} ?? [])}} onChange={{(e) => {{ try {{ onChange({{...comp, {}: yamlParse(e.target.value)}}); }} catch(_) {{}} }}}} />\n",
display_name, clean, clean
));
}
} else {
if !is_optional {
out.push_str(&format!(
" <textarea className=\"pe-textarea\" placeholder=\"{}\" value={{yamlStringify(comp.{} ?? null)}} onChange={{(e) => {{ try {{ onChange({{...comp, {}: yamlParse(e.target.value)}}); }} catch(_) {{}} }}}} />\n",
display_name, clean, clean
));
} else {
out.push_str(&format!(
" <textarea className=\"pe-textarea\" placeholder=\"{}\" value={{yamlStringify(comp.{} ?? null)}} onChange={{(e) => {{ try {{ onChange({{...comp, {}: yamlParse(e.target.value) || undefined}}); }} catch(_) {{}} }}}} />\n",
display_name, clean, clean
));
}
}
}
out.push_str(" </>);\n");
}
out.push_str(
" default: return <div className=\"pe-unknown\">Unknown component: {comp.type}</div>;\n",
);
out.push_str(" }\n");
out.push_str("}\n\n");
out.push_str(r#"function NestedComponentList({ components, onChange }: { components: ComponentData[]; onChange: (comps: ComponentData[]) => void }) {
const [addOpen, setAddOpen] = React.useState(false);
return (
<div className="pe-nested">
{components.map((c, i) => (
<div key={i} className="pe-component pe-component-nested">
<div className="pe-component-header">
<span className="pe-component-type">{c.type}</span>
<div className="pe-component-actions">
{i > 0 && <button className="pe-move-btn" onClick={() => { const n = [...components]; [n[i-1], n[i]] = [n[i], n[i-1]]; onChange(n); }}>↑</button>}
{i < components.length - 1 && <button className="pe-move-btn" onClick={() => { const n = [...components]; [n[i], n[i+1]] = [n[i+1], n[i]]; onChange(n); }}>↓</button>}
<button className="pe-remove-btn" onClick={() => onChange(components.filter((_, j) => j !== i))}>×</button>
</div>
</div>
<div className="pe-component-body">
<ComponentFieldEditor comp={c} onChange={(updated) => { const n = [...components]; n[i] = updated; onChange(n); }} />
</div>
</div>
))}
<div className="pe-add-wrap">
<button className="pe-list-add" onClick={() => setAddOpen(!addOpen)}>+ Add block</button>
{addOpen && <div className="pe-add-menu">{EDITOR_TYPES.map(t => <button key={t.type} className="pe-add-option" onClick={() => { onChange([...components, newEditorComponent(t.type)]); setAddOpen(false); }}><span className="pe-add-option-icon">{t.icon}</span><span className="pe-add-option-label">{t.label}</span></button>)}</div>}
</div>
</div>
);
}
"#);
out.push_str(r#"function KazamComponentEditor({ comp, index, onChange, onRemove, onMove, isFirst, isLast }: {
comp: ComponentData; index: number;
onChange: (index: number, data: ComponentData) => void;
onRemove: (index: number) => void;
onMove: (index: number, direction: -1 | 1) => void;
isFirst: boolean; isLast: boolean;
}) {
const label = EDITOR_TYPES.find(t => t.type === comp.type)?.label || comp.type;
return (
<div className="pe-component">
<div className="pe-component-header">
<span className="pe-component-type">{label}</span>
<div className="pe-component-actions">
{!isFirst && <button className="pe-move-btn" onClick={() => onMove(index, -1)} title="Move up">↑</button>}
{!isLast && <button className="pe-move-btn" onClick={() => onMove(index, 1)} title="Move down">↓</button>}
<button className="pe-remove-btn" onClick={() => onRemove(index)}>×</button>
</div>
</div>
<div className="pe-component-body">
<ComponentFieldEditor comp={comp} onChange={(c) => onChange(index, c)} />
</div>
</div>
);
}
"#);
out.push_str(r#"export function PageEditor({ page, onChange }: { page: PageData; onChange: (page: PageData) => void }) {
const [addMenuOpen, setAddMenuOpen] = React.useState(false);
const comps = page.components || [];
const updateComponent = React.useCallback((index: number, data: ComponentData) => {
const next = [...comps]; next[index] = data;
onChange({ ...page, components: next });
}, [page, comps, onChange]);
const removeComponent = React.useCallback((index: number) => {
onChange({ ...page, components: comps.filter((_, i) => i !== index) });
}, [page, comps, onChange]);
const moveComponent = React.useCallback((index: number, direction: -1 | 1) => {
const next = [...comps];
const target = index + direction;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target], next[index]];
onChange({ ...page, components: next });
}, [page, comps, onChange]);
const addComponent = React.useCallback((type: string) => {
onChange({ ...page, components: [...comps, newEditorComponent(type)] });
setAddMenuOpen(false);
}, [page, comps, onChange]);
return (
<div className="pe-root">
<div className="pe-meta">
<input className="pe-input pe-input-title" placeholder="Untitled" value={page.title || ""} onChange={(e) => onChange({ ...page, title: e.target.value })} />
<input className="pe-input pe-input-subtitle" placeholder="Add a subtitle..." value={page.subtitle || ""} onChange={(e) => onChange({ ...page, subtitle: e.target.value || undefined })} />
</div>
<div className="pe-components">
{comps.map((comp, i) => (
<KazamComponentEditor key={i} comp={comp} index={i} onChange={updateComponent} onRemove={removeComponent} onMove={moveComponent} isFirst={i === 0} isLast={i === comps.length - 1} />
))}
</div>
<div className="pe-add-wrap">
<button className="pe-add-btn" onClick={() => setAddMenuOpen(!addMenuOpen)}>
{comps.length === 0 ? "Click to add your first block..." : "+ Add block"}
</button>
{addMenuOpen && (
<div className="pe-add-menu">
{EDITOR_TYPES.map(t => (
<button key={t.type} className="pe-add-option" onClick={() => addComponent(t.type)}>
<span className="pe-add-option-icon">{t.icon}</span>
<span><span className="pe-add-option-label">{t.label}</span></span>
</button>
))}
</div>
)}
</div>
</div>
);
}
export { KazamComponentEditor, ComponentFieldEditor, NestedComponentList, EDITOR_TYPES, newEditorComponent };
"#);
}
fn emit_enum(out: &mut String, name: &str, variants: &[&str]) {
out.push_str("export type ");
out.push_str(name);
out.push_str(" = ");
for (i, v) in variants.iter().enumerate() {
if i > 0 {
out.push_str(" | ");
}
out.push('"');
out.push_str(v);
out.push('"');
}
out.push_str(";\n\n");
}
fn emit_interface(out: &mut String, name: &str, fields: &[(&str, &str)]) {
out.push_str("export interface ");
out.push_str(name);
out.push_str(" {\n");
for (name, ts_type) in fields {
if let Some(stripped) = name.strip_suffix('?') {
out.push_str(" ");
out.push_str(stripped);
out.push_str("?: ");
} else {
out.push_str(" ");
out.push_str(name);
out.push_str(": ");
}
out.push_str(ts_type);
out.push_str(";\n");
}
out.push_str("}\n\n");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typescript_output_contains_key_types() {
let ts = generate_typescript();
assert!(ts.contains("export type Shell ="));
assert!(ts.contains("export interface Page {"));
assert!(ts.contains("export interface SiteConfig {"));
assert!(ts.contains("export type Component ="));
assert!(ts.contains("export interface ValidationError {"));
assert!(ts.contains("{ type: \"header\""));
assert!(ts.contains("{ type: \"chart\""));
assert!(ts.contains("{ type: \"role_map\""));
}
#[test]
fn react_event_timeline_sorts_most_recent_first() {
let tsx = generate_react();
assert!(
tsx.contains(
"const events = [...rawEvents].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));"
),
"event_timeline must sort by date descending before rendering, not render authored order verbatim"
);
}
}