diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx
index 1514fc57..09f0e9d3 100644
--- a/app/src/components/chart-renderer.tsx
+++ b/app/src/components/chart-renderer.tsx
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { AlertCircle } from "lucide-react";
import { normalizeValue } from "@/lib/normalize-value";
import type { ChartType } from "@/lib/chart-registry";
+import { pluginRegistry } from "@/plugins";
import { ChartErrorBoundary } from "./chart-error-boundary";
import {
Skeleton,
@@ -185,6 +186,31 @@ function ChartRendererInner({
};
}, [onChartClick, data]);
+ // Plugin-driven rendering: check the plugin registry first. Charts
+ // registered as plugins go through this path; the switch below is the
+ // fallback for charts still using the legacy hard-coded dispatch.
+ const plugin = pluginRegistry.get(type);
+ if (plugin) {
+ const PluginComponent = plugin.component;
+ return (
+
+ );
+ }
+
switch (type) {
case "bar":
return (
diff --git a/app/src/plugins/__tests__/markdown.test.tsx b/app/src/plugins/__tests__/markdown.test.tsx
new file mode 100644
index 00000000..51def98b
--- /dev/null
+++ b/app/src/plugins/__tests__/markdown.test.tsx
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { markdownPlugin } from "../markdown";
+
+vi.mock("@neoboard/components", () => ({
+ MarkdownWidget: ({ content }: { content?: string }) => (
+
{content ?? ""}
+ ),
+}));
+
+describe("markdownPlugin", () => {
+ it("declares type = 'markdown'", () => {
+ expect(markdownPlugin.type).toBe("markdown");
+ });
+
+ it("declares content-only capabilities", () => {
+ expect(markdownPlugin.capabilities.supportsClickAction).toBe(false);
+ expect(markdownPlugin.capabilities.supportsStyling).toBe(false);
+ expect(markdownPlugin.capabilities.requiresQuery).toBe(false);
+ expect(markdownPlugin.capabilities.isECharts).toBe(false);
+ });
+
+ it("exposes a content option in the Content category", () => {
+ expect(markdownPlugin.options).toHaveLength(1);
+ expect(markdownPlugin.options?.[0]).toMatchObject({
+ key: "content",
+ type: "text",
+ category: "Content",
+ });
+ });
+
+ it("transform returns null (content-only widget)", () => {
+ expect(markdownPlugin.transform({ anything: "x" })).toBeNull();
+ });
+
+ it("renders MarkdownWidget with settings.content", () => {
+ const Component = markdownPlugin.component;
+ render();
+ expect(screen.getByTestId("markdown-widget")).toHaveTextContent(
+ "Hello **world**",
+ );
+ });
+
+ it("renders MarkdownWidget with empty content when none provided", () => {
+ const Component = markdownPlugin.component;
+ render();
+ expect(screen.getByTestId("markdown-widget")).toBeInTheDocument();
+ });
+});
diff --git a/app/src/plugins/__tests__/registry.test.ts b/app/src/plugins/__tests__/registry.test.ts
new file mode 100644
index 00000000..b6d93b9f
--- /dev/null
+++ b/app/src/plugins/__tests__/registry.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("@neoboard/components", () => ({
+ MarkdownWidget: () => null,
+}));
+
+// Import the global registry via the plugin index (this also triggers
+// registration side effects for all built-in plugins).
+import { pluginRegistry } from "../index";
+
+describe("global plugin registry (bootstrap)", () => {
+ it("has markdown plugin registered on import", () => {
+ expect(pluginRegistry.has("markdown")).toBe(true);
+ const plugin = pluginRegistry.get("markdown");
+ expect(plugin?.type).toBe("markdown");
+ expect(plugin?.label).toBe("Markdown");
+ });
+
+ it("re-importing plugins/index.ts is idempotent", async () => {
+ // Re-importing should NOT throw about duplicate registration.
+ const mod = await import("../index");
+ expect(mod.pluginRegistry.has("markdown")).toBe(true);
+ });
+
+ it("unknown chart types return undefined", () => {
+ expect(pluginRegistry.get("nonexistent-chart-type")).toBeUndefined();
+ expect(pluginRegistry.has("nonexistent-chart-type")).toBe(false);
+ });
+});
diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts
new file mode 100644
index 00000000..efafb56b
--- /dev/null
+++ b/app/src/plugins/index.ts
@@ -0,0 +1,28 @@
+/**
+ * Plugin registrations — imports and registers all chart plugins.
+ *
+ * Importing this module has the side effect of registering each built-in
+ * plugin with the global registry. Called once at app startup via an
+ * import in chart-renderer.tsx (or wherever the registry is first used).
+ *
+ * To add a new chart plugin:
+ * 1. Create `app/src/plugins/your-chart.ts` that exports a plugin
+ * via `defineChartPlugin({ ... })`
+ * 2. Add `registerPluginFromFile("./your-chart")` here
+ *
+ * During the v1.1 plugin migration, charts are moved from the legacy
+ * switch statement in chart-renderer.tsx into this registry one by one.
+ * Charts not yet migrated continue to work via the switch fallback.
+ */
+
+import { pluginRegistry } from "./registry";
+import { markdownPlugin } from "./markdown";
+
+// Idempotent registration — the first import of this module registers
+// plugins; subsequent imports are no-ops thanks to Node's module cache.
+if (!pluginRegistry.has(markdownPlugin.type)) {
+ pluginRegistry.register(markdownPlugin);
+}
+
+// Re-export for convenience
+export { pluginRegistry } from "./registry";
diff --git a/app/src/plugins/markdown.tsx b/app/src/plugins/markdown.tsx
new file mode 100644
index 00000000..d87fbabb
--- /dev/null
+++ b/app/src/plugins/markdown.tsx
@@ -0,0 +1,57 @@
+/**
+ * Markdown widget plugin.
+ *
+ * Renders static markdown content — no query, no data transform.
+ * This is the simplest possible plugin and serves as a reference
+ * implementation during the plugin migration.
+ */
+
+import { MarkdownWidget } from "@neoboard/components";
+import { defineChartPlugin } from "./registry";
+
+interface MarkdownWidgetProps {
+ content?: string;
+}
+
+/**
+ * Component adapter — extracts the `content` field from settings and
+ * renders the MarkdownWidget. The plugin contract passes the full
+ * settings object to the component as `settings` prop.
+ */
+function MarkdownPluginComponent({
+ settings,
+}: {
+ settings: Record;
+}) {
+ const props: MarkdownWidgetProps = {
+ content: settings.content as string | undefined,
+ };
+ return ;
+}
+
+export const markdownPlugin = defineChartPlugin({
+ type: "markdown",
+ label: "Markdown",
+ component: MarkdownPluginComponent,
+ // Content-only widget — no data transform needed
+ transform: () => null,
+ capabilities: {
+ supportsClickAction: false,
+ supportsStyling: false,
+ isECharts: false,
+ requiresQuery: false,
+ },
+ queryHint:
+ "Markdown widgets render static content — no query required. " +
+ "Use the content field to write your text.",
+ options: [
+ {
+ key: "content",
+ label: "Content",
+ type: "text",
+ default: "",
+ category: "Content",
+ description: "Markdown source for the widget body.",
+ },
+ ],
+});
diff --git a/app/src/plugins/registry.ts b/app/src/plugins/registry.ts
new file mode 100644
index 00000000..d844d30c
--- /dev/null
+++ b/app/src/plugins/registry.ts
@@ -0,0 +1,21 @@
+/**
+ * Global chart plugin registry.
+ *
+ * Singleton instance used by chart-renderer.tsx to look up plugins at
+ * render time. Plugins are registered via the `plugins/index.ts` module
+ * which is imported once when the app starts.
+ *
+ * This file is intentionally minimal — the registry implementation lives
+ * in `app/src/lib/chart-plugin-registry.ts`. This module just holds the
+ * singleton and re-exports the types for convenience.
+ */
+
+import { createPluginRegistry } from "@/lib/chart-plugin-registry";
+
+export const pluginRegistry = createPluginRegistry();
+
+export type {
+ ChartPlugin,
+ ChartPluginConfig,
+} from "@/lib/chart-plugin-registry";
+export { defineChartPlugin } from "@/lib/chart-plugin-registry";