use anyhow::Result;
use serde::de::{MapAccess, Visitor};
use serde::{Deserialize, Deserializer};
use std::fmt;
use std::marker::PhantomData;
pub(crate) struct OMap<T>(Vec<(String, T)>);
impl<T> OMap<T> {
pub(crate) fn iter(&self) -> impl Iterator<Item = (&String, &T)> {
self.0.iter().map(|(k, v)| (k, v))
}
pub(crate) fn get(&self, key: &str) -> Option<&T> {
self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
pub(crate) fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
pub(crate) fn into_iter(self) -> impl Iterator<Item = (String, T)> {
self.0.into_iter()
}
}
impl<T> Default for OMap<T> {
fn default() -> Self {
OMap(Vec::new())
}
}
impl<'a, T> IntoIterator for &'a OMap<T> {
type Item = (&'a String, &'a T);
type IntoIter = std::iter::Map<
std::slice::Iter<'a, (String, T)>,
fn(&'a (String, T)) -> (&'a String, &'a T),
>;
fn into_iter(self) -> Self::IntoIter {
fn split<T>(pair: &(String, T)) -> (&String, &T) {
(&pair.0, &pair.1)
}
self.0
.iter()
.map(split as fn(&'a (String, T)) -> (&'a String, &'a T))
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for OMap<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct V<T>(PhantomData<T>);
impl<'de, T: Deserialize<'de>> Visitor<'de> for V<T> {
type Value = OMap<T>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a JSON object")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<OMap<T>, A::Error> {
let mut out = Vec::with_capacity(access.size_hint().unwrap_or(0));
while let Some((k, v)) = access.next_entry::<String, T>()? {
out.push((k, v));
}
Ok(OMap(out))
}
}
deserializer.deserialize_map(V(PhantomData))
}
}
#[derive(Deserialize)]
struct SchemaField {
#[serde(rename = "type")]
field_type: String,
required: bool,
#[serde(default)]
kind: Option<String>,
}
#[derive(Deserialize, Clone)]
pub(crate) struct Guidance {
#[serde(default)]
pub use_when: Option<String>,
#[serde(default)]
pub avoid_when: Option<String>,
#[serde(default)]
pub example: Option<String>,
#[serde(default)]
pub rules: Vec<crate::shape::ShapeRule>,
}
#[derive(Deserialize)]
struct Schema {
enums: OMap<Vec<String>>,
types: OMap<OMap<SchemaField>>,
components: OMap<OMap<SchemaField>>,
#[serde(default)]
guidance: OMap<Guidance>,
}
pub(crate) fn schema_shape_rules() -> Vec<crate::shape::ShapeRule> {
let schema = load_schema();
let mut out = Vec::new();
for (ty, g) in &schema.guidance {
for r in &g.rules {
let mut r = r.clone();
r.component = Some(ty.clone());
out.push(r);
}
}
out
}
pub(crate) fn schema_guidance() -> Vec<(String, Guidance)> {
load_schema().guidance.into_iter().collect()
}
#[allow(dead_code)]
const CONTENT_KINDS: [&str; 4] = ["text", "markdown", "code", "number"];
fn kind_of(name: &str, field: &SchemaField, schema: &Schema) -> String {
if let Some(k) = &field.kind {
return k.clone();
}
let t = field.field_type.as_str();
if schema.enums.contains_key(t) {
return "enum".into();
}
match t {
"boolean" => "bool".into(),
"number" => "number".into(),
"string" => match name {
"body" | "definition" | "summary" => "markdown".into(),
"code" => "code".into(),
"href" | "src" | "url" | "link" | "email" | "linkedin" => "url".into(),
"id" | "key" | "slug" => "id".into(),
_ => "text".into(),
},
_ if t.ends_with("[]") => "list".into(),
_ => "object".into(),
}
}
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(" depth?: Depth;\n");
out.push_str(" motion?: boolean;\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(" depth?: Depth;\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"),
("severity", "\"error\" | \"warning\""),
],
);
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, &OMap<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(", \"kind\": \"");
out.push_str(&kind_of(fname, field, &schema));
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, &OMap<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(", \"kind\": \"");
out.push_str(&kind_of(fname, field, &schema));
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(())
}
pub fn emit_mcp() -> Result<()> {
print!("{}", generate_mcp());
Ok(())
}
pub(crate) fn example_yaml(name: &str) -> Option<&'static str> {
Some(match name {
"graph-tiered" => include_str!("../schema/examples/graph-tiered.yaml"),
"graph-flow" => include_str!("../schema/examples/graph-flow.yaml"),
"pipeline" => include_str!("../schema/examples/pipeline.yaml"),
"grid-diagram" => include_str!("../schema/examples/grid-diagram.yaml"),
"box-band" => include_str!("../schema/examples/box-band.yaml"),
"sequence" => include_str!("../schema/examples/sequence.yaml"),
"stat_grid" => include_str!("../schema/examples/stat_grid.yaml"),
"card_grid" => include_str!("../schema/examples/card_grid.yaml"),
"table" => include_str!("../schema/examples/table.yaml"),
"chart-bar" => include_str!("../schema/examples/chart-bar.yaml"),
"tree" => include_str!("../schema/examples/tree.yaml"),
"timeline" => include_str!("../schema/examples/timeline.yaml"),
_ => return None,
})
}
const GUIDED_TOOLS: [&str; 7] = [
"write_page",
"create_page",
"patch_page",
"create_from_template",
"get_component_reference",
"read_component",
"write_component",
];
fn generate_mcp() -> String {
let guidance = schema_guidance();
let guided: Vec<&str> = guidance
.iter()
.filter(|(_, g)| !g.rules.is_empty())
.map(|(k, _)| k.as_str())
.collect();
let layout_types: Vec<&str> = guided
.iter()
.copied()
.filter(|t| {
matches!(
*t,
"graph" | "pipeline" | "grid" | "box" | "sequence" | "chart"
)
})
.collect();
let layout_list = layout_types.join(", ");
let write_desc = format!(
"Create or update a page from full YAML. Every write is checked against shape rules \
(component layout guidance) and content rules. Shape rule hits do NOT block: the page \
is written and the response leads with a WARNINGS block naming the component path, the \
rule, and the fix. Treat a warning as a required follow-up edit. Before writing {layout_list}, \
call get_component_reference with component=<type> for a known-good example. To change \
one component, use read_component + write_component instead of rewriting the page."
);
let create_desc = format!(
"Create a new knowledge page. Search for duplicates FIRST (search_pages + get_related); \
patch an existing page instead of creating a near-duplicate. Same shape-rule warnings as \
write_page: the page is created, the response says what to fix. Before using {layout_list}, \
call get_component_reference with component=<type>. Tag the page with concepts."
);
let patch_desc = "Apply targeted operations to a page without rewriting full YAML. Operations \
target any component id, including ones nested in sections, grids, boxes, tabs, and \
columns; read_page returns an outline of ids. The patched page is shape-checked and the \
response carries the same WARNINGS block as write_page. For a one-component edit prefer \
write_component.";
let template_desc =
"Create a page from a template, interpolating {{variables}}. Templates are \
known-good starting points; start here for a new page whose type has a template. The \
result is validated and shape-checked like any write.";
let reference_desc = format!(
"Component authoring reference. Pass component=<type> ({layout_list}, and every other \
component type) to get a short slice: when to use it, when not to, a curated example you \
can copy, and the shape rules the write tools will warn about. Without component, returns \
the full reference. Call this before the first write that uses a component you have not \
used in this conversation."
);
let read_component_desc = "Read one component from a page by id (any depth: inside sections, grids, \
boxes, tabs, columns). read_page's outline lists the ids. Returns the component YAML, its \
keypath, parent id, sibling ids, and a component_hash to pass to write_component. Use this \
plus write_component to change one part of a page instead of rewriting it.";
let write_component_desc = format!(
"Replace one component on a page by id with new YAML for just that component. Keeps the id, \
validates the whole page, runs shape rules ({layout_list} and the rest) and content rules. \
Same warning contract as write_page: written, WARNINGS block on the response. Pass the \
component_hash from read_component so a concurrent edit to that component is caught; \
expected_hash (page hash) also works. Prefer this over write_page for a one-component edit."
);
let mut instructions = String::new();
instructions.push_str(
"## Writing components
",
);
instructions.push_str(
"A page is YAML with three top-level keys, all required: `title`, `shell` (usually \
`standard`), and `components` (a list of `- type: ...` blocks). Quote any inline YAML value \
that contains a comma or colon, or use block style. \
Pages are YAML components. The write tools run shape rules on every write and return \
warnings without blocking; fix them in a follow-up edit. Cold starts on layout components go \
wrong most often, so:
",
);
instructions.push_str(&format!(
"- Before using {layout_list}: call `get_component_reference` with `component=<type>` and \
copy the example's structure.
"
));
instructions.push_str(
"- Prefer a template (`list_templates`, `create_from_template`) for a new page when one \
exists for its type.
- To change one component, use `read_page` (outline) then `read_component` and \
`write_component`. Do not rewrite the whole page.
- A response that starts with `WARNINGS` was written but needs a fix. Read the rule and the \
path, then edit that component.
",
);
instructions.push_str(
"Shape rules currently active (warn on write):
",
);
for (ty, g) in &guidance {
for r in &g.rules {
instructions.push_str(&format!(
"- `{ty}`: {} (`{}`)
",
r.say, r.warn
));
}
}
let mut components = serde_json::Map::new();
for (ty, g) in &guidance {
let example = g.example.as_deref().and_then(example_yaml);
components.insert(
ty.clone(),
serde_json::json!({
"use_when": g.use_when,
"avoid_when": g.avoid_when,
"example_name": g.example,
"example_yaml": example,
"rules": g.rules.iter().map(|r| serde_json::json!({
"warn": r.warn,
"say": r.say,
"severity": r.severity.clone().unwrap_or_else(|| "warning".into()),
})).collect::<Vec<_>>(),
}),
);
}
let out = serde_json::json!({
"generated_by": "kazam sdk emit-mcp",
"guided_components": guided,
"tools": {
GUIDED_TOOLS[0]: write_desc,
GUIDED_TOOLS[1]: create_desc,
GUIDED_TOOLS[2]: patch_desc,
GUIDED_TOOLS[3]: template_desc,
GUIDED_TOOLS[4]: reference_desc,
GUIDED_TOOLS[5]: read_component_desc,
GUIDED_TOOLS[6]: write_component_desc,
},
"instructions_section": instructions,
"components": components,
});
serde_json::to_string_pretty(&out).expect("mcp bundle serializes") + "\n"
}
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(
"Components marked with **Use when** carry shape rules. `kazam validate` and every curata \
write tool check them and return warnings (the page still saves). When a component has a \
curated example below, copy its structure rather than inventing one from the field table.\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');
let guidance = schema.guidance.get(tag);
if let Some(g) = guidance {
if let Some(u) = &g.use_when {
out.push_str("**Use when:** ");
out.push_str(u);
out.push('\n');
}
if let Some(a) = &g.avoid_when {
out.push_str("**Avoid when:** ");
out.push_str(a);
out.push('\n');
}
out.push('\n');
}
let curated = guidance
.and_then(|g| g.example.as_deref())
.and_then(example_yaml);
out.push_str("```yaml\n");
if let Some(ex) = curated {
out.push_str(ex.trim_end());
out.push('\n');
} else {
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");
if let Some(g) = guidance {
if !g.rules.is_empty() {
out.push_str("**Shape rules (warn on write):**\n");
for r in &g.rules {
out.push_str("- ");
out.push_str(&r.say);
out.push_str(" (`");
out.push_str(&r.warn);
out.push_str("`)\n");
}
out.push('\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;
/** Play entrance motion for components with `animate`. Off by default. */
motion?: boolean;
}
interface ComponentData {
type: string;
[key: string]: unknown;
}
interface PageRendererProps {
page: PageData;
renderChart?: (comp: ComponentData) => React.ReactNode;
renderRoleMap?: (comp: ComponentData) => React.ReactNode;
/** Force entrance motion on regardless of `page.motion` (presentation mode). */
motion?: boolean;
/** 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;
/** Optional wrapper around each top-level component (edit mode chrome, drag handles, etc.). */
componentWrapper?: React.ComponentType<{ comp: ComponentData; index: number; children: React.ReactNode }>;
}
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[] {
// Underscore emphasis only at word boundaries so snake_case_names survive.
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|(?<![A-Za-z0-9])_(?:[^_\n]|_(?=[A-Za-z0-9]))+_(?![A-Za-z0-9])|\[[^\]]+\]\([^)]+\))/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++}>{renderInline(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 {
// `*text*` or `_text_`, recursing so **bold** inside emphasis renders
parts.push(<em key={key++}>{renderInline(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;
// Each list item accumulates its own children (paragraphs, a nested
// sub-list, a fenced code block) instead of one inline render. A "loose"
// item - anything followed by a detail paragraph, which is the shape of
// every numbered how-to step - used to fracture into a fresh <ol
// start=1> the moment a blank line appeared, since the old version
// flushed the whole list on any blank line with no way back in.
let listItems: { children: React.ReactNode[] }[] = [];
let listTag: "ul" | "ol" = "ul";
let itemPara: string[] = [];
let itemCode: string[] | null = null;
let itemCodeLang = "";
let itemSubList: React.ReactNode[] | null = null;
let itemSubListTag: "ul" | "ol" = "ul";
let paraLines: string[] = [];
let quoteLines: string[] = [];
let codeLines: string[] | null = null;
let codeLang = "";
let tableLines: string[] = [];
const inList = () => listItems.length > 0;
const currentItem = () => listItems[listItems.length - 1];
const flushItemSubList = () => {
if (itemSubList && itemSubList.length > 0) {
const List = itemSubListTag;
currentItem().children.push(<List key={key++} className="c-md-list">{itemSubList}</List>);
}
itemSubList = null;
};
// Paragraph-only close, no coupled sub-list flush. The nested-list
// branch below needs to close a pending paragraph WITHOUT touching an
// in-progress sub-list of the same type - flushItemPara() used to do
// both together, which flushed (and re-opened) the sub-list on every
// single nested item, leaving each one in its own one-item <ol>/<ul>
// instead of a shared list.
const closeItemPara = () => {
if (itemPara.length > 0) {
currentItem().children.push(<p key={key++}>{renderInline(itemPara.join(" "))}</p>);
itemPara = [];
}
};
const flushItemPara = () => {
flushItemSubList();
closeItemPara();
};
const flushItemCode = () => {
if (itemCode !== null) {
const code = itemCode.join("\n");
currentItem().children.push(
<pre key={key++} className="c-code" {...(itemCodeLang ? { "data-lang": itemCodeLang } : {})}>
<code>{code}</code>
</pre>
);
itemCode = null;
itemCodeLang = "";
}
};
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 = () => {
flushItemCode();
flushItemPara();
if (listItems.length > 0) {
const List = listTag;
elements.push(
<List key={key++} className="c-md-list">
{listItems.map((it, i) => <li key={i}>{it.children}</li>)}
</List>
);
listItems = [];
}
};
const flushPara = () => {
if (paraLines.length > 0) {
elements.push(<p key={key++}>{renderInline(paraLines.join(" "))}</p>);
paraLines = [];
}
};
const flushQuote = () => {
if (quoteLines.length > 0) {
elements.push(<blockquote key={key++}>{renderInline(quoteLines.join(" "))}</blockquote>);
quoteLines = [];
}
};
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 = "";
}
};
// Closes whatever the previous item still had open, then starts a new
// item - finalizing the old <ol>/<ul> first only if the marker type
// actually switched (bullet <-> numbered), not for every sibling item.
const startItem = (tag: "ul" | "ol", firstLineText: string) => {
flushItemPara();
flushItemCode();
if (listTag !== tag && listItems.length > 0) flushList();
listTag = tag;
listItems.push({ children: [] });
itemPara = firstLineText ? [firstLineText] : [];
};
for (const line of lines) {
const trimmed = line.trimStart();
const indented = line.length > trimmed.length;
if (codeLines !== null) {
if (/^`{3,}\s*$/.test(trimmed)) { flushCode(); } else { codeLines.push(line); }
continue;
}
if (itemCode !== null) {
if (/^`{3,}\s*$/.test(trimmed)) { flushItemCode(); } else { itemCode.push(trimmed); }
continue;
}
const taskMatch = trimmed.match(/^[-*] \[([ xX])\]\s(.*)$/);
const bulletMatch = !taskMatch && /^[-*]\s/.test(trimmed);
const orderedMatch = /^\d+\.\s/.test(trimmed);
const isListMarker = !!taskMatch || bulletMatch || orderedMatch;
if (inList() && trimmed === "") {
// Blank line inside a list: close whatever sub-block is open in the
// current item, but the list itself stays open. Only a genuinely
// new top-level block - not indented, not another item - ends it,
// checked below.
flushItemPara();
flushItemCode();
continue;
}
if (inList() && indented) {
// Continuation content for the current item: nested bullet/numbered
// line, a fenced code block, or a wrapped paragraph line.
const fenceMatch = trimmed.match(/^`{3,}(.*)$/);
if (fenceMatch) {
flushItemPara();
itemCode = [];
itemCodeLang = fenceMatch[1].trim();
continue;
}
const nestedTask = trimmed.match(/^[-*] \[([ xX])\]\s(.*)$/);
const nestedBullet = !nestedTask && /^[-*]\s/.test(trimmed);
const nestedOrdered = /^\d+\.\s/.test(trimmed);
if (nestedTask || nestedBullet || nestedOrdered) {
const subTag: "ul" | "ol" = nestedOrdered ? "ol" : "ul";
if (!itemSubList || itemSubListTag !== subTag) {
// Only close out prior state on an actual transition (first
// nested item, or a type switch) - repeat items of the same
// type must fall through untouched so they land in the same
// list instead of each getting flushed into its own.
closeItemPara();
flushItemSubList();
itemSubList = [];
itemSubListTag = subTag;
}
const subText = nestedTask
? nestedTask[2]
: trimmed.replace(/^[-*]\s/, "").replace(/^\d+\.\s/, "");
itemSubList!.push(<li key={key++}>{renderInline(subText)}</li>);
continue;
}
flushItemSubList();
itemPara.push(trimmed);
continue;
}
if (inList() && !indented && !isListMarker && trimmed !== "") {
// A genuine new top-level block - close the list before handling it.
flushList();
}
const fenceMatch = trimmed.match(/^`{3,}(.*)$/);
if (fenceMatch) {
flushPara();
flushList();
flushQuote();
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();
flushQuote();
tableLines.push(trimmed);
continue;
}
if (tableLines.length > 0) {
flushTable();
}
const quoteMatch = trimmed.match(/^>\s?(.*)$/);
if (quoteMatch) {
flushPara();
flushList();
quoteLines.push(quoteMatch[1]);
} else if (taskMatch) {
flushPara();
flushQuote();
startItem("ul", "");
const checked = taskMatch[1].toLowerCase() === "x";
currentItem().children.push(
<p key={key++} className="task-list-item">
<input type="checkbox" checked={checked} disabled readOnly />{" "}
{renderInline(taskMatch[2])}
</p>
);
} else if (bulletMatch) {
flushPara();
flushQuote();
startItem("ul", trimmed.slice(2));
} else if (orderedMatch) {
flushPara();
flushQuote();
startItem("ol", trimmed.replace(/^\d+\.\s/, ""));
} else if (trimmed === "") {
flushPara();
flushList();
flushQuote();
} else if (/^####\s/.test(trimmed)) {
// A regex, not a plain-string prefix check: four hashes right
// after a quote character would collide with this generated
// file's own raw-string delimiter.
flushPara();
flushList();
flushQuote();
elements.push(<h4 key={key++}>{renderInline(trimmed.replace(/^####\s/, ""))}</h4>);
} else if (trimmed.startsWith("### ")) {
flushPara();
flushList();
flushQuote();
elements.push(<h3 key={key++}>{renderInline(trimmed.slice(4))}</h3>);
} else if (trimmed.startsWith("## ")) {
flushPara();
flushList();
flushQuote();
elements.push(<h2 key={key++}>{renderInline(trimmed.slice(3))}</h2>);
} else if (trimmed.startsWith("# ")) {
flushPara();
flushList();
flushQuote();
elements.push(<h1 key={key++}>{renderInline(trimmed.slice(2))}</h1>);
} else if (/^!\[.*\]\(.*\)$/.test(trimmed)) {
flushPara();
flushList();
flushQuote();
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();
flushQuote();
elements.push(<hr key={key++} />);
} else {
// inList() is never true here: the check above already closed the
// list for unindented, non-marker, non-blank text.
flushList();
flushQuote();
paraLines.push(trimmed);
}
}
flushCode();
flushTable();
flushPara();
flushList();
flushQuote();
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];
};
// Shared SVG label budgeter: estimates rendered width (~0.56px per char per
// fontSize unit) and end-ellipsizes to fit budgetPx. When the result differs
// from the input, render the full text in a <title> child for hover.
const fitLabel = (label: string, budgetPx: number, fontSize = 11): string => {
const maxChars = Math.floor(budgetPx / (fontSize * 0.56));
return label.length > maxChars ? label.slice(0, Math.max(maxChars - 1, 1)) + "…" : label;
};
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 xLabel = comp.x_label as string | undefined;
const yLabel = comp.y_label 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><span data-kz-field={`data[${i}].label`}>{d.label}</span>: <span data-kz-field={`data[${i}].value`}>{d.value}</span></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") {
// Label sits on its own line above the bar - no gutter, so bars get the
// full width and names never truncate at any realistic length. fitLabel
// only guards absurd (>90 char) input.
const rowH = 40;
const svgH = data.length * rowH + 4;
const padL = 8;
const plotW = vw - padL - 48;
chart = (
<svg width="100%" viewBox={`0 0 ${vw} ${svgH}`} preserveAspectRatio="xMidYMid meet" style={{ overflow: "visible" }}>
{data.map((d, i) => {
const w = (d.value / maxVal) * plotW;
const rowY = i * rowH;
const shown = fitLabel(d.label, vw - padL - 12);
return <React.Fragment key={i}>
<text x={padL} y={rowY + 14} fontSize={11} fill="var(--color-text-muted, #999)" data-kz-field={`data[${i}].label`}>{shown}{shown !== d.label && <title>{d.label}</title>}</text>
<rect x={padL} y={rowY + 20} width={w} height={18} fill={getColor(d.color, i)} rx={3} className="c-chart-bar" onMouseEnter={() => setTip({ x: padL + w / 2, y: rowY + 16, text: `${d.label}: ${d.value}` })} onMouseLeave={() => setTip(null)} />
<text x={padL + w + 6} y={rowY + 33} fontSize={11} fill="var(--color-text-muted, #999)" data-kz-field={`data[${i}].value`}>{d.value}</text>
</React.Fragment>;
})}
{Tip}
</svg>
);
} else {
const plotH = h - padTop;
// Tilt x-labels when the longest one is wider than its slot; tilted
// labels are end-ellipsized past ~150px with the full text on hover.
const slotW = barW + gap;
const tilted = Math.max(...data.map(d => d.label.length), 1) * 6.2 > slotW;
const labelBudget = tilted ? 150 : slotW;
const extraH = tilted ? 90 : 30;
chart = (
<svg width="100%" viewBox={`0 0 ${vw} ${h + extraH}`} 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);
const shown = fitLabel(d.label, labelBudget);
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)" data-kz-field={`data[${i}].value`}>{d.value}</text>
{tilted
? <text x={x + barW / 2} y={h + 14} textAnchor="end" transform={`rotate(-30 ${x + barW / 2} ${h + 14})`} fontSize={11} fill="var(--color-text-muted, #999)" data-kz-field={`data[${i}].label`}>{shown}{shown !== d.label && <title>{d.label}</title>}</text>
: <text x={x + barW / 2} y={h + 16} textAnchor="middle" fontSize={11} fill="var(--color-text-muted, #999)" data-kz-field={`data[${i}].label`}>{shown}{shown !== d.label && <title>{d.label}</title>}</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" data-kz-field={`series[${si}].points[${pi}].value`} onMouseEnter={() => setTip({ x, y: y - 4, text: label })} onMouseLeave={() => setTip(null)} />;
})
);
// At most ~8 x-labels: every step-th point plus the last, each ellipsized
// to its slot so neighbors can't collide.
const labelStep = Math.ceil(n / 8);
const labelSlotPx = plotW / Math.min(n, 8);
const labels = (series[0]?.points || []).map((p, pi) => {
const isLast = pi === n - 1;
if (!isLast && pi % labelStep !== 0) return null;
if (!isLast && n - 1 - pi < labelStep / 2) return null;
const x = pad.l + (pi / Math.max(n - 1, 1)) * plotW;
const shown = fitLabel(p.label, labelSlotPx - 6, 10);
return <text key={pi} x={x} y={h - 2} textAnchor="middle" fontSize={10} fill="var(--color-text-muted, #999)" data-kz-field={`series[0].points[${pi}].label`}>{shown}{shown !== p.label && <title>{p.label}</title>}</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 data-kz-field={`series[${si}].label`}>{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>}
</>
);
}
const body = chart || <div className="c-chart-placeholder">Chart ({kind})</div>;
const showAxes = kind !== "pie" && (xLabel || yLabel);
return (
<div id={id} className="c-chart" data-kind={kind}>
{title && <h3 className="c-chart-title" data-kz-field="title">{title}</h3>}
{showAxes ? (
<div className="c-chart-axes">
{yLabel && <div className="c-chart-ylabel" data-kz-field="y_label">{yLabel}</div>}
<div className="c-chart-axes-main">
{body}
{xLabel && <div className="c-chart-xlabel" data-kz-field="x_label">{xLabel}</div>}
</div>
</div>
) : body}
</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; hex?: string; status?: string; row?: number; column?: number; 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 rowLabels = (comp.row_labels as Array<string | null | undefined>) || [];
const svgData = React.useMemo(() => {
if (nodes.length === 0) return null;
const isLR = direction === "left_to_right" || direction === "LR" || direction === "lr";
// A node with an explicit `row` switches the whole diagram from
// topological auto-layout into tiered/grid mode: rows come from `row`
// directly and x-position comes from `column`, instead of both being
// derived from edge topology.
const explicitMode = nodes.some(n => n.row !== undefined && n.row !== null);
const defaultW = 140;
const defaultH = 60;
const pad = 40;
const groupPad = 24;
const nodeGapV = 32;
const hasGroups = groups.length > 0;
const minColGap = hasGroups ? 120 : explicitMode ? 110 : 80;
const portSpread = 0.85;
const VB_W = 720;
// SVG <text> has no native wrap, so a long label/detail overflows into
// neighboring nodes. This estimates a wrap width from an average
// px-per-char and splits into lines; node height auto-grows to fit the
// wrapped line count when no explicit height is set.
const LABEL_CHAR_W = 7.0;
const DETAIL_CHAR_W = 5.6;
const LABEL_LINE_H = 15.0;
const DETAIL_LINE_H = 12.0;
const NODE_PAD_Y = 16.0;
const NODE_PAD_X = 20.0;
const LABEL_DETAIL_GAP = 6.0;
const MAX_WRAP_LINES = 4;
const wrapLines = (text: string, charW: number, maxWidth: number): string[] => {
const maxChars = Math.max(4, Math.floor(maxWidth / charW));
const lines: string[] = [];
let current = "";
for (const word of text.split(/\s+/).filter(Boolean)) {
const candidateLen = current ? current.length + 1 + word.length : word.length;
if (candidateLen > maxChars && current) {
lines.push(current);
current = "";
}
current = current ? `${current} ${word}` : word;
}
if (current) lines.push(current);
if (lines.length === 0) lines.push("");
if (lines.length > MAX_WRAP_LINES) {
lines.length = MAX_WRAP_LINES;
lines[lines.length - 1] += "…";
}
return lines;
};
// Per-node dimensions
const dims: Record<string, { w: number; h: number }> = {};
for (const n of nodes) {
const scale = n.shape === "diamond" ? 1.4 : 1;
const w = (n.width ?? defaultW) * scale;
let nh = n.height as number | undefined;
if (nh === undefined) {
const wrapW = w - NODE_PAD_X;
const labelLineCount = wrapLines(n.label, LABEL_CHAR_W, wrapW).length;
const detailLineCount = n.detail ? wrapLines(n.detail, DETAIL_CHAR_W, wrapW).length : 0;
const gap = detailLineCount > 0 ? LABEL_DETAIL_GAP : 0;
const textH = labelLineCount * LABEL_LINE_H + detailLineCount * DETAIL_LINE_H + gap + NODE_PAD_Y;
nh = Math.max(textH, defaultH);
}
dims[n.id] = { w, h: nh * 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}`));
const col: Record<string, number> = {};
if (explicitMode) {
for (const n of nodes) col[n.id] = n.row ?? 0;
} else {
// Topological column assignment
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));
// Aligned column count across ALL rows (not just the widest one), so a
// sparse row's nodes land in the same x-slots as their counterparts in a
// fuller row instead of centering independently.
const numAlignCols = explicitMode
? Math.max(1, ...nodes.map(n => (n.column ?? 0) + 1), ...colsNodes.map(cn => cn.length))
: 0;
const neededW = isLR
? pad * 2 + colMaxW.reduce((a, b) => a + b, 0) + Math.max(0, numCols - 1) * minColGap
: explicitMode
? pad * 2 + numAlignCols * defaultW + Math.max(0, numAlignCols - 1) * nodeGapV
: 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 }> = {};
// (row center y, row half-height), TB mode only, one entry per row in
// display order. Used to draw the row label + dashed rule above a tier.
const rowBands: Array<[number, 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;
const alignColW = defaultW + nodeGapV;
const gridW = numAlignCols * alignColW;
const alignXOffset = explicitMode && vbW > gridW ? (vbW - gridW) / 2 : 0;
for (let ci = 0; ci < numCols; ci++) {
const cn = colsNodes[ci];
rowBands.push([rowCy[ci] + yOffset, colMaxH[ci] / 2]);
if (explicitMode) {
for (let ni = 0; ni < cn.length; ni++) {
const nodeCol = cn[ni].column ?? ni;
positions[cn[ni].id] = {
cx: alignXOffset + nodeCol * alignColW + alignColW / 2,
cy: rowCy[ci] + yOffset,
};
}
} else {
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,
};
}
}
}
}
const rowLabelElems: React.ReactNode[] = [];
if (!isLR) {
for (let ri = 0; ri < rowBands.length; ri++) {
const label = rowLabels[ri];
if (!label) continue;
const [bandCy, halfH] = rowBands[ri];
const ruleY = bandCy - halfH - 18;
const textY = ruleY - 8;
rowLabelElems.push(
<text key={`rl-${ri}`} x={pad} y={textY} className="c-arch-node-detail" fontWeight={600} data-kz-field={`row_labels[${ri}]`}>{label}</text>
);
rowLabelElems.push(
<line key={`rr-${ri}`} x1={pad} y1={ruleY} x2={vbW - pad} y2={ruleY} stroke="rgba(var(--text-rgb),0.15)" strokeWidth={1} strokeDasharray="2 3" />
);
}
}
// 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 sameRow = Math.abs(tp.cy - fp.cy) < 0.5;
if (sameRow) {
// Lateral edge within a tier (explicit-mode row): connect
// left/right ports instead of top/bottom so it draws as a clean
// horizontal arrow rather than dipping below the box.
const forward = tp.cx >= fp.cx;
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" data-kz-field={`groups[${groups.indexOf(grp)}].label`}>{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 }} data-kz-field={`edges[${ei}].label`}>{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;
// Exact brand color wins over the fixed SemColor palette when valid
// (`#RGB`/`#RRGGBB`/`#RRGGBBAA`); an invalid value falls back to
// `color` rather than breaking the render.
const hexValid = n.hex && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(n.hex);
const stroke = hexValid ? (n.hex as string) : (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" />);
}
// Progress badge, anchored on the box's top-right corner. Deliberately
// a fixed color (not n.color/n.hex) so it reads as a progress signal,
// independent of whatever role color the box border is already using.
if (n.status === "completed" || n.status === "active") {
const bcx = pos.cx + nw / 2;
const bcy = pos.cy - nh / 2;
const badgeFill = n.status === "completed" ? (semToHex.green || "#34D399") : (semToHex.yellow || "#FBBF24");
nodeElems.push(
<React.Fragment key={`nb-${n.id}`}>
<circle cx={bcx} cy={bcy} r={9} fill={badgeFill} stroke="#0A0E17" strokeWidth={2} />
{n.status === "completed" ? (
<path d={`M ${(bcx - 4).toFixed(1)} ${bcy.toFixed(1)} L ${(bcx - 1.2).toFixed(1)} ${(bcy + 2.8).toFixed(1)} L ${(bcx + 4.2).toFixed(1)} ${(bcy - 3.2).toFixed(1)}`} fill="none" stroke="#0A0E17" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
) : (
<path d={`M ${(bcx - 2.5).toFixed(1)} ${(bcy - 3.5).toFixed(1)} L ${(bcx - 2.5).toFixed(1)} ${(bcy + 3.5).toFixed(1)} L ${(bcx + 3.5).toFixed(1)} ${bcy.toFixed(1)} Z`} fill="#0A0E17" />
)}
</React.Fragment>
);
}
const nodeWrapW = nw - NODE_PAD_X;
const labelLines = wrapLines(n.label, LABEL_CHAR_W, nodeWrapW);
const detailLines = n.detail ? wrapLines(n.detail, DETAIL_CHAR_W, nodeWrapW) : null;
const ldGap = detailLines ? LABEL_DETAIL_GAP : 0;
const totalTextH = labelLines.length * LABEL_LINE_H + ldGap + (detailLines ? detailLines.length * DETAIL_LINE_H : 0);
let cursorY = pos.cy - totalTextH / 2 + LABEL_LINE_H * 0.72;
nodeElems.push(
<text key={`nl-${n.id}`} data-kz-field={`nodes[${nodes.indexOf(n)}].label`}>
{labelLines.map((line, li) => (
<tspan key={li} x={pos.cx} y={cursorY + li * LABEL_LINE_H} textAnchor="middle" className="c-arch-node-label">{line}</tspan>
))}
</text>
);
cursorY += (labelLines.length - 1) * LABEL_LINE_H;
if (detailLines) {
cursorY += LABEL_LINE_H * 0.28 + ldGap + DETAIL_LINE_H * 0.72;
nodeElems.push(
<text key={`nd-${n.id}`} data-kz-field={`nodes[${nodes.indexOf(n)}].detail`}>
{detailLines.map((line, li) => (
<tspan key={li} x={pos.cx} y={cursorY + li * DETAIL_LINE_H} textAnchor="middle" className="c-arch-node-detail">{line}</tspan>
))}
</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} data-kz-field={`nodes[${nodes.indexOf(n)}].ports[${pi}].label`}>{port.label}</text>
);
}
}
return { vbW, h, rowLabelElems, groupElems, edgeElems, nodeElems };
}, [nodes, edges, groups, direction, compHeight, rowLabels]);
return (
<div id={id} className="c-chart" data-kind="graph">
{title && <h3 className="c-chart-title" data-kz-field="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.rowLabelElems}
{svgData.groupElems}
{svgData.edgeElems}
{svgData.nodeElems}
</svg>
) : (
<div className="c-chart-placeholder">Graph</div>
)}
</div>
);
}
function OrgNode({ person, depth, autoDepth, kzField }: { person: any; depth: number; autoDepth: number; kzField: string }) {
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" data-kz-field={`${kzField}.name`}>{person.name}</div>
{person.title && <div className="c-org-node-title" data-kz-field={`${kzField}.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"}`} data-kz-field={`${kzField}.tags[${ti}].label`}>{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} kzField={`${kzField}.reports[${i}]`} />)}
</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,
kzPath,
renderChart,
renderRoleMap,
}: {
id: string;
items: Array<{ title: string; components: ComponentData[] }>;
kzPath: 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)}>
<span data-kz-field={`items[${i}].title`}>{item.title}</span><span className="accordion-chevron">›</span>
</button>
{openIndex === i && (
<div className="accordion-body">
{(item.components || []).map((c, ci) => (
<ComponentView key={ci} comp={c} index={ci} kzPath={`${kzPath}.items[${i}].components[${ci}]`} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
)}
</div>
))}
</div>
);
}
// ── Venn text fitting ─────────────────────────────────
// Approximate glyph advance widths (em units) for the UI font. Good enough to
// pick a font size that keeps a label inside its circle without measuring DOM.
function vennTextWidth(text: string, fontSize: number, weight = 1.0): number {
let w = 0;
for (const ch of text) {
if (ch >= "A" && ch <= "Z") w += 0.68;
else if (ch >= "0" && ch <= "9") w += 0.6;
else if (ch >= "a" && ch <= "z") w += 0.54;
else if (ch === " " || ch === "," || ch === "." || ch === "'") w += 0.3;
else w += 0.5;
}
return w * fontSize * weight;
}
type VennFit = { lines: string[]; fontSize: number };
// Fit a set label into `avail` px: shrink from maxSize toward minSize, and if
// breaking at word boundaries into 2 or 3 lines ("INSPECTOR" over "(8,726)",
// or "CROWDSTRIKE" / "FALCON" / "(4,219)") keeps the text larger, use that.
// Ties go to fewer lines.
function vennFitLabel(text: string, avail: number, maxSize: number, minSize: number, weight: number): VennFit {
const sizeFor = (lines: string[]) => {
const widest = Math.max(...lines.map(l => vennTextWidth(l, 1, weight)), 0.001);
return Math.min(maxSize, avail / widest);
};
const single = [text.trim()];
let bestLines = single;
let bestSize = sizeFor(single);
if (bestSize >= maxSize) return { lines: single, fontSize: maxSize };
const words = text.trim().split(/\s+/);
const consider = (lines: string[]) => {
const s = sizeFor(lines);
if (s > bestSize + 0.01 || (Math.abs(s - bestSize) <= 0.01 && lines.length < bestLines.length)) {
bestLines = lines;
bestSize = s;
}
};
for (let i = 1; i < words.length; i++) {
consider([words.slice(0, i).join(" "), words.slice(i).join(" ")]);
for (let j = i + 1; j < words.length; j++) {
consider([words.slice(0, i).join(" "), words.slice(i, j).join(" "), words.slice(j).join(" ")]);
}
}
return { lines: bestLines, fontSize: Math.max(minSize, Math.min(maxSize, bestSize)) };
}
// Word-wrap an overlap label into an `avail` x `availH` box. Tries the largest
// font first and steps down until both the widest line and the stacked height
// fit - so a phrase shrinks a little rather than towering out of a narrow lune.
// Falls back to the minimum size (tspans squeeze any line still too wide).
function vennWrapLabel(text: string, avail: number, availH: number, maxSize: number, minSize: number, weight: number): VennFit {
const words = text.split(/\s+/).filter(Boolean);
const wrapAt = (size: number): string[] => {
const lines: string[] = [];
let cur = "";
for (const word of words) {
const candidate = cur ? `${cur} ${word}` : word;
if (cur && vennTextWidth(candidate, size, weight) > avail) {
lines.push(cur);
cur = word;
} else {
cur = candidate;
}
}
if (cur) lines.push(cur);
return lines.length ? lines : [text];
};
for (let size = maxSize; size >= minSize; size -= 0.5) {
const lines = wrapAt(size);
const widest = Math.max(...lines.map(l => vennTextWidth(l, size, weight)));
if (widest <= avail && lines.length * size * 1.2 <= availH) return { lines, fontSize: size };
}
return { lines: wrapAt(minSize), fontSize: minSize };
}
// Render fitted lines as centered tspans. A line that still overflows at the
// minimum size gets squeezed with textLength so it never escapes its region.
function vennTspans(fit: VennFit, x: number, avail: number, weight: number) {
const lineH = fit.fontSize * 1.2;
const startDy = -(fit.lines.length - 1) * lineH / 2;
return fit.lines.map((line, li) => {
const over = vennTextWidth(line, fit.fontSize, weight) > avail;
return (
<tspan key={li} x={x} dy={li === 0 ? startDy : lineH} {...(over ? { textLength: avail, lengthAdjust: "spacingAndGlyphs" as const } : {})}>{line}</tspan>
);
});
}
function ComponentView({
comp,
index,
kzPath,
renderChart,
renderRoleMap,
}: {
comp: ComponentData;
index: number;
/** Absolute data path of this component within the page, e.g. `components[2].components[0]`. */
kzPath?: string;
renderChart?: (comp: ComponentData) => React.ReactNode;
renderRoleMap?: (comp: ComponentData) => React.ReactNode;
}) {
const id = (comp.id as string) || `c-${index}`;
const kz = kzPath ?? `components[${index}]`;
const md = (s: string) => renderBlock(s);
const content = (() => {
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" data-kz-field="eyebrow">{eyebrow}</div>}
<h1 className="c-header-title" data-kz-field="title">{title}</h1>
{subtitle && <p className="c-header-subtitle" data-kz-field="subtitle">{subtitle}</p>}
</div>
);
}
case "markdown": {
const body = (comp.body as string) || "";
return <div id={id} className="c-markdown" data-kz-field="body" data-kz-block="">{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" data-kz-field="title">{renderInline(title)}</div>}
<div className="c-callout-body c-markdown" data-kz-field="body" data-kz-block="">{md(body)}</div>
{links.length > 0 && (
<div className="c-callout-links c-button-group" data-kz-list="links">
{links.map((l, i) => (
<a key={i} href={l.href} className={`c-button c-button-${l.variant || "secondary"}`} data-kz-field={`links[${i}].label`}>{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} data-kz-list="cards">
{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" data-kz-field={`cards[${i}].title`}>{card.title}</h2>
{card.badge && (
<span className={`c-badge c-badge-${card.badge.color || "default"}`} data-kz-field={`cards[${i}].badge.label`}>
{card.badge.label}
</span>
)}
</div>
{card.description && <p className="c-card-desc" data-kz-field={`cards[${i}].description`}>{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}
data-kz-list="stats"
>
{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" data-kz-field={`stats[${i}].label`}>{s.label}</div>
<div className="c-stat-value" data-kz-field={`stats[${i}].value`}>{s.value}</div>
{s.detail && <div className="c-stat-detail" data-kz-field={`stats[${i}].detail`}>{renderInline(s.detail)}</div>}
{s.trend && <span className={`c-stat-trend ${trendClass}`}>{trendArrow}</span>}
{s.previous && <span className="c-stat-previous">was <span data-kz-field={`stats[${i}].previous`}>{s.previous}</span></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" data-kz-list="items">
{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" data-kz-field={`items[${i}].title`}>{renderInline(item.title)}</div>
{item.detail && <div className="c-step-detail" data-kz-field={`items[${i}].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 data-kz-field="code" data-kz-block="">{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} data-kz-field="alt" />
{caption && <figcaption data-kz-field="caption">{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" data-kz-field="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"><span data-kz-field={`summary[${si}].label`}>{item.label}</span>: <span data-kz-field={`summary[${si}].value`}>{item.value}</span></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} data-kz-field={`columns[${i}].label`}>{col.label}</th>
))}
</tr>
</thead>
<tbody data-kz-list="rows">
{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} data-kz-field={`rows[${ri}].${col.key}`}>{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" data-kz-list="fields">
{fields.map((f, i) => (
<div key={i} className="c-meta-item">
<span className="c-meta-key" data-kz-field={`fields[${i}].key`}>{f.key}</span>
<span className="c-meta-value" data-kz-field={`fields[${i}].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" data-kz-field="eyebrow">{eyebrow}</div>}
{heading && <h2 className="c-section-heading" data-kz-field="heading">{heading}</h2>}
</div>
)}
{children.map((c, i) => (
<ComponentView key={i} comp={c} index={i} kzPath={`${kz}.components[${i}]`} 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 data-kz-field="body">{renderInline(body)}</p></blockquote>
{attribution && (
<figcaption className="c-blockquote-attribution">- <span data-kz-field="attribution">{attribution}</span></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}`} data-kz-field="label">{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 data-kz-field="label">{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 rawThresholds = comp.thresholds as Record<string, string> | undefined;
let autoColor = (comp.color as string) || "default";
if (rawThresholds && typeof rawThresholds === "object") {
const entries = Object.entries(rawThresholds)
.map(([k, v]) => ({ at: Number(k), color: v }))
.filter(t => !isNaN(t.at))
.sort((a, b) => b.at - a.at);
const matched = entries.find(t => value >= t.at);
if (matched) autoColor = matched.color;
}
const isHex = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(autoColor);
const fillStyle: React.CSSProperties = { "--progress": `${value}%` } as React.CSSProperties;
if (isHex) fillStyle.background = autoColor;
else if (semToHex[autoColor]) fillStyle.background = semToHex[autoColor];
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" data-kz-field="label">{label}</span> : <span />}
<span className="c-progress-value"><span data-kz-field="value">{value}</span>%</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" style={fillStyle} />
{targetPct != null && (
<div className="c-progress-target" style={{ left: `${targetPct}%` } as React.CSSProperties} aria-label={`Target: ${targetPct}%`} data-kz-field="target" />
)}
</div>
{detail && <div className="c-progress-detail" data-kz-field="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 data-kz-field="title" />
</div>
);
}
case "timeline": {
const items = (comp.items as Array<{ name: string; status: string }>) || [];
return (
<div id={id} className="c-timeline" data-kz-list="items">
{items.map((item, i) => (
<div key={i} className={`c-timeline-phase ${item.status || "upcoming"}`}>
<div className="c-timeline-dot" />
<div className="c-timeline-label" data-kz-field={`items[${i}].name`}>{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) => {
const side = pi === 0 ? "left" : "right";
return (
<div key={pi} className={`c-sc-panel c-sc-${side}`} style={{ "--sc-span": rowSpan } as React.CSSProperties}>
<div className="c-sc-eyebrow" data-kz-field={`${side}.eyebrow`}>{panel.eyebrow || ""}</div>
<div className="c-sc-title" data-kz-field={`${side}.title`}>{panel.title}</div>
{(panel.stats || []).map((s, si) => (
<div key={si} className="c-sc-stat">
<span className="c-sc-label" data-kz-field={`${side}.stats[${si}].label`}>{s.label}</span>
<span className={`c-sc-value color-${s.color || "default"}`} data-kz-field={`${side}.stats[${si}].value`}>{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" data-kz-list="items">
{items.map((item, i) => (
<div key={i} className="c-ba-card">
<div className="c-ba-title" data-kz-field={`items[${i}].title`}>{item.title}</div>
<div className="c-ba-before"><span data-kz-field="before_label">{beforeLabel}</span>: <span data-kz-field={`items[${i}].before`}>{item.before}</span></div>
<div className="c-ba-after"><span data-kz-field="after_label">{afterLabel}</span>: <span className="c-ba-highlight" data-kz-field={`items[${i}].after`}>{item.after}</span>{item.after_context ? <> - <span data-kz-field={`items[${i}].after_context`}>{item.after_context}</span></> : null}</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)} data-kz-field={`tabs[${ti}].label`}>{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} kzPath={`${kz}.tabs[${ti}].components[${ci}]`} 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} kzPath={`${kz}.columns[${ci}][${i}]`} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
))}
</div>
);
}
case "grid": {
type GridChild = { col?: number; row?: number; colspan?: number; rowspan?: number; component: ComponentData };
const children = (comp.children as GridChild[]) || [];
const gridStyle: Record<string, string | number> = { "--cols": Math.max(1, Number(comp.columns) || 1) };
if (comp.rows != null) gridStyle["--rows"] = Number(comp.rows);
if (comp.gap != null) gridStyle["--gap"] = `${Number(comp.gap)}px`;
const cellStyle = (ch: GridChild): React.CSSProperties => {
const s: React.CSSProperties = {};
const cs = Math.max(1, ch.colspan ?? 1);
const rs = Math.max(1, ch.rowspan ?? 1);
if (ch.col != null) s.gridColumn = `${ch.col} / span ${cs}`;
else if (cs > 1) s.gridColumn = `span ${cs}`;
if (ch.row != null) s.gridRow = `${ch.row} / span ${rs}`;
else if (rs > 1) s.gridRow = `span ${rs}`;
return s;
};
return (
<div id={id} className="c-grid" style={gridStyle as React.CSSProperties} data-kz-list="children">
{children.map((ch, i) => (
<div key={i} className="c-grid-cell" style={cellStyle(ch)}>
<ComponentView comp={ch.component} index={i} kzPath={`${kz}.children[${i}].component`} renderChart={renderChart} renderRoleMap={renderRoleMap} />
</div>
))}
</div>
);
}
case "box": {
const title = comp.title as string | undefined;
const tag = comp.tag as string | undefined;
const body = comp.body as string | undefined;
const children = (comp.components as ComponentData[]) || [];
const color = (comp.color as string) || "default";
const hex = comp.hex as string | undefined;
const hexOk = !!hex && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex);
const border = (comp.border as string) || "solid";
const style = hexOk ? ({ "--box-accent": hex } as React.CSSProperties) : undefined;
return (
<div id={id} className={`c-box c-box-${color} c-box-border-${border}`} style={style}>
{(title || tag) && (
<div className="c-box-h">
{title && <b className="c-box-title" data-kz-field="title">{title}</b>}
{tag && <span className="c-box-tag" data-kz-field="tag">{tag}</span>}
</div>
)}
{body && <div className="c-box-body c-markdown" data-kz-field="body" data-kz-block="">{md(body)}</div>}
{children.length > 0 && children.map((c, i) => (
<ComponentView key={i} comp={c} index={i} kzPath={`${kz}.components[${i}]`} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
);
}
case "connector": {
const label = comp.label as string | undefined;
const direction = (comp.direction as string) || "down";
const color = (comp.color as string) || "default";
const hex = comp.hex as string | undefined;
const hexOk = !!hex && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex);
const accent = hexOk ? hex : color !== "default" ? semToHex[color] : undefined;
const style = accent ? ({ "--connector-accent": accent } as React.CSSProperties) : undefined;
return (
<div id={id} className={`c-connector c-connector-${direction}`} style={style}>
{label && <span className="c-connector-label" data-kz-field="label">{label}</span>}
</div>
);
}
case "sequence": {
type SeqStep = { highlight?: string[]; note?: string };
const target = (comp.target as string) || "";
const steps = (comp.steps as SeqStep[]) || [];
const [cur, setCur] = React.useState(-1);
const rootRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (typeof document === "undefined") return;
const t = document.getElementById(target);
if (!t) return;
const clear = () => {
t.classList.remove("seq-active");
t.querySelectorAll(".seq-dim, .seq-hi").forEach((el) => el.classList.remove("seq-dim", "seq-hi"));
};
clear();
if (cur < 0 || !steps[cur]) return;
t.classList.add("seq-active");
const want = steps[cur].highlight ?? [];
const set = new Set(want);
const hi = want.map((w) => document.getElementById(w)).filter((x): x is HTMLElement => !!x);
t.querySelectorAll<HTMLElement>("[id]").forEach((el) => {
if (set.has(el.id)) { el.classList.add("seq-hi"); return; }
if (!hi.some((h) => el !== h && el.contains(h))) el.classList.add("seq-dim");
});
return clear;
}, [cur, target, steps]);
const shown = cur < 0 ? 0 : cur;
const step = steps[shown];
const go = (d: 1 | -1) => setCur((c) => (d > 0 ? Math.min(steps.length - 1, c < 0 ? 0 : c + 1) : Math.max(0, c - 1)));
return (
<div
id={id}
ref={rootRef}
className={`c-sequence${cur >= 0 ? " c-seq-live" : ""}`}
data-sequence=""
data-target={target}
tabIndex={0}
aria-label="Walkthrough"
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "]") { e.preventDefault(); go(1); }
else if (e.key === "ArrowLeft" || e.key === "[") { e.preventDefault(); go(-1); }
else if (e.key === "Escape") { setCur(-1); }
}}
>
<div className="c-seq-bar">
<button type="button" className="c-seq-btn" disabled={cur <= 0} onClick={() => go(-1)} aria-label="Previous step">←</button>
<span className="c-seq-count">{shown + 1} / {Math.max(1, steps.length)}</span>
<button type="button" className="c-seq-btn" disabled={cur >= steps.length - 1} onClick={() => go(1)} aria-label="Next step">→</button>
<button type="button" className="c-seq-btn c-seq-reset" onClick={() => setCur(-1)} aria-label="Show everything">Show all</button>
</div>
<div className="c-seq-steps" data-kz-list="steps">
{step && (
<div className="c-seq-step" data-highlight={(step.highlight ?? []).join(" ")}>
{step.note && <div className="c-seq-note c-markdown" data-kz-field={`steps[${shown}].note`} data-kz-block="">{md(step.note)}</div>}
</div>
)}
</div>
</div>
);
}
case "accordion": {
const items = (comp.items as Array<{ title: string; components: ComponentData[] }>) || [];
return <AccordionView id={id} items={items} kzPath={kz} 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" data-kz-field="eyebrow">{eyebrow}</div>}
<h1 className="c-hero-title" data-kz-field="title">{title}</h1>
{subtitle && <p className="c-hero-subtitle" data-kz-field="subtitle">{subtitle}</p>}
{buttons.length > 0 && (
<div className="c-hero-buttons" data-kz-list="buttons">
{buttons.map((b, i) => (
<a key={i} className={`c-btn c-btn-${b.variant || "primary"}`} href={b.href} data-kz-field={`buttons[${i}].label`}>{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.
// Carry the authored index so editors can map a rendered event back to events[i].
const events = rawEvents.map((e, idx) => ({ ...e, _kz: idx })).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" data-kz-list="events">
{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" data-kz-field={`events[${ev._kz}].date`}>{ev.date}</span>
{ev.severity && <span className="c-event-severity">{ev.severity}</span>}
{ev.source && <span className="c-event-source" data-kz-field={`events[${ev._kz}].source`}>{ev.source}</span>}
</div>
<div className="c-event-title" data-kz-field={`events[${ev._kz}].title`}>{ev.link ? <a href={ev.link}>{ev.title}</a> : ev.title}</div>
{ev.summary && <p className="c-event-summary" data-kz-field={`events[${ev._kz}].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, fieldPath: 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" data-kz-field={`${fieldPath}.label`}>{node.label as string}</span>
{node.owner ? <span className="c-tree-owner" data-kz-field={`${fieldPath}.owner`}>{String(node.owner)}</span> : null}
{hasChildren && <span className="c-tree-count">{descCount}</span>}
{node.note ? <span className="c-tree-note" data-kz-field={`${fieldPath}.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, `${fieldPath}.children[${i}]`)}</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" data-kz-list="nodes">{nodes.map((n, i) => <React.Fragment key={i}>{renderNode(n, 0, String(i), `nodes[${i}]`)}</React.Fragment>)}</ul>
</div>
);
}
case "priority_queue": {
// Items get bucketed and filtered below; keep the authored index for editors.
const items: Array<Record<string, unknown>> = ((comp.items as Array<Record<string, unknown>>) || []).map((it, idx) => ({ ...it, _kz: idx }));
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} data-kz-field={`items[${item._kz as number}].label`}>{label}</a> : <span className="c-queue-label" data-kz-field={`items[${item._kz as number}].label`}>{label}</span>}
{owner && <span className="c-queue-owner" data-kz-field={`items[${item._kz as number}].owner`}>{owner}</span>}
</div>
{detail && <div className="c-queue-detail" data-kz-field={`items[${item._kz as number}].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" : ""}`} data-kz-field={`items[${item._kz as number}].tags[${ti}].label`}>{tag.label}</span>
))}
</div>
)}
</div>
{showDates && due && (
<div className="c-queue-date">
<span className="c-queue-due" data-kz-field={`items[${item._kz as number}].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" data-kz-list="items">
{title && <div className="c-queue-title" data-kz-field="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} data-kz-list="cards">
{cards.map((card, i) => (
<div key={i} className="sel-card">
{card.eyebrow && <div className="c-sel-eyebrow" data-kz-field={`cards[${i}].eyebrow`}>{card.eyebrow}</div>}
<div className="c-sel-title" data-kz-field={`cards[${i}].title`}>{card.title}</div>
{card.body && <div className="c-sel-body" data-kz-field={`cards[${i}].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 data-kz-field={`cards[${i}].bullets[${bi}]`}>{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" data-kz-list="items">
{items.map((item, i) => (
<a key={i} className="c-resource" href={item.href}>
<div className="c-resource-title" data-kz-field={`items[${i}].title`}>{item.title}</div>
{item.description && <div className="c-resource-desc" data-kz-field={`items[${i}].description`}>{renderInline(item.description)}</div>}
{item.owner && <div className="c-resource-owner" data-kz-field={`items[${i}].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" data-kz-list="buttons">
{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" } : {})} data-kz-field={`buttons[${i}].label`}>{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" data-kz-list="items">
{items.map((item, i) => (
<div key={i} className="c-dl-row">
<dt className="c-dl-term" data-kz-field={`items[${i}].term`}>{item.term}</dt>
<dd className="c-dl-def" data-kz-field={`items[${i}].definition`}>{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" data-kz-field="name">{name}</div>
<div className="c-avatar-sub" data-kz-field="subtitle">{subtitle}</div>
</div>
</div>
);
}
return (
<div id={id} className={`c-avatar c-avatar-${sizeClass}`} data-kz-field="name" title={name}>
{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}`} data-kz-list="avatars">
{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} data-kz-field={`avatars[${i}].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" data-kz-list="items">
{items.map((item, i) => (
<span key={i}>
{i > 0 && <span className="c-breadcrumb-sep">/</span>}
{item.href ? <a href={item.href} data-kz-field={`items[${i}].label`}>{item.label}</a> : <span data-kz-field={`items[${i}].label`}>{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}`} data-kz-field="label">{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 data-kz-field={`keys[${i}]`}>{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" data-kz-field="title">{title}</h3>
{body && <p className="c-empty-body" data-kz-field="body">{renderInline(body)}</p>}
{action && <a className="c-btn c-btn-primary" href={action.href} data-kz-field="action.label">{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;
const rawDefault = (comp.default_view ?? comp.defaultView) as string | undefined;
const [view, setView] = React.useState<"venn" | "table">(rawDefault === "table" ? "table" : "venn");
const n = sets.length;
if (n === 0) {
return (
<div id={id} className="c-venn">
{title && <div className="c-venn-title" data-kz-field="title">{title}</div>}
<div className="c-venn-empty">No sets provided.</div>
</div>
);
}
const VB_W = 580, VB_H = 410, R = 108;
const centers: [number, number][] =
n === 1 ? [[VB_W / 2, VB_H / 2]] :
n === 2 ? [
[VB_W / 2 - R * 0.55, VB_H / 2],
[VB_W / 2 + R * 0.55, VB_H / 2],
] : (() => {
const d = R * 0.62;
const cx = VB_W / 2, cy = VB_H / 2 + d * 0.3;
return [
[cx, cy - d],
[cx - d * 0.866, cy + d * 0.5],
[cx + d * 0.866, cy + d * 0.5],
] as [number, number][];
})();
const shown = sets.slice(0, centers.length);
// viewBox hugs the circles' bounding box (plus padding) so the diagram
// fills its container instead of floating small inside a fixed canvas.
const PAD = 14;
const minX = Math.min(...centers.map(c => c[0])) - R - PAD;
const maxX = Math.max(...centers.map(c => c[0])) + R + PAD;
const minY = Math.min(...centers.map(c => c[1])) - R - PAD;
const maxY = Math.max(...centers.map(c => c[1])) + R + PAD;
const centroidX = centers.reduce((s, c) => s + c[0], 0) / centers.length;
const centroidY = centers.reduce((s, c) => s + c[1], 0) / centers.length;
// Where a set label sits (pushed from its circle's center away from the
// diagram centroid) and how much horizontal room the uncovered crescent
// gives it there. 2-set: the crescent runs from the circle edge (-1.0R) to
// the other circle's edge (+0.1R), so center at 0.45R with ~1.0R of width.
// 3-set: the top circle's crescent is wide open; the bottom two are hemmed
// in by their neighbor.
const setLabelPush = n === 1 ? 0 : n === 2 ? R * 0.45 : R * 0.55;
const setLabelAvail = (i: number) => n === 1 ? R * 1.7 : n === 2 ? R * 1.0 : i === 0 ? R * 1.5 : R * 0.95;
// Room inside an intersection region: width, and how tall a label block
// may stack before it pokes out of the lune.
const overlapAvail = (count: number) => n === 2 ? R * 0.8 : count >= 3 ? R * 0.75 : R * 0.62;
const overlapAvailH = (count: number) => n === 2 ? R * 1.2 : count >= 3 ? R * 0.7 : R * 0.45;
// Keep on-screen scale constant across layouts: ~1.85px per viewBox unit,
// so a lone circle doesn't balloon to fill the whole column.
const svgMaxWidth = Math.min(680, Math.round((maxX - minX) * 1.85));
const circles = shown.map((s, i) => (
<circle key={i} className={`c-venn-circle c-venn-circle-${s.color || "default"}`} cx={centers[i][0]} cy={centers[i][1]} r={R} />
));
const labels = shown.map((s, i) => {
const [cx, cy] = centers[i];
const dx = cx - centroidX, dy = cy - centroidY;
const mag = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const lx = cx + (dx / mag) * setLabelPush;
const ly = cy + (dy / mag) * setLabelPush;
const avail = setLabelAvail(i);
const fit = vennFitLabel(s.label, avail, 13, 9, 1.05);
return (
<text key={i} className={`c-venn-label c-venn-label-${s.color || "default"}`} x={lx} y={ly} textAnchor="middle" dominantBaseline="middle" style={{ fontSize: fit.fontSize }} data-kz-field={`sets[${i}].label`}>
{vennTspans(fit, lx, avail, 1.05)}
</text>
);
});
const overlapLabels = overlaps.map((o, i) => {
if (!o.sets || o.sets.length === 0 || !o.label) return null;
let sumX = 0, sumY = 0, count = 0;
for (const idx of o.sets) {
const c = centers[idx];
if (c) { sumX += c[0]; sumY += c[1]; count++; }
}
if (count === 0) return null;
let lx = sumX / count, ly = sumY / count;
if (n === 3 && count === 2) {
const thirdIdx = [0, 1, 2].find(x => !o.sets.includes(x));
if (thirdIdx !== undefined) {
const [tx, ty] = centers[thirdIdx];
const ddx = lx - tx, ddy = ly - ty;
const mag = Math.max(Math.sqrt(ddx * ddx + ddy * ddy), 1);
// 0.5R centers the label in the lune's usable span (third circle's
// edge at 0.38R from the centroid, lune tip at 1.15R).
const push = R * 0.5;
lx += (ddx / mag) * push;
ly += (ddy / mag) * push;
}
}
const avail = overlapAvail(count);
const fit = vennWrapLabel(o.label, avail, overlapAvailH(count), 12, 9, 1.0);
return (
<text key={i} className="c-venn-overlap-label" x={lx} y={ly} textAnchor="middle" dominantBaseline="middle" style={{ fontSize: fit.fontSize }} data-kz-field={`overlaps[${i}].label`}>
{vennTspans(fit, lx, avail, 1.0)}
</text>
);
});
// ── Matrix view: sets as both rows and columns ──
const parsed = shown.map((s) => {
const m = s.label.match(/^(.*?)\s*\(([^()]*)\)\s*$/);
return m ? { name: m[1].trim(), count: m[2].trim(), color: s.color || "default" } : { name: s.label, count: "", color: s.color || "default" };
});
const overlapFor = (idx: number[]): string => {
const key = [...idx].sort((a, b) => a - b).join(",");
const hit = overlaps.find(o => Array.isArray(o.sets) && [...o.sets].sort((a, b) => a - b).join(",") === key);
return hit?.label ?? "";
};
const allLabel = n >= 3 ? overlapFor(shown.map((_, i) => i)) : "";
const matrix = (
<div className="c-venn-matrix-wrap">
<table className="c-venn-matrix">
<thead>
<tr>
<th scope="col" aria-label="Set"></th>
{parsed.map((p, i) => <th key={i} scope="col" className={`c-venn-th-${p.color}`} data-kz-field={`sets[${i}].label`}>{p.name}</th>)}
</tr>
</thead>
<tbody>
{parsed.map((p, i) => (
<tr key={i}>
<th scope="row" className={`c-venn-th-${p.color}`} data-kz-field={`sets[${i}].label`}>{p.name}</th>
{parsed.map((_, j) => {
if (i === j) {
return <td key={j} className="c-venn-cell c-venn-cell-total" title={`${p.name} total`}>{p.count || "—"}</td>;
}
const v = overlapFor([i, j]);
return (
<td key={j} className={`c-venn-cell ${v ? "c-venn-cell-overlap" : "c-venn-cell-empty"}`} title={`${parsed[i].name} ∩ ${parsed[j].name}`}>{v || "—"}</td>
);
})}
</tr>
))}
{n >= 3 && (
<tr className="c-venn-matrix-all">
<th scope="row">All {n}</th>
<td colSpan={n} className={`c-venn-cell ${allLabel ? "c-venn-cell-all" : "c-venn-cell-empty"}`}>{allLabel || "—"}</td>
</tr>
)}
</tbody>
</table>
</div>
);
const diagram = (
<svg className="c-venn-svg" viewBox={`${minX.toFixed(1)} ${minY.toFixed(1)} ${(maxX - minX).toFixed(1)} ${(maxY - minY).toFixed(1)}`} style={{ maxWidth: svgMaxWidth }} role="img" aria-label={title || ""}>
{circles}
{labels}
{overlapLabels}
</svg>
);
const canToggle = n >= 2;
const showHead = !!title || canToggle;
return (
<div id={id} className="c-venn">
{showHead && (
<div className="c-venn-head">
{title && <div className="c-venn-title" data-kz-field="title">{title}</div>}
{canToggle && (
<div className="c-venn-toggle" role="group" aria-label="Venn view switcher">
<button type="button" className={view === "venn" ? "active" : ""} aria-pressed={view === "venn"} title="Venn diagram" onClick={() => setView("venn")}>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="6" cy="8" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><circle cx="10" cy="8" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" /></svg>
</button>
<button type="button" className={view === "table" ? "active" : ""} aria-pressed={view === "table"} title="Overlap matrix" onClick={() => setView("table")}>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="2" y="2" width="5" height="5" rx="1" fill="currentColor" /><rect x="9" y="2" width="5" height="5" rx="1" fill="currentColor" /><rect x="2" y="9" width="5" height="5" rx="1" fill="currentColor" /><rect x="9" y="9" width="5" height="5" rx="1" fill="currentColor" /></svg>
</button>
</div>
)}
</div>
)}
{view === "table" && canToggle ? matrix : diagram}
</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" }} data-kz-field="title">{title}</h3>}
{children.map((child, ci) => (
<ComponentView key={ci} comp={child} index={ci} kzPath={`${kz}.components[${ci}]`} 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" data-kz-field="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" data-kz-field={`flows[${i}].value`}><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;
const labelBudget = c === 0 || c === maxCol ? padX - 10 : Math.max(colSpacing - nodeW - 12, 60);
const shown = fitLabel(n, labelBudget, 12);
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">{shown}{shown !== n && <title>{n}</title>}</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" data-kz-field="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";
const labelBudget = anchor === "middle" ? 160 : anchor === "start" ? VB_W - lx - 4 : lx - 4;
const shown = fitLabel(label, labelBudget, 11);
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" data-kz-field={`axes[${i}]`}>{shown}{shown !== label && <title>{label}</title>}</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" data-kz-field={`curves[${ci}].values[${i}]`}><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 data-kz-field={`curves[${i}].label`}>{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" data-kz-field="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" data-kz-field="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" data-kz-field={`quadrants[${i}]`}>{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" data-kz-field="x_axis">{xParts[0]}</text>
<text x={left + plotW} y={top + plotH + 24} textAnchor="end" className="c-chart-axis" data-kz-field="x_axis">{xParts[1]}</text>
</>
) : (
<text x={midX} y={top + plotH + 24} textAnchor="middle" className="c-chart-axis" data-kz-field="x_axis">{xAxis}</text>
)}
<text x={left - 50} y={midY} textAnchor="middle" transform={`rotate(-90,${left - 50},${midY})`} className="c-chart-axis" data-kz-field="y_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" data-kz-field={`points[${i}].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" data-kz-field={`connections[${i}].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" data-kz-field={`nodes[${i}].label`}>{n.label}</text>
{n.detail && <text x={pos.cx} y={pos.cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" data-kz-field={`nodes[${i}].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" data-kz-field="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" data-kz-field="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" data-kz-field={`inputs[${i}].label`}>{item.label}</text>
{item.detail && <text x={cx} y={cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" data-kz-field={`inputs[${i}].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} data-kz-field={`stages[${si}].label`}>{stage.label}</text>
{stage.detail && <text x={stagesCx} y={sl.y + 30} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" fontSize={10} data-kz-field={`stages[${si}].detail`}>{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} data-kz-field={`stages[${si}].capabilities[${ci}].label`}>{cap.label}</text>
{cap.detail && <text x={tcx} y={tcy + 8} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" fontSize={9} data-kz-field={`stages[${si}].capabilities[${ci}].detail`}>{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" data-kz-field={`outputs[${i}].label`}>{item.label}</text>
{item.detail && <text x={cx} y={cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" data-kz-field={`outputs[${i}].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" data-kz-field={`context[${i}].label`}>{item.label}</text>
{item.detail && <text x={cx} y={cy + 10} textAnchor="middle" dominantBaseline="middle" className="c-arch-node-detail" data-kz-field={`context[${i}].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" data-kz-field="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} kzField={`people[${i}]`} />)}
</div>
) : (
people.map((p: any, i: number) => <OrgNode key={p.id || i} person={p} depth={0} autoDepth={autoDepth} kzField={`people[${i}]`} />)
)}
</div>
</OrgChartZoom>
</div>
);
}
case "aside": {
const body = (comp.body as string) || "";
return (
<div id={id} className="c-aside">
<div className="c-aside-body" data-kz-field="body" data-kz-block="">{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" data-kz-list="items">
{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" data-kz-field={`items[${i}].label`}>{item.label}</div>
<div className="c-rule-body" data-kz-field={`items[${i}].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 columns = (comp.columns as number) || 3;
const title = comp.title as string | undefined;
const cx = 32, cy = 32, r = 26, strokeW = 6;
const circ = 2 * Math.PI * r;
const fmtValue = (v: number) => (v % 1 === 0 ? String(v) : v.toFixed(1));
return (
<div id={id} className="c-gauge-grid" style={{ "--gauge-cols": columns } as React.CSSProperties}>
{title && <div className="c-gauge-title" data-kz-field="title">{title}</div>}
{items.map((item, i) => {
const fraction = maxVal > 0 ? Math.min(item.value / maxVal, 1) : 0;
const dash = fraction * circ;
const gap = circ - dash;
const color = semToHex[item.color || "default"] || semToHex.default;
return (
<div key={i} className="c-gauge-item">
<svg viewBox="0 0 64 64" className="c-gauge-ring">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={strokeW} />
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={color}
strokeWidth={strokeW}
strokeDasharray={`${dash.toFixed(1)} ${gap.toFixed(1)}`}
strokeLinecap="round"
transform={`rotate(-90 ${cx} ${cy})`}
/>
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="central" className="c-gauge-value" data-kz-field={`items[${i}].value`}>{fmtValue(item.value)}</text>
</svg>
<div className="c-gauge-label" data-kz-field={`items[${i}].label`}>{item.label}</div>
</div>
);
})}
</div>
);
}
default:
return (
<div id={id} className="c-unsupported" data-type={comp.type}>
<em>Unsupported component: {comp.type}</em>
</div>
);
}
})();
// Every component root carries its data path and type so editors can map
// rendered elements back to the page data without guessing.
const tagged = React.isValidElement(content) && typeof content.type === "string"
? React.cloneElement(content as React.ReactElement<Record<string, unknown>>, { "data-kz-path": kz, "data-kz-type": comp.type })
: <div data-kz-path={kz} data-kz-type={comp.type} className="kz-wrap" style={{ display: "contents" }}>{content}</div>;
const scale = comp.scale as number | undefined;
const scaled = scale != null
? <div className="c-chart-scale" style={{ ["--kz-scale" as any]: Math.min(2, Math.max(0.1, scale)) }}>{tagged}</div>
: tagged;
const animate = comp.animate as string | undefined;
if (animate && animate !== "none") {
return <div className="kz-anim" data-animate={animate.replace(/_/g, "-")}>{scaled}</div>;
}
return scaled;
}
/** Reveals `.kz-anim` carriers under `root` as they scroll into view. */
function useMotionReveal(root: React.RefObject<HTMLElement | null>, enabled: boolean) {
React.useEffect(() => {
if (!enabled || !root.current) return;
const els = Array.from(root.current.querySelectorAll<HTMLElement>(".kz-anim"));
if (els.length === 0) return;
const reduce = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce || typeof IntersectionObserver === "undefined") {
els.forEach((el) => el.classList.add("kz-in"));
return;
}
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) { e.target.classList.add("kz-in"); io.unobserve(e.target); }
});
}, { rootMargin: "0px 0px -8% 0px", threshold: 0.05 });
els.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [root, enabled]);
}
function DeckRenderer({ slides, renderChart, renderRoleMap }: { slides: SlideData[]; 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} kzPath={`slides[${si}].components[${ci}]`} 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, renderChart, renderRoleMap, motion, resolveHubHref, activeHubHref, exportMode, componentWrapper: CW }: PageRendererProps) {
const motionOn = !exportMode && (motion ?? page.motion ?? false);
const rootRef = React.useRef<HTMLDivElement | null>(null);
useMotionReveal(rootRef, motionOn);
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} kzPath={`slides[${si}].components[${ci}]`} renderChart={renderChart} renderRoleMap={renderRoleMap} />
))}
</div>
</div>
))}
</div>
);
}
return <DeckRenderer slides={page.slides} renderChart={renderChart} renderRoleMap={renderRoleMap} />;
}
const components = page.components ?? [];
const body = (
<>
{!exportMode && <FreshnessBanner freshness={page.freshness} />}
{components.map((comp, i) => {
const cv = <ComponentView comp={comp} index={i} kzPath={`components[${i}]`} renderChart={renderChart} renderRoleMap={renderRoleMap} />;
// Key by id when present so reorders move nodes instead of re-rendering every slot.
const key = typeof comp.id === "string" && comp.id ? `id-${comp.id}` : `i-${i}`;
return CW ? <CW key={key} comp={comp} index={i}>{cv}</CW> : <React.Fragment key={key}>{cv}</React.Fragment>;
})}
</>
);
if (page.shell === "hub" && page.hub) {
return (
<div ref={rootRef} className={`hub-root${exportMode ? " export-root" : ""}${motionOn ? " kz-motion" : ""}`}>
<HubMasthead hub={page.hub} resolveHref={resolveHubHref} activeHref={activeHubHref} exportMode={exportMode} />
<div className="hub-content">{body}</div>
</div>
);
}
return <div ref={rootRef} className={[exportMode ? "export-root" : "", motionOn ? "kz-motion" : ""].filter(Boolean).join(" ") || undefined}>{body}</div>;
}
export { ComponentView, DeckRenderer, HubMasthead, type SlideData, type PageData, type ComponentData, type PageRendererProps, type HubData };
"####;
fn json_str(v: &str) -> String {
serde_json::to_string(v).expect("string is always serializable")
}
fn schema_json(schema: &Schema) -> String {
let mut out = String::with_capacity(32768);
out.push_str("{\n \"enums\": {");
let mut first = true;
for (name, variants) in &schema.enums {
if !first {
out.push(',');
}
first = false;
out.push_str("\n ");
out.push_str(&json_str(name));
out.push_str(": [");
out.push_str(
&variants
.iter()
.map(|v| json_str(v))
.collect::<Vec<_>>()
.join(", "),
);
out.push(']');
}
out.push_str("\n },");
for (section, map) in [("types", &schema.types), ("components", &schema.components)] {
out.push_str("\n \"");
out.push_str(section);
out.push_str("\": {");
let mut first_owner = true;
for (owner, fields) in map {
if !first_owner {
out.push(',');
}
first_owner = false;
out.push_str("\n ");
out.push_str(&json_str(owner));
out.push_str(": [");
let items: Vec<String> = fields
.iter()
.map(|(fname, field)| {
format!(
"{{ \"name\": {}, \"type\": {}, \"required\": {}, \"kind\": {} }}",
json_str(fname),
json_str(&field.field_type),
if field.required { "true" } else { "false" },
json_str(&kind_of(fname, field, schema))
)
})
.collect();
out.push_str(&items.join(", "));
out.push(']');
}
out.push_str("\n }");
if section == "types" {
out.push(',');
}
}
out.push_str("\n}");
out
}
fn generate_editor(out: &mut String) {
let schema = load_schema();
out.push_str("\n// ── Schema (authoring order) shared by the editor and in-place binding ──\n");
out.push_str("export const KZ_SCHEMA: KzSchema = ");
out.push_str(&schema_json(&schema));
out.push_str(";\n");
out.push_str(EDITOR_RAW);
}
const EDITOR_RAW: &str = r##"
// ── Schema-driven editor ──────────────────────────────────────────────────
// Every form below reads KZ_SCHEMA at runtime, so a new component or field in
// schema/components.json shows up here with no emitter change.
export type KzKind = "text" | "markdown" | "code" | "number" | "layout" | "enum" | "id" | "url" | "bool" | "list" | "object";
export interface KzField { name: string; type: string; required: boolean; kind: KzKind }
export interface KzSchema { enums: Record<string, string[]>; types: Record<string, KzField[]>; components: Record<string, KzField[]> }
export const KZ_CONTENT_KINDS: ReadonlyArray<KzKind> = ["text", "markdown", "code", "number"];
/** `Stat[]` -> `Stat`, `Component[][]` -> `Component[]`, `(string | null)[]` -> `string`. */
export function kzElemType(t: string): string | null {
if (t.endsWith("[][]")) return t.slice(0, -2);
if (!t.endsWith("[]")) return null;
const inner = t.slice(0, -2);
if (inner.startsWith("(") && inner.endsWith(")")) return inner.slice(1, -1).split("|")[0].trim();
return inner;
}
export function kzTitle(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
export function kzEmptyValue(f: KzField): unknown {
switch (f.kind) {
case "bool": return false;
case "number": case "layout": return 0;
case "list": return [];
case "enum": return (KZ_SCHEMA.enums[f.type] || [""])[0];
case "object": return KZ_SCHEMA.types[f.type] ? kzEmptyItem(f.type) : {};
default: return "";
}
}
/** Minimal valid item for a schema type: required fields only. */
export function kzEmptyItem(typeName: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of KZ_SCHEMA.types[typeName] || []) if (f.required) out[f.name] = kzEmptyValue(f);
return out;
}
export function newEditorComponent(type: string): ComponentData {
const out: Record<string, unknown> = { type };
for (const f of KZ_SCHEMA.components[type] || []) if (f.required) out[f.name] = kzEmptyValue(f);
return out as ComponentData;
}
/**
* Resolve a component-relative field path (`stats[3].value`, `rows[1].owner`,
* `axes[0]`) to its schema field. Returns a synthetic field for scalar list
* items and untyped rows so callers always get a kind.
*/
export function kzResolveField(componentType: string, relPath: string): KzField | null {
const tokens = relPath.match(/[^.\[\]]+|\[\d+\]/g) || [];
let fields: KzField[] | undefined = KZ_SCHEMA.components[componentType];
let current: KzField | null = null;
let pendingType: string | null = null;
for (const tok of tokens) {
if (tok.startsWith("[")) {
if (!current) return null;
const elem = kzElemType(pendingType ?? current.type);
if (elem === null) return null;
pendingType = elem;
if (KZ_SCHEMA.types[elem]) { fields = KZ_SCHEMA.types[elem]; }
else if (elem === "string" || elem === "number") {
current = { name: current.name, type: elem, required: current.required, kind: elem === "number" ? "number" : "text" };
fields = undefined;
} else { fields = undefined; }
continue;
}
if (pendingType && !KZ_SCHEMA.types[pendingType] && current && kzElemType(current.type) !== null && !fields) {
// Untyped row (Record<string, unknown>): any key is editable text.
return { name: tok, type: "string", required: false, kind: "text" };
}
if (!fields) {
if (current && current.kind === "object" && KZ_SCHEMA.types[current.type]) fields = KZ_SCHEMA.types[current.type];
else return null;
}
const f: KzField | undefined = fields.find((x) => x.name === tok);
if (!f) return null;
current = f;
pendingType = null;
fields = f.kind === "object" && KZ_SCHEMA.types[f.type] ? KZ_SCHEMA.types[f.type] : undefined;
}
return current;
}
export const EDITOR_TYPES: Array<{type: string; label: string; icon: string}> = [
{ type: "header", label: "Header", icon: "H" },
{ type: "hero_banner", label: "Hero Banner", icon: "H" },
{ type: "meta", label: "Meta", icon: "M" },
{ type: "card_grid", label: "Card Grid", icon: ":::" },
{ type: "selectable_grid", label: "Selectable Grid", icon: "S" },
{ type: "timeline", label: "Timeline", icon: "T" },
{ type: "stat_grid", label: "Stat Grid", icon: "#" },
{ type: "before_after", label: "Before After", icon: "B" },
{ type: "split_compare", label: "Split Compare", icon: "S" },
{ type: "steps", label: "Steps", icon: "1." },
{ type: "markdown", label: "Markdown", icon: "Aa" },
{ type: "table", label: "Table", icon: "|||" },
{ type: "callout", label: "Callout", icon: "!" },
{ type: "code", label: "Code", icon: "</>" },
{ type: "tabs", label: "Tabs", icon: "⊑" },
{ type: "section", label: "Section", icon: "§" },
{ type: "columns", label: "Columns", icon: "||" },
{ type: "grid", label: "Grid", icon: "#" },
{ type: "box", label: "Box", icon: "▢" },
{ type: "connector", label: "Connector", icon: "↓" },
{ type: "sequence", label: "Sequence", icon: "▶" },
{ type: "accordion", label: "Accordion", icon: "≡" },
{ type: "event_timeline", label: "Event Timeline", icon: "E" },
{ type: "tree", label: "Tree", icon: "T" },
{ type: "org_chart", label: "Org Chart", icon: "👥" },
{ type: "venn", label: "Venn", icon: "V" },
{ type: "image", label: "Image", icon: "img" },
{ type: "embed", label: "Embed", icon: "E" },
{ type: "resources", label: "Resources", icon: "R" },
{ type: "badge", label: "Badge", icon: "B" },
{ type: "tag", label: "Tag", icon: "T" },
{ type: "divider", label: "Divider", icon: "---" },
{ type: "kbd", label: "Kbd", icon: "K" },
{ type: "status", label: "Status", icon: "S" },
{ type: "breadcrumb", label: "Breadcrumb", icon: "B" },
{ type: "button_group", label: "Button Group", icon: "B" },
{ type: "definition_list", label: "Definition List", icon: "D" },
{ type: "blockquote", label: "Blockquote", icon: "B" },
{ type: "avatar", label: "Avatar", icon: "A" },
{ type: "avatar_group", label: "Avatar Group", icon: "A" },
{ type: "progress_bar", label: "Progress Bar", icon: "P" },
{ type: "empty_state", label: "Empty State", icon: "E" },
{ type: "icon", label: "Icon", icon: "I" },
{ type: "chart", label: "Chart", icon: "◔" },
{ type: "chart_group", label: "Chart Group", icon: "◔◔" },
{ type: "role_map", label: "Role Map", icon: "R" },
{ type: "sankey", label: "Sankey", icon: "⇉" },
{ type: "radar", label: "Radar", icon: "◎" },
{ type: "quadrant", label: "Quadrant", icon: "⊞" },
{ type: "architecture", label: "Architecture", icon: "⬚" },
{ type: "pipeline", label: "Pipeline", icon: "➡" },
{ type: "graph", label: "Graph", icon: "G" },
{ type: "aside", label: "Aside", icon: "A" },
{ type: "rule_list", label: "Rule List", icon: "R" },
{ type: "gauge", label: "Gauge", icon: "G" },
{ type: "priority_queue", label: "Priority Queue", icon: "P" },
];
function KzControl({ field, value, onChange }: { field: KzField; value: unknown; onChange: (v: unknown) => void }) {
const clear = (v: string) => (field.required ? v : v || undefined);
switch (field.kind) {
case "markdown":
case "code":
return <textarea className={`pe-textarea${field.kind === "code" ? " pe-textarea-code" : ""}`} rows={field.kind === "code" ? 8 : 4} value={(value as string) ?? ""} onChange={(e) => onChange(clear(e.target.value))} />;
case "number":
case "layout":
return <input className="pe-input pe-input-number" type="number" value={value === undefined || value === null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? (field.required ? 0 : undefined) : Number(e.target.value))} />;
case "bool":
return <input className="pe-checkbox" type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked || (field.required ? false : undefined))} />;
case "enum": {
const opts = KZ_SCHEMA.enums[field.type] || [];
return (
<select className="pe-select" value={(value as string) ?? ""} onChange={(e) => onChange(clear(e.target.value))}>
{!field.required && <option value="">–</option>}
{opts.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
case "id":
case "url":
return <input className="pe-input pe-input-mono" value={(value as string) ?? ""} onChange={(e) => onChange(clear(e.target.value))} placeholder={field.kind === "url" ? "https://" : ""} />;
default:
return <input className="pe-input" value={(value as string) ?? ""} onChange={(e) => onChange(clear(e.target.value))} />;
}
}
function KzYaml({ value, onChange }: { value: unknown; onChange: (v: unknown) => void }) {
const [text, setText] = React.useState(value === undefined || value === null ? "" : yamlStringify(value).trimEnd());
const [err, setErr] = React.useState("");
return (
<div className="pe-yaml">
<textarea
className="pe-textarea pe-textarea-code"
rows={5}
value={text}
onChange={(e) => {
setText(e.target.value);
if (!e.target.value.trim()) { setErr(""); onChange(undefined); return; }
try { onChange(yamlParse(e.target.value)); setErr(""); } catch (ex) { setErr(ex instanceof Error ? ex.message : "Invalid YAML"); }
}}
/>
{err && <div className="pe-error">{err}</div>}
</div>
);
}
function KzRowsGrid({ rows, columns, onChange }: { rows: Array<Record<string, unknown>>; columns: Array<{ key: string; label?: string }>; onChange: (rows: Array<Record<string, unknown>>) => void }) {
const keys = columns.length > 0 ? columns.map((c) => c.key) : Array.from(new Set(rows.flatMap((r) => Object.keys(r))));
if (keys.length === 0) return <div className="pe-hint">Add a column first, then rows.</div>;
return (
<div className="pe-rows">
<table className="pe-rows-table">
<thead>
<tr>{keys.map((k) => <th key={k}>{columns.find((c) => c.key === k)?.label || k}</th>)}<th /></tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{keys.map((k) => (
<td key={k}>
<input className="pe-input pe-input-cell" value={row[k] === undefined || row[k] === null ? "" : String(row[k])} onChange={(e) => { const next = rows.map((r, i) => (i === ri ? { ...r, [k]: e.target.value } : r)); onChange(next); }} />
</td>
))}
<td><button type="button" className="pe-list-remove" title="Delete row" onClick={() => onChange(rows.filter((_, i) => i !== ri))}>×</button></td>
</tr>
))}
</tbody>
</table>
<button type="button" className="pe-list-add" onClick={() => onChange([...rows, Object.fromEntries(keys.map((k) => [k, ""]))])}>+ Row</button>
</div>
);
}
function KzObjectFields({ typeName, value, onChange, depth }: { typeName: string; value: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void; depth: number }) {
const fields = KZ_SCHEMA.types[typeName] || [];
return (
<div className="pe-object">
{fields.map((f) => (
<KzFieldRow key={f.name} field={f} value={value[f.name]} siblings={value} depth={depth} onChange={(v) => { const next = { ...value }; if (v === undefined) delete next[f.name]; else next[f.name] = v; onChange(next); }} />
))}
</div>
);
}
function KzListEditor({ field, items, siblings, onChange, depth }: { field: KzField; items: unknown[]; siblings: Record<string, unknown>; onChange: (items: unknown[]) => void; depth: number }) {
const elem = kzElemType(field.type) || "string";
const [open, setOpen] = React.useState<Record<number, boolean>>({});
if (elem === "Component") {
return <NestedComponentList components={(items as ComponentData[]) || []} onChange={(c) => onChange(c)} />;
}
if (elem === "Component[]") {
const cols = (items as ComponentData[][]) || [];
return (
<div className="pe-columns-editor">
{cols.map((col, ci) => (
<div key={ci} className="pe-column">
<div className="pe-column-label">Column {ci + 1}<button type="button" className="pe-list-remove" onClick={() => onChange(cols.filter((_, i) => i !== ci))}>×</button></div>
<NestedComponentList components={col} onChange={(c) => { const next = [...cols]; next[ci] = c; onChange(next); }} />
</div>
))}
<button type="button" className="pe-list-add" onClick={() => onChange([...cols, []])}>+ Column</button>
</div>
);
}
if (elem === "string" || elem === "number") {
const text = (items || []).map((v) => (v === null || v === undefined ? "" : String(v))).join("\n");
return (
<textarea
className="pe-textarea"
rows={Math.min(8, Math.max(2, (items || []).length + 1))}
placeholder="One per line"
value={text}
onChange={(e) => {
const lines = e.target.value.split("\n");
onChange(elem === "number" ? lines.filter((l) => l.trim() !== "").map((l) => Number(l)) : lines);
}}
/>
);
}
if (elem.startsWith("Record<")) {
const columns = (siblings.columns as Array<{ key: string; label?: string }>) || [];
return <KzRowsGrid rows={(items as Array<Record<string, unknown>>) || []} columns={columns} onChange={(r) => onChange(r)} />;
}
const typeFields = KZ_SCHEMA.types[elem];
if (!typeFields) return <KzYaml value={items} onChange={(v) => onChange((v as unknown[]) || [])} />;
const list = (items as Array<Record<string, unknown>>) || [];
const summaryOf = (it: Record<string, unknown>): string => {
for (const f of typeFields) {
if (KZ_CONTENT_KINDS.includes(f.kind) && typeof it[f.name] === "string" && (it[f.name] as string).trim()) return it[f.name] as string;
}
return `${kzTitle(elem)}`;
};
return (
<div className="pe-items">
{list.map((it, i) => {
const isOpen = open[i] ?? (depth === 0 && list.length <= 4);
return (
<div key={i} className={`pe-item${isOpen ? " pe-item-open" : ""}`}>
<div className="pe-item-head" onClick={() => setOpen((o) => ({ ...o, [i]: !isOpen }))}>
<span className="pe-item-chevron">{isOpen ? "▾" : "▸"}</span>
<span className="pe-item-summary">{summaryOf(it)}</span>
<span className="pe-item-actions" onClick={(e) => e.stopPropagation()}>
{i > 0 && <button type="button" className="pe-move-btn" title="Move up" onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i], n[i - 1]]; onChange(n); }}>↑</button>}
{i < list.length - 1 && <button type="button" className="pe-move-btn" title="Move down" onClick={() => { const n = [...list]; [n[i], n[i + 1]] = [n[i + 1], n[i]]; onChange(n); }}>↓</button>}
<button type="button" className="pe-remove-btn" title="Remove" onClick={() => onChange(list.filter((_, j) => j !== i))}>×</button>
</span>
</div>
{isOpen && (
<div className="pe-item-body">
<KzObjectFields typeName={elem} value={it} depth={depth + 1} onChange={(v) => { const n = [...list]; n[i] = v; onChange(n); }} />
</div>
)}
</div>
);
})}
<button type="button" className="pe-list-add" onClick={() => { onChange([...list, kzEmptyItem(elem)]); setOpen((o) => ({ ...o, [list.length]: true })); }}>+ {kzTitle(elem)}</button>
</div>
);
}
function KzFieldRow({ field, value, siblings, onChange, depth }: { field: KzField; value: unknown; siblings: Record<string, unknown>; onChange: (v: unknown) => void; depth: number }) {
const wide = field.kind === "list" || field.kind === "object" || field.kind === "markdown" || field.kind === "code";
let control: React.ReactNode;
if (field.kind === "list") {
control = <KzListEditor field={field} items={(value as unknown[]) || []} siblings={siblings} depth={depth} onChange={(items) => onChange(items.length === 0 && !field.required ? undefined : items)} />;
} else if (field.kind === "object" && field.type === "Component") {
// A single nested component (grid child). Reuse the list editor so the
// author gets the same type picker, and keep exactly one entry.
const one = value ? [value as ComponentData] : [];
control = <NestedComponentList components={one} onChange={(c) => onChange(c.length > 0 ? c[c.length - 1] : undefined)} />;
} else if (field.kind === "object") {
control = KZ_SCHEMA.types[field.type]
? <KzObjectFields typeName={field.type} value={(value as Record<string, unknown>) || {}} depth={depth + 1} onChange={(v) => onChange(Object.keys(v).length === 0 && !field.required ? undefined : v)} />
: <KzYaml value={value} onChange={onChange} />;
} else {
control = <KzControl field={field} value={value} onChange={onChange} />;
}
return (
<label className={`pe-field pe-field-${field.kind}${wide ? " pe-field-wide" : ""}`}>
<span className="pe-label">{kzTitle(field.name)}{field.required && <span className="pe-required" title="Required">*</span>}</span>
<span className="pe-control">{control}</span>
</label>
);
}
export function ComponentFieldEditor({ comp, onChange }: { comp: ComponentData; onChange: (c: ComponentData) => void }) {
const fields = KZ_SCHEMA.components[comp.type];
const data = comp as unknown as Record<string, unknown>;
if (!fields) return <KzYaml value={comp} onChange={(v) => onChange((v as ComponentData) || comp)} />;
return (
<div className="pe-fields">
{fields.map((f) => (
<KzFieldRow
key={f.name}
field={f}
value={data[f.name]}
siblings={data}
depth={0}
onChange={(v) => {
const next: Record<string, unknown> = { ...data };
if (v === undefined) delete next[f.name]; else next[f.name] = v;
onChange(next as ComponentData);
}}
/>
))}
</div>
);
}
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>
);
}
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>
);
}
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, NestedComponentList };
"##;
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::*;
fn render_region(out: &str, comp: &str) -> String {
let helpers: &[(&str, &str)] = &[
("org_chart", "function OrgNode("),
("chart", "function ChartSVG("),
("graph", "function GraphSVG("),
("accordion", "function AccordionView("),
];
let mut region = String::new();
let needle = format!("\n case \"{comp}\": {{");
if let Some(start) = out.find(&needle) {
let rest = &out[start + 1..];
let end = rest[1..]
.find("\n case \"")
.or_else(|| rest[1..].find("\n default:"))
.map(|i| i + 1)
.unwrap_or(rest.len());
region.push_str(&rest[..end]);
}
for (c, fn_needle) in helpers {
if *c == comp {
if let Some(start) = out.find(fn_needle) {
let rest = &out[start..];
let end = rest[1..]
.find("\nfunction ")
.map(|i| i + 1)
.unwrap_or(rest.len());
region.push_str(&rest[..end]);
}
}
}
region
}
fn reachable_types(schema: &Schema, fields: &OMap<SchemaField>, acc: &mut Vec<String>) {
for (_, field) in fields {
let t = field.field_type.trim_end_matches("[]");
let t = t
.trim_start_matches('(')
.split('|')
.next()
.unwrap_or("")
.trim();
if schema.types.contains_key(t) && !acc.iter().any(|x| x == t) {
acc.push(t.to_string());
if let Some(inner) = schema.types.get(t) {
reachable_types(schema, inner, acc);
}
}
}
}
#[test]
fn react_tags_every_content_field() {
let schema = load_schema();
let out = generate_react();
let exempt: &[(&str, &str)] = &[
("card_grid", "Link.label"), ("card_grid", "Link.href"),
("graph", "GraphNode.hex"), ("tree", "TreeNode.due"), ("tree", "TreeNode.original_due"),
("priority_queue", "QueueItem.original_due"), ("event_timeline", "EventItem.link"),
];
let mut missing: Vec<String> = Vec::new();
for (comp, fields) in &schema.components {
let region = render_region(&out, comp);
if region.is_empty() {
let has_content = fields
.iter()
.any(|(n, f)| CONTENT_KINDS.contains(&kind_of(n, f, &schema).as_str()));
if has_content {
missing.push(format!("{comp}: no render case"));
}
continue;
}
for (fname, field) in fields {
let kind = kind_of(fname, field, &schema);
if !CONTENT_KINDS.contains(&kind.as_str()) {
continue;
}
if exempt.contains(&(comp.as_str(), fname.as_str())) {
continue;
}
let direct = format!("data-kz-field=\"{fname}\"");
if !region.contains(&direct) {
missing.push(format!("{comp}.{fname}"));
}
}
let mut types = Vec::new();
reachable_types(&schema, fields, &mut types);
for tname in types {
let tfields = schema.types.get(&tname).expect("reachable type exists");
for (fname, field) in tfields {
let kind = kind_of(fname, field, &schema);
if !CONTENT_KINDS.contains(&kind.as_str()) {
continue;
}
let key = format!("{tname}.{fname}");
if exempt.contains(&(comp.as_str(), key.as_str())) {
continue;
}
let nested = format!(".{fname}`}}");
let nested_quoted = format!(".{fname}\"");
if !region.contains(&nested) && !region.contains(&nested_quoted) {
missing.push(format!("{comp}: {key}"));
}
}
}
}
assert!(
missing.is_empty(),
"content fields without data-kz-field tags:\n {}",
missing.join("\n ")
);
}
#[test]
fn react_tags_list_containers() {
let out = generate_react();
let lists: &[(&str, &str)] = &[
("card_grid", "cards"),
("stat_grid", "stats"),
("steps", "items"),
("meta", "fields"),
("timeline", "items"),
("resources", "items"),
("button_group", "buttons"),
("definition_list", "items"),
("avatar_group", "avatars"),
("breadcrumb", "items"),
("rule_list", "items"),
("selectable_grid", "cards"),
("event_timeline", "events"),
("table", "rows"),
("before_after", "items"),
("priority_queue", "items"),
("tree", "nodes"),
("callout", "links"),
("hero_banner", "buttons"),
];
let mut missing = Vec::new();
for (comp, field) in lists {
let region = render_region(&out, comp);
if !region.contains(&format!("data-kz-list=\"{field}\"")) {
missing.push(format!("{comp}.{field}"));
}
}
assert!(
missing.is_empty(),
"list fields without data-kz-list: {missing:?}"
);
}
#[test]
fn schema_kinds_resolve_and_order_is_preserved() {
let schema = load_schema();
let allowed = [
"text", "markdown", "code", "number", "layout", "enum", "id", "url", "bool", "list",
"object",
];
for (owner, fields) in schema.components.iter().chain(schema.types.iter()) {
for (fname, field) in fields {
let k = kind_of(fname, field, &schema);
assert!(
allowed.contains(&k.as_str()),
"{owner}.{fname} has unknown kind {k}"
);
}
}
let header: Vec<&String> = schema
.components
.get("header")
.unwrap()
.iter()
.map(|(n, _)| n)
.collect();
assert_eq!(header[0], "title");
assert_eq!(header[1], "subtitle");
let json = schema_json(&schema);
assert!(json.contains("\"kind\": \"markdown\""));
assert!(generate_react().contains("export const KZ_SCHEMA: KzSchema = "));
}
#[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 every_guidance_example_resolves_and_parses() {
for (ty, g) in schema_guidance() {
if let Some(name) = &g.example {
let yaml = example_yaml(name)
.unwrap_or_else(|| panic!("guidance.{ty}.example '{name}' has no fixture"));
let parsed: Vec<crate::types::Component> =
serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(!parsed.is_empty(), "{name} is empty");
}
for r in &g.rules {
assert!(!r.say.is_empty(), "guidance.{ty} rule without say");
}
}
}
#[test]
fn agents_reference_carries_guidance_and_curated_examples() {
let md = generate_agents();
let graph = md.split("### graph\n").nth(1).expect("graph section");
assert!(graph.contains("**Use when:**"));
assert!(
graph.contains("row: 1"),
"curated tiered example should replace placeholder"
);
assert!(graph.contains("**Shape rules (warn on write):**"));
assert!(!graph.contains("nodes: []"));
}
#[test]
fn mcp_bundle_is_json_naming_every_guided_component() {
let raw = generate_mcp();
let v: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
let guided = v["guided_components"].as_array().unwrap();
assert!(guided.iter().any(|g| g == "graph"));
for g in guided {
let ty = g.as_str().unwrap();
assert!(v["components"][ty].is_object(), "missing slice for {ty}");
}
for tool in GUIDED_TOOLS {
let d = v["tools"][tool].as_str().unwrap();
assert!(d.len() > 80, "{tool} description too short");
}
assert!(v["tools"]["write_page"].as_str().unwrap().contains("graph"));
assert!(v["instructions_section"]
.as_str()
.unwrap()
.contains("WARNINGS"));
}
#[test]
fn react_render_block_supports_task_list_checkboxes() {
let tsx = generate_react();
assert!(
tsx.contains("taskMatch = trimmed.match(/^[-*] \\[([ xX])\\]\\s(.*)$/)"),
"renderBlock must detect task-list syntax before the generic bullet branch"
);
assert!(
tsx.contains("<input type=\"checkbox\" checked={checked} disabled readOnly />"),
"task list items must render a real checkbox input, not literal [x]/[ ] text"
);
}
#[test]
fn react_render_block_supports_blockquotes() {
let tsx = generate_react();
assert!(
tsx.contains("<blockquote key={key++}>{renderInline(quoteLines.join(\" \"))}</blockquote>"),
"renderBlock must emit a real <blockquote>, not plain paragraph text with a leading '>'"
);
}
#[test]
fn react_render_block_supports_h4_headings() {
let tsx = generate_react();
assert!(
!tsx.contains("\"#### "),
"a literal \"#### string would collide with this file's r####\"...\"#### raw-string delimiter"
);
assert!(
tsx.contains("/^####\\s/.test(trimmed)"),
"renderBlock must detect a fourth '#' and emit <h4>, not fall through to a paragraph"
);
}
#[test]
fn react_render_block_keeps_nested_ordered_items_in_one_list() {
let tsx = generate_react();
assert!(
tsx.contains("const closeItemPara = () => {"),
"closeItemPara must exist as a paragraph-only close, decoupled from the sub-list flush"
);
let nested_branch_start = tsx
.find("if (nestedTask || nestedBullet || nestedOrdered) {")
.expect("nested-marker branch must exist");
let nested_branch = &tsx[nested_branch_start..nested_branch_start + 400];
assert!(
!nested_branch.contains("flushItemPara()"),
"the nested-marker branch must not call flushItemPara() unconditionally - \
that flushes (and reopens) the in-progress sub-list on every item, \
not just the first, so each item ends up in its own list"
);
assert!(
nested_branch.contains("if (!itemSubList || itemSubListTag !== subTag) {"),
"prior state must only be closed when the sub-list is new or switching type"
);
}
#[test]
fn react_event_timeline_sorts_most_recent_first() {
let tsx = generate_react();
assert!(
tsx.contains(
"const events = rawEvents.map((e, idx) => ({ ...e, _kz: idx })).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"
);
}
}