feat: release design system engine, studio and component contracts

This commit is contained in:
Clintchiz committed 2026-10-19 13:09:03 +05:30
1 parent ff1065fb27
commit 3943b946d6
693 files changed
+175546 -3894

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import type { ChartConfiguration, ChartData, ChartType } from "chart.js";
export interface ChartProps {
title?: string;
items?: unknown[];
data?: unknown[] | ChartData;
labels?: unknown[];
datasets?: ChartData["datasets"];
options?: Record<string, any>;
type?: string;
valueKey?: string;
labelKey?: string;
showLegend?: boolean;
showValues?: boolean;
}
export const chartTypes = [
"bar",
"line",
"pie",
"doughnut",
"radar",
"polarArea",
"scatter",
"bubble",
] as const;
export function mergeOptions(base: any, incoming: any): any {
const result = { ...base };
for (const [key, value] of Object.entries(incoming ?? {})) {
if (["__proto__", "constructor", "prototype"].includes(key)) continue;
result[key] =
value && typeof value === "object" && !Array.isArray(value)
? mergeOptions(result[key], value)
: value;
}
return result;
}
export function chartConfiguration(props: ChartProps): ChartConfiguration {
const kind = props.type || "bar";
const type = kind === "area" ? "line" : kind;
if (!chartTypes.includes(type as ChartType)) throw new Error(`Unsupported chart type: ${kind}`);
const native = props.data && !Array.isArray(props.data) && Array.isArray(props.data.datasets);
const rows = Array.isArray(props.data) && props.data.length ? props.data : (props.items ?? []);
const valueKey = props.valueKey || "value",
labelKey = props.labelKey || "label";
const coordinate = type === "scatter" || type === "bubble";
const values = rows.map((row: any) => {
if (coordinate) return row;
const value = row && typeof row === "object" ? row[valueKey] : row;
return value === null || value === undefined || value === "" || !Number.isFinite(Number(value))
? null
: Number(value);
});
const data = native
? structuredClone(props.data as ChartData)
: ({
labels: props.labels?.length
? structuredClone(props.labels)
: rows.map((row: any, i) => row?.[labelKey] ?? String(i + 1)),
datasets: props.datasets?.length
? structuredClone(props.datasets)
: [{ label: props.title || "Data", data: values }],
} as ChartData);
if (kind === "area")
data.datasets = data.datasets.map((dataset) => ({
...dataset,
fill: "fill" in dataset ? dataset.fill : true,
})) as ChartData["datasets"];
const cartesian = ["bar", "line", "scatter", "bubble"].includes(type);
const options = mergeOptions(
{
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: props.showLegend !== false } },
...(cartesian ? { scales: { y: { beginAtZero: type === "bar" } } } : {}),
},
props.options,
);
return { type: type as ChartType, data, options };
}
+232
View File
@@ -0,0 +1,232 @@
import Chart from "chart.js/auto";
import type { ChartConfiguration, Plugin } from "chart.js";
import { chartConfiguration, mergeOptions, type ChartProps } from "./chart-config";
const selector = '[data-wrnexus-runtime~="chartjs"][data-chart-config]';
const mounted = new Map<
HTMLElement,
{ chart: Chart; raw: string; kind: string; canvas: HTMLCanvasElement }
>();
const failures = new WeakMap<HTMLElement, string>();
const emit = (root: HTMLElement, name: string, detail: object) =>
root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
const radial = (type: string) => ["pie", "doughnut", "polarArea"].includes(type);
const valueLabels: Plugin = {
id: "wrnValues",
afterDatasetsDraw(chart) {
const root = chart.canvas.closest<HTMLElement>(selector);
if (!root || JSON.parse(root.dataset.chartConfig || "{}").showValues === false) return;
// Leave dense plots and arcs to Chart.js tooltips; label bars and line points.
if (!["bar", "line"].includes((chart.config as ChartConfiguration).type)) return;
const { ctx, chartArea } = chart;
ctx.save();
ctx.font = "11px sans-serif";
ctx.fillStyle = (chart.options.color as string) || "#666";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
for (const meta of chart.getSortedVisibleDatasetMetas()) {
if (meta.data.length > 24) continue;
meta.data.forEach((element, index) => {
const value: any = chart.data.datasets[meta.index].data[index];
if (value === null || (element as any).hidden || (element as any).skip) return;
const position = element.tooltipPosition(true);
if (position.x === null || position.y === null) return;
const label = typeof value === "object" ? String(value.y ?? "") : String(value);
ctx.fillText(
label,
Math.max(chartArea.left + 12, Math.min(chartArea.right - 12, position.x)),
Math.max(chartArea.top + 12, position.y - 5),
);
});
}
ctx.restore();
},
};
function accessibleTable(root: HTMLElement, config: ChartConfiguration) {
const host = root.querySelector<HTMLElement>("[data-chart-table]");
if (!host) return;
const table = document.createElement("table"),
caption = document.createElement("caption");
caption.textContent = root.querySelector("canvas")?.getAttribute("aria-label") || "Chart data";
table.append(caption);
const header = table.createTHead().insertRow();
for (const text of ["Label", ...config.data.datasets.map((d) => d.label || "Data")]) {
const th = document.createElement("th");
th.scope = "col";
th.textContent = text;
header.append(th);
}
const body = table.createTBody();
const count = Math.max(0, ...config.data.datasets.map((d) => d.data.length));
for (let i = 0; i < count; i++) {
const row = body.insertRow(),
label = document.createElement("th");
label.scope = "row";
label.textContent = String(config.data.labels?.[i] ?? i + 1);
row.append(label);
for (const dataset of config.data.datasets) {
const value = dataset.data[i];
row.insertCell().textContent =
value == null ? "—" : typeof value === "object" ? JSON.stringify(value) : String(value);
}
}
host.replaceChildren(table);
}
function configuration(root: HTMLElement, props: ChartProps): ChartConfiguration {
const config = chartConfiguration(props),
style = getComputedStyle(root);
const text = style.color || "#666",
grid = style.getPropertyValue("--wrn-color-border").trim() || "rgba(0,0,0,.1)";
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
config.options = mergeOptions(
{
color: text,
font: { family: style.fontFamily },
...(reduced ? { animation: false } : {}),
...(["bar", "line", "scatter", "bubble"].includes(config.type)
? {
scales: {
x: { ticks: { color: text }, grid: { color: grid } },
y: { ticks: { color: text }, grid: { color: grid } },
},
}
: {}),
},
config.options,
);
config.options!.onClick = (event, elements, chart) => {
if (!elements.length) return;
const { index, datasetIndex } = elements[0];
const rows = Array.isArray(props.data) && props.data.length ? props.data : props.items;
const item =
(!Array.isArray(props.data) && props.data?.datasets) || props.datasets?.length
? chart.data.datasets[datasetIndex].data[index]
: rows?.[index];
const detail = { item, index, datasetIndex, sourceEvent: event.native };
emit(root, "chartpointclick", detail);
emit(root, "chartselect", detail);
};
const plugins = config.options!.plugins;
if (plugins && typeof plugins === "object" && (plugins.legend as unknown) !== false) {
const legend = (plugins!.legend ??= {});
legend.onClick = (event, item, context) => {
const chart = context.chart,
arc = radial(config.type),
index = arc ? item.index! : item.datasetIndex!;
const defaultHandler =
Chart.overrides[config.type]?.plugins?.legend?.onClick ??
Chart.defaults.plugins.legend.onClick;
defaultHandler.call(context, event, item, context);
emit(root, "chartlegendtoggle", {
item: arc ? chart.data.datasets[0].data[index] : chart.data.datasets[index],
index,
datasetIndex: arc ? 0 : index,
hidden: arc ? !chart.getDataVisibility(index) : !chart.isDatasetVisible(index),
sourceEvent: event.native,
});
};
}
config.plugins = [valueLabels];
return config;
}
function refresh(root: HTMLElement, force = false) {
const canvas = root.querySelector<HTMLCanvasElement>("[data-chart-canvas]");
if (!canvas) return;
const raw = root.dataset.chartConfig || "{}",
previous = mounted.get(root);
if (
!force &&
((previous?.raw === raw && previous.canvas === canvas) || failures.get(root) === raw)
)
return;
const status = root.querySelector<HTMLElement>("[data-chart-status]");
try {
const props = JSON.parse(raw) as ChartProps,
config = configuration(root, props);
accessibleTable(root, config);
if (previous && previous.kind === (props.type || "bar") && previous.canvas === canvas) {
previous.chart.data = config.data;
previous.chart.options = config.options!;
previous.chart.update();
previous.raw = raw;
} else {
previous?.chart.destroy();
mounted.delete(root);
const chart = new Chart(canvas, config);
mounted.set(root, { chart, raw, kind: props.type || "bar", canvas });
}
failures.delete(root);
if (status)
status.textContent = config.data.datasets.some((d) => d.data.some((v) => v !== null))
? ""
: "No data to show";
} catch (error) {
failures.set(root, raw);
Chart.getChart(canvas)?.destroy();
mounted.delete(root);
if (status)
status.textContent = error instanceof Error ? error.message : "Unable to display chart";
}
}
export function startChartRuntime() {
const scan = () => {
for (const [root, state] of mounted)
if (!root.isConnected) {
state.chart.destroy();
mounted.delete(root);
}
document.querySelectorAll<HTMLElement>(selector).forEach((root) => refresh(root));
};
scan();
const themed = new Set<HTMLElement>();
let themeTimer: ReturnType<typeof setTimeout> | undefined;
const refreshTheme = (root: HTMLElement) => {
refresh(root, true);
themed.add(root);
clearTimeout(themeTimer);
// Theme CSS can transition color after the attribute mutation. Read the
// settled color as well, so unchanged charts do not retain the old palette.
themeTimer = setTimeout(() => {
for (const chart of themed) if (chart.isConnected) refresh(chart, true);
themed.clear();
}, 250);
};
const finishTransition = (event: TransitionEvent) => {
if (event.propertyName !== "color" || !(event.target instanceof HTMLElement)) return;
for (const root of mounted.keys())
if (event.target === root || event.target.contains(root)) refresh(root, true);
};
document.addEventListener("transitionend", finishTransition);
const observer = new MutationObserver((records) => {
if (records.some((r) => r.type === "childList")) scan();
for (const record of records)
if (record.type === "attributes" && record.target instanceof HTMLElement) {
if (record.target.matches(selector)) {
if (record.attributeName === "data-chart-config") refresh(record.target);
else refreshTheme(record.target);
} else if (["class", "data-theme", "style"].includes(record.attributeName || "")) {
record.target.querySelectorAll<HTMLElement>(selector).forEach(refreshTheme);
}
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-chart-config", "data-color", "data-theme", "class", "style"],
});
return () => {
observer.disconnect();
clearTimeout(themeTimer);
themed.clear();
document.removeEventListener("transitionend", finishTransition);
for (const state of mounted.values()) state.chart.destroy();
mounted.clear();
};
}
if (typeof document !== "undefined") {
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", () => startChartRuntime(), { once: true });
else startChartRuntime();
}
+8
View File
@@ -0,0 +1,8 @@
/** Optional, browser-safe metadata entry; default UI imports stay unchanged. */
export {
CORE_COMPONENT_CONTRACTS,
COMPONENT_CONTRACTS,
CATALOG_COMPONENT_CONTRACTS,
componentDesignContract,
} from "@wrnexus/design-system";
export type { ComponentDesignContract } from "@wrnexus/design-system";
+27
View File
@@ -0,0 +1,27 @@
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
export function drawflowPlugin() {
return definePlugin({
name: "@wrnexus/ui/drawflow",
clientRuntimes: [
{
id: "drawflow",
entry: fileURLToPath(
new URL(
import.meta.url.endsWith(".js") ? "./drawflow-runtime.js" : "./drawflow-runtime.ts",
import.meta.url,
),
),
bundle: true,
},
],
styleSources: [
{
id: "drawflow-vendor",
entry: createRequire(import.meta.url).resolve("drawflow/dist/drawflow.min.css"),
},
{ id: "drawflow", entry: fileURLToPath(new URL("../drawflow.css", import.meta.url)) },
],
});
}
+312
View File
@@ -0,0 +1,312 @@
import Drawflow from "drawflow";
type Node = {
id: string;
label: string;
x: number;
y: number;
input?: boolean;
ports: { key: string; label: string; target: string }[];
};
const mounted = new WeakMap<HTMLElement, () => void>();
/** Drawflow 0.0.60 assigns innerHTML even for an empty node. Keep its graph/interaction
* engine, but construct our fixed node markup with DOM APIs under Trusted Types. */
class SafeDrawflow extends Drawflow {
addWorkflowNode(n: Node): number {
const id = Number(this.nodeId++),
parent = document.createElement("div"),
element = document.createElement("div");
parent.className = "parent-node";
element.className = "drawflow-node wrn-workflow-node";
element.id = "node-" + id;
element.style.left = n.x + "px";
element.style.top = n.y + "px";
const inputs = document.createElement("div"),
outputs = document.createElement("div"),
content = document.createElement("div");
inputs.className = "inputs";
outputs.className = "outputs";
content.className = "drawflow_content_node";
if (n.input !== false) {
const input = document.createElement("div");
input.className = "input input_1";
inputs.append(input);
}
const title = document.createElement("div"),
label = document.createElement("strong"),
key = document.createElement("small");
title.className = "wrn-drawflow-title";
label.textContent = n.label;
key.textContent = n.id;
title.append(label, key);
content.append(title);
const ports: Record<string, { connections: [] }> = {};
n.ports.forEach((p, i) => {
const output = document.createElement("div"),
caption = document.createElement("div");
output.className = "output output_" + (i + 1);
outputs.append(output);
caption.className = "wrn-drawflow-port-label";
caption.textContent = p.label;
content.append(caption);
ports["output_" + (i + 1)] = { connections: [] };
});
element.append(inputs, content, outputs);
parent.append(element);
this.precanvas.append(parent);
this.drawflow.drawflow[this.module].data[id] = {
id,
name: n.id,
data: { workflowId: n.id },
class: "wrn-workflow-node",
html: "",
typenode: false,
inputs: n.input === false ? {} : { input_1: { connections: [] } },
outputs: ports,
pos_x: n.x,
pos_y: n.y,
};
this.dispatch("nodeCreated", id);
return id;
}
}
function mount(root: HTMLElement) {
const host = root.querySelector<HTMLElement>("[data-drawflow-host]")!;
const controls = root.closest("form") || root;
const editor = new SafeDrawflow(host);
editor.start();
editor.reroute = false;
let nodes: Node[] = [],
quiet = false,
last = "";
const ids = new Map<string, number>();
const byNumber = (id: number | string) => nodes.find((n) => ids.get(n.id) === Number(id));
const emit = (name: string, detail: object) =>
root.dispatchEvent(
new CustomEvent("flow" + name, {
bubbles: true,
detail: { ...detail, sourceEvent: { target: root } },
}),
);
const moved = () =>
emit("move", {
nodes: nodes.map((n) => {
const row = editor.getNodeFromId(ids.get(n.id)!);
return {
...n,
x: Math.max(0, Math.round(row.pos_x)),
y: Math.max(0, Math.round(row.pos_y)),
};
}),
});
const fit = () => {
if (!host.clientWidth || !nodes.length) return;
const width =
Math.max(...nodes.map((n) => editor.getNodeFromId(ids.get(n.id)!).pos_x + 250)) + 40,
height = Math.max(...nodes.map((n) => editor.getNodeFromId(ids.get(n.id)!).pos_y + 180)) + 40;
editor.canvas_x = 0;
editor.canvas_y = 0;
editor.zoom = Math.max(0.2, Math.min(1, host.clientWidth / width, host.clientHeight / height));
editor.zoom_refresh();
};
function refresh() {
const raw = root.dataset.flowNodes || "[]";
if (raw === last) return;
last = raw;
const incoming = JSON.parse(raw) as Node[];
const fresh = incoming.filter((n) => !ids.has(n.id));
nodes = incoming;
quiet = true;
for (const n of fresh) ids.set(n.id, editor.addWorkflowNode(n));
for (const n of nodes) {
const source = ids.get(n.id)!;
const title = host.querySelector<HTMLElement>("#node-" + source + " strong");
if (title) title.textContent = n.label;
for (let i = 0; i < n.ports.length; i++) {
const target = ids.get(n.ports[i].target),
output = "output_" + (i + 1);
if (!target) continue;
const connections = editor.getNodeFromId(source).outputs[output].connections;
if (connections.length === 1 && Number(connections[0].node) === target) continue;
for (const old of [...connections])
editor.removeSingleConnection(source, Number(old.node), output, old.output);
editor.addConnection(source, target, output, "input_1");
}
}
quiet = false;
}
editor.on("nodeSelected", (id: number) => {
if (!quiet) {
const n = byNumber(id);
if (n) emit("select", { id: n.id });
}
});
editor.on("nodeMoved", () => {
if (!quiet) moved();
});
editor.on(
"connectionCreated",
(c: { output_id: string; input_id: string; output_class: string; input_class: string }) => {
if (quiet) return;
const source = byNumber(c.output_id),
target = byNumber(c.input_id);
const index = Number(c.output_class.slice(7)) - 1;
if (!source || !target || !source.ports[index]) return;
const reaches = (id: string, seen = new Set<string>()): boolean => {
if (id === source.id) return true;
if (seen.has(id)) return false;
seen.add(id);
return nodes.find((n) => n.id === id)?.ports.some((p) => reaches(p.target, seen)) || false;
};
if (target.input === false || reaches(target.id)) {
quiet = true;
editor.removeSingleConnection(
Number(c.output_id),
Number(c.input_id),
c.output_class,
c.input_class,
);
quiet = false;
return;
}
const row = editor.getNodeFromId(Number(c.output_id));
quiet = true;
for (const old of [...row.outputs[c.output_class].connections])
if (String(old.node) !== String(c.input_id))
editor.removeSingleConnection(
Number(c.output_id),
Number(old.node),
c.output_class,
old.output,
);
quiet = false;
source.ports[index].target = target.id;
emit("connect", { source: source.id, port: source.ports[index].key, target: target.id });
},
);
// Required workflow paths are edited by connecting another destination; deletion uses the reviewed settings action.
host.addEventListener(
"keydown",
(e) => {
if (e.key === "Delete" || e.key === "Backspace") {
e.stopImmediatePropagation();
e.preventDefault();
}
},
true,
);
host.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
e.stopImmediatePropagation();
},
true,
);
controls.addEventListener("click", (e) => {
const b = (e.target as Element).closest<HTMLElement>("[data-flow-zoom],[data-flow-arrange]");
if (!b) return;
if (b.hasAttribute("data-flow-arrange")) {
quiet = true;
nodes.forEach((n, i) => {
const id = ids.get(n.id)!;
const row = editor.drawflow.drawflow.Home.data[id];
row.pos_x = 40 + i * 280;
row.pos_y = 60 + (i % 2) * 100;
const element = host.querySelector<HTMLElement>("#node-" + id)!;
element.style.left = row.pos_x + "px";
element.style.top = row.pos_y + "px";
editor.updateConnectionNodes("node-" + id);
});
quiet = false;
moved();
} else if (b.dataset.flowZoom === "in") editor.zoom_in();
else if (b.dataset.flowZoom === "out") editor.zoom_out();
else if (b.dataset.flowZoom === "fit") fit();
else editor.zoom_reset();
});
controls.addEventListener("dragstart", (e) => {
const kind = (e.target as Element).closest<HTMLElement>("[data-flow-kind]")?.dataset.flowKind;
if (kind) (e as DragEvent).dataTransfer?.setData("application/x-wrn-flow-node", kind);
});
host.addEventListener("dragover", (e) => e.preventDefault());
host.addEventListener("drop", (e) => {
e.preventDefault();
const kind = e.dataTransfer?.getData("application/x-wrn-flow-node");
if (kind) {
const rect = host.getBoundingClientRect();
emit("add", {
kind,
x: Math.max(0, (e.clientX - rect.left - editor.canvas_x) / editor.zoom),
y: Math.max(0, (e.clientY - rect.top - editor.canvas_y) / editor.zoom),
});
}
});
const form = root.closest("form");
const settingsChanged = (e: Event) => {
const field = e.target as HTMLSelectElement;
const match =
field.name === "entry"
? ["entry", "__start", "entry"]
: /^node:([^:]+):(next|timeoutNext|yes|no)$/.exec(field.name || "");
if (!match) return;
const source = nodes.find((n) => n.id === match[1]);
const index = source?.ports.findIndex((p) => p.key === match[2]) ?? -1;
const target = ids.get(field.value);
if (!source || index < 0 || !target) return;
const targetNode = nodes.find((n) => n.id === field.value);
if (targetNode?.input === false) return;
const output = "output_" + (index + 1),
id = ids.get(source.id)!;
quiet = true;
for (const old of [...editor.getNodeFromId(id).outputs[output].connections])
editor.removeSingleConnection(id, Number(old.node), output, old.output);
editor.addConnection(id, target, output, "input_1");
source.ports[index].target = field.value;
quiet = false;
};
form?.addEventListener("change", settingsChanged);
const observer = new MutationObserver(refresh);
observer.observe(root, { attributes: true, attributeFilter: ["data-flow-nodes"] });
refresh();
let fitted = false;
const resize = new ResizeObserver(() => {
nodes.forEach((n) => editor.updateConnectionNodes("node-" + ids.get(n.id)));
if (!fitted && host.clientWidth) {
fit();
fitted = true;
}
});
resize.observe(host);
mounted.set(root, () => {
observer.disconnect();
resize.disconnect();
form?.removeEventListener("change", settingsChanged);
mounted.delete(root);
});
}
function scan() {
document.querySelectorAll<HTMLElement>("[data-drawflow-editor]").forEach((root) => {
if (!mounted.has(root)) mount(root);
});
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", scan, { once: true });
else scan();
new MutationObserver((records) => {
if (
records.some((r) =>
Array.from(r.addedNodes).some(
(n) =>
n instanceof Element &&
(n.matches("[data-drawflow-editor]") || n.querySelector("[data-drawflow-editor]")),
),
)
)
scan();
for (const record of records)
for (const node of record.removedNodes)
if (node instanceof HTMLElement) {
for (const root of [node, ...node.querySelectorAll<HTMLElement>("[data-drawflow-editor]")])
if (!root.isConnected) mounted.get(root)?.();
}
}).observe(document.documentElement, { childList: true, subtree: true });