You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ms...@apache.org on 2026/07/30 21:38:38 UTC
(superset) 01/01: feat(canvas): add Canvas v2 declarative dashboard prototype
This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a commit to branch msyavuz/feat/canvas-v2
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 8b2f140eb63789f1aaff97e57eaa5ac3cc528dfe
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Fri Jul 31 00:38:18 2026 +0300
feat(canvas): add Canvas v2 declarative dashboard prototype
Introduces CDL (Canvas Definition Language), a declarative component tree
rendered by a new frontend runtime, plus the backend model, REST API,
migration, and MCP tools for generating and patching canvases.
- superset/models/canvas.py, superset/canvas/: model, API, filters
- superset/mcp_service/canvas/: generate/get/update/schema tools, patch ops,
CDL validation
- superset-frontend/src/Canvas/: renderer, runtime, resolver, viz adapters,
action dispatch, validator
- superset-frontend/src/pages/Canvas{,List}/: routes and list view
---
.../src/Canvas/CanvasRenderer.stories.tsx | 41 ++
.../src/Canvas/CanvasRenderer.test.tsx | 115 ++++
superset-frontend/src/Canvas/CanvasRenderer.tsx | 188 ++++++
superset-frontend/src/Canvas/CanvasViewer.tsx | 131 ++++
superset-frontend/src/Canvas/DESIGN.md | 325 +++++++++
superset-frontend/src/Canvas/EchartsViz.tsx | 244 +++++++
superset-frontend/src/Canvas/FilterControl.tsx | 126 ++++
superset-frontend/src/Canvas/SupersetChartViz.tsx | 204 ++++++
superset-frontend/src/Canvas/actions.ts | 113 ++++
superset-frontend/src/Canvas/catalog.ts | 191 ++++++
superset-frontend/src/Canvas/cdl.test.ts | 306 +++++++++
.../src/Canvas/fixtures/salesCanvas.ts | 125 ++++
superset-frontend/src/Canvas/index.ts | 28 +
superset-frontend/src/Canvas/manifest.tsx | 468 +++++++++++++
superset-frontend/src/Canvas/queryRunner.ts | 118 ++++
superset-frontend/src/Canvas/resolve.ts | 359 ++++++++++
superset-frontend/src/Canvas/runtime.tsx | 314 +++++++++
superset-frontend/src/Canvas/style.ts | 177 +++++
superset-frontend/src/Canvas/types.ts | 189 ++++++
superset-frontend/src/Canvas/validator.ts | 326 +++++++++
superset-frontend/src/pages/Canvas/index.tsx | 28 +
superset-frontend/src/pages/CanvasList/index.tsx | 121 ++++
superset-frontend/src/views/routePaths.ts | 2 +
superset-frontend/src/views/routes.tsx | 12 +
superset/canvas/__init__.py | 16 +
superset/canvas/api.py | 70 ++
superset/canvas/filters.py | 59 ++
superset/initialization/__init__.py | 11 +
superset/mcp_service/app.py | 6 +
superset/mcp_service/canvas/__init__.py | 16 +
superset/mcp_service/canvas/patch.py | 187 ++++++
superset/mcp_service/canvas/schemas.py | 139 ++++
superset/mcp_service/canvas/tool/__init__.py | 28 +
.../mcp_service/canvas/tool/generate_canvas.py | 121 ++++
superset/mcp_service/canvas/tool/get_canvas.py | 73 ++
.../mcp_service/canvas/tool/get_canvas_schema.py | 55 ++
superset/mcp_service/canvas/tool/update_canvas.py | 119 ++++
superset/mcp_service/canvas/utils.py | 33 +
superset/mcp_service/canvas/validation.py | 734 +++++++++++++++++++++
...6-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py | 78 +++
superset/models/canvas.py | 48 ++
superset/subjects/models.py | 38 ++
superset/views/canvas.py | 41 ++
superset/views/core.py | 25 +
tests/unit_tests/mcp_service/canvas/__init__.py | 16 +
.../mcp_service/canvas/test_canvas_patch.py | 151 +++++
.../mcp_service/canvas/test_canvas_validation.py | 81 +++
47 files changed, 6396 insertions(+)
diff --git a/superset-frontend/src/Canvas/CanvasRenderer.stories.tsx b/superset-frontend/src/Canvas/CanvasRenderer.stories.tsx
new file mode 100644
index 00000000000..5ec138ec9eb
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasRenderer.stories.tsx
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Meta, StoryObj } from '@storybook/react-webpack5';
+import { CanvasRenderer } from './CanvasRenderer';
+import { salesCanvas, createMockRunner } from './fixtures/salesCanvas';
+
+export default {
+ title: 'Canvas/CanvasRenderer',
+ component: CanvasRenderer,
+} as Meta<typeof CanvasRenderer>;
+
+type Story = StoryObj<typeof CanvasRenderer>;
+
+/** Change the Region select (or hit Reset) and watch the bound chart re-query. */
+export const InteractiveCanvas: Story = {
+ render: () => (
+ <div style={{ maxWidth: 720 }}>
+ <CanvasRenderer
+ definition={salesCanvas}
+ queryRunner={createMockRunner(300)}
+ />
+ </div>
+ ),
+};
diff --git a/superset-frontend/src/Canvas/CanvasRenderer.test.tsx b/superset-frontend/src/Canvas/CanvasRenderer.test.tsx
new file mode 100644
index 00000000000..9a8b158f7ee
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasRenderer.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ render,
+ screen,
+ userEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import { CanvasRenderer } from './CanvasRenderer';
+import { CanvasDefinition, CdlQueryContext } from './types';
+import type { QueryRunner } from './runtime';
+import { QueryResult } from './resolve';
+
+// echarts touches canvas APIs jsdom lacks; the reactive loop under test is the
+// query dispatch, not the render, so stub the engine.
+jest.mock('echarts/core', () => ({
+ use: jest.fn(),
+ registerTheme: jest.fn(),
+ init: jest.fn(() => ({
+ setOption: jest.fn(),
+ resize: jest.fn(),
+ dispose: jest.fn(),
+ })),
+}));
+
+const definition: CanvasDefinition = {
+ cdlVersion: 2,
+ variables: { region: { type: 'string', default: 'APAC', scope: 'query' } },
+ tree: {
+ id: 'root',
+ type: 'Column',
+ children: [
+ {
+ id: 'to-emea',
+ type: 'Button',
+ props: { children: 'EMEA' },
+ on: {
+ click: [{ action: 'setVariable', name: 'region', value: 'EMEA' }],
+ },
+ },
+ {
+ id: 'chart',
+ type: 'Viz',
+ renderer: 'echarts',
+ data: {
+ queryContext: {
+ datasetId: 1,
+ metrics: ['SUM(sales)'],
+ groupby: ['month'],
+ filters: [{ col: 'region', op: '==', val: '$region' }],
+ },
+ encoding: { x: 'month', y: 'SUM(sales)' },
+ },
+ option: { series: [{ type: 'line' }] },
+ },
+ ],
+ },
+};
+
+const regionOf = (qc: CdlQueryContext): unknown =>
+ qc.filters?.find(f => f.col === 'region')?.val;
+
+test('a bound query re-runs with the new value when a control changes a variable', async () => {
+ const run = jest.fn(
+ async (_queryContext: CdlQueryContext): Promise<QueryResult> => ({
+ columns: ['month', 'SUM(sales)'],
+ records: [{ month: 'Jan', 'SUM(sales)': 1 }],
+ }),
+ );
+ const runner: QueryRunner = { run };
+
+ render(<CanvasRenderer definition={definition} queryRunner={runner} />);
+
+ // Initial fetch resolves $region to its default.
+ await waitFor(() => expect(run).toHaveBeenCalled());
+ expect(regionOf(run.mock.calls[0][0])).toBe('APAC');
+
+ run.mockClear();
+ await userEvent.click(screen.getByText('EMEA'));
+
+ // The variable change re-runs the bound query with the new value.
+ await waitFor(() => expect(run).toHaveBeenCalled());
+ const lastCall = run.mock.calls[run.mock.calls.length - 1][0];
+ expect(regionOf(lastCall)).toBe('EMEA');
+});
+
+test('an invalid canvas renders validation errors instead of the tree', () => {
+ const broken = {
+ ...definition,
+ tree: { ...definition.tree, children: [{ id: 'bad', type: 'NotAThing' }] },
+ } as CanvasDefinition;
+
+ render(
+ <CanvasRenderer definition={broken} queryRunner={{ run: jest.fn() }} />,
+ );
+
+ expect(screen.getByTestId('canvas-validation-errors')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/Canvas/CanvasRenderer.tsx b/superset-frontend/src/Canvas/CanvasRenderer.tsx
new file mode 100644
index 00000000000..6315ff79341
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasRenderer.tsx
@@ -0,0 +1,188 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo } from 'react';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { CanvasDefinition, CdlFilter, CdlNode, Primitive } from './types';
+import { MANIFEST } from './manifest';
+import { runActions } from './actions';
+import { resolveVars } from './resolve';
+import { resolveStyle } from './style';
+import {
+ FilterProvider,
+ QueryRunner,
+ QueryRunnerProvider,
+ UiStateProvider,
+ VariableProvider,
+ VariableProviderProps,
+ useFilters,
+ useUiState,
+ useVariables,
+} from './runtime';
+import { validateCanvas, ValidationError } from './validator';
+
+const InvalidNode = styled.div`
+ padding: ${({ theme }) => theme.sizeUnit * 2}px;
+ border: 1px solid ${({ theme }) => theme.colorErrorBorder};
+ border-radius: ${({ theme }) => theme.borderRadius}px;
+ color: ${({ theme }) => theme.colorError};
+ font-family: ${({ theme }) => theme.fontFamilyCode};
+ font-size: ${({ theme }) => theme.fontSizeSM}px;
+`;
+
+/** The recursive resolver: catalog lookup, prop/bind/event wiring, recursion. */
+function NodeRenderer({ node }: { node: CdlNode }) {
+ const { vars, decls, setVariable, reset } = useVariables();
+ const { setFilter, clearFilters } = useFilters();
+ const { setActiveTab, setModalOpen, refresh } = useUiState();
+ const theme = useTheme();
+ const entry = MANIFEST[node.type];
+
+ if (!entry) {
+ return <InvalidNode>{t('Unknown node type: %s', node.type)}</InvalidNode>;
+ }
+
+ const resolvedProps = resolveVars(node.props ?? {}, vars);
+
+ const getBound = (prop: string): Primitive | undefined => {
+ const ref = node.bind?.[prop];
+ return ref ? vars[ref.slice(1)] : undefined;
+ };
+
+ const setBound = (prop: string, value: Primitive) => {
+ const ref = node.bind?.[prop];
+ if (!ref) {
+ return;
+ }
+ const name = ref.slice(1);
+ // Controls emit strings (an Input always does). Coerce to the variable's
+ // declared type so a numeric parameter reaches the query as a number.
+ const declared = decls[name]?.type;
+ let next: Primitive = value;
+ if (declared === 'number' && typeof value === 'string') {
+ const parsed = Number(value);
+ next = Number.isFinite(parsed) ? parsed : 0;
+ } else if (declared === 'boolean' && typeof value === 'string') {
+ next = value === 'true';
+ }
+ setVariable(name, next);
+ };
+
+ const fire = (event: string, value?: Primitive) => {
+ runActions(node.on?.[event], {
+ vars,
+ setVariable,
+ // Keyed by node+column so repeated clicks replace rather than accumulate.
+ applyFilter: filter =>
+ setFilter(`${node.id}:${filter.col}`, { filter: filter as CdlFilter }),
+ crossFilter: filter =>
+ setFilter(`${node.id}:${filter.col}`, { filter: filter as CdlFilter }),
+ clearFilters: () => {
+ clearFilters();
+ reset();
+ },
+ navigateTab: (tabsId, tab) => setActiveTab(tabsId, tab),
+ setModalOpen: (modalId, open) => setModalOpen(modalId, open),
+ refresh: () => refresh(),
+ eventValue: value,
+ });
+ };
+
+ const Component = entry.component;
+ const renderNode = (child: CdlNode) => (
+ <NodeRenderer key={child.id} node={child} />
+ );
+ const children = node.children?.map(renderNode);
+
+ return (
+ <Component
+ node={node}
+ resolvedProps={resolvedProps}
+ fire={fire}
+ getBound={getBound}
+ setBound={setBound}
+ renderNode={renderNode}
+ style={resolveStyle(
+ node.style,
+ theme as unknown as Record<string, unknown>,
+ )}
+ >
+ {children}
+ </Component>
+ );
+}
+
+const ErrorList = styled.ul`
+ margin: 0;
+ padding: ${({ theme }) => theme.sizeUnit * 2}px
+ ${({ theme }) => theme.sizeUnit * 4}px;
+ color: ${({ theme }) => theme.colorError};
+ font-family: ${({ theme }) => theme.fontFamilyCode};
+ font-size: ${({ theme }) => theme.fontSizeSM}px;
+`;
+
+function ValidationErrors({ errors }: { errors: ValidationError[] }) {
+ return (
+ <ErrorList data-test="canvas-validation-errors">
+ {errors.map(error => (
+ <li key={`${error.path}:${error.message}`}>
+ <strong>{error.path}</strong>: {error.message}
+ </li>
+ ))}
+ </ErrorList>
+ );
+}
+
+export interface CanvasRendererProps {
+ definition: CanvasDefinition;
+ queryRunner: QueryRunner;
+ dataMaskSink?: VariableProviderProps['dataMaskSink'];
+}
+
+/**
+ * Top-level entry: validate the CDL (hard-reject on failure), then render the
+ * tree inside the query-runner and variable providers.
+ */
+export function CanvasRenderer({
+ definition,
+ queryRunner,
+ dataMaskSink,
+}: CanvasRendererProps) {
+ const validation = useMemo(() => validateCanvas(definition), [definition]);
+
+ if (!validation.valid) {
+ return <ValidationErrors errors={validation.errors} />;
+ }
+
+ return (
+ <QueryRunnerProvider runner={queryRunner}>
+ <UiStateProvider>
+ <FilterProvider>
+ <VariableProvider
+ variables={definition.variables}
+ dataMaskSink={dataMaskSink}
+ >
+ <NodeRenderer node={definition.tree} />
+ </VariableProvider>
+ </FilterProvider>
+ </UiStateProvider>
+ </QueryRunnerProvider>
+ );
+}
diff --git a/superset-frontend/src/Canvas/CanvasViewer.tsx b/superset-frontend/src/Canvas/CanvasViewer.tsx
new file mode 100644
index 00000000000..1d0954531a7
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasViewer.tsx
@@ -0,0 +1,131 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Loading } from '@superset-ui/core/components';
+import { CanvasRenderer } from './CanvasRenderer';
+import { createSupersetQueryRunner } from './queryRunner';
+import { CanvasDefinition } from './types';
+
+const runner = createSupersetQueryRunner();
+
+const Page = styled.div<{ $maxWidth: string }>`
+ /* width:100% so the page fills the app's flex main area instead of
+ collapsing to its content width. The cap is canvas-controlled
+ (envelope.canvasWidth); default 'full' → no cap, matching dashboards. */
+ width: 100%;
+ max-width: ${({ $maxWidth }) => $maxWidth};
+ margin: 0 auto;
+ padding: ${({ theme }) => theme.sizeUnit * 6}px;
+
+ /* There is no global border-box reset in this codebase, so any node that
+ combines width:100% with author-supplied padding would overflow and force
+ horizontal scrolling. Scope the reset to the canvas subtree. */
+ &,
+ & *,
+ & *::before,
+ & *::after {
+ box-sizing: border-box;
+ }
+`;
+
+const Title = styled.h1`
+ font-size: ${({ theme }) => theme.fontSizeXL}px;
+ font-weight: ${({ theme }) => theme.fontWeightStrong};
+ margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
+`;
+
+interface CanvasResponse {
+ result: {
+ name?: string;
+ // The REST API returns the CDL as a stored JSON string.
+ definition: CanvasDefinition | string;
+ };
+}
+
+const parseDefinition = (
+ definition: CanvasDefinition | string,
+): CanvasDefinition =>
+ typeof definition === 'string' ? JSON.parse(definition) : definition;
+
+interface ViewerState {
+ loading: boolean;
+ error?: string;
+ title?: string;
+ definition?: CanvasDefinition;
+}
+
+/**
+ * Loads a saved Canvas by id/uuid from the REST API and renders it against real
+ * data. The route element supplies `idOrUuid` from the URL.
+ */
+export function CanvasViewer({ idOrUuid }: { idOrUuid: string }) {
+ const [state, setState] = useState<ViewerState>({ loading: true });
+
+ useEffect(() => {
+ let live = true;
+ setState({ loading: true });
+ SupersetClient.get({ endpoint: `/api/v1/canvas/${idOrUuid}` })
+ .then(({ json }) => {
+ if (!live) return;
+ const { result } = json as unknown as CanvasResponse;
+ setState({
+ loading: false,
+ title: result.name,
+ definition: parseDefinition(result.definition),
+ });
+ })
+ .catch((error: unknown) => {
+ if (!live) return;
+ setState({
+ loading: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
+ return () => {
+ live = false;
+ };
+ }, [idOrUuid]);
+
+ if (state.loading) {
+ return <Loading />;
+ }
+ if (state.error || !state.definition) {
+ return (
+ <Page $maxWidth="760px">
+ {t('Could not load canvas: %s', state.error ?? 'not found')}
+ </Page>
+ );
+ }
+
+ // Default to full-bleed (like a dashboard); a canvas opts into a narrower
+ // reading measure via envelope.canvasWidth (e.g. "820px").
+ const width = state.definition.canvasWidth;
+ const maxWidth = !width || width === 'full' ? 'none' : width;
+
+ return (
+ <Page $maxWidth={maxWidth}>
+ {state.title && <Title>{state.title}</Title>}
+ <CanvasRenderer definition={state.definition} queryRunner={runner} />
+ </Page>
+ );
+}
diff --git a/superset-frontend/src/Canvas/DESIGN.md b/superset-frontend/src/Canvas/DESIGN.md
new file mode 100644
index 00000000000..ccd7daa38b8
--- /dev/null
+++ b/superset-frontend/src/Canvas/DESIGN.md
@@ -0,0 +1,325 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Design: AI-Native Dashboards v2 ("Canvas")
+
+**Status:** Draft for review
+**Working names:** *Canvas* = the v2 artifact · *CDL* (Canvas Definition Language) = the JSON tree it stores. Both are placeholders; rename before implementation.
+
+---
+
+## 1. Goal & framing
+
+Let a user (via AI, or by hand) compose a dashboard as a **typed, declarative component tree** that a **new renderer** walks and draws. The tree can hold three kinds of leaf, rendered uniformly:
+
+- **raw ECharts** blocks (AI authors an ECharts `option` directly — near-arbitrary chart expressiveness)
+- **governed Superset charts** (existing `Slice`, full query-context / RLS / caching / drill)
+- **interactive Superset UI components** (Button, Select, Input, …) for dashboard-side interactivity
+
+The central invariant that keeps this "constrained, not sandpack":
+
+> **Nothing the AI emits is ever code.** Presentation is a data `option`. Data is a declarative `queryContext` + `encoding`. Behavior is a bounded `action` enum. No function string ever crosses the persistence boundary.
+
+Everything expressive comes from *config objects*, never from `eval`. That is the entire safety and maintainability thesis.
+
+### Non-goals (v1)
+- Arbitrary user JS / custom React components (the sandpack path — explicitly excluded).
+- Replacing v1 dashboards. Canvas is additive; the renderer can also be embedded *inside* a v1 dashboard as a single node for incremental adoption.
+- Cross-canvas data flow, server-driven layout, or realtime collaboration.
+
+### Design principles
+1. **Data, not code** (above).
+2. **Reuse Superset's governance spine.** Bound queries run through the *existing* `query_context` path — RLS, datasource access, caching come for free. Reactivity runs through the *existing* `dataMask`/native-filter state.
+3. **One dispatch table.** A single component *manifest* is simultaneously (a) the renderer's type→component map, (b) the prop/event contract, and (c) the schema handed to the AI. No parallel registries drifting apart.
+4. **Typed tree, versioned.** The tree is validated server-side against a schema with an explicit `version`; schema evolution is a real migration, not blob archaeology.
+
+---
+
+## 2. The artifact & persistence
+
+A **Canvas** is a new UUID-keyed model, deliberately separate from `Dashboard`/`Slice`.
+
+```
+canvas
+ id BIGINT PK
+ uuid UUID (external addressing — AI/agents reference by uuid)
+ slug VARCHAR unique, nullable
+ title VARCHAR
+ definition MEDIUMTEXT -- the CDL JSON tree (typed, versioned)
+ version INT -- CDL schema version (for migrations)
+ created_by / changed_by / owners (FAB audit + M2M owners, like Dashboard)
+ roles M2M -> ab_role -- dashboard-style RBAC
+```
+
+- `definition` is the whole CDL tree (§3). We keep it as one JSON blob (like `position_json`) — the *validator*, not the column shape, is what enforces structure.
+- Standard Superset command/DAO/REST stack: `CanvasRestApi` (`@protect()`), `Create/Update/Delete/GetCanvasCommand` (`@transaction()`), `CanvasDAO`, Marshmallow schemas for the OpenAPI envelope. **The CDL body itself is validated by a dedicated CDL validator** (§10), not by Marshmallow field-by-field.
+- Object-level auth: `security_manager.raise_for_access(canvas=...)` on read/mutate, mirroring dashboards (Canvas is a data-bearing resource).
+
+**Migration:** one Alembic migration adds the table using `superset.migrations.shared.utils` helpers.
+
+---
+
+## 3. CDL — the node tree
+
+### 3.1 Envelope
+```jsonc
+{
+ "cdlVersion": 2,
+ "variables": { /* name -> VariableDecl */ },
+ "tree": { /* root Node */ }
+}
+```
+
+### 3.2 Common node fields
+Every node shares:
+```jsonc
+{
+ "id": "n_abc", // stable id (AI-addressable; used for targeted edits/diffs)
+ "type": "Row", // manifest key -> component
+ "props": { ... }, // typed, validated against the manifest propSchema
+ "bind": { "propName": "$var" }, // one/two-way binding of a prop to a variable
+ "on": { "eventName": [ Action, ... ] }, // declarative event handlers
+ "children": [ Node, ... ] // only for container types
+}
+```
+`Viz` nodes add `renderer`, `data`, `option`, `encoding`, `chartId` (§5).
+
+### 3.3 Node categories (v1 allowlist — grows deliberately)
+
+| Category | Types | Notes |
+|---|---|---|
+| Layout | `Column`, `Row`, `Grid`, `Tabs`, `Tab`, `Divider` | containers; recurse over `children` |
+| Viz | `Viz` (renderer: `echarts` \| `supersetChart`) | the two-headed leaf (§5) |
+| Control | `Select`, `Button`, `Input`, `Switch`, `DateRange`, `RadioGroup` | interactive; drive variables via `bind`/`on` |
+| Display | `Markdown`, `Header`, `Text`, `Image` | static presentation |
+
+Control components come straight from `@superset-ui/core/components` via the manifest — no new UI built, just registered and constrained.
+
+---
+
+## 4. Variables, binding & reactivity
+
+### 4.1 Variable declaration
+```jsonc
+"variables": {
+ "region": { "type": "string", "default": "APAC", "scope": "query" },
+ "activeTab":{ "type": "string", "default": "overview", "scope": "ui" }
+}
+```
+- `scope: "query"` → the variable is **projected onto `dataMask`** (a synthetic native-filter/extra-form-data entry). Changing it invalidates and re-runs every bound query that references it — reusing Superset's existing query-invalidation, caching, and RLS. **This is the confirmed reactive substrate.**
+- `scope: "ui"` → a client-only store value (active tab, toggle open/closed). Never touches the backend; drives props/visibility only.
+
+### 4.2 Reference syntax
+`$region` inside any prop, `queryContext` filter value, or action param is resolved against the variable store at render time. Resolution is **pure substitution of a value** — not expression evaluation. (A later version may add a tiny safe expression grammar; v1 is references only, keeping the injection surface at zero.)
+
+### 4.3 Binding
+```jsonc
+{ "type": "Select", "props": { "options": ["APAC","EMEA","AMER"] }, "bind": { "value": "$region" } }
+```
+Two-way: the Select reads `$region` and, on change, writes it back. For a `query`-scoped variable, that write is a `dataMask` update → bound queries re-run. This is *identical plumbing* to a native filter driving charts; we're just exposing it as an author-composable control.
+
+---
+
+## 5. Viz nodes
+
+### 5.1 `renderer: "supersetChart"` — governed
+```jsonc
+{ "type": "Viz", "renderer": "supersetChart",
+ "chartId": 345,
+ "filters": [ { "col": "region", "op": "==", "val": "$region" } ] }
+```
+Renders the existing `SuperChart` via `chartId`. `filters` merge into the chart's `dataMask` (cross-filter mechanism). Auth: chart access checked at load; RLS applies in the normal query path. Zero new query machinery.
+
+### 5.2 `renderer: "echarts"` — bound + declarative
+```jsonc
+{ "type": "Viz", "renderer": "echarts",
+ "data": {
+ "queryContext": {
+ "datasetId": 12,
+ "metrics": ["SUM(sales)"],
+ "groupby": ["month"],
+ "filters": [ { "col": "region", "op": "==", "val": "$region" } ]
+ },
+ "encoding": { "x": "month", "y": "SUM(sales)", "series": null }
+ },
+ "option": {
+ "xAxis": { "type": "category" },
+ "yAxis": { "type": "value" },
+ "series": [ { "type": "line" } ],
+ "tooltip": { "formatter": { "kind": "currency", "currency": "USD" } }
+ }
+}
+```
+
+**Pipeline (all in the renderer):**
+1. Resolve `$vars` in `data.queryContext`.
+2. Run it through the **existing** query-context endpoint (`POST /api/v1/chart/data`-equivalent). → governed, cached, RLS-enforced result set. **The dataset filter values are parameterized query-context filters, never string-concatenated SQL — `$region` is a *value*, not a fragment. No injection surface.**
+3. Map the result columns into `option.series[].data` using `encoding` (`x`→category dim, `y`→series value, optional `series` field → one series per group).
+4. **Resolve formatters** from the declarative vocabulary into real functions *client-side, at render*: `{ kind: "currency"|"number"|"percent"|"date"|"template", ... }`. **A string-valued `formatter` (or any `function`/`=>` anywhere in `option`) is rejected by the validator (§10).** This is the hard line between "config" and "code."
+
+`encoding` is intentionally small in v1 (`x`, `y`, `series`, `size`, `color`). It covers the common cartesian/pie cases; anything exotic can inline pre-shaped `series.data` via a `query`-bound transform in a later version.
+
+---
+
+## 6. Action vocabulary (behavior, bounded)
+
+Event handlers (`on.click`, `on.change`, …) hold an **ordered list of declarative actions** from a closed enum. No arbitrary handlers, ever.
+
+| Action | Params | Effect |
+|---|---|---|
+| `setVariable` | `name`, `value` (may be `$other` or an event value) | write the variable store |
+| `applyFilter` / `crossFilter` | `col`, `op`, `val` | push a `dataMask` filter |
+| `clearFilters` | `scope?` | reset dataMask/vars |
+| `navigateTab` | `tabsId`, `tab` | switch a `Tabs` node's active tab (ui-scoped var) |
+| `openUrl` | `url`, `newTab?` | navigate; **`http(s)` only, `javascript:`/`data:` rejected by validator** |
+| `refresh` | `target?` | force re-query a Viz / the whole canvas |
+
+Adding a capability = adding an entry here (with a typed param schema), not opening a code hole. That's the maintainability property.
+
+---
+
+## 7. Component manifest (the one dispatch table)
+
+The gap the codebase scan found: `@superset-ui/core/components` has no machine-readable registry. We build one — it is the renderer's dispatch table **and** the AI-facing contract.
+
+```ts
+interface ManifestEntry {
+ name: string; // CDL "type"
+ component: React.ComponentType<any>; // resolved React component (or a Viz sentinel)
+ category: 'layout' | 'viz' | 'control' | 'display';
+ propSchema: JSONSchema; // validates node.props; also handed to the AI
+ events?: EventSpec[]; // which on.* events this type emits + their payload shape
+ bindableProps?: string[]; // which props accept $var / two-way bind
+ container?: boolean; // accepts children
+}
+type ComponentManifest = Record<string, ManifestEntry>;
+```
+
+- **Allowlist is the manifest's key set** — a `type` not in the manifest fails validation and never renders. No dynamic/arbitrary component resolution.
+- Mirrors the two existing string→component registries (dashboard `componentLookup`, `ChartComponentRegistry`) — same proven pattern, extended to general UI.
+- `propSchema`s are authored once and consumed three ways (validate, render, prompt). A build step can seed them from the components' TS prop types to reduce drift, but the manifest is the source of truth.
+- **Backend needs the schema too** (server-side validation, MCP tool). Ship the manifest's *schema half* (names + propSchema + events, no React) as a JSON artifact importable by Python — one generated file, single source, no hand-synced dual registry.
+
+---
+
+## 8. Reactive substrate — dataMask mapping (detail)
+
+Confirmed approach: **variables project onto `dataMask`.**
+
+- On load, each `query`-scoped variable seeds a synthetic `dataMask` entry (`extraFormData` filter).
+- A `bind`/`setVariable`/`applyFilter` write dispatches the existing dataMask update action.
+- Every bound `Viz` subscribes to dataMask; a change to a referenced variable invalidates its query cache key → re-fetch, exactly as native charts behave.
+- **Governance inheritance:** because bound queries are ordinary query-contexts, RLS rules, datasource access checks, row limits, and cache all apply unchanged. We add *no* new query authorization path.
+- `ui`-scoped variables live in a lightweight React context (or a slice of the canvas store) and never enter dataMask — they only gate props/visibility/active-tab.
+
+Net: the *only* genuinely new runtime is the tree-walking renderer + the encoding/formatter mapper. Data governance and reactivity are borrowed wholesale.
+
+---
+
+## 9. Renderer architecture
+
+A new component, `CanvasRenderer`, analogous to `DashboardBuilder` + `DashboardComponent`:
+
+```
+CanvasRenderer(definition)
+ ├─ VariableProvider (seeds store; projects query-scoped vars onto dataMask)
+ └─ NodeRenderer(node) // the recursive resolver
+ ├─ manifest[node.type] ──► ManifestEntry (else: invalid-node boundary)
+ ├─ resolve props: substitute $vars, apply bind
+ ├─ wire on.* -> ActionRunner (bounded dispatch)
+ ├─ if container: map children -> NodeRenderer
+ └─ if Viz:
+ ├─ supersetChart -> <SuperChart chartId .../>
+ └─ echarts -> useBoundQuery(queryContext) -> encode -> resolveFormatters -> <ECharts option/>
+```
+
+- Each node gets an error boundary (a broken node degrades locally, like `ChartHolder` today).
+- **Embeddable:** register a `CANVAS` node `type` in the v1 dashboard `componentLookup` so a Canvas can live inside a legacy dashboard — the incremental-adoption path.
+- `useBoundQuery` wraps the existing chart-data fetch + Redux cache; it is the *only* data-fetch primitive the renderer introduces.
+
+---
+
+## 10. Validation (the enforcement point)
+
+A single **CDL validator** (shared shape, TS in the renderer + generated JSON-schema for Python) runs on every save (command layer) and on AI output. It enforces:
+
+1. **Structural** — valid node `type`s (in manifest), valid nesting (containers only have children), required fields per `renderer`.
+2. **Prop** — `node.props` validates against `manifest[type].propSchema`.
+3. **No-code invariant** — reject any string that is a function (`function`, `=>`, `new Function`) or any `formatter`/callback that isn't a declarative formatter object; reject `javascript:`/`data:` URLs in `openUrl`.
+4. **Action** — every action name in the enum; params match the action's schema.
+5. **Binding** — `bind` only targets `bindableProps`; every `$var` referenced is declared in `variables`.
+6. **Reference integrity** — `chartId`/`datasetId` exist and are accessible to the actor (deferred to command-layer auth via `raise_for_access`).
+
+Validation failure = hard reject with a structured error path (so the AI can self-correct — same pattern as the existing `generate_chart` 5-layer validation).
+
+---
+
+## 11. Security model (maps to SECURITY.md)
+
+- **Resource class:** Canvas is data-bearing (dashboard-like) → route-level `@protect()` **and** object-level `security_manager.raise_for_access(canvas=...)`; owners/roles M2M for scoping. In scope for the authorization matrix exactly like dashboards.
+- **Referenced charts/datasets:** access enforced at load via the existing `raise_for_access` on the chart/datasource; bound `queryContext` runs the normal governed path (RLS, DB access). No new data authorization surface.
+- **No-code invariant** (validator §10) is the primary novel control: model/AI output cannot introduce executable code, so there is no new XSS/RCE vector beyond what markdown/links already allow.
+- **`openUrl`** restricted to `http(s)`; equivalent to today's markdown links (authors are Alpha+; viewers see only what an author with dashboard-edit capability could already put in front of them). Not a privilege escalation.
+- **Variable→query interpolation** is value substitution into *parameterized* query-context filters, never SQL string assembly → no SQLi surface introduced.
+- **Principal note for any future finding:** author = a role with chart/dashboard-create capability (Alpha+ or explicit grant); viewer = whatever role the Canvas RBAC grants. A finding must name the matrix row + principal per repo policy.
+
+---
+
+## 12. AI authoring flow (MCP)
+
+Extends the existing `mcp_service` pattern (which already emits typed configs + validates + compile-checks):
+
+- `get_canvas_schema` — returns the manifest schema half + action/encoding/formatter grammar (the contract the model targets). Mirrors existing `get_chart_type_schema`.
+- `generate_canvas` — model emits a CDL tree → **validator (§10) runs → structured errors fed back for self-correction** → optional preview (dry render + dry-run each bound query) → `save=false` by default, mirroring `generate_chart`'s preview-first/compile-check discipline.
+- `update_canvas` — targeted edits by node `id` (why nodes carry stable ids): patch/replace/move a subtree; re-validate.
+- Reuses the existing chart tools for `supersetChart` leaves (author a governed chart, drop its id into the tree).
+
+---
+
+## 13. Schema versioning
+
+- `cdlVersion` in the envelope; `canvas.version` column.
+- Forward migrations are pure functions `migrate_v{n}_to_v{n+1}(tree)`, run lazily on read and/or in a batch Alembic data migration — feasible precisely because the tree is typed (contrast: migrating free-form `position_json`).
+- The manifest is versioned alongside; a removed component type gets a migration to a replacement or a placeholder node.
+
+---
+
+## 14. Delivery plan (thin vertical slice first)
+
+1. **Contract** — CDL schema + validator (TS) + generated JSON-schema (Python). Envelope, common fields, `Viz`, `Column/Row`, `Select`, `Button`. *No renderer yet — validate hand-written fixtures.*
+2. **Manifest** — the 4-component manifest + the schema-export build step.
+3. **Backend** — `canvas` model + migration + REST/command/DAO + `raise_for_access` + save-time validation.
+4. **Renderer** — `CanvasRenderer`/`NodeRenderer`, variable store, dataMask projection, layout + control nodes. Prove: a `Select` bound to `$region` updates a `supersetChart` leaf via dataMask. *(reactive loop proven with governed charts only)*
+5. **ECharts bound path** — `useBoundQuery` + `encoding`→`option` mapper + formatter resolver + no-code validation. Prove: a raw-echarts leaf re-queries on `$region` change.
+6. **Actions** — `ActionRunner` + full v1 enum.
+7. **MCP** — `get_canvas_schema` / `generate_canvas` / `update_canvas`.
+8. **Embed** — `CANVAS` node in v1 dashboard `componentLookup`.
+
+Each step is independently shippable/testable; steps 4–5 are the proof-of-concept milestone.
+
+---
+
+## 15. Open questions
+
+1. **`encoding` ceiling** — how far do we push declarative encoding before allowing a bound "shape the series" transform? v1 stays cartesian/pie; revisit once real charts stress it.
+2. **Multi-query Viz** — a single echarts block needing >1 query (e.g. overlay). Defer, or allow `data` to be an array of named queries?
+3. **Theming** — echarts blocks must consume the Superset antd theme tokens (not hardcode colors) so light/dark works. Renderer injects a themed base `option`; authors override sparingly.
+4. **Cross-filtering *out* of a Canvas** — should a Canvas emit cross-filters to a host v1 dashboard when embedded? Probably yes (it's already dataMask), but scope it explicitly.
+5. **Naming** — Canvas/CDL are placeholders. Decide before any public API/model name is minted (hard to change post-migration).
+6. **Prop-schema authoring** — hand-authored vs generated-from-TS. Start hand-authored for the 4-component slice; evaluate a generator before the manifest grows large.
diff --git a/superset-frontend/src/Canvas/EchartsViz.tsx b/superset-frontend/src/Canvas/EchartsViz.tsx
new file mode 100644
index 00000000000..c5aaf3fad0d
--- /dev/null
+++ b/superset-frontend/src/Canvas/EchartsViz.tsx
@@ -0,0 +1,244 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, useEffect, useMemo, useRef } from 'react';
+import { useTheme } from '@apache-superset/core/theme';
+import {
+ init,
+ registerTheme,
+ use,
+ type EChartsCoreOption,
+ type EChartsType,
+} from 'echarts/core';
+import {
+ BarChart,
+ FunnelChart,
+ GaugeChart,
+ HeatmapChart,
+ LineChart,
+ PieChart,
+ RadarChart,
+ ScatterChart,
+ SunburstChart,
+ TreemapChart,
+} from 'echarts/charts';
+import {
+ DataZoomComponent,
+ GraphicComponent,
+ GridComponent,
+ LegendComponent,
+ MarkLineComponent,
+ PolarComponent,
+ RadarComponent,
+ TitleComponent,
+ TooltipComponent,
+ VisualMapComponent,
+} from 'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import { t } from '@apache-superset/core/translation';
+import { VizNode } from './types';
+import { encodeToOption, resolveFormatters } from './resolve';
+import { useBoundQuery } from './runtime';
+
+use([
+ CanvasRenderer,
+ LineChart,
+ BarChart,
+ PieChart,
+ ScatterChart,
+ RadarChart,
+ FunnelChart,
+ GaugeChart,
+ TreemapChart,
+ SunburstChart,
+ HeatmapChart,
+ GridComponent,
+ TooltipComponent,
+ LegendComponent,
+ TitleComponent,
+ VisualMapComponent,
+ PolarComponent,
+ RadarComponent,
+ DataZoomComponent,
+ GraphicComponent,
+ MarkLineComponent,
+]);
+
+const THEME_NAME = 'supersetCanvas';
+
+type ThemeTokens = ReturnType<typeof useTheme>;
+
+/** Signature that changes when the app flips light/dark — drives re-theming. */
+const themeSignature = (theme: ThemeTokens): string =>
+ `${theme.colorText}|${theme.colorBgContainer}|${theme.colorPrimary}`;
+
+/**
+ * A full echarts theme built from antd tokens. Registered globally so axis,
+ * legend and title colours follow the app theme — the parts a per-chart
+ * `option` can't reach, since option values can't reference `@themeToken`s.
+ */
+function buildEchartsTheme(theme: ThemeTokens): Record<string, unknown> {
+ const text = theme.colorText;
+ const sub = theme.colorTextSecondary;
+ const border = theme.colorBorderSecondary ?? theme.colorBorder;
+ const split = theme.colorSplit ?? border;
+ const palette = [
+ theme.colorPrimary,
+ theme.colorSuccess,
+ theme.colorWarning,
+ theme.colorInfo,
+ theme.colorError,
+ theme.colorPrimaryBorder,
+ theme.colorWarningBorder,
+ ].filter(Boolean);
+ const axis = {
+ axisLine: { lineStyle: { color: border } },
+ axisTick: { lineStyle: { color: border } },
+ axisLabel: { color: sub },
+ splitLine: { lineStyle: { color: split } },
+ splitArea: { areaStyle: { color: ['transparent', 'transparent'] } },
+ };
+ return {
+ color: palette,
+ backgroundColor: 'transparent',
+ textStyle: { color: text, fontFamily: theme.fontFamily },
+ title: { textStyle: { color: text }, subtextStyle: { color: sub } },
+ legend: { textStyle: { color: sub } },
+ categoryAxis: axis,
+ valueAxis: axis,
+ logAxis: axis,
+ timeAxis: axis,
+ visualMap: { textStyle: { color: sub } },
+ graph: { color: palette },
+ };
+}
+
+/** Default framing; colours now come from the registered theme. */
+const themedBase: Record<string, unknown> = {
+ grid: { left: 56, right: 16, top: 32, bottom: 32, containLabel: true },
+};
+
+function EchartsChart({
+ option,
+ themeKey,
+ height = 320,
+ style,
+}: {
+ option: EChartsCoreOption;
+ themeKey: string;
+ height?: number;
+ style?: CSSProperties;
+}) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const chartRef = useRef<EChartsType>();
+ const optionRef = useRef(option);
+ optionRef.current = option;
+
+ // Re-init when the theme flips (an echarts instance binds its theme at init).
+ useEffect(() => {
+ const element = containerRef.current;
+ if (!element) {
+ return undefined;
+ }
+ const chart = init(element, THEME_NAME);
+ chartRef.current = chart;
+ chart.setOption(optionRef.current, { notMerge: true });
+ const handleResize = () => chart.resize();
+ const observer = new ResizeObserver(handleResize);
+ observer.observe(element);
+ window.addEventListener('resize', handleResize);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener('resize', handleResize);
+ chart.dispose();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [themeKey]);
+
+ useEffect(() => {
+ chartRef.current?.setOption(option, { notMerge: true });
+ }, [option]);
+
+ return (
+ <div
+ ref={containerRef}
+ data-test="canvas-echarts"
+ css={{ width: '100%', height }}
+ style={style}
+ />
+ );
+}
+
+/**
+ * The echarts head of the Viz union: fetch a governed query, map the result onto
+ * the declarative option via `encoding`, resolve formatters, render.
+ */
+export function EchartsViz({
+ node,
+ style,
+}: {
+ node: VizNode;
+ style?: CSSProperties;
+}) {
+ const theme = useTheme();
+ const { data } = node;
+ const { loading, error, result } = useBoundQuery(
+ data?.queryContext ?? { datasetId: -1, metrics: [] },
+ );
+
+ // Register (or refresh) the theme during render, before the child's init
+ // effect runs — child effects fire before parent effects, so an effect here
+ // would be too late for the first paint.
+ const themeKey = themeSignature(theme);
+ useMemo(() => {
+ registerTheme(THEME_NAME, buildEchartsTheme(theme));
+ return null;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [themeKey]);
+
+ const option = useMemo<EChartsCoreOption | undefined>(() => {
+ if (!result || !data) {
+ return undefined;
+ }
+ const encoded = encodeToOption(
+ { ...themedBase, ...node.option },
+ data.encoding,
+ result,
+ );
+ return resolveFormatters(encoded) as EChartsCoreOption;
+ }, [result, data, node.option]);
+
+ if (loading) {
+ return <div data-test="canvas-echarts-loading">{t('Loading…')}</div>;
+ }
+ if (error) {
+ return (
+ <div data-test="canvas-echarts-error">{t('Query error: %s', error)}</div>
+ );
+ }
+ if (result && result.records.length === 0) {
+ return (
+ <div data-test="canvas-echarts-empty">{t('No data for this query')}</div>
+ );
+ }
+ if (!option) {
+ return null;
+ }
+ return <EchartsChart option={option} themeKey={themeKey} style={style} />;
+}
diff --git a/superset-frontend/src/Canvas/FilterControl.tsx b/superset-frontend/src/Canvas/FilterControl.tsx
new file mode 100644
index 00000000000..f94601d50bc
--- /dev/null
+++ b/superset-frontend/src/Canvas/FilterControl.tsx
@@ -0,0 +1,126 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, useEffect, useState } from 'react';
+import { Select } from '@superset-ui/core/components';
+import { CdlFilter, Primitive } from './types';
+import { ActiveFilter, useFilters, useQueryRunner } from './runtime';
+
+interface Option {
+ value: string | number;
+ label: string;
+}
+
+export interface FilterControlProps {
+ filterId: string;
+ column: string;
+ datasetId?: number;
+ label?: string;
+ multiple?: boolean;
+ op?: CdlFilter['op'];
+ options?: Option[];
+ style?: CSSProperties;
+}
+
+/**
+ * A canvas filter: renders a (multi)select of a column's distinct values and
+ * writes the selection into the canvas-global filter store, which every bound
+ * chart on the same dataset merges into its query.
+ */
+export function FilterControl({
+ filterId,
+ column,
+ datasetId,
+ label,
+ multiple,
+ op,
+ options: provided,
+ style,
+}: FilterControlProps) {
+ const runner = useQueryRunner();
+ const { setFilter } = useFilters();
+ const [options, setOptions] = useState<Option[]>(provided ?? []);
+ const [value, setValue] = useState<Primitive | Primitive[] | undefined>(
+ multiple ? [] : undefined,
+ );
+
+ // Auto-populate options with the column's distinct values (COUNT(*) works on
+ // any dataset without needing a saved metric).
+ useEffect(() => {
+ if (provided?.length || datasetId == null || !column) {
+ return undefined;
+ }
+ let live = true;
+ runner
+ .run({ datasetId, metrics: ['COUNT(*)'], groupby: [column] })
+ .then(result => {
+ if (!live) return;
+ const seen = new Set<string>();
+ const opts: Option[] = [];
+ result.records.forEach(row => {
+ const raw = row[column];
+ const key = String(raw);
+ if (raw != null && !seen.has(key)) {
+ seen.add(key);
+ opts.push({ value: raw as string | number, label: key });
+ }
+ });
+ setOptions(opts);
+ })
+ .catch(() => undefined);
+ return () => {
+ live = false;
+ };
+ }, [runner, datasetId, column, provided]);
+
+ const handleChange = (next: unknown) => {
+ const selected = next as Primitive | Primitive[] | undefined;
+ setValue(selected);
+ const isEmpty =
+ selected == null ||
+ selected === '' ||
+ (Array.isArray(selected) && selected.length === 0);
+ const active: ActiveFilter | null = isEmpty
+ ? null
+ : {
+ datasetId,
+ filter: {
+ col: column,
+ op: op ?? (multiple ? 'IN' : '=='),
+ val: selected as CdlFilter['val'],
+ },
+ };
+ setFilter(filterId, active);
+ };
+
+ return (
+ <div style={style}>
+ <Select
+ ariaLabel={label ?? column}
+ header={label ?? column}
+ placeholder={label ?? column}
+ mode={multiple ? 'multiple' : undefined}
+ options={options}
+ value={value as string | number | (string | number)[] | undefined}
+ onChange={handleChange}
+ allowClear
+ />
+ </div>
+ );
+}
diff --git a/superset-frontend/src/Canvas/SupersetChartViz.tsx b/superset-frontend/src/Canvas/SupersetChartViz.tsx
new file mode 100644
index 00000000000..602d86b2cc2
--- /dev/null
+++ b/superset-frontend/src/Canvas/SupersetChartViz.tsx
@@ -0,0 +1,204 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, useEffect, useMemo, useState } from 'react';
+import {
+ getClientErrorObject,
+ SupersetClient,
+ SuperChart,
+ type QueryData,
+} from '@superset-ui/core';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import { t } from '@apache-superset/core/translation';
+import { CdlFilter, VizNode } from './types';
+import { resolveVars } from './resolve';
+import { useActiveFilters, useUiState, useVariables } from './runtime';
+
+interface ChartMeta {
+ vizType: string;
+ formData: Record<string, unknown>;
+ datasourceId?: number;
+}
+
+const parseJson = (value: unknown): Record<string, unknown> | undefined => {
+ if (typeof value === 'string' && value.trim()) {
+ try {
+ return JSON.parse(value) as Record<string, unknown>;
+ } catch {
+ return undefined;
+ }
+ }
+ return (value as Record<string, unknown>) || undefined;
+};
+
+/** SupersetClient rejects with a Response, not an Error — unwrap it. */
+const describeError = async (err: unknown): Promise<string> => {
+ try {
+ const obj = await getClientErrorObject(
+ err as Parameters<typeof getClientErrorObject>[0],
+ );
+ return obj.error || obj.message || JSON.stringify(obj).slice(0, 200);
+ } catch {
+ return err instanceof Error ? err.message : String(err);
+ }
+};
+
+/**
+ * The governed head of the Viz union: renders an existing saved Superset chart
+ * with its own viz plugin and form_data. Data goes through the same
+ * getChartDataRequest the dashboard uses, so the plugin's buildQuery (and the
+ * legacy API fallback) apply. Canvas filters are passed as `extra_form_data`,
+ * exactly how native dashboard filters reach a chart.
+ */
+export function SupersetChartViz({
+ node,
+ style,
+}: {
+ node: VizNode;
+ style?: CSSProperties;
+}) {
+ const { chartId } = node;
+ const { vars } = useVariables();
+ const { refreshNonce } = useUiState();
+ const [meta, setMeta] = useState<ChartMeta | undefined>();
+ const [queriesData, setQueriesData] = useState<QueryData[] | undefined>();
+ const [error, setError] = useState<string | undefined>();
+
+ // 1. Load the saved chart's viz type and form_data.
+ useEffect(() => {
+ if (!chartId) {
+ return undefined;
+ }
+ let live = true;
+ setError(undefined);
+ SupersetClient.get({ endpoint: `/api/v1/chart/${chartId}` })
+ .then(({ json }) => {
+ if (!live) return;
+ const { result } = json as unknown as {
+ result: Record<string, unknown>;
+ };
+ // Prefer the API's form_data (already normalised); fall back to params.
+ const base =
+ parseJson(result.form_data) ?? parseJson(result.params) ?? {};
+ const vizType = String(result.viz_type ?? base.viz_type ?? '');
+ // A saved chart's params.datasource can be stale (example charts ship
+ // with the wrong id). Slice.form_data overrides it server-side with the
+ // authoritative datasource_id, so do the same here.
+ const datasourceId = result.datasource_id;
+ const datasourceType = String(result.datasource_type ?? 'table');
+ const datasource =
+ datasourceId != null
+ ? `${datasourceId}__${datasourceType}`
+ : base.datasource;
+ setMeta({
+ vizType,
+ formData: {
+ ...base,
+ datasource,
+ viz_type: vizType,
+ slice_id: chartId,
+ },
+ datasourceId: Number(datasourceId ?? NaN),
+ });
+ })
+ .catch(async err => {
+ const message = await describeError(err);
+ if (live) setError(message);
+ });
+ return () => {
+ live = false;
+ };
+ }, [chartId]);
+
+ // Canvas-global filters for this chart's dataset, plus node-level ones.
+ const activeFilters = useActiveFilters(meta?.datasourceId ?? -1);
+ const extraFilters = useMemo<CdlFilter[]>(
+ () => [...activeFilters, ...resolveVars(node.filters ?? [], vars)],
+ [activeFilters, node.filters, vars],
+ );
+ const filterKey = JSON.stringify(extraFilters);
+
+ // 2. Fetch data through the standard chart-data path.
+ useEffect(() => {
+ if (!meta) {
+ return undefined;
+ }
+ let live = true;
+ const formData = extraFilters.length
+ ? {
+ ...meta.formData,
+ extra_form_data: {
+ ...((meta.formData.extra_form_data as Record<string, unknown>) ??
+ {}),
+ filters: extraFilters.map(f => ({
+ col: f.col,
+ op: f.op,
+ val: f.val,
+ })),
+ },
+ }
+ : meta.formData;
+
+ getChartDataRequest({ formData, resultFormat: 'json', resultType: 'full' })
+ .then(({ json }) => {
+ if (live) setQueriesData(json.result);
+ })
+ .catch(async err => {
+ const message = await describeError(err);
+ if (live) setError(message);
+ });
+ return () => {
+ live = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [meta, filterKey, refreshNonce]);
+
+ if (!chartId) {
+ return (
+ <div data-test="canvas-superset-chart-error">
+ {t('chartId is required')}
+ </div>
+ );
+ }
+ if (error) {
+ return (
+ <div data-test="canvas-superset-chart-error">
+ {t('Chart error: %s', error)}
+ </div>
+ );
+ }
+ if (!meta || !queriesData) {
+ return <div data-test="canvas-superset-chart-loading">{t('Loading…')}</div>;
+ }
+
+ return (
+ <div
+ data-test="canvas-superset-chart"
+ style={{ width: '100%', height: 320, ...style }}
+ >
+ <SuperChart
+ chartType={meta.vizType}
+ formData={meta.formData}
+ queriesData={queriesData}
+ width="100%"
+ height="100%"
+ />
+ </div>
+ );
+}
diff --git a/superset-frontend/src/Canvas/actions.ts b/superset-frontend/src/Canvas/actions.ts
new file mode 100644
index 00000000000..be0b1fde5de
--- /dev/null
+++ b/superset-frontend/src/Canvas/actions.ts
@@ -0,0 +1,113 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ CdlAction,
+ CdlFilter,
+ Primitive,
+ VariableScope,
+ VariableValues,
+} from './types';
+import { isVarRef } from './resolve';
+
+/** Token that resolves to the value emitted by the triggering event. */
+const EVENT_TOKEN = '$event';
+const HTTP_URL = /^https?:\/\//i;
+
+/**
+ * The side-effecting surface an action can touch. Every capability is an
+ * explicit method here; there is no escape to arbitrary code. In-app, filter
+ * methods dispatch onto dataMask; in the prototype they are injected.
+ */
+export interface ActionContext {
+ vars: VariableValues;
+ setVariable: (name: string, value: Primitive) => void;
+ applyFilter: (filter: Pick<CdlFilter, 'col' | 'op' | 'val'>) => void;
+ crossFilter: (filter: Pick<CdlFilter, 'col' | 'op' | 'val'>) => void;
+ clearFilters: (scope?: VariableScope) => void;
+ navigateTab: (tabsId: string, tab: string) => void;
+ setModalOpen: (modalId: string, open: boolean) => void;
+ refresh: (target?: string) => void;
+ /** Value from the event that triggered this action list (e.g. Select value). */
+ eventValue?: Primitive;
+}
+
+function resolveActionValue(value: unknown, ctx: ActionContext): Primitive {
+ if (value === EVENT_TOKEN) {
+ return ctx.eventValue as Primitive;
+ }
+ if (isVarRef(value)) {
+ return ctx.vars[value.slice(1)];
+ }
+ return value as Primitive;
+}
+
+/** Run an ordered list of declarative actions against the injected context. */
+export function runActions(
+ actions: CdlAction[] | undefined,
+ ctx: ActionContext,
+): void {
+ (actions ?? []).forEach(action => {
+ switch (action.action) {
+ case 'setVariable':
+ ctx.setVariable(action.name, resolveActionValue(action.value, ctx));
+ break;
+ case 'applyFilter':
+ ctx.applyFilter({
+ col: action.col,
+ op: action.op,
+ val: resolveActionValue(action.val, ctx),
+ });
+ break;
+ case 'crossFilter':
+ ctx.crossFilter({
+ col: action.col,
+ op: action.op,
+ val: resolveActionValue(action.val, ctx),
+ });
+ break;
+ case 'clearFilters':
+ ctx.clearFilters(action.scope);
+ break;
+ case 'navigateTab':
+ ctx.navigateTab(action.tabsId, action.tab);
+ break;
+ case 'openModal':
+ ctx.setModalOpen(action.modalId, true);
+ break;
+ case 'closeModal':
+ ctx.setModalOpen(action.modalId, false);
+ break;
+ case 'openUrl':
+ if (HTTP_URL.test(action.url)) {
+ if (action.newTab) {
+ window.open(action.url, '_blank', 'noopener,noreferrer');
+ } else {
+ window.location.assign(action.url);
+ }
+ }
+ break;
+ case 'refresh':
+ ctx.refresh(action.target);
+ break;
+ default:
+ break;
+ }
+ });
+}
diff --git a/superset-frontend/src/Canvas/catalog.ts b/superset-frontend/src/Canvas/catalog.ts
new file mode 100644
index 00000000000..8cffc0409ce
--- /dev/null
+++ b/superset-frontend/src/Canvas/catalog.ts
@@ -0,0 +1,191 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The catalog is the schema half of the component manifest — pure metadata, no
+ * React. It is the allowlist of node types plus their contract (events,
+ * bindable props). The renderer's manifest maps these names to components; the
+ * validator and (eventually) the backend/MCP JSON-schema consume the same
+ * source so the AI-facing contract never drifts from the renderer.
+ */
+
+export type NodeCategory = 'layout' | 'viz' | 'control' | 'display';
+
+export interface CatalogEntry {
+ name: string;
+ category: NodeCategory;
+ /** May hold `children`. */
+ container: boolean;
+ /** Allowed `on.*` event names. */
+ events: string[];
+ /** Props that accept a `$var` reference / two-way `bind`. */
+ bindableProps: string[];
+ /** Props that must be present. */
+ requiredProps: string[];
+}
+
+export const NODE_CATALOG: Record<string, CatalogEntry> = {
+ Column: {
+ name: 'Column',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Row: {
+ name: 'Row',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Card: {
+ name: 'Card',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Tabs: {
+ name: 'Tabs',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Board: {
+ name: 'Board',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Tab: {
+ name: 'Tab',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: ['label'],
+ },
+ Divider: {
+ name: 'Divider',
+ category: 'display',
+ container: false,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Alert: {
+ name: 'Alert',
+ category: 'display',
+ container: false,
+ events: [],
+ bindableProps: [],
+ requiredProps: ['message'],
+ },
+ Progress: {
+ name: 'Progress',
+ category: 'display',
+ container: false,
+ events: [],
+ bindableProps: ['value'],
+ requiredProps: [],
+ },
+ Collapse: {
+ name: 'Collapse',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Modal: {
+ name: 'Modal',
+ category: 'layout',
+ container: true,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Input: {
+ name: 'Input',
+ category: 'control',
+ container: false,
+ events: ['change'],
+ bindableProps: ['value'],
+ requiredProps: [],
+ },
+ Switch: {
+ name: 'Switch',
+ category: 'control',
+ container: false,
+ events: ['change'],
+ bindableProps: ['value'],
+ requiredProps: [],
+ },
+ Select: {
+ name: 'Select',
+ category: 'control',
+ container: false,
+ events: ['change'],
+ bindableProps: ['value'],
+ requiredProps: ['options'],
+ },
+ Button: {
+ name: 'Button',
+ category: 'control',
+ container: false,
+ events: ['click'],
+ bindableProps: [],
+ requiredProps: [],
+ },
+ Filter: {
+ name: 'Filter',
+ category: 'control',
+ container: false,
+ events: [],
+ bindableProps: [],
+ requiredProps: ['column'],
+ },
+ Markdown: {
+ name: 'Markdown',
+ category: 'display',
+ container: false,
+ events: [],
+ bindableProps: [],
+ requiredProps: ['text'],
+ },
+ Viz: {
+ name: 'Viz',
+ category: 'viz',
+ container: false,
+ events: [],
+ bindableProps: [],
+ requiredProps: [],
+ },
+};
+
+export const isKnownType = (type: string): boolean =>
+ Object.prototype.hasOwnProperty.call(NODE_CATALOG, type);
diff --git a/superset-frontend/src/Canvas/cdl.test.ts b/superset-frontend/src/Canvas/cdl.test.ts
new file mode 100644
index 00000000000..0e4adba0914
--- /dev/null
+++ b/superset-frontend/src/Canvas/cdl.test.ts
@@ -0,0 +1,306 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { validateCanvas } from './validator';
+import { encodeToOption, resolveVars, resolveFormatters } from './resolve';
+import { runActions, ActionContext } from './actions';
+import { salesCanvas } from './fixtures/salesCanvas';
+import { CanvasDefinition } from './types';
+
+const clone = (): CanvasDefinition => JSON.parse(JSON.stringify(salesCanvas));
+
+test('the demo canvas is valid', () => {
+ expect(validateCanvas(salesCanvas)).toEqual({ valid: true, errors: [] });
+});
+
+test('rejects an executable string in an echarts option (no-code invariant)', () => {
+ const def = clone();
+ const chart = def.tree.children![2] as { option: Record<string, unknown> };
+ chart.option.tooltip = { formatter: '(v) => v.toFixed(2)' };
+ const result = validateCanvas(def);
+ expect(result.valid).toBe(false);
+ expect(
+ result.errors.some(e =>
+ /formatter must be a declarative object/.test(e.message),
+ ),
+ ).toBe(true);
+});
+
+test('rejects a reference to an undeclared variable', () => {
+ const def = clone();
+ const select = def.tree.children![1].children![0] as {
+ bind: Record<string, string>;
+ };
+ select.bind.value = '$undeclared';
+ const result = validateCanvas(def);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => /undeclared variable/.test(e.message))).toBe(
+ true,
+ );
+});
+
+test('rejects a javascript: url in openUrl', () => {
+ const def = clone();
+ def.tree.children![1].children![1].on = {
+ // eslint-disable-next-line no-script-url
+ click: [{ action: 'openUrl', url: 'javascript:alert(1)' }],
+ };
+ const result = validateCanvas(def);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => /http\(s\) only/.test(e.message))).toBe(true);
+});
+
+test('rejects children on a non-container node', () => {
+ const def = clone();
+ (def.tree.children![0] as { children: unknown[] }).children = [
+ { id: 'x', type: 'Markdown', props: { text: 'nope' } },
+ ];
+ const result = validateCanvas(def);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => /cannot have children/.test(e.message))).toBe(
+ true,
+ );
+});
+
+test('accepts the Alert/Progress/Collapse/Modal nodes and modal actions', () => {
+ const definition: CanvasDefinition = {
+ cdlVersion: 2,
+ variables: { goal: { type: 'number', default: 60, scope: 'ui' } },
+ tree: {
+ id: 'root',
+ type: 'Column',
+ children: [
+ {
+ id: 'a',
+ type: 'Alert',
+ props: { message: 'Partial year', type: 'warning' },
+ },
+ { id: 'p', type: 'Progress', bind: { value: '$goal' } },
+ {
+ id: 'c',
+ type: 'Collapse',
+ children: [
+ {
+ id: 'sec',
+ type: 'Column',
+ props: { label: 'Notes' },
+ children: [{ id: 'm', type: 'Markdown', props: { text: 'hi' } }],
+ },
+ ],
+ },
+ {
+ id: 'btn',
+ type: 'Button',
+ props: { label: 'Details' },
+ on: { click: [{ action: 'openModal', modalId: 'dlg' }] },
+ },
+ {
+ id: 'dlg',
+ type: 'Modal',
+ props: { title: 'Detail' },
+ children: [{ id: 'm2', type: 'Markdown', props: { text: 'drill' } }],
+ },
+ ],
+ },
+ };
+ expect(validateCanvas(definition)).toEqual({ valid: true, errors: [] });
+});
+
+test('openModal without a modalId is rejected', () => {
+ const definition = {
+ cdlVersion: 2,
+ variables: {},
+ tree: {
+ id: 'b',
+ type: 'Button',
+ on: { click: [{ action: 'openModal' }] },
+ },
+ } as unknown as CanvasDefinition;
+ const result = validateCanvas(definition);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => /missing "modalId"/.test(e.message))).toBe(
+ true,
+ );
+});
+
+test('accepts a Board with layout placement and freeform styles', () => {
+ const definition: CanvasDefinition = {
+ cdlVersion: 2,
+ variables: {},
+ tree: {
+ id: 'board',
+ type: 'Board',
+ props: { columns: 12, rowHeight: 40 },
+ children: [
+ {
+ id: 'bg',
+ type: 'Markdown',
+ props: { text: 'behind' },
+ layout: { x: 0, y: 0, w: 8, h: 4 },
+ },
+ {
+ id: 'front',
+ type: 'Markdown',
+ props: { text: 'over it, tilted' },
+ layout: { x: 5, y: 1, w: 5, h: 3, z: 2 },
+ style: { position: 'relative', transform: 'rotate(-4deg)' },
+ },
+ ],
+ },
+ };
+ expect(validateCanvas(definition)).toEqual({ valid: true, errors: [] });
+});
+
+test('rejects a malformed Board layout', () => {
+ const definition = {
+ cdlVersion: 2,
+ variables: {},
+ tree: {
+ id: 'board',
+ type: 'Board',
+ children: [
+ {
+ id: 'x',
+ type: 'Markdown',
+ props: { text: 'a' },
+ layout: { x: 0, y: 0, w: 0, h: 'tall' },
+ },
+ ],
+ },
+ } as unknown as CanvasDefinition;
+ const result = validateCanvas(definition);
+ expect(result.valid).toBe(false);
+ expect(result.errors.some(e => /w must be at least 1/.test(e.message))).toBe(
+ true,
+ );
+ expect(result.errors.some(e => /h must be a number/.test(e.message))).toBe(
+ true,
+ );
+});
+
+test('resolveVars substitutes $var references deeply', () => {
+ const resolved = resolveVars(
+ { filters: [{ col: 'region', val: '$region' }], keep: 1 },
+ { region: 'EMEA' },
+ );
+ expect(resolved).toEqual({
+ filters: [{ col: 'region', val: 'EMEA' }],
+ keep: 1,
+ });
+});
+
+test('encodeToOption maps query results onto series data', () => {
+ const option = encodeToOption(
+ { series: [{ type: 'bar' }] },
+ { x: 'month', y: 'sales' },
+ {
+ columns: ['month', 'sales'],
+ records: [
+ { month: 'Jan', sales: 10 },
+ { month: 'Feb', sales: 20 },
+ ],
+ },
+ );
+ expect(option.xAxis).toEqual({ type: 'category', data: ['Jan', 'Feb'] });
+ expect(option.series).toEqual([
+ { type: 'bar', name: 'sales', data: [10, 20] },
+ ]);
+});
+
+test('encodeToOption shapes pie data as name/value and drops axes', () => {
+ const option = encodeToOption(
+ { series: [{ type: 'pie' }] },
+ { x: 'genre', y: 'sales' },
+ {
+ columns: ['genre', 'sales'],
+ records: [
+ { genre: 'Action', sales: 5 },
+ { genre: 'Sports', sales: 7 },
+ ],
+ },
+ );
+ expect(option.series).toEqual([
+ {
+ type: 'pie',
+ name: 'sales',
+ data: [
+ { name: 'Action', value: 5 },
+ { name: 'Sports', value: 7 },
+ ],
+ },
+ ]);
+ expect(option.xAxis).toBeUndefined();
+});
+
+test('encodeToOption shapes scatter data as [x, y, label] pairs', () => {
+ const option = encodeToOption(
+ { series: [{ type: 'scatter' }] },
+ { x: 'publisher', y: ['na', 'eu'] },
+ {
+ columns: ['publisher', 'na', 'eu'],
+ records: [{ publisher: 'Nintendo', na: 1, eu: 2 }],
+ },
+ );
+ const series = option.series as Array<{ data: unknown[] }>;
+ expect(series[0].data).toEqual([[1, 2, 'Nintendo']]);
+});
+
+test('encodeToOption builds radar indicators from categories', () => {
+ const option = encodeToOption(
+ { series: [{ type: 'radar' }] },
+ { x: 'genre', y: 'sales' },
+ {
+ columns: ['genre', 'sales'],
+ records: [
+ { genre: 'Action', sales: 5 },
+ { genre: 'Sports', sales: 9 },
+ ],
+ },
+ );
+ const radar = option.radar as { indicator: Array<{ name: string }> };
+ expect(radar.indicator.map(i => i.name)).toEqual(['Action', 'Sports']);
+ const series = option.series as Array<{ data: Array<{ value: number[] }> }>;
+ expect(series[0].data[0].value).toEqual([5, 9]);
+});
+
+test('resolveFormatters turns a declarative spec into a function', () => {
+ const resolved = resolveFormatters({
+ tooltip: { valueFormatter: { kind: 'currency', currency: 'USD' } },
+ }) as { tooltip: { valueFormatter: (v: unknown) => string } };
+ const fn = resolved.tooltip.valueFormatter;
+ expect(typeof fn).toBe('function');
+ expect(fn(1000)).toMatch(/\$1,000/);
+});
+
+test('runActions dispatches setVariable with the event value', () => {
+ const writes: Array<[string, unknown]> = [];
+ const ctx: ActionContext = {
+ vars: {},
+ setVariable: (name, value) => writes.push([name, value]),
+ applyFilter: () => {},
+ crossFilter: () => {},
+ clearFilters: () => {},
+ navigateTab: () => {},
+ setModalOpen: () => {},
+ refresh: () => {},
+ eventValue: 'EMEA',
+ };
+ runActions([{ action: 'setVariable', name: 'region', value: '$event' }], ctx);
+ expect(writes).toEqual([['region', 'EMEA']]);
+});
diff --git a/superset-frontend/src/Canvas/fixtures/salesCanvas.ts b/superset-frontend/src/Canvas/fixtures/salesCanvas.ts
new file mode 100644
index 00000000000..48cef3cf052
--- /dev/null
+++ b/superset-frontend/src/Canvas/fixtures/salesCanvas.ts
@@ -0,0 +1,125 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CanvasDefinition, CdlQueryContext } from '../types';
+import { QueryResult } from '../resolve';
+import { QueryRunner } from '../runtime';
+
+/**
+ * Demo canvas: a Region select two-way bound to `$region`, a Reset button, and
+ * a bound echarts line chart whose query filters on `$region`. Changing the
+ * select re-runs the query — the reactive loop the prototype proves.
+ */
+export const salesCanvas: CanvasDefinition = {
+ cdlVersion: 2,
+ variables: {
+ region: { type: 'string', default: 'APAC', scope: 'query' },
+ },
+ tree: {
+ id: 'root',
+ type: 'Column',
+ children: [
+ {
+ id: 'title',
+ type: 'Markdown',
+ props: { text: 'Monthly sales by region' },
+ },
+ {
+ id: 'controls',
+ type: 'Row',
+ children: [
+ {
+ id: 'region-select',
+ type: 'Select',
+ props: {
+ label: 'Region',
+ options: [
+ { value: 'APAC', label: 'APAC' },
+ { value: 'EMEA', label: 'EMEA' },
+ { value: 'AMER', label: 'AMER' },
+ ],
+ },
+ bind: { value: '$region' },
+ },
+ {
+ id: 'reset',
+ type: 'Button',
+ props: { children: 'Reset', buttonStyle: 'secondary' },
+ on: {
+ click: [{ action: 'setVariable', name: 'region', value: 'APAC' }],
+ },
+ },
+ ],
+ },
+ {
+ id: 'chart',
+ type: 'Viz',
+ renderer: 'echarts',
+ data: {
+ queryContext: {
+ datasetId: 1,
+ metrics: ['SUM(sales)'],
+ groupby: ['month'],
+ filters: [{ col: 'region', op: '==', val: '$region' }],
+ },
+ encoding: { x: 'month', y: 'SUM(sales)' },
+ },
+ option: {
+ series: [{ type: 'line', smooth: true }],
+ tooltip: {
+ trigger: 'axis',
+ valueFormatter: { kind: 'currency', currency: 'USD' },
+ },
+ yAxis: {
+ type: 'value',
+ axisLabel: { formatter: { kind: 'currency', currency: 'USD' } },
+ },
+ },
+ },
+ ],
+ },
+};
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+const SERIES_BY_REGION: Record<string, number[]> = {
+ APAC: [120, 132, 101, 134, 90, 230],
+ EMEA: [220, 182, 191, 234, 290, 330],
+ AMER: [150, 232, 201, 154, 190, 330],
+};
+
+/** In-memory QueryRunner standing in for /api/v1/chart/data during the prototype. */
+export function createMockRunner(delayMs = 0): QueryRunner {
+ return {
+ run: (queryContext: CdlQueryContext): Promise<QueryResult> => {
+ const regionFilter = queryContext.filters?.find(f => f.col === 'region');
+ const region = String(regionFilter?.val ?? 'APAC');
+ const values = SERIES_BY_REGION[region] ?? SERIES_BY_REGION.APAC;
+ const records = MONTHS.map((month, i) => ({
+ month,
+ 'SUM(sales)': values[i] * 1000,
+ }));
+ return new Promise(resolve => {
+ setTimeout(
+ () => resolve({ columns: ['month', 'SUM(sales)'], records }),
+ delayMs,
+ );
+ });
+ },
+ };
+}
diff --git a/superset-frontend/src/Canvas/index.ts b/superset-frontend/src/Canvas/index.ts
new file mode 100644
index 00000000000..0c63f5d9b15
--- /dev/null
+++ b/superset-frontend/src/Canvas/index.ts
@@ -0,0 +1,28 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { CanvasRenderer } from './CanvasRenderer';
+export type { CanvasRendererProps } from './CanvasRenderer';
+export { validateCanvas } from './validator';
+export type { ValidationError, ValidationResult } from './validator';
+export { MANIFEST } from './manifest';
+export { NODE_CATALOG } from './catalog';
+export type { QueryRunner } from './runtime';
+export { createSupersetQueryRunner } from './queryRunner';
+export * from './types';
diff --git a/superset-frontend/src/Canvas/manifest.tsx b/superset-frontend/src/Canvas/manifest.tsx
new file mode 100644
index 00000000000..c2854616de4
--- /dev/null
+++ b/superset-frontend/src/Canvas/manifest.tsx
@@ -0,0 +1,468 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, FC, ReactNode } from 'react';
+import { styled } from '@apache-superset/core/theme';
+import {
+ Button,
+ Card,
+ Collapse,
+ Divider,
+ Input,
+ Modal,
+ Progress,
+ SafeMarkdown,
+ Select,
+ Switch,
+ Tabs,
+} from '@superset-ui/core/components';
+import { Alert } from '@apache-superset/core/components';
+import { CatalogEntry, NODE_CATALOG } from './catalog';
+import { CdlFilter, CdlNode, Primitive, isVizNode } from './types';
+import { EchartsViz } from './EchartsViz';
+import { FilterControl } from './FilterControl';
+import { SupersetChartViz } from './SupersetChartViz';
+import { useUiState } from './runtime';
+
+/**
+ * The manifest maps a catalog type to a React adapter. Adapters are thin: the
+ * renderer resolves props/bindings/events generically and hands them here; each
+ * adapter only knows how to wire the normalized inputs onto its component.
+ */
+export interface AdapterProps {
+ node: CdlNode;
+ resolvedProps: Record<string, unknown>;
+ children?: ReactNode;
+ /** Run the node's declarative handler list for an event. */
+ fire: (event: string, value?: Primitive) => void;
+ /** Current value of a two-way-bound prop (from the variable store). */
+ getBound: (prop: string) => Primitive | undefined;
+ /** Write a two-way-bound prop back to the variable store. */
+ setBound: (prop: string, value: Primitive) => void;
+ /**
+ * Render a specific child node. Containers that need structural control over
+ * their children (e.g. Tabs) use this instead of the flat `children`.
+ */
+ renderNode: (child: CdlNode) => ReactNode;
+ /** Resolved inline styling from the node's `style` (theme tokens applied). */
+ style?: CSSProperties;
+}
+
+export interface ManifestEntry {
+ component: FC<AdapterProps>;
+ catalog: CatalogEntry;
+}
+
+const Column = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.sizeUnit * 3}px;
+ width: 100%;
+ /* padding must not widen the box (no global reset in this codebase) */
+ box-sizing: border-box;
+ min-width: 0;
+`;
+
+const Row = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: ${({ theme }) => theme.sizeUnit * 3}px;
+ width: 100%;
+ box-sizing: border-box;
+ min-width: 0;
+ /* Wrap rather than overflow on narrow viewports. */
+ flex-wrap: wrap;
+ & > * {
+ flex: 1 1 0;
+ min-width: 0;
+ }
+`;
+
+const ColumnAdapter: FC<AdapterProps> = ({ children, style }) => (
+ <Column style={style}>{children}</Column>
+);
+
+const RowAdapter: FC<AdapterProps> = ({ children, style }) => (
+ <Row style={style}>{children}</Row>
+);
+
+interface Option {
+ value: string | number;
+ label: string;
+}
+
+const SelectAdapter: FC<AdapterProps> = ({
+ resolvedProps,
+ fire,
+ getBound,
+ setBound,
+ style,
+}) => {
+ const bound = getBound('value');
+ const value = bound ?? (resolvedProps.value as Primitive | undefined);
+ const options = (resolvedProps.options as Option[]) ?? [];
+ return (
+ <div style={style}>
+ <Select
+ ariaLabel={(resolvedProps.label as string) ?? 'Select'}
+ header={resolvedProps.label as ReactNode}
+ options={options}
+ value={(value ?? null) as string | number | null}
+ onChange={(next: unknown) => {
+ const val = next as Primitive;
+ setBound('value', val);
+ fire('change', val);
+ }}
+ />
+ </div>
+ );
+};
+
+// Agents label controls under varying keys — accept the common ones.
+const firstText = (
+ props: Record<string, unknown>,
+ keys: string[],
+): ReactNode => {
+ const key = keys.find(k => props[k] != null);
+ return key ? (props[key] as ReactNode) : undefined;
+};
+
+const ButtonAdapter: FC<AdapterProps> = ({ resolvedProps, fire, style }) => (
+ <Button
+ style={style}
+ buttonStyle={
+ (resolvedProps.buttonStyle as 'primary' | 'secondary') ?? 'secondary'
+ }
+ buttonSize="small"
+ onClick={() => fire('click')}
+ >
+ {firstText(resolvedProps, [
+ 'children',
+ 'label',
+ 'text',
+ 'title',
+ 'content',
+ ]) ?? 'Button'}
+ </Button>
+);
+
+const MarkdownAdapter: FC<AdapterProps> = ({ resolvedProps, style }) => {
+ const source = firstText(resolvedProps, [
+ 'text',
+ 'source',
+ 'content',
+ 'markdown',
+ ]);
+ return (
+ <div
+ data-test="canvas-markdown"
+ // SafeMarkdown wraps text in <p>/<h*> with default outer margins, which
+ // inflates a node's height — in a fixed-height Board that overflows into
+ // the cell below. Collapse the leading/trailing margins; inter-paragraph
+ // spacing (for narrative docs) is preserved.
+ css={{
+ '& > :first-child': { marginTop: 0 },
+ '& > :last-child': { marginBottom: 0 },
+ }}
+ style={style}
+ >
+ <SafeMarkdown source={source == null ? '' : String(source)} />
+ </div>
+ );
+};
+
+const InlineControl = styled.div`
+ display: flex;
+ align-items: center;
+ gap: ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+const CardAdapter: FC<AdapterProps> = ({ resolvedProps, children, style }) => (
+ <Card
+ title={firstText(resolvedProps, ['title', 'label', 'header'])}
+ padded
+ style={style}
+ >
+ {children}
+ </Card>
+);
+
+const DividerAdapter: FC<AdapterProps> = ({ style }) => (
+ <Divider style={style} />
+);
+
+/** Tabs owns its children structurally, so it renders them via `renderNode`. */
+const TabsAdapter: FC<AdapterProps> = ({
+ node,
+ resolvedProps,
+ renderNode,
+ style,
+}) => {
+ const { activeTabs, setActiveTab } = useUiState();
+ const tabNodes = (node.children ?? []).filter(child => child.type === 'Tab');
+ const defaultKey = (resolvedProps.defaultTab as string) ?? tabNodes[0]?.id;
+ const activeKey = activeTabs[node.id] ?? defaultKey;
+ return (
+ <Tabs
+ style={style}
+ activeKey={activeKey}
+ onChange={key => setActiveTab(node.id, key)}
+ items={tabNodes.map(tab => ({
+ key: tab.id,
+ label: String(
+ firstText(tab.props ?? {}, ['label', 'title', 'text']) ?? tab.id,
+ ),
+ children: renderNode(tab),
+ }))}
+ />
+ );
+};
+
+const TabAdapter: FC<AdapterProps> = ({ children, style }) => (
+ <Column style={style}>{children}</Column>
+);
+
+/**
+ * Freeform layout: children are placed by their `layout` {x,y,w,h,z} on a
+ * `columns`-wide grid of `rowHeight`px rows. Overlap is allowed (grid areas can
+ * share cells), which is why `z` exists. Responsive — columns are fractions.
+ */
+const BoardAdapter: FC<AdapterProps> = ({
+ node,
+ resolvedProps,
+ renderNode,
+ style,
+}) => {
+ const columns = Number(resolvedProps.columns ?? 12);
+ const rowHeight = Number(resolvedProps.rowHeight ?? 40);
+ const gap = Number(resolvedProps.gap ?? 8);
+ return (
+ <div
+ style={{
+ display: 'grid',
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
+ // rowHeight is a MINIMUM, not a cap: rows grow to fit content so an
+ // over-tall cell pushes the layout down instead of overlapping.
+ gridAutoRows: `minmax(${rowHeight}px, auto)`,
+ gap,
+ width: '100%',
+ ...style,
+ }}
+ >
+ {(node.children ?? []).map(child => {
+ const l = child.layout;
+ // display:grid makes the single child stretch to fill the cell in both
+ // dimensions, so a Viz with height:100% fills its board cell.
+ const cell: CSSProperties = l
+ ? {
+ gridColumn: `${l.x + 1} / span ${l.w}`,
+ gridRow: `${l.y + 1} / span ${l.h}`,
+ zIndex: l.z,
+ display: 'grid',
+ minWidth: 0,
+ minHeight: 0,
+ }
+ : { display: 'grid' };
+ return (
+ <div key={child.id} style={cell}>
+ {renderNode(child)}
+ </div>
+ );
+ })}
+ </div>
+ );
+};
+
+/** Narrative callouts — caveats, data-quality notes, "read this first". */
+const AlertAdapter: FC<AdapterProps> = ({ resolvedProps, style }) => (
+ <Alert
+ style={style}
+ type={
+ (resolvedProps.type as 'info' | 'success' | 'warning' | 'error') ?? 'info'
+ }
+ message={firstText(resolvedProps, ['message', 'title', 'text', 'label'])}
+ description={resolvedProps.description as ReactNode}
+ showIcon={resolvedProps.showIcon !== false}
+ closable={Boolean(resolvedProps.closable)}
+ />
+);
+
+/** Goal / target tracking — value is bindable to a variable. */
+const ProgressAdapter: FC<AdapterProps> = ({
+ resolvedProps,
+ getBound,
+ style,
+}) => {
+ const bound = getBound('value');
+ const percent = Number(bound ?? resolvedProps.value ?? 0);
+ return (
+ <div style={style}>
+ {resolvedProps.label != null && <div>{String(resolvedProps.label)}</div>}
+ <Progress
+ percent={Number.isFinite(percent) ? percent : 0}
+ type={(resolvedProps.type as 'line' | 'circle' | 'dashboard') ?? 'line'}
+ status={
+ resolvedProps.status as
+ 'success' | 'exception' | 'active' | 'normal' | undefined
+ }
+ strokeColor={resolvedProps.strokeColor as string | undefined}
+ />
+ </div>
+ );
+};
+
+/** Collapsible sections — each child becomes a panel titled by its label. */
+const CollapseAdapter: FC<AdapterProps> = ({ node, renderNode, style }) => {
+ const sections = node.children ?? [];
+ return (
+ <Collapse
+ style={style}
+ ghost
+ items={sections.map(section => ({
+ key: section.id,
+ label: String(
+ firstText(section.props ?? {}, ['label', 'title', 'text']) ??
+ section.id,
+ ),
+ children: renderNode(section),
+ }))}
+ />
+ );
+};
+
+/**
+ * A drill-in panel: hidden until an `openModal` action targets it, so a button
+ * can reveal detail charts without leaving the canvas.
+ */
+const ModalAdapter: FC<AdapterProps> = ({ node, resolvedProps, children }) => {
+ const { openModals, setModalOpen } = useUiState();
+ return (
+ <Modal
+ show={Boolean(openModals[node.id])}
+ onHide={() => setModalOpen(node.id, false)}
+ title={firstText(resolvedProps, ['title', 'label', 'text'])}
+ footer={null}
+ width={resolvedProps.width as string | number | undefined}
+ hideFooter
+ >
+ {children}
+ </Modal>
+ );
+};
+
+const InputAdapter: FC<AdapterProps> = ({
+ resolvedProps,
+ fire,
+ getBound,
+ setBound,
+ style,
+}) => {
+ const bound = getBound('value');
+ const value = String(bound ?? (resolvedProps.value as Primitive) ?? '');
+ return (
+ <InlineControl style={style}>
+ {resolvedProps.label != null && (
+ <span>{String(resolvedProps.label)}</span>
+ )}
+ <Input
+ placeholder={resolvedProps.placeholder as string | undefined}
+ value={value}
+ onChange={event => {
+ const next = event.target.value;
+ setBound('value', next);
+ fire('change', next);
+ }}
+ />
+ </InlineControl>
+ );
+};
+
+const SwitchAdapter: FC<AdapterProps> = ({
+ resolvedProps,
+ fire,
+ getBound,
+ setBound,
+ style,
+}) => {
+ const bound = getBound('value');
+ const checked = Boolean(bound ?? resolvedProps.value);
+ return (
+ <InlineControl style={style}>
+ <Switch
+ checked={checked}
+ onChange={(next: boolean) => {
+ setBound('value', next);
+ fire('change', next);
+ }}
+ />
+ {resolvedProps.label != null && (
+ <span>{String(resolvedProps.label)}</span>
+ )}
+ </InlineControl>
+ );
+};
+
+interface FilterOption {
+ value: string | number;
+ label: string;
+}
+
+const FilterAdapter: FC<AdapterProps> = ({ node, resolvedProps, style }) => (
+ <FilterControl
+ style={style}
+ filterId={node.id}
+ column={resolvedProps.column as string}
+ datasetId={resolvedProps.dataset as number | undefined}
+ label={resolvedProps.label as string | undefined}
+ multiple={Boolean(resolvedProps.multiple)}
+ op={resolvedProps.op as CdlFilter['op'] | undefined}
+ options={resolvedProps.options as FilterOption[] | undefined}
+ />
+);
+
+const VizAdapter: FC<AdapterProps> = ({ node, style }) => {
+ if (!isVizNode(node)) {
+ return null;
+ }
+ if (node.renderer === 'echarts') {
+ return <EchartsViz node={node} style={style} />;
+ }
+ return <SupersetChartViz node={node} style={style} />;
+};
+
+export const MANIFEST: Record<string, ManifestEntry> = {
+ Column: { component: ColumnAdapter, catalog: NODE_CATALOG.Column },
+ Row: { component: RowAdapter, catalog: NODE_CATALOG.Row },
+ Card: { component: CardAdapter, catalog: NODE_CATALOG.Card },
+ Tabs: { component: TabsAdapter, catalog: NODE_CATALOG.Tabs },
+ Tab: { component: TabAdapter, catalog: NODE_CATALOG.Tab },
+ Board: { component: BoardAdapter, catalog: NODE_CATALOG.Board },
+ Input: { component: InputAdapter, catalog: NODE_CATALOG.Input },
+ Switch: { component: SwitchAdapter, catalog: NODE_CATALOG.Switch },
+ Divider: { component: DividerAdapter, catalog: NODE_CATALOG.Divider },
+ Alert: { component: AlertAdapter, catalog: NODE_CATALOG.Alert },
+ Progress: { component: ProgressAdapter, catalog: NODE_CATALOG.Progress },
+ Collapse: { component: CollapseAdapter, catalog: NODE_CATALOG.Collapse },
+ Modal: { component: ModalAdapter, catalog: NODE_CATALOG.Modal },
+ Select: { component: SelectAdapter, catalog: NODE_CATALOG.Select },
+ Button: { component: ButtonAdapter, catalog: NODE_CATALOG.Button },
+ Filter: { component: FilterAdapter, catalog: NODE_CATALOG.Filter },
+ Markdown: { component: MarkdownAdapter, catalog: NODE_CATALOG.Markdown },
+ Viz: { component: VizAdapter, catalog: NODE_CATALOG.Viz },
+};
diff --git a/superset-frontend/src/Canvas/queryRunner.ts b/superset-frontend/src/Canvas/queryRunner.ts
new file mode 100644
index 00000000000..4aaa8579dd6
--- /dev/null
+++ b/superset-frontend/src/Canvas/queryRunner.ts
@@ -0,0 +1,118 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SupersetClient } from '@superset-ui/core';
+import { CdlQueryContext, Primitive } from './types';
+import { QueryResult } from './resolve';
+import { QueryRunner } from './runtime';
+
+type AdhocMetric = {
+ expressionType: 'SQL';
+ sqlExpression: string;
+ label: string;
+ hasCustomLabel: boolean;
+};
+
+/**
+ * A metric string is either a saved-metric name or a SQL aggregate expression
+ * (e.g. `SUM(sales)`). The latter becomes an adhoc SQL metric so the demo works
+ * whether the agent used a saved metric or an inline expression.
+ */
+function toMetric(metric: string): string | AdhocMetric {
+ if (/[()]/.test(metric)) {
+ return {
+ expressionType: 'SQL',
+ sqlExpression: metric,
+ label: metric,
+ hasCustomLabel: false,
+ };
+ }
+ return metric;
+}
+
+function toQueryContext(qc: CdlQueryContext): Record<string, unknown> {
+ return {
+ datasource: { id: qc.datasetId, type: 'table' },
+ force: false,
+ queries: [
+ {
+ columns: qc.groupby ?? [],
+ metrics: qc.metrics.map(toMetric),
+ filters: (qc.filters ?? []).map(f => ({
+ col: f.col,
+ op: f.op,
+ val: f.val,
+ })),
+ row_limit: qc.rowLimit ?? 1000,
+ // query_context orderby is [[metric-or-column, isAscending], ...];
+ // a metric must be the same shape as it appears in `metrics`.
+ orderby: (qc.orderby ?? []).map(({ by, desc }) => [
+ qc.metrics.includes(by) ? toMetric(by) : by,
+ !desc,
+ ]),
+ },
+ ],
+ result_format: 'json',
+ result_type: 'full',
+ };
+}
+
+interface ChartDataPayload {
+ result?: Array<{
+ data?: Array<Record<string, Primitive>>;
+ colnames?: string[];
+ error?: string;
+ status?: string;
+ }>;
+ message?: string;
+}
+
+/**
+ * The real QueryRunner: bound queries go through the governed /api/v1/chart/data
+ * endpoint, inheriting RLS, row limits, and caching. Used by the Canvas viewer.
+ */
+export function createSupersetQueryRunner(): QueryRunner {
+ return {
+ run: async (queryContext: CdlQueryContext): Promise<QueryResult> => {
+ const { json } = await SupersetClient.post({
+ endpoint: '/api/v1/chart/data',
+ jsonPayload: toQueryContext(queryContext),
+ });
+ const payload = json as ChartDataPayload;
+ const first = payload.result?.[0];
+ // The endpoint returns HTTP 200 even when a query fails — the real
+ // message is per-query, so surface it instead of showing "No data".
+ // When the shape is unexpected (async job, error envelope, …) echo the
+ // raw body so we can see exactly what came back.
+ if (!first) {
+ throw new Error(
+ payload.message ??
+ `unexpected response: ${JSON.stringify(json).slice(0, 400)}`,
+ );
+ }
+ if (first.error || first.status === 'failed') {
+ throw new Error(first.error ?? 'query failed');
+ }
+ const records = first.data ?? [];
+ const columns =
+ first.colnames ?? (records[0] ? Object.keys(records[0]) : []);
+ return { columns, records };
+ },
+ };
+}
diff --git a/superset-frontend/src/Canvas/resolve.ts b/superset-frontend/src/Canvas/resolve.ts
new file mode 100644
index 00000000000..bae0cda5816
--- /dev/null
+++ b/superset-frontend/src/Canvas/resolve.ts
@@ -0,0 +1,359 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ Encoding,
+ EncodingShape,
+ Formatter,
+ Primitive,
+ VariableValues,
+} from './types';
+
+const VAR_REF = /^\$([A-Za-z_][\w]*)$/;
+
+export const isVarRef = (v: unknown): v is string =>
+ typeof v === 'string' && VAR_REF.test(v);
+
+/** Deep-substitute every `$var` reference in a value with its current value. */
+export function resolveVars<T>(value: T, vars: VariableValues): T {
+ if (isVarRef(value)) {
+ return vars[value.slice(1)] as unknown as T;
+ }
+ if (Array.isArray(value)) {
+ return value.map(v => resolveVars(v, vars)) as unknown as T;
+ }
+ if (value && typeof value === 'object') {
+ const out: Record<string, unknown> = {};
+ Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
+ out[k] = resolveVars(v, vars);
+ });
+ return out as unknown as T;
+ }
+ return value;
+}
+
+export interface QueryResult {
+ columns: string[];
+ records: Array<Record<string, Primitive>>;
+}
+
+const distinct = (values: string[]): string[] => Array.from(new Set(values));
+const toNumber = (v: unknown): number =>
+ typeof v === 'number' ? v : Number(v);
+
+/**
+ * Resolve a requested column name against what the query actually returned.
+ * The AI's `encoding` label (e.g. `SUM(global_sales)`) may not exactly match
+ * the response column, so fall back to a case-insensitive match, a substring
+ * match, or — if only one measure column remains — that column.
+ */
+function resolveColumn(
+ requested: string,
+ columns: string[],
+ exclude: string[],
+): string {
+ if (columns.includes(requested)) {
+ return requested;
+ }
+ const lower = requested.toLowerCase();
+ const caseInsensitive = columns.find(c => c.toLowerCase() === lower);
+ if (caseInsensitive) {
+ return caseInsensitive;
+ }
+ const candidates = columns.filter(c => !exclude.includes(c));
+ const substring = candidates.find(
+ c => c.toLowerCase().includes(lower) || lower.includes(c.toLowerCase()),
+ );
+ if (substring) {
+ return substring;
+ }
+ if (candidates.length === 1) {
+ return candidates[0];
+ }
+ return requested;
+}
+
+const NAME_VALUE_TYPES = new Set([
+ 'pie',
+ 'funnel',
+ 'treemap',
+ 'sunburst',
+ 'gauge',
+]);
+
+function firstSeriesType(
+ baseOption: Record<string, unknown>,
+): string | undefined {
+ const { series } = baseOption;
+ const first = Array.isArray(series) ? series[0] : series;
+ const type = (first as Record<string, unknown> | undefined)?.type;
+ return typeof type === 'string' ? type : undefined;
+}
+
+/** Infer the data shape from the series type unless the encoding overrides it. */
+function detectShape(
+ baseOption: Record<string, unknown>,
+ encoding: Encoding,
+): EncodingShape {
+ if (encoding.shape) {
+ return encoding.shape;
+ }
+ const type = firstSeriesType(baseOption);
+ if (type === 'scatter' || type === 'effectScatter') {
+ return 'pairs';
+ }
+ if (type === 'heatmap') {
+ return 'matrix';
+ }
+ if (type === 'radar') {
+ return 'radar';
+ }
+ if (type && NAME_VALUE_TYPES.has(type)) {
+ return 'nameValue';
+ }
+ return 'categoryValue';
+}
+
+/**
+ * Map a query result onto an echarts option using the declarative `encoding`.
+ * The base `option` supplies presentation (series type, axis config); we fill in
+ * `xAxis.data` and `series[].data`.
+ */
+export function encodeToOption(
+ baseOption: Record<string, unknown>,
+ encoding: Encoding,
+ result: QueryResult,
+): Record<string, unknown> {
+ const { x, y, series } = encoding;
+ const rows = result.records;
+ const columns = result.columns.length
+ ? result.columns
+ : Object.keys(rows[0] ?? {});
+
+ // Reconcile the encoding's labels with the actual result columns.
+ const xKey = resolveColumn(x, columns, []);
+ const seriesKey = series ? resolveColumn(series, columns, [xKey]) : undefined;
+ const exclude = seriesKey ? [xKey, seriesKey] : [xKey];
+ const metricKeys = (Array.isArray(y) ? y : [y]).map(metric =>
+ resolveColumn(metric, columns, exclude),
+ );
+
+ const categories = distinct(rows.map(r => String(r[xKey])));
+
+ const baseSeriesList = Array.isArray(baseOption.series)
+ ? (baseOption.series as Array<Record<string, unknown>>)
+ : [(baseOption.series as Record<string, unknown>) ?? {}];
+ const withBase = (index: number, spec: Record<string, unknown>) => ({
+ ...baseSeriesList[index % baseSeriesList.length],
+ ...spec,
+ });
+ const axis = (key: 'xAxis' | 'yAxis') =>
+ (baseOption[key] as Record<string, unknown>) ?? {};
+ const valueAt = (cat: string, metric: string): number | null => {
+ const row = rows.find(r => String(r[xKey]) === cat);
+ return row ? toNumber(row[metric]) : null;
+ };
+
+ switch (detectShape(baseOption, encoding)) {
+ // pie / funnel / treemap / sunburst / gauge — no axes, [{name, value}]
+ case 'nameValue': {
+ const metric = metricKeys[0];
+ return {
+ ...baseOption,
+ series: [
+ withBase(0, {
+ name: metric,
+ data: categories.map(cat => ({
+ name: cat,
+ value: valueAt(cat, metric),
+ })),
+ }),
+ ],
+ };
+ }
+ // scatter — [[xMetric, yMetric, categoryLabel]]
+ case 'pairs': {
+ const [mx, my = metricKeys[0]] = metricKeys;
+ return {
+ ...baseOption,
+ xAxis: { type: 'value', ...axis('xAxis') },
+ yAxis: { type: 'value', ...axis('yAxis') },
+ series: [
+ withBase(0, {
+ name: `${mx} vs ${my}`,
+ data: rows.map(r => [
+ toNumber(r[mx]),
+ toNumber(r[my]),
+ String(r[xKey]),
+ ]),
+ }),
+ ],
+ };
+ }
+ // heatmap — [[xIndex, yIndex, value]] across x and the series column
+ case 'matrix': {
+ const metric = metricKeys[0];
+ const yCats = seriesKey
+ ? distinct(rows.map(r => String(r[seriesKey])))
+ : [];
+ return {
+ ...baseOption,
+ xAxis: { type: 'category', ...axis('xAxis'), data: categories },
+ yAxis: { type: 'category', ...axis('yAxis'), data: yCats },
+ series: [
+ withBase(0, {
+ name: metric,
+ data: rows.map(r => [
+ categories.indexOf(String(r[xKey])),
+ seriesKey ? yCats.indexOf(String(r[seriesKey])) : 0,
+ toNumber(r[metric]),
+ ]),
+ }),
+ ],
+ };
+ }
+ // radar — categories become indicators, one ring per metric
+ case 'radar': {
+ const values = metricKeys.flatMap(metric =>
+ categories.map(cat => valueAt(cat, metric) ?? 0),
+ );
+ const max = Math.max(1, ...values);
+ return {
+ ...baseOption,
+ radar: {
+ ...((baseOption.radar as Record<string, unknown>) ?? {}),
+ indicator: categories.map(cat => ({ name: cat, max })),
+ },
+ series: [
+ withBase(0, {
+ type: 'radar',
+ data: metricKeys.map(metric => ({
+ name: metric,
+ value: categories.map(cat => valueAt(cat, metric) ?? 0),
+ })),
+ }),
+ ],
+ };
+ }
+ default:
+ break;
+ }
+
+ let seriesData: Array<{ name: string; data: Array<number | null> }>;
+ if (seriesKey) {
+ const groups = distinct(rows.map(r => String(r[seriesKey])));
+ seriesData = groups.map(group => ({
+ name: group,
+ data: categories.map(cat => {
+ const row = rows.find(
+ r => String(r[xKey]) === cat && String(r[seriesKey]) === group,
+ );
+ return row ? toNumber(row[metricKeys[0]]) : null;
+ }),
+ }));
+ } else {
+ seriesData = metricKeys.map(metric => ({
+ name: metric,
+ data: categories.map(cat => {
+ const row = rows.find(r => String(r[xKey]) === cat);
+ return row ? toNumber(row[metric]) : null;
+ }),
+ }));
+ }
+
+ const baseSeries = Array.isArray(baseOption.series)
+ ? (baseOption.series as Array<Record<string, unknown>>)
+ : [(baseOption.series as Record<string, unknown>) ?? {}];
+ const mergedSeries = seriesData.map((s, i) => ({
+ ...baseSeries[i % baseSeries.length],
+ ...s,
+ }));
+
+ const baseXAxis = (baseOption.xAxis as Record<string, unknown>) ?? {
+ type: 'category',
+ };
+ return {
+ ...baseOption,
+ xAxis: { ...baseXAxis, data: categories },
+ series: mergedSeries,
+ };
+}
+
+const FORMATTER_KINDS = new Set<Formatter['kind']>([
+ 'number',
+ 'currency',
+ 'percent',
+ 'date',
+ 'template',
+]);
+
+function makeFormatter(f: Formatter): (v: unknown) => string {
+ switch (f.kind) {
+ case 'currency': {
+ const nf = new Intl.NumberFormat(undefined, {
+ style: 'currency',
+ currency: f.currency,
+ maximumFractionDigits: f.decimals ?? 0,
+ });
+ return v => nf.format(toNumber(v));
+ }
+ case 'number': {
+ const nf = new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: f.decimals ?? 2,
+ });
+ return v => nf.format(toNumber(v));
+ }
+ case 'percent':
+ return v => `${(toNumber(v) * 100).toFixed(f.decimals ?? 0)}%`;
+ case 'date':
+ return v => String(v);
+ case 'template':
+ return v => f.template.replace('{value}', String(v));
+ default:
+ return v => String(v);
+ }
+}
+
+const isFormatterSpec = (v: unknown): v is Formatter =>
+ !!v &&
+ typeof v === 'object' &&
+ typeof (v as { kind?: unknown }).kind === 'string' &&
+ FORMATTER_KINDS.has((v as Formatter).kind);
+
+/**
+ * Convert declarative formatter objects in an option into real functions — the
+ * only place functions are ever produced, and only at render time from a fixed
+ * vocabulary, never from persisted strings.
+ */
+export function resolveFormatters(value: unknown): unknown {
+ if (isFormatterSpec(value)) {
+ return makeFormatter(value);
+ }
+ if (Array.isArray(value)) {
+ return value.map(resolveFormatters);
+ }
+ if (value && typeof value === 'object') {
+ const out: Record<string, unknown> = {};
+ Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
+ out[k] = resolveFormatters(v);
+ });
+ return out;
+ }
+ return value;
+}
diff --git a/superset-frontend/src/Canvas/runtime.tsx b/superset-frontend/src/Canvas/runtime.tsx
new file mode 100644
index 00000000000..439baa58f0d
--- /dev/null
+++ b/superset-frontend/src/Canvas/runtime.tsx
@@ -0,0 +1,314 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ CdlFilter,
+ CdlQueryContext,
+ Primitive,
+ VariableDecl,
+ VariableValues,
+} from './types';
+import { QueryResult, resolveVars } from './resolve';
+
+/* -------------------------------------------------------------------------- */
+/* Variable store — the reactive spine. */
+/* */
+/* In-app, `query`-scoped variables project onto the host dashboard dataMask */
+/* (governed re-queries, caching, RLS). The prototype keeps them in React */
+/* state; `dataMaskSink` is the seam where the real dataMask dispatch plugs in.*/
+/* -------------------------------------------------------------------------- */
+
+interface VariableStore {
+ vars: VariableValues;
+ decls: Record<string, VariableDecl>;
+ setVariable: (name: string, value: Primitive) => void;
+ reset: () => void;
+}
+
+const VariableContext = createContext<VariableStore | undefined>(undefined);
+
+export interface VariableProviderProps {
+ variables: Record<string, VariableDecl>;
+ children: ReactNode;
+ /** Seam: receives every write so query-scoped vars can be pushed to dataMask. */
+ dataMaskSink?: (
+ name: string,
+ value: Primitive,
+ scope: VariableDecl['scope'],
+ ) => void;
+}
+
+export function VariableProvider({
+ variables,
+ children,
+ dataMaskSink,
+}: VariableProviderProps) {
+ const initial = useMemo<VariableValues>(
+ () =>
+ Object.fromEntries(
+ Object.entries(variables).map(([name, decl]) => [name, decl.default]),
+ ),
+ [variables],
+ );
+ const [vars, setVars] = useState<VariableValues>(initial);
+
+ const setVariable = useCallback(
+ (name: string, value: Primitive) => {
+ setVars(prev => ({ ...prev, [name]: value }));
+ dataMaskSink?.(name, value, variables[name]?.scope ?? 'ui');
+ },
+ [dataMaskSink, variables],
+ );
+
+ const reset = useCallback(() => setVars(initial), [initial]);
+
+ const value = useMemo<VariableStore>(
+ () => ({ vars, decls: variables, setVariable, reset }),
+ [vars, variables, setVariable, reset],
+ );
+
+ return (
+ <VariableContext.Provider value={value}>
+ {children}
+ </VariableContext.Provider>
+ );
+}
+
+export function useVariables(): VariableStore {
+ const ctx = useContext(VariableContext);
+ if (!ctx) {
+ throw new Error('useVariables must be used within a VariableProvider');
+ }
+ return ctx;
+}
+
+/* -------------------------------------------------------------------------- */
+/* Query runner — the data seam. */
+/* */
+/* The prototype injects a runner (mock or real). The real runner maps a */
+/* CdlQueryContext to a Superset query_context and POSTs /api/v1/chart/data, */
+/* inheriting RLS/caching. Bound Viz nodes re-fetch when a referenced */
+/* variable changes. */
+/* -------------------------------------------------------------------------- */
+
+export interface QueryRunner {
+ run: (queryContext: CdlQueryContext) => Promise<QueryResult>;
+}
+
+const QueryRunnerContext = createContext<QueryRunner | undefined>(undefined);
+
+export function QueryRunnerProvider({
+ runner,
+ children,
+}: {
+ runner: QueryRunner;
+ children: ReactNode;
+}) {
+ return (
+ <QueryRunnerContext.Provider value={runner}>
+ {children}
+ </QueryRunnerContext.Provider>
+ );
+}
+
+export function useQueryRunner(): QueryRunner {
+ const ctx = useContext(QueryRunnerContext);
+ if (!ctx) {
+ throw new Error('useQueryRunner must be used within a QueryRunnerProvider');
+ }
+ return ctx;
+}
+
+/* -------------------------------------------------------------------------- */
+/* Filter store — canvas-global filters (the native-filter analogue). */
+/* */
+/* A Filter node writes an entry here; every bound Viz on the same dataset */
+/* merges the active filters into its query — so the AI places one filter and */
+/* it applies across charts without wiring each query by hand. */
+/* -------------------------------------------------------------------------- */
+
+export interface ActiveFilter {
+ /** When set, the filter only applies to queries on this dataset. */
+ datasetId?: number;
+ filter: CdlFilter;
+}
+
+interface FilterStore {
+ filters: Record<string, ActiveFilter | null>;
+ setFilter: (id: string, value: ActiveFilter | null) => void;
+ clearFilters: () => void;
+}
+
+const FilterContext = createContext<FilterStore | undefined>(undefined);
+
+export function FilterProvider({ children }: { children: ReactNode }) {
+ const [filters, setFilters] = useState<Record<string, ActiveFilter | null>>(
+ {},
+ );
+ const setFilter = useCallback(
+ (id: string, value: ActiveFilter | null) =>
+ setFilters(prev => ({ ...prev, [id]: value })),
+ [],
+ );
+ const clearFilters = useCallback(() => setFilters({}), []);
+ const value = useMemo<FilterStore>(
+ () => ({ filters, setFilter, clearFilters }),
+ [filters, setFilter, clearFilters],
+ );
+ return (
+ <FilterContext.Provider value={value}>{children}</FilterContext.Provider>
+ );
+}
+
+export function useFilters(): FilterStore {
+ const ctx = useContext(FilterContext);
+ if (!ctx) {
+ throw new Error('useFilters must be used within a FilterProvider');
+ }
+ return ctx;
+}
+
+/** The active filters applicable to a query on `datasetId`. */
+export function useActiveFilters(datasetId: number): CdlFilter[] {
+ const { filters } = useFilters();
+ return useMemo(
+ () =>
+ Object.values(filters)
+ .filter((f): f is ActiveFilter => f != null)
+ .filter(f => f.datasetId === undefined || f.datasetId === datasetId)
+ .map(f => f.filter),
+ [filters, datasetId],
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* UI state — active tabs and a refresh nonce. */
+/* */
+/* Backs the `navigateTab` and `refresh` actions so the bounded action */
+/* vocabulary is fully functional rather than partly stubbed. */
+/* -------------------------------------------------------------------------- */
+
+interface UiState {
+ activeTabs: Record<string, string>;
+ setActiveTab: (tabsId: string, tab: string) => void;
+ /** Modal nodes are hidden until an `openModal` action opens them. */
+ openModals: Record<string, boolean>;
+ setModalOpen: (modalId: string, open: boolean) => void;
+ /** Bumped by the `refresh` action; participates in every query cache key. */
+ refreshNonce: number;
+ refresh: () => void;
+}
+
+const UiStateContext = createContext<UiState | undefined>(undefined);
+
+export function UiStateProvider({ children }: { children: ReactNode }) {
+ const [activeTabs, setActiveTabs] = useState<Record<string, string>>({});
+ const [openModals, setOpenModals] = useState<Record<string, boolean>>({});
+ const [refreshNonce, setRefreshNonce] = useState(0);
+ const setActiveTab = useCallback(
+ (tabsId: string, tab: string) =>
+ setActiveTabs(prev => ({ ...prev, [tabsId]: tab })),
+ [],
+ );
+ const setModalOpen = useCallback(
+ (modalId: string, open: boolean) =>
+ setOpenModals(prev => ({ ...prev, [modalId]: open })),
+ [],
+ );
+ const refresh = useCallback(() => setRefreshNonce(n => n + 1), []);
+ const value = useMemo<UiState>(
+ () => ({
+ activeTabs,
+ setActiveTab,
+ openModals,
+ setModalOpen,
+ refreshNonce,
+ refresh,
+ }),
+ [activeTabs, setActiveTab, openModals, setModalOpen, refreshNonce, refresh],
+ );
+ return (
+ <UiStateContext.Provider value={value}>{children}</UiStateContext.Provider>
+ );
+}
+
+export function useUiState(): UiState {
+ const ctx = useContext(UiStateContext);
+ if (!ctx) {
+ throw new Error('useUiState must be used within a UiStateProvider');
+ }
+ return ctx;
+}
+
+export interface BoundQueryState {
+ loading: boolean;
+ error?: string;
+ result?: QueryResult;
+}
+
+/**
+ * Resolve `$vars` in a query context against the live store, merge in active
+ * canvas filters, fetch, and re-fetch whenever the resolved context changes.
+ */
+export function useBoundQuery(queryContext: CdlQueryContext): BoundQueryState {
+ const runner = useQueryRunner();
+ const { vars } = useVariables();
+ const { refreshNonce } = useUiState();
+ const activeFilters = useActiveFilters(queryContext.datasetId);
+ const resolved = useMemo(() => {
+ const base = resolveVars(queryContext, vars);
+ return { ...base, filters: [...(base.filters ?? []), ...activeFilters] };
+ }, [queryContext, vars, activeFilters]);
+ const key = `${JSON.stringify(resolved)}|${refreshNonce}`;
+ const [state, setState] = useState<BoundQueryState>({ loading: true });
+
+ useEffect(() => {
+ let live = true;
+ setState({ loading: true });
+ runner
+ .run(resolved)
+ .then(result => {
+ if (live) setState({ loading: false, result });
+ })
+ .catch((error: unknown) => {
+ if (live) {
+ setState({
+ loading: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ });
+ return () => {
+ live = false;
+ };
+ // `key` captures the resolved context; runner is stable.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key]);
+
+ return state;
+}
diff --git a/superset-frontend/src/Canvas/style.ts b/superset-frontend/src/Canvas/style.ts
new file mode 100644
index 00000000000..805a01621a7
--- /dev/null
+++ b/superset-frontend/src/Canvas/style.ts
@@ -0,0 +1,177 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties } from 'react';
+import { CdlStyle } from './types';
+
+/**
+ * Styling stays inside the no-code invariant: a node's `style` is a data object
+ * (never a CSS string), restricted to an allowlist of layout/appearance
+ * properties. Values may reference antd theme tokens as `@tokenName`, so
+ * AI-authored styling still respects light/dark theming.
+ */
+export const STYLE_PROPERTIES: ReadonlySet<string> = new Set([
+ // spacing
+ 'padding',
+ 'paddingTop',
+ 'paddingRight',
+ 'paddingBottom',
+ 'paddingLeft',
+ 'margin',
+ 'marginTop',
+ 'marginRight',
+ 'marginBottom',
+ 'marginLeft',
+ 'gap',
+ 'rowGap',
+ 'columnGap',
+ // sizing
+ 'width',
+ 'minWidth',
+ 'maxWidth',
+ 'height',
+ 'minHeight',
+ 'maxHeight',
+ // surface
+ 'background',
+ 'backgroundColor',
+ 'color',
+ 'border',
+ 'borderColor',
+ 'borderWidth',
+ 'borderStyle',
+ 'borderRadius',
+ 'boxShadow',
+ 'opacity',
+ 'overflow',
+ // typography
+ 'fontSize',
+ 'fontWeight',
+ 'fontFamily',
+ 'lineHeight',
+ 'letterSpacing',
+ 'textAlign',
+ 'textTransform',
+ // layout
+ 'display',
+ 'flex',
+ 'flexDirection',
+ 'flexWrap',
+ 'alignItems',
+ 'justifyContent',
+ 'alignSelf',
+ 'gridTemplateColumns',
+ 'gridTemplateRows',
+ 'gridColumn',
+ 'gridRow',
+ // freeform positioning + transforms (overlap, rotation, pinning)
+ 'position',
+ 'top',
+ 'right',
+ 'bottom',
+ 'left',
+ 'inset',
+ 'zIndex',
+ 'transform',
+ 'transformOrigin',
+ 'rotate',
+ 'scale',
+ 'translate',
+ 'transition',
+ 'aspectRatio',
+ 'objectFit',
+ 'filter',
+ 'backdropFilter',
+ 'mixBlendMode',
+ 'cursor',
+ 'pointerEvents',
+]);
+
+/** Values that could smuggle behaviour or external fetches into CSS. */
+const UNSAFE_VALUE = /url\(|expression\(|javascript:|@import|<|\/\*/i;
+
+const TOKEN_REF = /^@([A-Za-z_][\w]*)$/;
+
+export const isTokenRef = (value: unknown): value is string =>
+ typeof value === 'string' && TOKEN_REF.test(value);
+
+/**
+ * Validate a node's `style`. Returns human-readable errors (empty == valid).
+ * Shared shape with the server-side validator in
+ * superset/mcp_service/canvas/validation.py.
+ */
+export function validateStyle(style: unknown, path: string): string[] {
+ const errors: string[] = [];
+ if (style === undefined) {
+ return errors;
+ }
+ if (typeof style !== 'object' || style === null || Array.isArray(style)) {
+ errors.push(`${path}: style must be an object`);
+ return errors;
+ }
+ Object.entries(style as Record<string, unknown>).forEach(([prop, value]) => {
+ if (!STYLE_PROPERTIES.has(prop)) {
+ errors.push(`${path}.${prop}: unsupported style property`);
+ return;
+ }
+ if (typeof value !== 'string' && typeof value !== 'number') {
+ errors.push(`${path}.${prop}: style values must be a string or number`);
+ return;
+ }
+ if (typeof value === 'string' && UNSAFE_VALUE.test(value)) {
+ errors.push(`${path}.${prop}: disallowed value`);
+ }
+ });
+ return errors;
+}
+
+/**
+ * Resolve a validated `style` into React inline styles, substituting
+ * `@tokenName` references from the active theme. Unknown properties and unsafe
+ * values are dropped rather than thrown, so a bad style never breaks a render.
+ */
+export function resolveStyle(
+ style: CdlStyle | undefined,
+ theme: Record<string, unknown>,
+): CSSProperties | undefined {
+ if (!style) {
+ return undefined;
+ }
+ const out: Record<string, string | number> = {};
+ Object.entries(style).forEach(([prop, value]) => {
+ if (!STYLE_PROPERTIES.has(prop)) {
+ return;
+ }
+ if (typeof value === 'string' && UNSAFE_VALUE.test(value)) {
+ return;
+ }
+ if (isTokenRef(value)) {
+ const token = value.slice(1);
+ const resolved = theme[token];
+ if (typeof resolved === 'string' || typeof resolved === 'number') {
+ out[prop] = resolved;
+ }
+ return;
+ }
+ if (typeof value === 'string' || typeof value === 'number') {
+ out[prop] = value;
+ }
+ });
+ return Object.keys(out).length ? (out as CSSProperties) : undefined;
+}
diff --git a/superset-frontend/src/Canvas/types.ts b/superset-frontend/src/Canvas/types.ts
new file mode 100644
index 00000000000..fb531c24932
--- /dev/null
+++ b/superset-frontend/src/Canvas/types.ts
@@ -0,0 +1,189 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * CDL (Canvas Definition Language) — the typed, declarative tree an AI (or a
+ * human) emits to describe a v2 dashboard. Nothing here is executable code:
+ * presentation is a data `option`, data is a `queryContext` + `encoding`, and
+ * behaviour is a bounded `action` enum. See canvas-v2-design.md.
+ */
+
+export type Primitive = string | number | boolean;
+
+/** A reference to a declared variable, written `$name` anywhere a value is expected. */
+export type VarRef = `$${string}`;
+
+export type VariableScope = 'query' | 'ui';
+
+export interface VariableDecl {
+ type: 'string' | 'number' | 'boolean';
+ default: Primitive;
+ /**
+ * `query` variables project onto the host dashboard's dataMask (governed,
+ * re-queries bound Viz nodes). `ui` variables live client-side only.
+ */
+ scope: VariableScope;
+}
+
+export type VariableValues = Record<string, Primitive>;
+
+/** A value that may be a literal or a `$var` reference resolved at render time. */
+export type Bindable<T extends Primitive> = T | VarRef;
+
+export interface CdlFilter {
+ col: string;
+ op: '==' | '!=' | '>' | '<' | '>=' | '<=' | 'IN' | 'LIKE';
+ /** May be a literal or a `$var` reference. */
+ val: Primitive | Primitive[] | VarRef;
+}
+
+/** Declarative formatter vocabulary — resolved into a real function client-side. */
+export type Formatter =
+ | { kind: 'number'; decimals?: number }
+ | { kind: 'currency'; currency: string; decimals?: number }
+ | { kind: 'percent'; decimals?: number }
+ | { kind: 'date'; format?: string }
+ | { kind: 'template'; template: string };
+
+/** Minimal query spec; expands into a Superset query_context at fetch time. */
+export interface CdlQueryContext {
+ datasetId: number;
+ metrics: string[];
+ groupby?: string[];
+ filters?: CdlFilter[];
+ rowLimit?: number;
+ /**
+ * Sort the result. Combine with `rowLimit` for top-N charts, e.g.
+ * `[{ by: 'SUM(global_sales)', desc: true }]` with `rowLimit: 10`.
+ * `by` may name a metric (from `metrics`) or a groupby column.
+ */
+ orderby?: Array<{ by: string; desc?: boolean }>;
+}
+
+/**
+ * How the query result is shaped onto the echarts series. Inferred from the
+ * series type when omitted:
+ * - `categoryValue` — xAxis categories + numeric series (bar, line, area)
+ * - `nameValue` — [{name, value}] (pie, funnel, treemap, sunburst, gauge)
+ * - `pairs` — [[x, y]] from two metrics (scatter)
+ * - `matrix` — [[xIndex, yIndex, value]] (heatmap; needs `series`)
+ * - `radar` — indicators from categories, one ring per metric
+ */
+export type EncodingShape =
+ 'categoryValue' | 'nameValue' | 'pairs' | 'matrix' | 'radar';
+
+/** How query result columns map onto an echarts option. */
+export interface Encoding {
+ /** Category/x dimension column. */
+ x: string;
+ /** Value column(s) for the series. */
+ y: string | string[];
+ /** Optional column whose distinct values fan out into one series each. */
+ series?: string | null;
+ /** Override the inferred data shape. */
+ shape?: EncodingShape;
+}
+
+export type CdlAction =
+ | { action: 'setVariable'; name: string; value: Primitive | VarRef }
+ | {
+ action: 'applyFilter';
+ col: string;
+ op: CdlFilter['op'];
+ val: CdlFilter['val'];
+ }
+ | {
+ action: 'crossFilter';
+ col: string;
+ op: CdlFilter['op'];
+ val: CdlFilter['val'];
+ }
+ | { action: 'clearFilters'; scope?: VariableScope }
+ | { action: 'navigateTab'; tabsId: string; tab: string }
+ | { action: 'openModal'; modalId: string }
+ | { action: 'closeModal'; modalId: string }
+ | { action: 'openUrl'; url: string; newTab?: boolean }
+ | { action: 'refresh'; target?: string };
+
+export type CdlActionName = CdlAction['action'];
+
+/**
+ * Declarative styling: an allowlisted CSS-property object (never a CSS string).
+ * Values may be literals or `@themeToken` references resolved from the antd
+ * theme at render time. See style.ts.
+ */
+export type CdlStyle = Record<string, string | number>;
+
+/**
+ * Placement for a node inside a `Board` (freeform) container, in grid units:
+ * `x`/`y` are the top-left cell (0-based), `w`/`h` the span, `z` the stacking
+ * order for deliberate overlap. Ignored outside a Board.
+ */
+export interface BoardLayout {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ z?: number;
+}
+
+export interface BaseNode {
+ /** Stable id — AI-addressable for targeted edits/diffs. */
+ id: string;
+ type: string;
+ props?: Record<string, unknown>;
+ /** Allowlisted inline styling, theme-token aware. */
+ style?: CdlStyle;
+ /** Position within a parent Board (grid units). */
+ layout?: BoardLayout;
+ /** Two-way binding of a prop to a `$var`. */
+ bind?: Record<string, VarRef>;
+ /** Declarative event handlers: event name -> ordered action list. */
+ on?: Record<string, CdlAction[]>;
+ children?: CdlNode[];
+}
+
+export interface VizNode extends BaseNode {
+ type: 'Viz';
+ renderer: 'echarts' | 'supersetChart';
+ /** supersetChart: reference an existing governed Slice. */
+ chartId?: number;
+ /** supersetChart: extra dataMask filters. */
+ filters?: CdlFilter[];
+ /** echarts: bound data spec. */
+ data?: { queryContext: CdlQueryContext; encoding: Encoding };
+ /** echarts: the presentation option (data, not code). */
+ option?: Record<string, unknown>;
+}
+
+export type CdlNode = BaseNode | VizNode;
+
+export interface CanvasDefinition {
+ cdlVersion: number;
+ variables: Record<string, VariableDecl>;
+ tree: CdlNode;
+ /**
+ * Outer width cap. `"full"` (default) is full-bleed like a dashboard; a CSS
+ * width (e.g. `"820px"`) centres a narrower reading measure for documents.
+ */
+ canvasWidth?: string;
+}
+
+export const isVizNode = (node: CdlNode): node is VizNode =>
+ node.type === 'Viz';
diff --git a/superset-frontend/src/Canvas/validator.ts b/superset-frontend/src/Canvas/validator.ts
new file mode 100644
index 00000000000..cc54356e0b6
--- /dev/null
+++ b/superset-frontend/src/Canvas/validator.ts
@@ -0,0 +1,326 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { NODE_CATALOG, isKnownType } from './catalog';
+import { validateStyle } from './style';
+import {
+ CanvasDefinition,
+ CdlAction,
+ CdlActionName,
+ CdlNode,
+ isVizNode,
+} from './types';
+
+export interface ValidationError {
+ path: string;
+ message: string;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+ errors: ValidationError[];
+}
+
+const VAR_REF = /^\$([A-Za-z_][\w]*)$/;
+const EVENT_TOKEN = '$event';
+/** The no-code invariant: reject anything that smells like an executable string. */
+const CODE_SMELL = /=>|\bfunction\b|new\s+Function/i;
+const JS_URL = /^\s*(javascript|data|vbscript):/i;
+
+/**
+ * Keyed by the action union, so adding an action to `CdlAction` without
+ * declaring its required params is a compile error rather than a runtime
+ * "unknown action" that only shows up in the browser.
+ */
+const ACTION_REQUIRED: Record<CdlActionName, string[]> = {
+ setVariable: ['name', 'value'],
+ applyFilter: ['col', 'op', 'val'],
+ crossFilter: ['col', 'op', 'val'],
+ clearFilters: [],
+ navigateTab: ['tabsId', 'tab'],
+ openModal: ['modalId'],
+ closeModal: ['modalId'],
+ openUrl: ['url'],
+ refresh: [],
+};
+
+const isVarRef = (v: unknown): v is string =>
+ typeof v === 'string' && VAR_REF.test(v);
+
+const varName = (ref: string): string => ref.slice(1);
+
+/** Deep-scan a value for executable-looking strings (the core safety gate). */
+function scanNoCode(
+ value: unknown,
+ path: string,
+ errors: ValidationError[],
+): void {
+ if (typeof value === 'string') {
+ if (CODE_SMELL.test(value)) {
+ errors.push({
+ path,
+ message: `disallowed executable string (no-code invariant): ${value.slice(0, 40)}`,
+ });
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((v, i) => scanNoCode(v, `${path}[${i}]`, errors));
+ return;
+ }
+ if (value && typeof value === 'object') {
+ Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
+ // A `formatter` must be a declarative object, never a raw string/function.
+ if (k === 'formatter' && typeof v === 'string') {
+ errors.push({
+ path: `${path}.formatter`,
+ message: 'formatter must be a declarative object, not a string',
+ });
+ }
+ scanNoCode(v, `${path}.${k}`, errors);
+ });
+ }
+}
+
+/** Collect every `$var` reference in a value (excluding the `$event` token). */
+function collectRefs(value: unknown, out: Set<string>): void {
+ if (isVarRef(value) && value !== EVENT_TOKEN) {
+ out.add(varName(value));
+ } else if (Array.isArray(value)) {
+ value.forEach(v => collectRefs(v, out));
+ } else if (value && typeof value === 'object') {
+ Object.values(value as Record<string, unknown>).forEach(v =>
+ collectRefs(v, out),
+ );
+ }
+}
+
+function validateAction(
+ action: CdlAction,
+ path: string,
+ declared: Set<string>,
+ errors: ValidationError[],
+): void {
+ const required = ACTION_REQUIRED[action.action];
+ if (!required) {
+ errors.push({ path, message: `unknown action "${action.action}"` });
+ return;
+ }
+ const record = action as unknown as Record<string, unknown>;
+ required.forEach(key => {
+ if (record[key] === undefined) {
+ errors.push({
+ path,
+ message: `action "${action.action}" missing "${key}"`,
+ });
+ }
+ });
+ if (action.action === 'openUrl' && JS_URL.test(action.url)) {
+ errors.push({
+ path: `${path}.url`,
+ message: 'openUrl allows http(s) only',
+ });
+ }
+ if (action.action === 'setVariable' && !declared.has(action.name)) {
+ errors.push({
+ path: `${path}.name`,
+ message: `undeclared variable "${action.name}"`,
+ });
+ }
+}
+
+function validateNode(
+ node: CdlNode,
+ path: string,
+ declared: Set<string>,
+ errors: ValidationError[],
+): void {
+ if (!node || typeof node !== 'object') {
+ errors.push({ path, message: 'node must be an object' });
+ return;
+ }
+ if (typeof node.id !== 'string' || !node.id) {
+ errors.push({ path, message: 'node.id (string) is required' });
+ }
+ if (!isKnownType(node.type)) {
+ errors.push({
+ path,
+ message: `unknown node type "${node.type}" (not in catalog)`,
+ });
+ return; // can't validate further against an unknown contract
+ }
+ const entry = NODE_CATALOG[node.type];
+
+ // Required props
+ entry.requiredProps.forEach(prop => {
+ if (node.props?.[prop] === undefined) {
+ errors.push({
+ path: `${path}.props.${prop}`,
+ message: `required prop "${prop}" missing`,
+ });
+ }
+ });
+
+ // Children only on containers
+ if (node.children?.length && !entry.container) {
+ errors.push({
+ path: `${path}.children`,
+ message: `"${node.type}" cannot have children`,
+ });
+ }
+
+ // bind targets must be bindable + reference declared vars
+ Object.entries(node.bind ?? {}).forEach(([prop, ref]) => {
+ if (!entry.bindableProps.includes(prop)) {
+ errors.push({
+ path: `${path}.bind.${prop}`,
+ message: `prop "${prop}" is not bindable`,
+ });
+ }
+ if (!isVarRef(ref)) {
+ errors.push({
+ path: `${path}.bind.${prop}`,
+ message: `bind must be a $var reference`,
+ });
+ } else if (!declared.has(varName(ref))) {
+ errors.push({
+ path: `${path}.bind.${prop}`,
+ message: `undeclared variable "${ref}"`,
+ });
+ }
+ });
+
+ // Events must be in the catalog; actions must be valid
+ Object.entries(node.on ?? {}).forEach(([event, actions]) => {
+ if (!entry.events.includes(event)) {
+ errors.push({
+ path: `${path}.on.${event}`,
+ message: `"${node.type}" does not emit "${event}"`,
+ });
+ }
+ (actions ?? []).forEach((action, i) =>
+ validateAction(action, `${path}.on.${event}[${i}]`, declared, errors),
+ );
+ });
+
+ // Viz-specific
+ if (isVizNode(node)) {
+ if (node.renderer === 'echarts') {
+ if (!node.data?.queryContext) {
+ errors.push({
+ path: `${path}.data`,
+ message: 'echarts Viz requires data.queryContext',
+ });
+ }
+ if (!node.data?.encoding) {
+ errors.push({
+ path: `${path}.data`,
+ message: 'echarts Viz requires data.encoding',
+ });
+ }
+ scanNoCode(node.option, `${path}.option`, errors);
+ } else if (node.renderer === 'supersetChart') {
+ if (typeof node.chartId !== 'number') {
+ errors.push({
+ path: `${path}.chartId`,
+ message: 'supersetChart Viz requires chartId',
+ });
+ }
+ } else {
+ errors.push({
+ path: `${path}.renderer`,
+ message: `unknown Viz renderer`,
+ });
+ }
+ }
+
+ // No-code scan over props (option scanned above for Viz)
+ scanNoCode(node.props, `${path}.props`, errors);
+
+ // Declarative styling: allowlisted properties, safe values only.
+ validateStyle(node.style, `${path}.style`).forEach(message =>
+ errors.push({ path: `${path}.style`, message }),
+ );
+
+ // Board placement: numeric grid units.
+ if (node.layout !== undefined) {
+ const layout = node.layout as unknown as Record<string, unknown>;
+ (['x', 'y', 'w', 'h'] as const).forEach(key => {
+ if (typeof layout[key] !== 'number') {
+ errors.push({
+ path: `${path}.layout.${key}`,
+ message: `${key} must be a number`,
+ });
+ }
+ });
+ if (typeof layout.w === 'number' && layout.w < 1) {
+ errors.push({
+ path: `${path}.layout.w`,
+ message: 'w must be at least 1',
+ });
+ }
+ if (typeof layout.h === 'number' && layout.h < 1) {
+ errors.push({
+ path: `${path}.layout.h`,
+ message: 'h must be at least 1',
+ });
+ }
+ }
+
+ // Reference integrity across props / bind / on / data
+ const refs = new Set<string>();
+ collectRefs(node.props, refs);
+ collectRefs(node.on, refs);
+ if (isVizNode(node)) collectRefs(node.data?.queryContext, refs);
+ refs.forEach(name => {
+ if (!declared.has(name)) {
+ errors.push({
+ path,
+ message: `references undeclared variable "$${name}"`,
+ });
+ }
+ });
+
+ node.children?.forEach((child, i) =>
+ validateNode(child, `${path}.children[${i}]`, declared, errors),
+ );
+}
+
+export function validateCanvas(definition: CanvasDefinition): ValidationResult {
+ const errors: ValidationError[] = [];
+
+ if (typeof definition?.cdlVersion !== 'number') {
+ errors.push({
+ path: 'cdlVersion',
+ message: 'cdlVersion (number) is required',
+ });
+ }
+ if (!definition?.variables || typeof definition.variables !== 'object') {
+ errors.push({ path: 'variables', message: 'variables object is required' });
+ }
+ if (!definition?.tree) {
+ errors.push({ path: 'tree', message: 'tree (root node) is required' });
+ return { valid: false, errors };
+ }
+
+ const declared = new Set(Object.keys(definition.variables ?? {}));
+ validateNode(definition.tree, 'tree', declared, errors);
+
+ return { valid: errors.length === 0, errors };
+}
diff --git a/superset-frontend/src/pages/Canvas/index.tsx b/superset-frontend/src/pages/Canvas/index.tsx
new file mode 100644
index 00000000000..1d3a52711c0
--- /dev/null
+++ b/superset-frontend/src/pages/Canvas/index.tsx
@@ -0,0 +1,28 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { FC } from 'react';
+import { useParams } from 'react-router-dom';
+import { CanvasViewer } from 'src/Canvas/CanvasViewer';
+
+const CanvasRoute: FC = () => {
+ const { canvasId } = useParams<{ canvasId: string }>();
+ return <CanvasViewer idOrUuid={canvasId} />;
+};
+
+export default CanvasRoute;
diff --git a/superset-frontend/src/pages/CanvasList/index.tsx b/superset-frontend/src/pages/CanvasList/index.tsx
new file mode 100644
index 00000000000..41d6d83ec59
--- /dev/null
+++ b/superset-frontend/src/pages/CanvasList/index.tsx
@@ -0,0 +1,121 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { t } from '@apache-superset/core/translation';
+import withToasts from 'src/components/MessageToasts/withToasts';
+import SubMenu from 'src/features/home/SubMenu';
+import { useListViewResource } from 'src/views/CRUD/hooks';
+import {
+ ListView,
+ ListViewFilterOperator as FilterOperator,
+ type ListViewFilters,
+} from 'src/components';
+
+const PAGE_SIZE = 25;
+
+interface CanvasObject {
+ id: number;
+ name: string;
+ changed_on_delta_humanized?: string;
+ created_by?: { first_name: string; last_name: string };
+}
+
+interface CanvasListProps {
+ addDangerToast: (msg: string) => void;
+ addSuccessToast: (msg: string) => void;
+}
+
+function CanvasList({ addDangerToast, addSuccessToast }: CanvasListProps) {
+ const {
+ state: {
+ loading,
+ resourceCount: canvasCount,
+ resourceCollection: canvases,
+ },
+ fetchData,
+ refreshData,
+ } = useListViewResource<CanvasObject>(
+ 'canvas',
+ t('Canvases'),
+ addDangerToast,
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ accessor: 'name',
+ Header: t('Name'),
+ Cell: ({ row: { original } }: { row: { original: CanvasObject } }) => (
+ <Link to={`/canvas/${original.id}/`}>{original.name}</Link>
+ ),
+ },
+ {
+ accessor: 'created_by',
+ Header: t('Created by'),
+ disableSortBy: true,
+ Cell: ({ row: { original } }: { row: { original: CanvasObject } }) =>
+ original.created_by
+ ? `${original.created_by.first_name} ${original.created_by.last_name}`
+ : '',
+ },
+ {
+ accessor: 'changed_on_delta_humanized',
+ Header: t('Last modified'),
+ },
+ ],
+ [],
+ );
+
+ const filters: ListViewFilters = useMemo(
+ () => [
+ {
+ Header: t('Name'),
+ key: 'search',
+ id: 'name',
+ input: 'search',
+ operator: FilterOperator.Contains,
+ },
+ ],
+ [],
+ );
+
+ return (
+ <>
+ <SubMenu name={t('Canvases')} />
+ <ListView<CanvasObject>
+ className="canvas-list-view"
+ columns={columns}
+ count={canvasCount}
+ data={canvases}
+ fetchData={fetchData}
+ refreshData={refreshData}
+ filters={filters}
+ initialSort={[{ id: 'changed_on_delta_humanized', desc: true }]}
+ loading={loading}
+ pageSize={PAGE_SIZE}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ </>
+ );
+}
+
+export default withToasts(CanvasList);
diff --git a/superset-frontend/src/views/routePaths.ts b/superset-frontend/src/views/routePaths.ts
index 0295e0897e4..f4e1d235dbf 100644
--- a/superset-frontend/src/views/routePaths.ts
+++ b/superset-frontend/src/views/routePaths.ts
@@ -27,6 +27,8 @@ export const RoutePaths = {
FILE_HANDLER: '/file-handler',
DASHBOARD: '/dashboard/:idOrSlug/',
DASHBOARD_LIST: '/dashboard/list/',
+ CANVAS: '/canvas/:canvasId/',
+ CANVAS_LIST: '/canvas/list/',
CHART_ADD: '/chart/add',
CHART_LIST: '/chart/list/',
DATASET_LIST: '/tablemodelview/list/',
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 0e1901256fb..bb683cf4f00 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -80,6 +80,14 @@ const Dashboard = lazy(
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
);
+const Canvas = lazy(
+ () => import(/* webpackChunkName: "Canvas" */ 'src/pages/Canvas'),
+);
+
+const CanvasList = lazy(
+ () => import(/* webpackChunkName: "CanvasList" */ 'src/pages/CanvasList'),
+);
+
const DatabaseList = lazy(
() => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'),
);
@@ -206,6 +214,10 @@ export const routes: Routes = [
{ path: RoutePaths.FILE_HANDLER, Component: FileHandler },
{ path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
{ path: RoutePaths.DASHBOARD, Component: Dashboard },
+ // CANVAS_LIST must precede CANVAS: '/canvas/:canvasId/' would otherwise
+ // match '/canvas/list/' with canvasId="list".
+ { path: RoutePaths.CANVAS_LIST, Component: CanvasList },
+ { path: RoutePaths.CANVAS, Component: Canvas },
{ path: RoutePaths.CHART_ADD, Component: ChartCreation },
{ path: RoutePaths.CHART_LIST, Component: ChartList },
{ path: RoutePaths.DATASET_LIST, Component: DatasetList },
diff --git a/superset/canvas/__init__.py b/superset/canvas/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/canvas/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
new file mode 100644
index 00000000000..f3d583fd1a8
--- /dev/null
+++ b/superset/canvas/api.py
@@ -0,0 +1,70 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+
+from superset.canvas.filters import CanvasAccessFilter
+from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
+from superset.models.canvas import Canvas
+from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter
+from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedUsers
+
+logger = logging.getLogger(__name__)
+
+
+class CanvasRestApi(BaseSupersetModelRestApi):
+ datamodel = SQLAInterface(Canvas)
+
+ include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {RouteMethod.RELATED}
+ class_permission_name = "Canvas"
+ method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
+
+ resource_name = "canvas"
+ allow_browser_login = True
+
+ base_filters = [["id", CanvasAccessFilter, lambda: []]]
+
+ show_columns = [
+ "id",
+ "name",
+ "definition",
+ "created_by.first_name",
+ "created_by.id",
+ "created_by.last_name",
+ "changed_on_delta_humanized",
+ ]
+ list_columns = [
+ "id",
+ "name",
+ "changed_on_delta_humanized",
+ "created_by.first_name",
+ "created_by.id",
+ "created_by.last_name",
+ ]
+ add_columns = ["name", "definition"]
+ edit_columns = add_columns
+ order_columns = ["name", "changed_on_delta_humanized"]
+
+ allowed_rel_fields = {"created_by", "changed_by"}
+ related_field_filters = {
+ "created_by": RelatedFieldFilter("first_name", FilterRelatedUsers),
+ }
+ base_related_field_filters = {
+ "created_by": [["id", BaseFilterRelatedUsers, lambda: []]],
+ }
+ openapi_spec_tag = "Canvas"
diff --git a/superset/canvas/filters.py b/superset/canvas/filters.py
new file mode 100644
index 00000000000..02da9ab2940
--- /dev/null
+++ b/superset/canvas/filters.py
@@ -0,0 +1,59 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import Any
+
+from flask_appbuilder.models.sqla.filters import BaseFilter
+from sqlalchemy import false, or_
+from sqlalchemy.orm.query import Query
+
+from superset.extensions import db, security_manager
+from superset.models.canvas import Canvas
+from superset.utils.core import get_user_id
+
+
+class CanvasAccessFilter(BaseFilter): # pylint: disable=too-few-public-methods
+ """Scope canvases to the current user's editor/viewer grants.
+
+ Mirrors the chart/dashboard editors-viewers pattern (see
+ ``superset/charts/filters.py``); admins are unfiltered.
+ """
+
+ def apply(self, query: Query, value: Any) -> Query:
+ if security_manager.is_admin():
+ return query
+
+ from superset.subjects.models import canvas_editors, canvas_viewers
+ from superset.subjects.utils import get_user_subject_ids_subquery
+
+ user_id = get_user_id()
+ if not user_id:
+ return query.filter(false())
+
+ subject_subquery = get_user_subject_ids_subquery(user_id)
+ editor_query = (
+ db.session.query(Canvas.id)
+ .join(canvas_editors, Canvas.id == canvas_editors.c.canvas_id)
+ .filter(canvas_editors.c.subject_id.in_(subject_subquery))
+ )
+ viewer_query = (
+ db.session.query(Canvas.id)
+ .join(canvas_viewers, Canvas.id == canvas_viewers.c.canvas_id)
+ .filter(canvas_viewers.c.subject_id.in_(subject_subquery))
+ )
+ return query.filter(
+ or_(Canvas.id.in_(editor_query), Canvas.id.in_(viewer_query))
+ )
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 7cf9e49a0c8..8b9d87d42f8 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -164,6 +164,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.async_events.api import AsyncEventsRestApi
from superset.available_domains.api import AvailableDomainsRestApi
from superset.cachekeys.api import CacheRestApi
+ from superset.canvas.api import CanvasRestApi
from superset.charts.api import ChartRestApi
from superset.charts.data.api import ChartDataRestApi
from superset.css_templates.api import CssTemplateRestApi
@@ -201,6 +202,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.all_entities import TaggedObjectsModelView
from superset.views.annotations import AnnotationLayerView
from superset.views.api import Api
+ from superset.views.canvas import CanvasModelView
from superset.views.chart.views import SliceModelView
from superset.views.core import Superset
from superset.views.css_templates import CssTemplateModelView
@@ -256,6 +258,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(CacheRestApi)
appbuilder.add_api(ChartRestApi)
appbuilder.add_api(ChartDataRestApi)
+ appbuilder.add_api(CanvasRestApi)
appbuilder.add_api(CssTemplateRestApi)
appbuilder.add_api(ThemeRestApi)
appbuilder.add_api(CurrentUserRestApi)
@@ -347,6 +350,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category="",
category_icon="",
)
+ appbuilder.add_view(
+ CanvasModelView,
+ "Canvases",
+ label=_("Canvases"),
+ icon="fa-object-group",
+ category="",
+ category_icon="",
+ )
appbuilder.add_link(
"Datasets",
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index d92d6361d73..87d42c64b7e 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -752,6 +752,12 @@ from superset.mcp_service.annotation_layer.tool import ( # noqa: F401, E402
list_annotation_layers,
list_layer_annotations,
)
+from superset.mcp_service.canvas.tool import ( # noqa: F401, E402
+ generate_canvas,
+ get_canvas,
+ get_canvas_schema,
+ update_canvas,
+)
from superset.mcp_service.chart import ( # noqa: F401, E402
prompts as chart_prompts,
resources as chart_resources,
diff --git a/superset/mcp_service/canvas/__init__.py b/superset/mcp_service/canvas/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/mcp_service/canvas/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/mcp_service/canvas/patch.py b/superset/mcp_service/canvas/patch.py
new file mode 100644
index 00000000000..da864879a88
--- /dev/null
+++ b/superset/mcp_service/canvas/patch.py
@@ -0,0 +1,187 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Targeted edits to a CDL tree, addressed by stable node id.
+
+This is why nodes carry ids: an agent can restyle, move, replace or remove a
+single node instead of regenerating the whole canvas (which silently drifts the
+parts nobody asked to change).
+"""
+
+from __future__ import annotations
+
+import copy
+from collections.abc import Iterator
+from typing import Any
+
+Node = dict[str, Any]
+
+
+def _walk(node: Node, parent: Node | None = None) -> Iterator[tuple[Node, Node | None]]:
+ """Yield (node, parent) for the tree, depth-first."""
+ yield node, parent
+ for child in node.get("children") or []:
+ yield from _walk(child, node)
+
+
+def find_node(tree: Node, node_id: str) -> tuple[Node | None, Node | None]:
+ """Return (node, parent) for `node_id`; (None, None) when absent."""
+ for node, parent in _walk(tree):
+ if node.get("id") == node_id:
+ return node, parent
+ return None, None
+
+
+def _detach(tree: Node, node_id: str) -> Node | None:
+ node, parent = find_node(tree, node_id)
+ if node is None or parent is None:
+ return None
+ parent["children"] = [c for c in parent.get("children") or [] if c is not node]
+ return node
+
+
+def _insert_into(
+ parent: Node,
+ node: Node,
+ before: str | None,
+ after: str | None,
+ index: int | None,
+) -> None:
+ children = parent.setdefault("children", [])
+ position = len(children)
+ if index is not None:
+ position = max(0, min(index, len(children)))
+ elif before is not None:
+ position = next(
+ (i for i, c in enumerate(children) if c.get("id") == before), len(children)
+ )
+ elif after is not None:
+ position = next(
+ (i + 1 for i, c in enumerate(children) if c.get("id") == after),
+ len(children),
+ )
+ children.insert(position, node)
+
+
+def apply_ops( # noqa: C901
+ definition: Node, ops: list[dict[str, Any]]
+) -> tuple[Node, list[str]]:
+ """Apply patch ops to a CDL definition.
+
+ Returns (new_definition, errors). The input is never mutated; on any error
+ the caller should discard the result rather than persist a partial edit.
+ """
+ updated = copy.deepcopy(definition)
+ tree = updated.get("tree")
+ errors: list[str] = []
+ if not isinstance(tree, dict):
+ return updated, ["definition.tree is missing or not an object"]
+
+ for i, op in enumerate(ops):
+ path = f"ops[{i}]"
+ kind = op.get("op")
+ node_id = op.get("id")
+ if not kind:
+ errors.append(f"{path}: 'op' is required")
+ continue
+ if not node_id:
+ errors.append(f"{path}: 'id' is required")
+ continue
+
+ target, parent = find_node(tree, node_id)
+
+ # `insert` addresses the PARENT, so it is the one op that tolerates a
+ # target that is not itself being edited.
+ if kind == "insert":
+ new_node = op.get("node")
+ if not isinstance(new_node, dict):
+ errors.append(f"{path}: 'node' object is required for insert")
+ elif target is None:
+ errors.append(f"{path}: no parent node with id {node_id!r}")
+ else:
+ _insert_into(
+ target,
+ new_node,
+ op.get("before"),
+ op.get("after"),
+ op.get("index"),
+ )
+ continue
+
+ if target is None:
+ errors.append(f"{path}: no node with id {node_id!r}")
+ continue
+
+ if kind == "setStyle":
+ style = op.get("style") or {}
+ if op.get("merge", True):
+ target["style"] = {**(target.get("style") or {}), **style}
+ else:
+ target["style"] = dict(style)
+ elif kind == "setProps":
+ props = op.get("props") or {}
+ if op.get("merge", True):
+ target["props"] = {**(target.get("props") or {}), **props}
+ else:
+ target["props"] = dict(props)
+ elif kind == "setOption":
+ option = op.get("option") or {}
+ if op.get("merge", True):
+ target["option"] = {**(target.get("option") or {}), **option}
+ else:
+ target["option"] = dict(option)
+ elif kind == "remove":
+ if parent is None:
+ errors.append(f"{path}: cannot remove the root node")
+ continue
+ _detach(tree, node_id)
+ elif kind == "replace":
+ node = op.get("node")
+ if not isinstance(node, dict):
+ errors.append(f"{path}: 'node' object is required for replace")
+ continue
+ if parent is None:
+ updated["tree"] = node
+ tree = node
+ continue
+ children = parent.get("children") or []
+ parent["children"] = [node if c is target else c for c in children]
+ elif kind == "move":
+ if parent is None:
+ errors.append(f"{path}: cannot move the root node")
+ continue
+ new_parent_id = op.get("parent")
+ before, after, index = op.get("before"), op.get("after"), op.get("index")
+ if new_parent_id:
+ new_parent, _ = find_node(tree, new_parent_id)
+ if new_parent is None:
+ errors.append(f"{path}: no parent node with id {new_parent_id!r}")
+ continue
+ else:
+ # Default to reordering within the current parent.
+ new_parent = parent
+ if before is None and after is None and index is None:
+ errors.append(f"{path}: move needs one of 'before', 'after', 'index'")
+ continue
+ detached = _detach(tree, node_id)
+ if detached is None:
+ errors.append(f"{path}: could not detach {node_id!r}")
+ continue
+ _insert_into(new_parent, detached, before, after, index)
+ else:
+ errors.append(f"{path}: unknown op {kind!r}")
+
+ return updated, errors
diff --git a/superset/mcp_service/canvas/schemas.py b/superset/mcp_service/canvas/schemas.py
new file mode 100644
index 00000000000..767c875bba2
--- /dev/null
+++ b/superset/mcp_service/canvas/schemas.py
@@ -0,0 +1,139 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Pydantic request/response schemas for the canvas MCP tools."""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+
+class GetCanvasSchemaRequest(BaseModel):
+ """No arguments — returns the static CDL contract."""
+
+
+class GetCanvasSchemaResponse(BaseModel):
+ cdl_schema: dict[str, Any] = Field(
+ ..., description="The CDL contract to author a canvas against."
+ )
+
+
+class GenerateCanvasRequest(BaseModel):
+ name: str = Field(..., description="Human-readable canvas title.")
+ definition: dict[str, Any] = Field(
+ ...,
+ description=(
+ "The full CDL tree ({cdlVersion, variables, tree}). "
+ "Call get_canvas_schema first for the contract."
+ ),
+ )
+
+
+class CanvasInfo(BaseModel):
+ id: int
+ name: str
+ url: str
+ uuid: str | None = None
+
+
+class GetCanvasRequest(BaseModel):
+ identifier: int | str = Field(..., description="Canvas id or uuid.")
+
+
+class GetCanvasResponse(BaseModel):
+ canvas: CanvasInfo | None = None
+ definition: dict[str, Any] | None = Field(
+ default=None, description="The stored CDL tree — read node ids from here."
+ )
+ error: str | None = None
+
+
+class CanvasPatchOp(BaseModel):
+ """A single targeted edit addressed by node id."""
+
+ op: Literal[
+ "setStyle", "setProps", "setOption", "move", "replace", "insert", "remove"
+ ] = Field(..., description="The edit to perform.")
+ id: str = Field(
+ ...,
+ description=(
+ "Target node id. For 'insert' this is the PARENT node to insert into."
+ ),
+ )
+ style: dict[str, Any] | None = Field(
+ default=None, description="setStyle: style object to apply."
+ )
+ props: dict[str, Any] | None = Field(
+ default=None, description="setProps: props to apply."
+ )
+ option: dict[str, Any] | None = Field(
+ default=None, description="setOption: echarts option to apply (Viz nodes)."
+ )
+ node: dict[str, Any] | None = Field(
+ default=None, description="replace/insert: the full node object."
+ )
+ parent: str | None = Field(
+ default=None,
+ description="move: id of the new parent (defaults to the current parent).",
+ )
+ before: str | None = Field(
+ default=None, description="move/insert: place before this sibling id."
+ )
+ after: str | None = Field(
+ default=None, description="move/insert: place after this sibling id."
+ )
+ index: int | None = Field(
+ default=None, description="move/insert: explicit position among children."
+ )
+ merge: bool = Field(
+ default=True,
+ description=(
+ "setStyle/setProps/setOption: merge into the existing object (True) "
+ "or replace it wholesale (False)."
+ ),
+ )
+
+
+class UpdateCanvasRequest(BaseModel):
+ identifier: int | str = Field(..., description="Canvas id or uuid.")
+ ops: list[CanvasPatchOp] = Field(
+ ..., description="Ordered patch operations, applied in sequence."
+ )
+ name: str | None = Field(default=None, description="Optionally rename the canvas.")
+
+
+class UpdateCanvasResponse(BaseModel):
+ canvas: CanvasInfo | None = None
+ canvas_url: str | None = None
+ applied_ops: int | None = None
+ error: str | None = None
+ validation_errors: list[str] | None = Field(
+ default=None,
+ description="Patch or CDL failures — nothing was saved; fix and retry.",
+ )
+
+
+class GenerateCanvasResponse(BaseModel):
+ canvas: CanvasInfo | None = None
+ canvas_url: str | None = None
+ error: str | None = None
+ validation_errors: list[str] | None = Field(
+ default=None,
+ description="CDL validation failures — fix these and retry.",
+ )
+ warnings: list[str] | None = None
diff --git a/superset/mcp_service/canvas/tool/__init__.py b/superset/mcp_service/canvas/tool/__init__.py
new file mode 100644
index 00000000000..10b60c22c62
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/__init__.py
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from superset.mcp_service.canvas.tool.generate_canvas import generate_canvas
+from superset.mcp_service.canvas.tool.get_canvas import get_canvas
+from superset.mcp_service.canvas.tool.get_canvas_schema import get_canvas_schema
+from superset.mcp_service.canvas.tool.update_canvas import update_canvas
+
+__all__ = [
+ "generate_canvas",
+ "get_canvas",
+ "get_canvas_schema",
+ "update_canvas",
+]
diff --git a/superset/mcp_service/canvas/tool/generate_canvas.py b/superset/mcp_service/canvas/tool/generate_canvas.py
new file mode 100644
index 00000000000..0d799e37a1f
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/generate_canvas.py
@@ -0,0 +1,121 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: generate_canvas."""
+
+import logging
+
+from fastmcp import Context
+from flask import g
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import db, event_logger
+from superset.mcp_service.canvas.schemas import (
+ CanvasInfo,
+ GenerateCanvasRequest,
+ GenerateCanvasResponse,
+)
+from superset.mcp_service.canvas.validation import validate_cdl
+from superset.mcp_service.utils.url_utils import get_superset_base_url
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Canvas",
+ annotations=ToolAnnotations(
+ title="Create canvas",
+ readOnlyHint=False,
+ destructiveHint=False,
+ ),
+)
+def generate_canvas(
+ request: GenerateCanvasRequest,
+ ctx: Context, # noqa: ARG001
+) -> GenerateCanvasResponse:
+ """Create a NEW AI-native canvas (v2 dashboard) from a CDL definition.
+
+ Call get_canvas_schema first for the contract, and list_datasets /
+ get_dataset_info to resolve real datasetId/columns/metrics for bound
+ charts. The definition is validated server-side; on failure the response
+ carries ``validation_errors`` to fix and retry (no canvas is created).
+
+ Returns the canvas id and URL on success.
+ """
+ definition = request.definition
+
+ if validation_errors := validate_cdl(definition):
+ return GenerateCanvasResponse(
+ error="CDL validation failed; fix validation_errors and retry.",
+ validation_errors=validation_errors,
+ )
+
+ try:
+ # Imported lazily to avoid encrypted-column init before app setup,
+ # matching generate_dashboard.
+ from superset.extensions import security_manager
+ from superset.models.canvas import Canvas
+ from superset.subjects.utils import get_user_subject
+
+ with event_logger.log_context(action="mcp.generate_canvas.db_write"):
+ canvas = Canvas()
+ canvas.name = request.name
+ canvas.definition = json.dumps(definition)
+
+ # Re-query the user in this session (g.user may be bound to a
+ # torn-down session in the MCP context; see generate_dashboard).
+ current_user = (
+ db.session.query(security_manager.user_model)
+ .filter_by(id=g.user.id)
+ .first()
+ )
+ if current_user:
+ subject = get_user_subject(current_user.id)
+ if subject:
+ canvas.editors = [subject]
+
+ db.session.add(canvas)
+ db.session.commit() # pylint: disable=consider-using-transaction
+ try:
+ db.session.refresh(canvas)
+ except SQLAlchemyError:
+ logger.warning(
+ "Canvas %s created but refresh failed", canvas.id, exc_info=True
+ )
+
+ canvas_url = f"{get_superset_base_url()}/canvas/{canvas.id}/"
+ logger.info("Created canvas %s", canvas.id)
+ return GenerateCanvasResponse(
+ canvas=CanvasInfo(
+ id=canvas.id,
+ name=canvas.name,
+ url=canvas_url,
+ uuid=str(canvas.uuid) if canvas.uuid else None,
+ ),
+ canvas_url=canvas_url,
+ )
+ except (SQLAlchemyError, ValueError, AttributeError) as ex:
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Rollback failed during error handling", exc_info=True)
+ logger.error("Error creating canvas: %s", ex, exc_info=True)
+ return GenerateCanvasResponse(
+ error="Failed to create canvas due to an internal error."
+ )
diff --git a/superset/mcp_service/canvas/tool/get_canvas.py b/superset/mcp_service/canvas/tool/get_canvas.py
new file mode 100644
index 00000000000..bb27ea33071
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/get_canvas.py
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: get_canvas."""
+
+import logging
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.canvas.schemas import (
+ CanvasInfo,
+ GetCanvasRequest,
+ GetCanvasResponse,
+)
+from superset.mcp_service.canvas.utils import find_canvas
+from superset.mcp_service.utils.url_utils import get_superset_base_url
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+ tags=["read"],
+ class_permission_name="Canvas",
+ annotations=ToolAnnotations(
+ title="Get canvas",
+ readOnlyHint=True,
+ destructiveHint=False,
+ ),
+)
+def get_canvas(
+ request: GetCanvasRequest,
+ ctx: Context, # noqa: ARG001
+) -> GetCanvasResponse:
+ """Read a canvas and its CDL definition by id or uuid.
+
+ Call this before update_canvas so you can read the node ids you want to
+ target with patch operations.
+ """
+ try:
+ canvas = find_canvas(request.identifier)
+ if canvas is None:
+ return GetCanvasResponse(error=f"Canvas {request.identifier!r} not found.")
+ definition = json.loads(canvas.definition) if canvas.definition else None
+ return GetCanvasResponse(
+ canvas=CanvasInfo(
+ id=canvas.id,
+ name=canvas.name,
+ url=f"{get_superset_base_url()}/canvas/{canvas.id}/",
+ uuid=str(canvas.uuid) if canvas.uuid else None,
+ ),
+ definition=definition,
+ )
+ except (SQLAlchemyError, ValueError) as ex:
+ logger.error("Error reading canvas: %s", ex, exc_info=True)
+ return GetCanvasResponse(
+ error="Failed to read canvas due to an internal error."
+ )
diff --git a/superset/mcp_service/canvas/tool/get_canvas_schema.py b/superset/mcp_service/canvas/tool/get_canvas_schema.py
new file mode 100644
index 00000000000..6aa50362681
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/get_canvas_schema.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: get_canvas_schema."""
+
+import logging
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.canvas.schemas import (
+ GetCanvasSchemaRequest,
+ GetCanvasSchemaResponse,
+)
+from superset.mcp_service.canvas.validation import build_cdl_schema
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+ tags=["read"],
+ class_permission_name="Canvas",
+ annotations=ToolAnnotations(
+ title="Get canvas schema",
+ readOnlyHint=True,
+ destructiveHint=False,
+ ),
+)
+def get_canvas_schema(
+ request: GetCanvasSchemaRequest, # noqa: ARG001
+ ctx: Context, # noqa: ARG001
+) -> GetCanvasSchemaResponse:
+ """Return the CDL contract to author an AI-native canvas (v2 dashboard).
+
+ Call this BEFORE composing a canvas so you emit a valid definition. It
+ lists node types, the action vocabulary, formatter kinds, hard rules
+ (no code, declarative formatters), and a worked example.
+
+ Workflow: get_canvas_schema -> list_datasets -> get_dataset_info (columns
+ and metrics) -> compose CDL -> generate_canvas.
+ """
+ return GetCanvasSchemaResponse(cdl_schema=build_cdl_schema())
diff --git a/superset/mcp_service/canvas/tool/update_canvas.py b/superset/mcp_service/canvas/tool/update_canvas.py
new file mode 100644
index 00000000000..e9744473aad
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/update_canvas.py
@@ -0,0 +1,119 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: update_canvas."""
+
+import logging
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import db, event_logger
+from superset.mcp_service.canvas.patch import apply_ops
+from superset.mcp_service.canvas.schemas import (
+ CanvasInfo,
+ UpdateCanvasRequest,
+ UpdateCanvasResponse,
+)
+from superset.mcp_service.canvas.utils import find_canvas
+from superset.mcp_service.canvas.validation import validate_cdl
+from superset.mcp_service.utils.url_utils import get_superset_base_url
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Canvas",
+ annotations=ToolAnnotations(
+ title="Update canvas",
+ readOnlyHint=False,
+ destructiveHint=False,
+ ),
+)
+def update_canvas(
+ request: UpdateCanvasRequest,
+ ctx: Context, # noqa: ARG001
+) -> UpdateCanvasResponse:
+ """Edit an existing canvas with targeted patch ops instead of regenerating it.
+
+ Call get_canvas first to read node ids. Ops are applied in order:
+ - setStyle {id, style} restyle a node (merge by default)
+ - setProps {id, props} change component props (e.g. a title)
+ - setOption {id, option} change a Viz node's echarts option
+ - move {id, before|after|index, parent?} reorder / reparent a node
+ - insert {id: <parentId>, node, before|after|index} add a node
+ - replace {id, node} swap a node wholesale
+ - remove {id} delete a node
+
+ The patched CDL is fully re-validated; on any failure NOTHING is saved and
+ validation_errors explains why. Prefer this over generate_canvas for edits —
+ regenerating drifts the parts the user did not ask to change.
+ """
+ try:
+ canvas = find_canvas(request.identifier)
+ if canvas is None:
+ return UpdateCanvasResponse(
+ error=f"Canvas {request.identifier!r} not found."
+ )
+
+ definition = json.loads(canvas.definition) if canvas.definition else {}
+ ops = [op.model_dump(exclude_none=True) for op in request.ops]
+
+ updated, patch_errors = apply_ops(definition, ops)
+ if patch_errors:
+ return UpdateCanvasResponse(
+ error="Patch failed; nothing was saved.",
+ validation_errors=patch_errors,
+ )
+
+ cdl_errors = validate_cdl(updated)
+ if cdl_errors:
+ return UpdateCanvasResponse(
+ error="Patched canvas is not valid CDL; nothing was saved.",
+ validation_errors=cdl_errors,
+ )
+
+ with event_logger.log_context(action="mcp.update_canvas.db_write"):
+ canvas.definition = json.dumps(updated)
+ if request.name:
+ canvas.name = request.name
+ db.session.add(canvas)
+ db.session.commit() # pylint: disable=consider-using-transaction
+
+ canvas_url = f"{get_superset_base_url()}/canvas/{canvas.id}/"
+ logger.info("Updated canvas %s with %s ops", canvas.id, len(ops))
+ return UpdateCanvasResponse(
+ canvas=CanvasInfo(
+ id=canvas.id,
+ name=canvas.name,
+ url=canvas_url,
+ uuid=str(canvas.uuid) if canvas.uuid else None,
+ ),
+ canvas_url=canvas_url,
+ applied_ops=len(ops),
+ )
+ except (SQLAlchemyError, ValueError, AttributeError) as ex:
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Rollback failed during error handling", exc_info=True)
+ logger.error("Error updating canvas: %s", ex, exc_info=True)
+ return UpdateCanvasResponse(
+ error="Failed to update canvas due to an internal error."
+ )
diff --git a/superset/mcp_service/canvas/utils.py b/superset/mcp_service/canvas/utils.py
new file mode 100644
index 00000000000..eafef83a2e5
--- /dev/null
+++ b/superset/mcp_service/canvas/utils.py
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Shared helpers for the canvas MCP tools."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset.extensions import db
+
+
+def find_canvas(identifier: int | str) -> Any:
+ """Look up a Canvas by integer id or uuid string."""
+ from superset.models.canvas import Canvas
+
+ query = db.session.query(Canvas)
+ if isinstance(identifier, int) or str(identifier).isdigit():
+ return query.filter(Canvas.id == int(identifier)).one_or_none()
+ return query.filter(Canvas.uuid == str(identifier)).one_or_none()
diff --git a/superset/mcp_service/canvas/validation.py b/superset/mcp_service/canvas/validation.py
new file mode 100644
index 00000000000..bb25e21a3a4
--- /dev/null
+++ b/superset/mcp_service/canvas/validation.py
@@ -0,0 +1,734 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Server-side CDL validation.
+
+A faithful port of the frontend validator (superset-frontend/src/Canvas/
+validator.ts). It enforces structure, the no-code invariant, and variable
+reference integrity so that ``generate_canvas`` can reject bad AI output with
+structured errors the agent self-corrects against.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+# The catalog: the allowlist of node types plus their contract. Mirrors
+# superset-frontend/src/Canvas/catalog.ts.
+NODE_CATALOG: dict[str, dict[str, Any]] = {
+ "Column": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Row": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Card": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Tabs": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Board": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Tab": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": ["label"],
+ }, # noqa: E501
+ "Divider": {
+ "category": "display",
+ "container": False,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Alert": {
+ "category": "display",
+ "container": False,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": ["message"],
+ }, # noqa: E501
+ "Progress": {
+ "category": "display",
+ "container": False,
+ "events": [],
+ "bindableProps": ["value"],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Collapse": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Modal": {
+ "category": "layout",
+ "container": True,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Input": {
+ "category": "control",
+ "container": False,
+ "events": ["change"],
+ "bindableProps": ["value"],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Switch": {
+ "category": "control",
+ "container": False,
+ "events": ["change"],
+ "bindableProps": ["value"],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Select": {
+ "category": "control",
+ "container": False,
+ "events": ["change"],
+ "bindableProps": ["value"],
+ "requiredProps": ["options"],
+ }, # noqa: E501
+ "Button": {
+ "category": "control",
+ "container": False,
+ "events": ["click"],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+ "Filter": {
+ "category": "control",
+ "container": False,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": ["column"],
+ }, # noqa: E501
+ "Markdown": {
+ "category": "display",
+ "container": False,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": ["text"],
+ }, # noqa: E501
+ "Viz": {
+ "category": "viz",
+ "container": False,
+ "events": [],
+ "bindableProps": [],
+ "requiredProps": [],
+ }, # noqa: E501
+}
+
+ACTION_REQUIRED: dict[str, list[str]] = {
+ "setVariable": ["name", "value"],
+ "applyFilter": ["col", "op", "val"],
+ "crossFilter": ["col", "op", "val"],
+ "clearFilters": [],
+ "navigateTab": ["tabsId", "tab"],
+ "openModal": ["modalId"],
+ "closeModal": ["modalId"],
+ "openUrl": ["url"],
+ "refresh": [],
+}
+
+FORMATTER_KINDS = ["number", "currency", "percent", "date", "template"]
+
+# Declarative styling: an allowlisted CSS-property object (never a CSS string).
+# Values may be literals or "@themeToken" references resolved from the antd
+# theme at render time. Mirrors superset-frontend/src/Canvas/style.ts.
+STYLE_PROPERTIES = [
+ "padding",
+ "paddingTop",
+ "paddingRight",
+ "paddingBottom",
+ "paddingLeft",
+ "margin",
+ "marginTop",
+ "marginRight",
+ "marginBottom",
+ "marginLeft",
+ "gap",
+ "rowGap",
+ "columnGap",
+ "width",
+ "minWidth",
+ "maxWidth",
+ "height",
+ "minHeight",
+ "maxHeight",
+ "background",
+ "backgroundColor",
+ "color",
+ "border",
+ "borderColor",
+ "borderWidth",
+ "borderStyle",
+ "borderRadius",
+ "boxShadow",
+ "opacity",
+ "overflow",
+ "fontSize",
+ "fontWeight",
+ "fontFamily",
+ "lineHeight",
+ "letterSpacing",
+ "textAlign",
+ "textTransform",
+ "display",
+ "flex",
+ "flexDirection",
+ "flexWrap",
+ "alignItems",
+ "justifyContent",
+ "alignSelf",
+ "gridTemplateColumns",
+ "gridTemplateRows",
+ "gridColumn",
+ "gridRow",
+ "position",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "inset",
+ "zIndex",
+ "transform",
+ "transformOrigin",
+ "rotate",
+ "scale",
+ "translate",
+ "transition",
+ "aspectRatio",
+ "objectFit",
+ "filter",
+ "backdropFilter",
+ "mixBlendMode",
+ "cursor",
+ "pointerEvents",
+]
+
+_UNSAFE_STYLE_VALUE = re.compile(r"url\(|expression\(|javascript:|@import|<|/\*", re.I)
+
+_VAR_REF = re.compile(r"^\$([A-Za-z_]\w*)$")
+_CODE_SMELL = re.compile(r"=>|\bfunction\b|new\s+Function", re.IGNORECASE)
+_JS_URL = re.compile(r"^\s*(javascript|data|vbscript):", re.IGNORECASE)
+_EVENT_TOKEN = "$event" # noqa: S105
+
+
+def _is_var_ref(value: Any) -> bool:
+ return isinstance(value, str) and bool(_VAR_REF.match(value))
+
+
+def _scan_no_code(value: Any, path: str, errors: list[str]) -> None:
+ """The core safety gate: reject executable-looking strings."""
+ if isinstance(value, str):
+ if _CODE_SMELL.search(value):
+ errors.append(f"{path}: disallowed executable string (no-code invariant)")
+ return
+ if isinstance(value, list):
+ for i, item in enumerate(value):
+ _scan_no_code(item, f"{path}[{i}]", errors)
+ return
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if key == "formatter" and isinstance(item, str):
+ errors.append(
+ f"{path}.formatter: formatter must be a declarative object, "
+ "not a string"
+ )
+ _scan_no_code(item, f"{path}.{key}", errors)
+
+
+def _collect_refs(value: Any, out: set[str]) -> None:
+ if _is_var_ref(value) and value != _EVENT_TOKEN:
+ out.add(value[1:])
+ elif isinstance(value, list):
+ for item in value:
+ _collect_refs(item, out)
+ elif isinstance(value, dict):
+ for item in value.values():
+ _collect_refs(item, out)
+
+
+def _validate_style(style: Any, path: str, errors: list[str]) -> None:
+ if style is None:
+ return
+ if not isinstance(style, dict):
+ errors.append(f"{path}: style must be an object")
+ return
+ for prop, value in style.items():
+ if prop not in STYLE_PROPERTIES:
+ errors.append(f"{path}.{prop}: unsupported style property")
+ continue
+ if not isinstance(value, (str, int, float)) or isinstance(value, bool):
+ errors.append(f"{path}.{prop}: style values must be a string or number")
+ continue
+ if isinstance(value, str) and _UNSAFE_STYLE_VALUE.search(value):
+ errors.append(f"{path}.{prop}: disallowed value")
+
+
+def _validate_layout(layout: Any, path: str, errors: list[str]) -> None:
+ if layout is None:
+ return
+ if not isinstance(layout, dict):
+ errors.append(f"{path}: layout must be an object")
+ return
+ for key in ("x", "y", "w", "h"):
+ value = layout.get(key)
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
+ errors.append(f"{path}.{key}: {key} must be a number")
+ for key in ("w", "h"):
+ value = layout.get(key)
+ if isinstance(value, (int, float)) and value < 1:
+ errors.append(f"{path}.{key}: {key} must be at least 1")
+
+
+def _validate_action(
+ action: Any, path: str, declared: set[str], errors: list[str]
+) -> None:
+ if not isinstance(action, dict):
+ errors.append(f"{path}: action must be an object")
+ return
+ name = action.get("action")
+ required = ACTION_REQUIRED.get(name) if isinstance(name, str) else None
+ if required is None:
+ errors.append(f"{path}: unknown action {name!r}")
+ return
+ for key in required:
+ if action.get(key) is None:
+ errors.append(f"{path}: action {name!r} missing {key!r}")
+ if name == "openUrl":
+ url = action.get("url")
+ if isinstance(url, str) and _JS_URL.match(url):
+ errors.append(f"{path}.url: openUrl allows http(s) only")
+ if name == "setVariable" and action.get("name") not in declared:
+ errors.append(f"{path}.name: undeclared variable {action.get('name')!r}")
+
+
+def _validate_node( # noqa: C901
+ node: Any, path: str, declared: set[str], errors: list[str]
+) -> None:
+ if not isinstance(node, dict):
+ errors.append(f"{path}: node must be an object")
+ return
+ if not isinstance(node.get("id"), str) or not node.get("id"):
+ errors.append(f"{path}: node.id (string) is required")
+
+ node_type = node.get("type")
+ if node_type not in NODE_CATALOG:
+ errors.append(f"{path}: unknown node type {node_type!r} (not in catalog)")
+ return
+ entry = NODE_CATALOG[node_type]
+ props = node.get("props") or {}
+
+ for prop in entry["requiredProps"]:
+ if props.get(prop) is None:
+ errors.append(f"{path}.props.{prop}: required prop missing")
+
+ children = node.get("children") or []
+ if children and not entry["container"]:
+ errors.append(f"{path}.children: {node_type!r} cannot have children")
+
+ for prop, ref in (node.get("bind") or {}).items():
+ if prop not in entry["bindableProps"]:
+ errors.append(f"{path}.bind.{prop}: prop is not bindable")
+ elif not _is_var_ref(ref):
+ errors.append(f"{path}.bind.{prop}: must be a $var reference")
+ elif ref[1:] not in declared:
+ errors.append(f"{path}.bind.{prop}: undeclared variable {ref!r}")
+
+ for event, actions in (node.get("on") or {}).items():
+ if event not in entry["events"]:
+ errors.append(f"{path}.on.{event}: {node_type!r} does not emit {event!r}")
+ for i, action in enumerate(actions or []):
+ _validate_action(action, f"{path}.on.{event}[{i}]", declared, errors)
+
+ if node_type == "Viz":
+ renderer = node.get("renderer")
+ if renderer == "echarts":
+ data = node.get("data") or {}
+ if not data.get("queryContext"):
+ errors.append(f"{path}.data: echarts Viz requires data.queryContext")
+ if not data.get("encoding"):
+ errors.append(f"{path}.data: echarts Viz requires data.encoding")
+ _scan_no_code(node.get("option"), f"{path}.option", errors)
+ elif renderer == "supersetChart":
+ if not isinstance(node.get("chartId"), int):
+ errors.append(f"{path}.chartId: supersetChart Viz requires chartId")
+ else:
+ errors.append(f"{path}.renderer: unknown Viz renderer {renderer!r}")
+
+ _scan_no_code(props, f"{path}.props", errors)
+ _validate_style(node.get("style"), f"{path}.style", errors)
+ _validate_layout(node.get("layout"), f"{path}.layout", errors)
+
+ refs: set[str] = set()
+ _collect_refs(props, refs)
+ _collect_refs(node.get("on"), refs)
+ if node_type == "Viz":
+ _collect_refs((node.get("data") or {}).get("queryContext"), refs)
+ for name in refs:
+ if name not in declared:
+ errors.append(f"{path}: references undeclared variable ${name}")
+
+ for i, child in enumerate(children):
+ _validate_node(child, f"{path}.children[{i}]", declared, errors)
+
+
+def validate_cdl(definition: Any) -> list[str]:
+ """Return a list of human-readable validation errors (empty == valid)."""
+ errors: list[str] = []
+ if not isinstance(definition, dict):
+ return ["definition must be an object"]
+ if not isinstance(definition.get("cdlVersion"), int):
+ errors.append("cdlVersion (int) is required")
+ variables = definition.get("variables")
+ if not isinstance(variables, dict):
+ errors.append("variables object is required")
+ variables = {}
+ tree = definition.get("tree")
+ if not tree:
+ errors.append("tree (root node) is required")
+ return errors
+ declared = set(variables.keys())
+ _validate_node(tree, "tree", declared, errors)
+ return errors
+
+
+def build_cdl_schema() -> dict[str, Any]:
+ """The machine-readable CDL contract handed to the authoring agent."""
+ return {
+ "cdlVersion": 2,
+ "summary": (
+ "Canvas Definition Language: a typed, declarative component tree for "
+ "an AI-native dashboard. Compose layout, controls, and charts. Nothing "
+ "is code — presentation is a data 'option', data is a 'queryContext' + "
+ "'encoding', behaviour is a bounded 'action' enum."
+ ),
+ "envelope": {
+ "cdlVersion": "int (use 2)",
+ "variables": "map of name -> {type: string|number|boolean, default, scope: query|ui}", # noqa: E501
+ "tree": "the root node",
+ "canvasWidth": (
+ "optional outer width cap. Omit or 'full' for full-bleed "
+ "(default, like a dashboard — use for boards and overviews); a "
+ "CSS width like '820px' centres a narrow reading column (use "
+ "for the narrative idiom)."
+ ),
+ },
+ "variableScopes": {
+ "query": "projected onto dataMask; drives bound queries (governed, RLS, cached)", # noqa: E501
+ "ui": "client-only (active tab, toggles); never hits the backend",
+ },
+ "composition": {
+ "summary": (
+ "Do NOT default to a grid of chart cards with a filter bar on "
+ "top — that is just a dashboard, and Superset already builds "
+ "those. Choose the layout that fits the job, and put controls "
+ "next to the thing they control."
+ ),
+ "idioms": {
+ "narrative": (
+ "A written brief. Set envelope canvasWidth:'820px' (or a "
+ "root Column with style {maxWidth:'820px', margin:'0 auto'}); "
+ "prose Markdown, charts as BARE Viz nodes "
+ "between paragraphs (no Card), each followed by a small "
+ "caption Markdown styled {color:'@colorTextSecondary', "
+ "fontSize:'13px'}. Use when the canvas makes an argument."
+ ),
+ "tool": (
+ "A parameterised instrument. Inline controls (see "
+ "controlPlacement) followed by the charts they govern; "
+ "split into sections so a later control only affects what "
+ "comes after it."
+ ),
+ "bento": (
+ "An overview. A Row with style {display:'grid', "
+ "gridTemplateColumns:'repeat(12, minmax(0, 1fr))', gap:'20px'} "
+ "and children given varied {gridColumn:'span N'} (and "
+ "optionally {gridRow:'span 2'}). Vary the spans — a uniform "
+ "grid reads as a dashboard."
+ ),
+ "sidebar": (
+ "A Row containing a Column {flex:'0 0 300px'} rail and a "
+ "Column {flex:'1 1 420px'} main area."
+ ),
+ "freeform": (
+ "A Board node — coordinate placement, like a whiteboard or "
+ "a report designer. Set props {columns:12, rowHeight:40, "
+ "gap:8}; give each child a 'layout' {x,y,w,h,z?} in grid "
+ "units (x,y = top-left cell, w,h = span). Children may "
+ "OVERLAP — use 'z' to order them, e.g. a big-number tile "
+ "floating over a faint background chart, or a callout "
+ "pinned to a corner. Nothing else in Superset can do this."
+ ),
+ },
+ "controlPlacement": {
+ "rule": (
+ "Prefer controls embedded where they are relevant over a "
+ "control panel at the top. Spread multiple controls down "
+ "the page, each above the charts it affects."
+ ),
+ "inlineSentenceRecipe": (
+ "A Row styled {alignItems:'center', gap:'10px', "
+ "flexWrap:'wrap', fontSize:'19px'} containing: Markdown "
+ "fragment, the Input/Select, another Markdown fragment. "
+ "Give each Markdown {flex:'0 0 auto', margin:'0'} and the "
+ "input {flex:'0 0 110px'}, and drop the control's label — "
+ "the sentence carries the meaning."
+ ),
+ },
+ "parametersVsFilters": (
+ "A Filter node picks values FROM a column — the same thing a "
+ "native dashboard filter does. A variable bound to an Input, "
+ "referenced in a filter with an operator "
+ "({col:'sales', op:'>', val:'$threshold'}), is a real "
+ "PARAMETER and has no dashboard equivalent. Reach for it "
+ "whenever the question is 'above/below X'. Declare the "
+ "variable as type 'number' so typed text is coerced."
+ ),
+ "gotchas": [
+ "Modal must be a child of the ROOT node, never of a grid — it "
+ "portals out of the DOM and would leave an empty grid cell.",
+ "Buttons/Switches inside a Row need {flex:'0 0 auto'}, or the "
+ "row stretches them to equal width.",
+ "Use 'repeat(12, minmax(0, 1fr))'; plain '1fr' lets a wide "
+ "chart blow out its track and force horizontal scrolling.",
+ "Panels sharing a grid band should have similar heights, or "
+ "set the grid {alignItems:'stretch'} and the cards "
+ "{height:'100%'}.",
+ "Any node that HOSTS a control (Filter/Select/Input) should "
+ "use '@colorBgContainer' for its background, not a hardcoded "
+ "colour — the control's label follows the theme and will be "
+ "invisible on a fixed surface in one of light/dark.",
+ ],
+ },
+ "styling": {
+ "summary": (
+ "Every node accepts an optional 'style' OBJECT (never a CSS "
+ "string) for layout and appearance. Values are literals "
+ "('16px', '1fr 1fr') or '@themeToken' references resolved from "
+ "the antd theme at render time — prefer tokens so light/dark "
+ "theming keeps working."
+ ),
+ "properties": STYLE_PROPERTIES,
+ "tokenExamples": [
+ "@colorPrimary",
+ "@colorBgContainer",
+ "@colorBorder",
+ "@colorText",
+ "@colorTextSecondary",
+ "@borderRadius",
+ "@boxShadow",
+ "@fontSizeLG",
+ ],
+ "example": {
+ "style": {
+ "padding": "16px",
+ "background": "@colorBgContainer",
+ "borderRadius": "@borderRadius",
+ "boxShadow": "@boxShadow",
+ }
+ },
+ },
+ "commonNodeFields": {
+ "id": "stable unique string",
+ "type": "one of nodeTypes",
+ "props": "typed props for the component",
+ "style": "optional allowlisted style object (see 'styling')",
+ "layout": "optional {x,y,w,h,z?} placement inside a Board parent",
+ "bind": "{prop: '$var'} two-way binding (only bindableProps)",
+ "on": "{event: [action, ...]} declarative handlers (only listed events)",
+ "children": "array of nodes (containers only)",
+ },
+ "nodeTypes": NODE_CATALOG,
+ "vizRenderers": {
+ "echarts": {
+ "data": {
+ "queryContext": {
+ "datasetId": "int (from list_datasets / get_dataset_info)",
+ "metrics": "list of saved-metric names or SQL like 'SUM(sales)'", # noqa: E501
+ "groupby": "list of column names",
+ "filters": "list of {col, op, val} — val may be '$var'",
+ "rowLimit": "int — combine with orderby for top-N",
+ "orderby": (
+ "list of {by, desc} — 'by' names a metric or groupby "
+ "column. Use [{by: 'SUM(sales)', desc: true}] with "
+ "rowLimit for top-N, or [{by: 'year'}] to sort a "
+ "time axis chronologically."
+ ),
+ },
+ "encoding": {
+ "x": "category column",
+ "y": "value column or list",
+ "series": "optional column that fans into one series each",
+ },
+ },
+ "option": "an echarts option object (DATA ONLY — no functions)",
+ },
+ "supersetChart": {
+ "chartId": "int — an existing saved chart (use list_charts to find one)", # noqa: E501
+ "filters": "optional extra [{col, op, val}] — val may be '$var'",
+ "notes": (
+ "Renders the saved chart with its own viz plugin and "
+ "form_data. Canvas Filter nodes on the same dataset are "
+ "merged into its query automatically, so governed charts "
+ "react to canvas filters. Prefer this over an echarts Viz "
+ "when a suitable saved chart already exists."
+ ),
+ },
+ },
+ "filters": {
+ "summary": (
+ "A Filter node is a dashboard-style filter the user places. It "
+ "auto-populates its options from the column's distinct values and "
+ "auto-applies to EVERY echarts Viz on the same dataset — you do "
+ "NOT need to add it to each chart's queryContext.filters."
+ ),
+ "props": {
+ "column": "column to filter on (required)",
+ "dataset": "datasetId the filter applies to (match your Viz datasetId)", # noqa: E501
+ "label": "display label",
+ "multiple": "true for a multi-select (IN) filter",
+ "op": "override the operator (default '==' single, 'IN' multiple)",
+ "options": "optional explicit [{value,label}]; omit to auto-fetch",
+ },
+ "example": {
+ "id": "f_platform",
+ "type": "Filter",
+ "props": {
+ "column": "platform",
+ "dataset": 1,
+ "label": "Platform",
+ "multiple": True,
+ },
+ },
+ },
+ "actions": {
+ name: {"required": required} for name, required in ACTION_REQUIRED.items()
+ },
+ "formatters": {
+ "kinds": FORMATTER_KINDS,
+ "shape": "{kind: 'currency', currency: 'USD'} etc — declarative, resolved to a function client-side", # noqa: E501
+ "usage": "attach under option.yAxis.axisLabel.formatter or option.tooltip.valueFormatter", # noqa: E501
+ },
+ "rules": [
+ "NEVER emit a function or code string anywhere (formatters, handlers, option).", # noqa: E501
+ "A 'formatter' must be a declarative object, never a string.",
+ "openUrl allows http(s) only.",
+ "Every '$var' referenced must be declared in variables.",
+ "Only containers (Column, Row, Card, Tabs, Tab) may have children.",
+ "Tabs must contain Tab children (each needs a 'label'); the navigateTab "
+ "action switches them via {tabsId: <Tabs node id>, tab: <Tab node id>}.",
+ "Button actions are fully wired: setVariable, applyFilter/crossFilter "
+ "(write canvas filters), clearFilters (clears filters + resets variables), "
+ "navigateTab, refresh (re-runs every bound query).",
+ "Input and Switch two-way bind their 'value' to a $var, like Select.",
+ "Modal is hidden until a Button fires "
+ "{action:'openModal', modalId:<Modal id>} "
+ "— use it for drill-in detail panels without leaving the canvas.",
+ "Collapse children each become a panel titled by their props.label.",
+ "Alert (message + type info|success|warning|error) is for narrative "
+ "callouts; Progress binds 'value' to a $var for goal tracking.",
+ "Introspect datasets via list_datasets + get_dataset_info before building queryContext.", # noqa: E501
+ ],
+ "example": {
+ "cdlVersion": 2,
+ "variables": {
+ "region": {"type": "string", "default": "APAC", "scope": "query"}
+ }, # noqa: E501
+ "tree": {
+ "id": "root",
+ "type": "Column",
+ "children": [
+ {
+ "id": "title",
+ "type": "Markdown",
+ "props": {"text": "Sales by month"},
+ }, # noqa: E501
+ {
+ "id": "controls",
+ "type": "Row",
+ "children": [
+ {
+ "id": "region",
+ "type": "Select",
+ "props": {
+ "label": "Region",
+ "options": [
+ {"value": "APAC", "label": "APAC"},
+ {"value": "EMEA", "label": "EMEA"},
+ ],
+ },
+ "bind": {"value": "$region"},
+ }
+ ],
+ },
+ {
+ "id": "chart",
+ "type": "Viz",
+ "renderer": "echarts",
+ "data": {
+ "queryContext": {
+ "datasetId": 1,
+ "metrics": ["SUM(sales)"],
+ "groupby": ["month"],
+ "filters": [
+ {"col": "region", "op": "==", "val": "$region"}
+ ], # noqa: E501
+ },
+ "encoding": {"x": "month", "y": "SUM(sales)"},
+ },
+ "option": {
+ "series": [{"type": "line"}],
+ "tooltip": {
+ "valueFormatter": {
+ "kind": "currency",
+ "currency": "USD",
+ }
+ },
+ },
+ },
+ ],
+ },
+ },
+ }
diff --git a/superset/migrations/versions/2026-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py b/superset/migrations/versions/2026-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py
new file mode 100644
index 00000000000..1ae31b1176b
--- /dev/null
+++ b/superset/migrations/versions/2026-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py
@@ -0,0 +1,78 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""add canvas tables
+
+Revision ID: c4a1b2d3e5f6
+Revises: e5f6a7b8c9d0
+Create Date: 2026-07-24 00:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+from sqlalchemy_utils import UUIDType
+
+from superset.migrations.shared.utils import create_table, drop_table
+
+# revision identifiers, used by Alembic.
+revision = "c4a1b2d3e5f6"
+down_revision = "e5f6a7b8c9d0"
+
+
+def upgrade() -> None:
+ create_table(
+ "canvas",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("uuid", UUIDType(binary=True), nullable=True),
+ sa.Column("name", sa.String(length=500), nullable=False),
+ sa.Column("definition", sa.Text(), nullable=True),
+ sa.Column("created_on", sa.DateTime(), nullable=True),
+ sa.Column("changed_on", sa.DateTime(), nullable=True),
+ sa.Column("created_by_fk", sa.Integer(), nullable=True),
+ sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
+ sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("uuid"),
+ )
+
+ create_table(
+ "canvas_editors",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("subject_id", sa.Integer(), nullable=False),
+ sa.Column("canvas_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(["subject_id"], ["subjects.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["canvas_id"], ["canvas.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("subject_id", "canvas_id"),
+ )
+
+ create_table(
+ "canvas_viewers",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("subject_id", sa.Integer(), nullable=False),
+ sa.Column("canvas_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(["subject_id"], ["subjects.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["canvas_id"], ["canvas.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("subject_id", "canvas_id"),
+ )
+
+
+def downgrade() -> None:
+ drop_table("canvas_viewers")
+ drop_table("canvas_editors")
+ drop_table("canvas")
diff --git a/superset/models/canvas.py b/superset/models/canvas.py
new file mode 100644
index 00000000000..349ec6ca5d7
--- /dev/null
+++ b/superset/models/canvas.py
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""A Canvas is a v2, AI-native dashboard.
+
+Unlike a v1 ``Dashboard`` (which stores a ``position_json`` layout referencing
+saved charts), a Canvas stores a single CDL (Canvas Definition Language) tree in
+``definition`` — a typed, declarative component tree an AI emits and the
+``CanvasRenderer`` walks. See superset-frontend/src/Canvas/DESIGN.md.
+"""
+
+from __future__ import annotations
+
+from flask_appbuilder import Model
+from sqlalchemy import Column, Integer, String
+from sqlalchemy.orm import relationship
+
+from superset.models.helpers import AuditMixinNullable, UUIDMixin
+from superset.subjects.models import canvas_editors, canvas_viewers, Subject
+from superset.utils.core import MediumText
+
+
+class Canvas(AuditMixinNullable, UUIDMixin, Model):
+ __tablename__ = "canvas"
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String(500), nullable=False, default="Untitled canvas")
+ # The CDL definition, stored as a JSON string.
+ definition = Column(MediumText())
+
+ editors = relationship(Subject, secondary=canvas_editors, passive_deletes=True)
+ viewers = relationship(Subject, secondary=canvas_viewers, passive_deletes=True)
+
+ def __repr__(self) -> str:
+ return f"Canvas<{self.id} {self.name}>"
diff --git a/superset/subjects/models.py b/superset/subjects/models.py
index aaf173ba9b6..7ae2dbb34ac 100644
--- a/superset/subjects/models.py
+++ b/superset/subjects/models.py
@@ -201,3 +201,41 @@ report_schedule_editors = Table(
),
UniqueConstraint("subject_id", "report_schedule_id"),
)
+
+canvas_editors = Table(
+ "canvas_editors",
+ metadata,
+ Column("id", Integer, primary_key=True),
+ Column(
+ "subject_id",
+ Integer,
+ ForeignKey("subjects.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ Column(
+ "canvas_id",
+ Integer,
+ ForeignKey("canvas.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ UniqueConstraint("subject_id", "canvas_id"),
+)
+
+canvas_viewers = Table(
+ "canvas_viewers",
+ metadata,
+ Column("id", Integer, primary_key=True),
+ Column(
+ "subject_id",
+ Integer,
+ ForeignKey("subjects.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ Column(
+ "canvas_id",
+ Integer,
+ ForeignKey("canvas.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ UniqueConstraint("subject_id", "canvas_id"),
+)
diff --git a/superset/views/canvas.py b/superset/views/canvas.py
new file mode 100644
index 00000000000..947bfe230a3
--- /dev/null
+++ b/superset/views/canvas.py
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from flask_appbuilder.api import expose
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_appbuilder.security.decorators import has_access
+
+from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod
+from superset.models.canvas import Canvas
+from superset.superset_typing import FlaskResponse
+from superset.views.base import DeleteMixin, SupersetModelView
+
+
+class CanvasModelView( # pylint: disable=too-many-ancestors
+ SupersetModelView,
+ DeleteMixin,
+):
+ route_base = "/canvas"
+ datamodel = SQLAInterface(Canvas)
+ include_route_methods = RouteMethod.LIST
+
+ class_permission_name = "Canvas"
+ method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
+
+ @expose("/list/")
+ @has_access
+ def list(self) -> FlaskResponse:
+ return super().render_app_template()
diff --git a/superset/views/core.py b/superset/views/core.py
index 37d13f0a1c2..17e36a4d337 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -847,6 +847,31 @@ class Superset(BaseSupersetView):
standalone_mode=ReservedUrlParameters.is_standalone_mode(),
)
+ @has_access
+ @expose("/canvas/<int:pk>/")
+ def canvas(self, pk: int) -> FlaskResponse:
+ """Server-side entry that serves the SPA shell for a v2 canvas.
+
+ Object-level data access is enforced by ``CanvasRestApi`` (the viewer
+ fetches the definition through it); this view only gates the shell.
+ """
+ from superset.models.canvas import Canvas
+
+ canvas_obj = db.session.query(Canvas).filter(Canvas.id == pk).one_or_none()
+ if not canvas_obj:
+ if not get_current_user():
+ return redirect_to_login()
+ abort(404)
+
+ bootstrap_payload = {
+ "user": bootstrap_user_data(g.user, include_perms=True),
+ "common": common_bootstrap_payload(),
+ }
+ return self.render_app_template(
+ extra_bootstrap_data=bootstrap_payload,
+ title=canvas_obj.name,
+ )
+
@has_access
@expose("/dashboard/p/<key>/", methods=("GET",))
def dashboard_permalink(
diff --git a/tests/unit_tests/mcp_service/canvas/__init__.py b/tests/unit_tests/mcp_service/canvas/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/canvas/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/tests/unit_tests/mcp_service/canvas/test_canvas_patch.py b/tests/unit_tests/mcp_service/canvas/test_canvas_patch.py
new file mode 100644
index 00000000000..c744f2fba91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/canvas/test_canvas_patch.py
@@ -0,0 +1,151 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for targeted CDL patch operations."""
+
+from copy import deepcopy
+from typing import Any
+
+from superset.mcp_service.canvas.patch import apply_ops, find_node
+from superset.mcp_service.canvas.validation import validate_cdl
+
+
+def _definition() -> dict[str, Any]:
+ return {
+ "cdlVersion": 2,
+ "variables": {},
+ "tree": {
+ "id": "root",
+ "type": "Column",
+ "children": [
+ {
+ "id": "a",
+ "type": "Markdown",
+ "props": {"text": "A"},
+ "style": {"color": "#fff"},
+ },
+ {"id": "b", "type": "Markdown", "props": {"text": "B"}},
+ {"id": "c", "type": "Markdown", "props": {"text": "C"}},
+ ],
+ },
+ }
+
+
+def _order(definition: dict[str, Any]) -> list[str]:
+ return [child["id"] for child in definition["tree"]["children"]]
+
+
+def test_set_style_merges_by_default() -> None:
+ updated, errors = apply_ops(
+ _definition(),
+ [{"op": "setStyle", "id": "a", "style": {"background": "@colorBgContainer"}}],
+ )
+ assert errors == []
+ node, _ = find_node(updated["tree"], "a")
+ assert node is not None
+ assert node["style"] == {"color": "#fff", "background": "@colorBgContainer"}
+
+
+def test_set_style_replaces_when_merge_false() -> None:
+ updated, errors = apply_ops(
+ _definition(),
+ [
+ {
+ "op": "setStyle",
+ "id": "a",
+ "style": {"padding": "8px"},
+ "merge": False,
+ }
+ ],
+ )
+ assert errors == []
+ node, _ = find_node(updated["tree"], "a")
+ assert node is not None
+ assert node["style"] == {"padding": "8px"}
+
+
+def test_move_reorders_within_parent() -> None:
+ updated, errors = apply_ops(
+ _definition(), [{"op": "move", "id": "c", "before": "a"}]
+ )
+ assert errors == []
+ assert _order(updated) == ["c", "a", "b"]
+
+
+def test_remove_and_insert() -> None:
+ updated, errors = apply_ops(
+ _definition(),
+ [
+ {"op": "remove", "id": "b"},
+ {
+ "op": "insert",
+ "id": "root",
+ "node": {"id": "z", "type": "Divider"},
+ "after": "a",
+ },
+ ],
+ )
+ assert errors == []
+ assert _order(updated) == ["a", "z", "c"]
+
+
+def test_replace_swaps_a_node() -> None:
+ updated, errors = apply_ops(
+ _definition(),
+ [
+ {
+ "op": "replace",
+ "id": "b",
+ "node": {"id": "b", "type": "Markdown", "props": {"text": "new"}},
+ }
+ ],
+ )
+ assert errors == []
+ node, _ = find_node(updated["tree"], "b")
+ assert node is not None
+ assert node["props"]["text"] == "new"
+
+
+def test_input_definition_is_never_mutated() -> None:
+ definition = _definition()
+ snapshot = deepcopy(definition)
+ apply_ops(definition, [{"op": "move", "id": "c", "before": "a"}])
+ assert definition == snapshot
+
+
+def test_errors_for_unknown_id_and_op() -> None:
+ _, errors = apply_ops(
+ _definition(), [{"op": "setStyle", "id": "nope", "style": {}}]
+ )
+ assert any("no node with id" in e for e in errors)
+
+ _, errors = apply_ops(_definition(), [{"op": "bogus", "id": "a"}])
+ assert any("unknown op" in e for e in errors)
+
+
+def test_move_requires_a_target_position() -> None:
+ _, errors = apply_ops(_definition(), [{"op": "move", "id": "a"}])
+ assert any("needs one of" in e for e in errors)
+
+
+def test_root_cannot_be_removed() -> None:
+ _, errors = apply_ops(_definition(), [{"op": "remove", "id": "root"}])
+ assert any("cannot remove the root" in e for e in errors)
+
+
+def test_patched_tree_still_validates() -> None:
+ updated, _ = apply_ops(_definition(), [{"op": "move", "id": "c", "before": "a"}])
+ assert validate_cdl(updated) == []
diff --git a/tests/unit_tests/mcp_service/canvas/test_canvas_validation.py b/tests/unit_tests/mcp_service/canvas/test_canvas_validation.py
new file mode 100644
index 00000000000..17e40923819
--- /dev/null
+++ b/tests/unit_tests/mcp_service/canvas/test_canvas_validation.py
@@ -0,0 +1,81 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for the server-side CDL validator."""
+
+from copy import deepcopy
+from typing import Any
+
+from superset.mcp_service.canvas.validation import build_cdl_schema, validate_cdl
+
+
+def _example() -> dict[str, Any]:
+ return deepcopy(build_cdl_schema()["example"])
+
+
+def test_schema_example_is_valid() -> None:
+ assert validate_cdl(_example()) == []
+
+
+def test_rejects_function_string_in_option() -> None:
+ definition = _example()
+ definition["tree"]["children"][2]["option"]["tooltip"] = {
+ "formatter": "(v) => v.toFixed(2)"
+ }
+ errors = validate_cdl(definition)
+ assert any("no-code invariant" in e for e in errors)
+ assert any("must be a declarative object" in e for e in errors)
+
+
+def test_rejects_undeclared_variable() -> None:
+ definition = _example()
+ definition["tree"]["children"][1]["children"][0]["bind"]["value"] = "$nope"
+ errors = validate_cdl(definition)
+ assert any("undeclared variable" in e for e in errors)
+
+
+def test_rejects_javascript_url_in_open_url() -> None:
+ definition = _example()
+ definition["tree"]["children"][0] = {
+ "id": "link",
+ "type": "Button",
+ "props": {"children": "Go"},
+ "on": {"click": [{"action": "openUrl", "url": "javascript:alert(1)"}]},
+ }
+ errors = validate_cdl(definition)
+ assert any("http(s) only" in e for e in errors)
+
+
+def test_rejects_children_on_non_container() -> None:
+ definition = _example()
+ definition["tree"]["children"][0]["children"] = [
+ {"id": "x", "type": "Markdown", "props": {"text": "no"}}
+ ]
+ errors = validate_cdl(definition)
+ assert any("cannot have children" in e for e in errors)
+
+
+def test_rejects_unknown_node_type() -> None:
+ definition = _example()
+ definition["tree"]["children"][0]["type"] = "NotAThing"
+ errors = validate_cdl(definition)
+ assert any("unknown node type" in e for e in errors)
+
+
+def test_missing_tree_is_reported() -> None:
+ assert validate_cdl({"cdlVersion": 2, "variables": {}}) == [
+ "tree (root node) is required"
+ ]