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/09/21 06:03:56 UTC
(superset) 04/06: feat(dashboard-v2): render widgets in host apps and artifacts
This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a commit to branch msyavuz/feat/dashboard-v2-embedding
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 98caf39499e3fe811cfe625f03c0825b879c38e3
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Mon Sep 21 09:03:19 2026 +0300
feat(dashboard-v2): render widgets in host apps and artifacts
SupersetProvider gives a host app one event bus, one authenticated data
client and the Superset theme; Widget renders a saved widget by id or an
inline one by type and props, and widgets under one provider cross-filter
each other. The host drives them with useSupersetFilter/useWidgetEvent.
Claude artifacts have no network and no storage, so they cannot hold a guest
token: there, data comes from the viewer's own Superset MCP connector, and
createSession mounts widgets into a page that brings its own React. Both
paths reach the same widget components and the same widget data API.
The example app under example/ is a working host: token broker, gallery and
the code for each widget it renders.
---
.../packages/superset-widgets/AGENTS.md | 129 ++
.../packages/superset-widgets/README.md | 281 +++
.../packages/superset-widgets/artifact/.gitignore | 1 +
.../packages/superset-widgets/artifact/build.mjs | 223 ++
.../packages/superset-widgets/artifact/entry.ts | 74 +
.../superset-widgets/artifact/examples/cdn.html | 184 ++
.../superset-widgets/artifact/examples/inline.html | 184 ++
.../artifact/examples/react-artifact.jsx | 265 +++
.../packages/superset-widgets/example/.env.example | 37 +
.../packages/superset-widgets/example/.gitignore | 3 +
.../packages/superset-widgets/example/README.md | 74 +
.../packages/superset-widgets/example/index.html | 30 +
.../superset-widgets/example/package-lock.json | 2306 ++++++++++++++++++++
.../packages/superset-widgets/example/package.json | 20 +
.../packages/superset-widgets/example/server.mjs | 114 +
.../packages/superset-widgets/example/src/App.tsx | 309 +++
.../superset-widgets/example/src/EventLog.tsx | 75 +
.../superset-widgets/example/src/Gallery.tsx | 580 +++++
.../superset-widgets/example/src/HostFilterBar.tsx | 75 +
.../superset-widgets/example/src/MinimalEmbed.tsx | 44 +
.../superset-widgets/example/src/WidgetCode.tsx | 97 +
.../superset-widgets/example/src/config.ts | 51 +
.../packages/superset-widgets/example/src/jsx.ts | 107 +
.../packages/superset-widgets/example/src/main.tsx | 28 +
.../example/src/minimalEmbedSource.ts | 21 +
.../superset-widgets/example/src/structure.tsx | 218 ++
.../superset-widgets/example/src/styles.css | 549 +++++
.../superset-widgets/example/src/vite-env.d.ts | 33 +
.../superset-widgets/example/tsconfig.json | 26 +
.../superset-widgets/example/vite.config.ts | 87 +
.../src/embed/SupersetArtifactProvider.test.tsx | 85 +
.../src/embed/SupersetArtifactProvider.tsx | 123 ++
.../src/embed/SupersetProvider.tsx | 178 ++
.../superset-widgets/src/embed/Widget.test.tsx | 181 ++
.../packages/superset-widgets/src/embed/Widget.tsx | 308 +++
.../superset-widgets/src/embed/guestToken.ts | 96 +
.../packages/superset-widgets/src/embed/hooks.ts | 104 +
.../superset-widgets/src/embed/httpClient.test.ts | 148 ++
.../superset-widgets/src/embed/httpClient.ts | 136 ++
.../superset-widgets/src/embed/session.test.tsx | 237 ++
.../superset-widgets/src/embed/session.tsx | 239 ++
.../packages/superset-widgets/src/index.ts | 150 ++
.../superset-widgets/src/mcp/client.test.ts | 393 ++++
.../packages/superset-widgets/src/mcp/client.ts | 370 ++++
.../packages/superset-widgets/src/mcp/errors.ts | 251 +++
.../src/core/dashboard/EmbedWidgetModal.tsx | 140 ++
46 files changed, 9364 insertions(+)
diff --git a/superset-frontend/packages/superset-widgets/AGENTS.md b/superset-frontend/packages/superset-widgets/AGENTS.md
new file mode 100644
index 00000000000..c7643136fa6
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/AGENTS.md
@@ -0,0 +1,129 @@
+# Embedding Superset widgets — instructions for coding agents
+
+Use this when writing a React app or artifact that shows Superset data with
+`@apache-superset/widgets`. Prefer it over inferring an API from code.
+
+## Pick the form
+
+- **Inline** (`<Chart>`, `<MetricTile>`, `<Table>`, `<FilterSelect>`,
+ `<Markdown>`, or `<Widget type props>`): default for artifacts and generated
+ apps. Nothing has to exist in Superset first.
+- **Saved** (`<Widget id>`): when the user names an existing widget, or wants
+ the query fixed and governed in Superset.
+
+Everything goes under one `<SupersetProvider>`; widgets under the same provider
+cross-filter each other.
+
+## Never invent names or ids
+
+- Dataset ids, column names and saved metric names: MCP `list_datasets`,
+ `get_dataset_info` (or `GET /api/v1/dataset/<id>`).
+- Widget props: MCP `get_widget_control_schema` for the widget type
+ (`echarts`, `metric-tile`, `ag-grid-table`, `filter.select`, `markdown`).
+- Saved widget ids: `GET /api/v1/widget/`.
+
+If you can't look them up, ask.
+
+## Inline props, minimal
+
+```tsx
+<Chart
+ dataBinding={{ datasetId: 17, metrics: ['sum__num'], dimensions: ['state'], rowLimit: 10 }}
+ chartType="bar"
+ echartsOptions={{ xAxis: { type: 'category', data: { $bind: { source: 'dimension', alias: 'state' } } }, yAxis: { type: 'value' } }}
+/>
+<MetricTile dataBinding={{ datasetId: 17, metrics: ['sum__num'] }} label="Births" />
+<FilterSelect datasetId={17} column="gender" />
+```
+
+- Metrics are saved metric names, or
+ `{ expressionType: 'SIMPLE', aggregate: 'SUM', column: { column_name: 'num' } }`.
+ No SQL expressions: guests are refused.
+- `echartsOptions` is a normal ECharts option. Put data in with `$bind` markers:
+ `{ $bind: { source: 'dimension' | 'metric', alias } }`, or
+ `{ $bind: { source: 'records', fields: { name, value } } }` for pies.
+- Give every widget a sized parent; widgets fill it.
+
+## Extension widgets
+
+A type like `extensions.acme.widgets.funnel` comes from a Superset extension.
+Render it exactly like a built-in one (`<Widget type props>` or `<Widget id>`);
+the provider loads the extension the first time it appears. Do not try to
+import or bundle the extension yourself. It needs a `supersetDomain` provider,
+so it cannot work in a Claude artifact, and guest-token pages need the
+deployment's `EMBEDDED_EXTENSION_ASSETS_PUBLIC` on. Get its props from MCP
+`get_widget_control_schema` for that type, same as any other widget.
+
+## Host interaction
+
+```tsx
+const setState = useSupersetFilter('state');
+setState({ datasetId: 17, column: 'state', operator: 'IN', value: ['CA'] }); // null clears
+useWidgetEvent('valueChanged', e => console.log(e.nodeId, e.payload));
+```
+
+## Token broker (server-side, required)
+
+- Mint with a Superset service account: `POST /api/v1/security/guest_token/`.
+ Never from the browser; never put Superset credentials in `VITE_*` or
+ `NEXT_PUBLIC_*` variables.
+- Authenticate the app's own user first.
+- Grant exactly what the page renders: `resources: [{ type: 'widget', id }]` for
+ saved widgets, `datasets: [id]` for inline ones.
+- Add `rls` rules scoped to the user. If the data is genuinely public, say so.
+
+```ts
+body: JSON.stringify({
+ user: { username: user.id },
+ resources: [{ type: 'widget', id: SAVED_WIDGET_ID }],
+ datasets: [17],
+ rls: [{ clause: `tenant_id = ${Number(user.tenantId)}` }],
+});
+```
+
+## Superset config the host needs
+
+`EMBEDDED_SUPERSET` on, `GUEST_ROLE_NAME` set, `ENABLE_CORS` with the host
+origin, headers `Content-Type` and `X-GuestToken` on `/api/*`.
+
+## Failures
+
+- **403 on `/api/v1/widget/data`**: for a saved widget, the token doesn't name
+ it; for an inline widget, the token has no `datasets` claim with that dataset.
+- **400**: invalid props for the widget type, an unknown column in a filter, or
+ a SQL metric/filter from a guest.
+- **CORS error**: origin or `X-GuestToken` header missing from `CORS_OPTIONS`.
+
+## Trust boundary
+
+Widgets run in the host's JS context. Use `@superset-ui/embedded-sdk`
+(iframe) when the host must not be trusted with the data.
+
+## Embedding in Claude artifacts
+
+- Artifacts have no network access and no storage: use
+ `SupersetArtifactProvider` (MCP connector transport), never
+ `SupersetProvider` with `supersetDomain`/`fetchGuestToken`, and never a
+ token broker.
+- `server` must be the Superset connector's display name in the viewer's
+ Claude settings. Ask for it; do not guess.
+- Declare the connector in the artifact manifest:
+ `capabilities: {mcp: {servers: [{server, tools}]}}` with exactly the tools
+ the strategy calls (`query_dataset`, or `get_widget_data`,
+ `get_widget_values`, `get_saved_widget`). MCP artifacts cannot be public.
+- Prefer `strategy: "widget-tools"` when the deployment has the widget tools;
+ `query-dataset` supports inline widgets with saved metric names and SIMPLE
+ filters only.
+- Artifacts cannot import npm packages: load `superset-widgets.min.js` with a
+ `<script>` from the published URL (the `build_widget_artifact` MCP prompt
+ carries it when `MCP_WIDGETS_BUNDLE_URL` is set; otherwise ask the user).
+ Never guess the URL or paste the bundle into the page.
+- Drive it through `SupersetWidgets.createSession({server, strategy})`:
+ `mount(element, {type, props} | {id}, {instanceId})`, `setFilter(key,
+filter | null)`, `on("valueChanged", fn)`, `setThemeMode`, `dispose()`.
+ One session per page.
+- In a React artifact, mount into empty `ref` elements inside `useEffect`,
+ dispose on cleanup, and never render children into those elements; the
+ bundle brings its own React.
+- Render each widget's error message as-is: it names the fix (reconnect, add
+ the connector, approve access, retry later).
diff --git a/superset-frontend/packages/superset-widgets/README.md b/superset-frontend/packages/superset-widgets/README.md
new file mode 100644
index 00000000000..53f9905a49d
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/README.md
@@ -0,0 +1,281 @@
+<!--
+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.
+-->
+
+# @apache-superset/widgets
+
+Superset widgets (charts, metric tiles, tables, filters, markdown) as plain
+React components. The Dashboard v2 builder renders its widgets from this
+package, and any React app or AI-generated artifact can render the same
+widgets in its own tree. Status: POC (initiative 3.13), not published.
+
+## Usage
+
+```tsx
+import {
+ SupersetProvider,
+ Widget,
+ Chart,
+ MetricTile,
+ FilterSelect,
+ useSupersetFilter,
+ useWidgetEvent,
+} from '@apache-superset/widgets';
+
+<SupersetProvider
+ supersetDomain="https://superset.example.com"
+ fetchGuestToken={() =>
+ fetch('/api/superset-token', { method: 'POST' }).then(r => r.text())
+ }
+ themeMode="default" // 'default' | 'dark' | 'system'
+>
+ {/* inline: defined in the host, validated and executed by Superset */}
+ <Chart
+ dataBinding={{
+ datasetId: 17,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 10,
+ }}
+ chartType="bar"
+ crossFilter
+ echartsOptions={{
+ xAxis: {
+ type: 'category',
+ data: { $bind: { source: 'dimension', alias: 'state' } },
+ },
+ yAxis: { type: 'value' },
+ }}
+ />
+ <FilterSelect datasetId={17} column="gender" />
+
+ {/* generic form of the same thing */}
+ <Widget
+ type="metric-tile"
+ props={{
+ dataBinding: { datasetId: 17, metrics: ['sum__num'] },
+ label: 'Births',
+ }}
+ />
+
+ {/* saved in Superset (GET /api/v1/widget/) */}
+ <Widget id="cedf0501-fa36-4e19-9f84-e9da6d657dcc" />
+</SupersetProvider>;
+```
+
+- Widgets fill their container; give the parent a size.
+- Widgets under one provider share an event bus and cross-filter each other.
+- Common props on every widget: `instanceId`, `className`, `style`, `fallback`, `renderError`.
+- Typed components: `Chart`, `MetricTile`, `Table`, `FilterSelect`, `Markdown`.
+ Their props mirror the widget's control schema
+ (`GET /api/v1/widgets/type/<type>/control-schema`).
+- `useSupersetFilter(key)` returns a setter for a host-owned filter
+ `{ datasetId, column, operator, value, targets? }` (operators `EQUALS`,
+ `NOT_EQUALS`, `IN`, `NOT_IN`, `RANGE`, `TIME_RANGE`), or `null` to clear.
+- `useWidgetEvent('valueChanged', e => …)` receives filter and cross-filter
+ selections from widgets (`e.payload.resolved`).
+- `useWidgetValue(instanceId)` reads a widget's current selection.
+
+## Extension widgets
+
+A widget type contributed by a Superset extension embeds like any other — the
+type names the extension that draws it, so the provider fetches that extension
+and loads its frontend the first time one of its widgets renders:
+
+```tsx
+<Widget type="extensions.acme.widgets.funnel" props={{ dataBinding }} />
+<Widget id="<uuid of a saved widget of that type>" />
+```
+
+The extension's module runs in the host page with the host's React, and gets a
+`@apache-superset/core` whose `views` and `dashboard` namespaces are backed by
+the embedded widget: `dashboard.getNode(nodeId)` returns the props the host
+passed, `emit`/`on`/`getValue` are this provider's bus (so it cross-filters
+with the built-in widgets), and `fetchQueryData` runs through the widget data
+API under the page's own credentials. Contributions to anything other than
+`dashboard.widgets`, and calls the embedded host cannot answer (placing nodes,
+opening editors), are no-ops or throw rather than being silently wrong.
+
+- `extensionWidgets={false}` turns loading off; a
+ `createExtensionWidgetLoader({ supersetDomain, getGuestToken })` of your own
+ replaces it (`createSession` takes one under the same name).
+- The deployment needs `EMBEDDED_EXTENSION_ASSETS_PUBLIC = True` for guest
+ tokens: extension chunks load as plain `<script>` tags, which cannot carry
+ the token. With the browser's Superset session (same-site) it is not needed.
+- Extensions built before the CLI's `publicPath: "auto"` default can only be
+ embedded same-origin; their chunk URLs are pinned to the page's own origin.
+- Claude artifacts cannot load extensions at all: there is no network to fetch
+ the bundle from.
+
+## Tokens
+
+Mint guest tokens on your backend (`POST /api/v1/security/guest_token/`) after
+authenticating your own user. Never mint them in the browser.
+
+| You render | The token needs |
+| -------------- | ------------------------------------------------------------------- |
+| `<Widget id>` | `resources: [{ "type": "widget", "id": "<uuid>" }]` |
+| inline widgets | `datasets: [<dataset id>, …]` (inline specs are refused without it) |
+
+Add `rls` rules scoped to your user; without them the widget sees every row the
+dataset returns. For guests, inline specs may only use saved metrics or simple
+aggregates on existing columns: SQL metrics and SQL filters are rejected.
+Filters from the host are always ANDed onto the widget's query.
+
+## Superset configuration
+
+```python
+FEATURE_FLAGS = {"EMBEDDED_SUPERSET": True}
+GUEST_ROLE_NAME = "Gamma" # a role with can_read on Chart
+ENABLE_CORS = True
+CORS_OPTIONS = {
+ "origins": ["https://app.example.com"],
+ "resources": [r"/api/*"],
+ "allow_headers": ["Content-Type", "X-GuestToken"],
+}
+```
+
+Without `fetchGuestToken` the provider uses the browser's Superset session
+(cookies); cross-origin that also needs `"supports_credentials": True`.
+
+## Consuming from source
+
+The package is not built for publishing yet. A host bundler must compile it and
+its Superset dependencies from `src`:
+
+- alias `@apache-superset/widgets`, `@apache-superset/core(/*)` and
+ `@superset-ui/core(/*)` to `packages/<name>/src`;
+- React automatic JSX runtime with `importSource: '@emotion/react'` and
+ `@emotion/babel-plugin`;
+- SVG imports as React components (svgr);
+- define `process.env.NODE_ENV`, `process.env.WEBPACK_MODE`, `global`;
+- dedupe `react`, `react-dom`, `@emotion/react`, `antd`.
+
+[`example/vite.config.ts`](example/vite.config.ts) does all of this.
+
+## Trust boundary
+
+Widgets run in the host page's JavaScript context: the host trusts this code,
+and an XSS in the host can read the guest token and any rows it grants. Keep
+tokens short-lived and narrowly scoped. For third-party or untrusted hosts use
+the iframe embed (`@superset-ui/embedded-sdk`).
+
+## Embedding in Claude artifacts
+
+Claude artifacts cannot make network requests or use browser storage, so the
+REST client (and a token broker) cannot work there. Instead, data comes from
+the **viewer's own Superset connector** in Claude: the page calls MCP tools
+through the artifact runtime and never sees a token. Queries run with the
+viewer's Superset permissions and row-level security.
+
+Artifacts cannot install npm packages either, so they load the single-file
+bundle (`superset-widgets.min.js`: React, the widgets and their CSS; sets
+`window.SupersetWidgets`) from a CDN the artifact host allows, and drive it
+through a session:
+
+```js
+const loadSupersetWidgets = url =>
+ window.SupersetWidgets
+ ? Promise.resolve(window.SupersetWidgets)
+ : new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = url;
+ script.onload = () => resolve(window.SupersetWidgets);
+ script.onerror = () => reject(new Error(`Could not load ${url}`));
+ document.head.appendChild(script);
+ });
+
+const W = await loadSupersetWidgets(BUNDLE_URL);
+const session = W.createSession({
+ server: 'Superset',
+ strategy: 'query-dataset',
+});
+const chart = session.mount(element, {
+ type: 'echarts',
+ props: {
+ chartType: 'bar',
+ dataBinding: {
+ datasetId: 17,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ },
+ },
+});
+session.setFilter('state', {
+ datasetId: 17,
+ column: 'state',
+ operator: 'IN',
+ value: ['CA'],
+});
+const off = session.on('valueChanged', event =>
+ console.log(event.nodeId, event.payload),
+);
+session.setThemeMode('dark');
+chart.unmount();
+session.dispose();
+```
+
+- **Session:** `createSession({server, strategy?, proxy?, themeMode?,
+themeConfig?, mcp?, client?, loadFonts?})`. Every `mount(element, {type,
+props} | {id}, {instanceId?, className?, style?, showTitle?})` is its own
+ React root sharing one bus and data client, so widgets cross-filter.
+ `setFilter(key, filter | null)` publishes `host:<key>`; `dispose()` unmounts
+ everything and clears host filters. Fonts come from Google Fonts (the only
+ stylesheet host artifacts allow) unless `loadFonts: false`.
+- **React artifacts** keep their own React: mount into empty `ref` elements and
+ never render children into them. See `artifact/examples/react-artifact.jsx`.
+- **Build:** `node artifact/build.mjs` writes `artifact/dist/superset-widgets.min.js`
+ and `artifact/dist/examples/inline.html` (bundle inlined, no CDN needed).
+ With `SUPERSET_WIDGETS_URL=https://...` it also writes
+ `artifact/dist/examples/cdn.html`.
+- **MCP prompt:** `build_widget_artifact(connector_name, strategy,
+artifact_kind)` hands Claude the loader, session API, specs and manifest.
+ Set `MCP_WIDGETS_BUNDLE_URL` to the published bundle URL; without it the
+ prompt tells Claude to ask the user for one.
+- **In-tree React** apps that can install the package use
+ `<SupersetArtifactProvider server strategy>` with the components directly.
+- **Connector:** the viewer must have added Superset as a remote MCP connector
+ in Claude; `server` is its display name.
+- **Manifest:** publish with
+ `capabilities: {mcp: {servers: [{server: "Superset", tools: [...]}]}}`, listing
+ the tools the strategy calls. MCP-backed artifacts cannot be shared publicly.
+- **Strategies:**
+ - `widget-tools` (default): `get_widget_data`, `get_widget_values`,
+ `get_saved_widget`. Supports saved widgets (`<Widget id>`) and every inline
+ widget.
+ - `query-dataset`: the existing `query_dataset` tool. Inline widgets only,
+ saved metric names only, SIMPLE filters only.
+ - `proxy`: set when the connector only exposes the `call_tool` meta-tool.
+- **Errors** are `WidgetMcpError`s with a `code` and a message that says what
+ fixes it (reconnect the connector, add it, approve access, try again).
+
+## Design notes
+
+- The widget, not the dashboard, is the unit of embedding. The builder
+ implements the same `WidgetBus` / `WidgetDataClient` contracts as
+ `SupersetProvider`, so there is one implementation of each widget.
+- Data always runs on the server through the widget registry
+ (`Widget.fetch_data` / `fetch_values`), either from the stored definition
+ (`{id}`) or from a validated inline spec (`{widget: {type, props}}`), via
+ `POST /api/v1/widget/data` and `/values`.
+- An extension's widget is loaded by the page that renders it, not bundled: the
+ type (`extensions.<publisher>.<name>.<type>`) names the extension, and the
+ same `views.registerView` call it makes in Superset registers it here.
+- Open with other owners: widget JSON shape and data hook (3.8), bus and filter
+ vocabulary (3.4), sandboxing extension widgets (3.17) — extension code runs
+ in the host page's context today, on the host's trust.
diff --git a/superset-frontend/packages/superset-widgets/artifact/.gitignore b/superset-frontend/packages/superset-widgets/artifact/.gitignore
new file mode 100644
index 00000000000..849ddff3b7e
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/artifact/.gitignore
@@ -0,0 +1 @@
+dist/
diff --git a/superset-frontend/packages/superset-widgets/artifact/build.mjs b/superset-frontend/packages/superset-widgets/artifact/build.mjs
new file mode 100644
index 00000000000..bc4eeabf346
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/artifact/build.mjs
@@ -0,0 +1,223 @@
+/**
+ * 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.
+ */
+
+// Builds the widgets bundle for backend-less hosts (Claude artifacts):
+// - dist/superset-widgets.min.js: React and the widgets in one IIFE that sets
+// window.SupersetWidgets and injects its own CSS. No separate assets, no
+// font files (the session API loads fonts from Google Fonts), no dynamic
+// imports, so it can be served from a CDN or inlined.
+// - dist/examples/inline.html: examples/inline.html with the bundle inlined.
+// - dist/examples/cdn.html: examples/cdn.html pointing at SUPERSET_WIDGETS_URL,
+// only when that variable is set.
+// Run: node artifact/build.mjs
+import { createRequire } from 'node:module';
+import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const here = fileURLToPath(new URL('.', import.meta.url));
+const packages = fileURLToPath(new URL('../..', import.meta.url));
+const distDir = join(here, 'dist');
+const bundleDir = join(distDir, 'bundle');
+const pkg = JSON.parse(
+ await readFile(new URL('../package.json', import.meta.url), 'utf8'),
+);
+
+// Vite and its plugins are installed once, in the example app.
+const fromExample = createRequire(
+ new URL('../example/package.json', import.meta.url),
+);
+const importFromExample = async name => {
+ let mod = await import(pathToFileURL(fromExample.resolve(name)).href);
+ // A CommonJS build arrives as {default: module.exports}, which may itself
+ // be {default: plugin}.
+ while (typeof mod !== 'function' && mod?.default) mod = mod.default;
+ return mod;
+};
+const { build } = await import(
+ pathToFileURL(
+ join(
+ dirname(fromExample.resolve('vite/package.json')),
+ 'dist/node/index.js',
+ ),
+ ).href
+);
+const react = await importFromExample('@vitejs/plugin-react');
+const svgr = await importFromExample('vite-plugin-svgr');
+const emotionBabelPlugin = createRequire(import.meta.url).resolve(
+ '@emotion/babel-plugin',
+);
+
+// Superset's theme imports @fontsource CSS, which would inline ~1.5 MB of
+// base64 font files into the bundle.
+const NO_FONT_FILES = '\0superset-widgets-no-font-files';
+const skipFontFiles = {
+ name: 'superset-widgets-skip-font-files',
+ enforce: 'pre',
+ resolveId: id => (id.startsWith('@fontsource/') ? NO_FONT_FILES : null),
+ load: id => (id === NO_FONT_FILES ? 'export {};' : null),
+};
+
+// AsyncIcon's webpack-only `import('!!@svgr/webpack!...')` cannot resolve in a
+// single file; such icons render empty instead of failing at runtime.
+const noAsyncIcons = {
+ name: 'superset-widgets-no-async-icons',
+ enforce: 'pre',
+ transform(code, id) {
+ if (!id.endsWith('/Icons/AsyncIcon.tsx')) return null;
+ const next = code.replace(
+ /import\(\s*`!!@svgr\/webpack![^`]*`\s*\)/,
+ 'Promise.resolve({ default: () => null })',
+ );
+ if (next === code) throw new Error('AsyncIcon dynamic import not found');
+ return next;
+ },
+};
+
+await build({
+ configFile: false,
+ root: here,
+ logLevel: 'warn',
+ plugins: [
+ skipFontFiles,
+ noAsyncIcons,
+ react({
+ jsxImportSource: '@emotion/react',
+ babel: { plugins: [emotionBabelPlugin] },
+ }),
+ svgr({ include: '**/*.svg', svgrOptions: { exportType: 'default' } }),
+ ],
+ define: {
+ __SUPERSET_WIDGETS_VERSION__: JSON.stringify(pkg.version),
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ 'process.env.WEBPACK_MODE': JSON.stringify('production'),
+ 'process.env.SCARF_ANALYTICS': JSON.stringify('false'),
+ global: 'globalThis',
+ },
+ resolve: {
+ alias: [
+ {
+ find: /^@apache-superset\/core$/,
+ replacement: `${packages}superset-core/src/index.ts`,
+ },
+ {
+ find: /^@apache-superset\/core\/(.*)$/,
+ replacement: `${packages}superset-core/src/$1`,
+ },
+ {
+ find: /^@superset-ui\/core$/,
+ replacement: `${packages}superset-ui-core/src/index.ts`,
+ },
+ {
+ find: /^@superset-ui\/core\/(.*)$/,
+ replacement: `${packages}superset-ui-core/src/$1`,
+ },
+ ],
+ dedupe: ['react', 'react-dom', '@emotion/react', 'antd'],
+ },
+ build: {
+ outDir: bundleDir,
+ emptyOutDir: true,
+ sourcemap: false,
+ minify: 'esbuild',
+ cssCodeSplit: false,
+ // Artifacts cannot fetch anything, so any remaining asset becomes a data URI.
+ assetsInlineLimit: () => true,
+ chunkSizeWarningLimit: 16_000,
+ lib: {
+ entry: join(here, 'entry.ts'),
+ name: 'SupersetWidgetsBundle',
+ formats: ['iife'],
+ fileName: () => 'superset-widgets.js',
+ },
+ rollupOptions: { output: { inlineDynamicImports: true } },
+ },
+});
+
+const files = await readdir(bundleDir);
+const extra = files.filter(file => !/\.(js|css)$/.test(file));
+if (extra.length) {
+ throw new Error(`Bundle emitted non-inlined files: ${extra.join(', ')}`);
+}
+const js = await readFile(join(bundleDir, 'superset-widgets.js'), 'utf8');
+const css = (
+ await Promise.all(
+ files
+ .filter(file => file.endsWith('.css'))
+ .map(file => readFile(join(bundleDir, file), 'utf8')),
+ )
+).join('\n');
+
+// Artifacts allow no stylesheet hosts but Google Fonts, so CSS travels in the JS.
+const injectCss = css.trim()
+ ? `(function(){if(typeof document==="undefined")return;var s=document.createElement("style");s.setAttribute("data-superset-widgets","");s.textContent=${JSON.stringify(css)};document.head.appendChild(s);})();\n`
+ : '';
+const bundle = `/*! @apache-superset/widgets ${pkg.version} | Apache-2.0 */\n${injectCss}${js}`;
+
+await mkdir(join(distDir, 'examples'), { recursive: true });
+const bundlePath = join(distDir, 'superset-widgets.min.js');
+await writeFile(bundlePath, bundle);
+await rm(bundleDir, { recursive: true, force: true });
+await rm(join(distDir, 'widgets-artifact.html'), { force: true });
+
+// Keep the inlined code from closing its own <script> element early.
+const inlineSafe = bundle
+ .replace(/<\/script/gi, '<\\/script')
+ .replace(/<!--/g, '<\\!--');
+const JS_MARKER = '<!-- SUPERSET_WIDGETS_JS -->';
+const inlineTemplate = await readFile(
+ join(here, 'examples', 'inline.html'),
+ 'utf8',
+);
+if (!inlineTemplate.includes(JS_MARKER)) {
+ throw new Error(`examples/inline.html is missing ${JS_MARKER}`);
+}
+const inlinePath = join(distDir, 'examples', 'inline.html');
+await writeFile(
+ inlinePath,
+ inlineTemplate.replace(JS_MARKER, () => `<script>${inlineSafe}</script>`),
+);
+
+const outputs = [bundlePath, inlinePath];
+const bundleUrl = process.env.SUPERSET_WIDGETS_URL;
+if (bundleUrl) {
+ if (!/^https:\/\//.test(bundleUrl)) {
+ throw new Error('SUPERSET_WIDGETS_URL must be an https:// URL');
+ }
+ const cdnTemplate = await readFile(join(here, 'examples', 'cdn.html'), 'utf8');
+ const cdnPath = join(distDir, 'examples', 'cdn.html');
+ await writeFile(
+ cdnPath,
+ cdnTemplate.replaceAll('{{SUPERSET_WIDGETS_URL}}', bundleUrl),
+ );
+ outputs.push(cdnPath);
+}
+
+const mb = bytes => `${(bytes / 1024 / 1024).toFixed(2)} MB`;
+const dynamicImports = (js.match(/\bimport\(/g) ?? []).length;
+if (dynamicImports) {
+ throw new Error(`Bundle still contains ${dynamicImports} dynamic import(s)`);
+}
+for (const output of outputs) {
+ const size = Buffer.byteLength(await readFile(output));
+ console.log(`${output} ${mb(size)}`);
+}
+console.log(
+ `css injected ${mb(Buffer.byteLength(css))}; no dynamic imports`,
+);
diff --git a/superset-frontend/packages/superset-widgets/artifact/entry.ts b/superset-frontend/packages/superset-widgets/artifact/entry.ts
new file mode 100644
index 00000000000..732e784cbbb
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/artifact/entry.ts
@@ -0,0 +1,74 @@
+/**
+ * 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.
+ */
+
+// Single-file browser build for backend-less hosts such as Claude artifacts,
+// which cannot install npm packages: React and the widgets are bundled and
+// handed to the page as one global.
+import * as React from 'react';
+import * as ReactDOM from 'react-dom/client';
+import {
+ Chart,
+ FilterSelect,
+ Markdown,
+ MetricTile,
+ SupersetArtifactProvider,
+ SupersetProvider,
+ Table,
+ Widget,
+ createMcpWidgetClient,
+ createSession,
+ getArtifactMcp,
+ registerWidgetComponent,
+ useSupersetFilter,
+ useWidgetEvent,
+ useWidgetValue,
+} from '../src';
+
+declare const __SUPERSET_WIDGETS_VERSION__: string;
+
+const SupersetWidgets = Object.freeze({
+ version: __SUPERSET_WIDGETS_VERSION__,
+ createSession,
+ React,
+ ReactDOM,
+ SupersetArtifactProvider,
+ SupersetProvider,
+ Widget,
+ Chart,
+ MetricTile,
+ Table,
+ FilterSelect,
+ Markdown,
+ useSupersetFilter,
+ useWidgetEvent,
+ useWidgetValue,
+ createMcpWidgetClient,
+ getArtifactMcp,
+ registerWidgetComponent,
+});
+
+const host = globalThis as typeof globalThis & {
+ SupersetWidgets?: typeof SupersetWidgets;
+};
+
+// Loading the bundle twice keeps the first copy, so widgets already mounted
+// and new ones share a single React.
+if (!host.SupersetWidgets) {
+ host.SupersetWidgets = SupersetWidgets;
+}
diff --git a/superset-frontend/packages/superset-widgets/artifact/examples/cdn.html b/superset-frontend/packages/superset-widgets/artifact/examples/cdn.html
new file mode 100644
index 00000000000..0b1e5e0fce0
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/artifact/examples/cdn.html
@@ -0,0 +1,184 @@
+<!doctype html>
+<!--
+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.
+-->
+<!--
+Claude artifact that loads the published widgets bundle from a CDN. Replace
+{{SUPERSET_WIDGETS_URL}} with the bundle URL (an allowlisted CDN), or run
+SUPERSET_WIDGETS_URL=https://... node artifact/build.mjs.
+
+Publish it with this capabilities manifest:
+ {"mcp": {"servers": [{"server": "Superset", "tools": ["query_dataset"]}]}}
+For strategy "widget-tools" (saved widgets, every inline widget) list:
+ ["get_widget_data", "get_widget_values", "get_saved_widget"]
+MCP-backed artifacts cannot be shared publicly.
+-->
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>Births by state</title>
+ <style>
+ body {
+ margin: 0;
+ font-family: system-ui, sans-serif;
+ background: #f5f4f0;
+ color: #1d1d1b;
+ }
+ .page {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 16px;
+ }
+ .toolbar,
+ .status {
+ grid-column: 1 / -1;
+ }
+ .toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+ .card {
+ box-sizing: border-box;
+ height: 340px;
+ padding: 12px;
+ border: 1px solid #e4e2dc;
+ border-radius: 8px;
+ background: #fff;
+ }
+ .card.small {
+ height: 110px;
+ }
+ .card.wide {
+ grid-column: 1 / -1;
+ }
+ .status {
+ min-height: 1.5em;
+ font-size: 13px;
+ opacity: 0.75;
+ }
+ @media (max-width: 640px) {
+ .page {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <div class="page">
+ <div class="toolbar" role="group" aria-label="Host filter on state">
+ <button type="button" data-state="CA">CA</button>
+ <button type="button" data-state="NY">NY</button>
+ <button type="button" data-state="TX">TX</button>
+ <button type="button" data-state="">All states</button>
+ </div>
+ <div id="gender" class="card small"></div>
+ <div id="total" class="card small"></div>
+ <div id="by-state" class="card wide"></div>
+ <output id="status" class="status"></output>
+ </div>
+ <script src="{{SUPERSET_WIDGETS_URL}}"></script>
+ <script>
+ (function () {
+ var W = window.SupersetWidgets;
+ var status = document.getElementById('status');
+ if (!W) {
+ status.textContent =
+ 'The Superset widgets bundle did not load from {{SUPERSET_WIDGETS_URL}}.';
+ return;
+ }
+
+ var DATASET_ID = 17;
+ var session = W.createSession({
+ // The Superset connector's display name in Claude settings.
+ server: 'Superset',
+ // Works against deployments that only have query_dataset.
+ strategy: 'query-dataset',
+ });
+
+ session.mount(
+ document.getElementById('gender'),
+ { type: 'filter.select', props: { datasetId: DATASET_ID, column: 'gender' } },
+ { instanceId: 'gender' },
+ );
+ session.mount(
+ document.getElementById('total'),
+ {
+ type: 'metric-tile',
+ props: {
+ dataBinding: { datasetId: DATASET_ID, metrics: ['sum__num'] },
+ label: 'Total births',
+ },
+ },
+ { instanceId: 'total' },
+ );
+ session.mount(
+ document.getElementById('by-state'),
+ {
+ type: 'echarts',
+ props: {
+ chartType: 'bar',
+ crossFilter: true,
+ dataBinding: {
+ datasetId: DATASET_ID,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 10,
+ },
+ echartsOptions: {
+ xAxis: {
+ type: 'category',
+ data: { $bind: { source: 'dimension', alias: 'state' } },
+ },
+ yAxis: { type: 'value' },
+ },
+ chrome: { titleText: 'Births by state (top 10)' },
+ },
+ },
+ { instanceId: 'by-state' },
+ );
+
+ session.on('valueChanged', function (event) {
+ var resolved = event.payload && event.payload.resolved;
+ status.textContent =
+ event.nodeId +
+ ': ' +
+ (resolved
+ ? resolved.column + ' ' + resolved.operator + ' ' + JSON.stringify(resolved.value)
+ : 'cleared');
+ });
+
+ document.querySelectorAll('[data-state]').forEach(function (button) {
+ button.addEventListener('click', function () {
+ var value = button.getAttribute('data-state');
+ session.setFilter(
+ 'state',
+ value
+ ? { datasetId: DATASET_ID, column: 'state', operator: 'IN', value: [value] }
+ : null,
+ );
+ });
+ });
+ })();
+ </script>
+ </body>
+</html>
diff --git a/superset-frontend/packages/superset-widgets/artifact/examples/inline.html b/superset-frontend/packages/superset-widgets/artifact/examples/inline.html
new file mode 100644
index 00000000000..510928f15f1
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/artifact/examples/inline.html
@@ -0,0 +1,184 @@
+<!doctype html>
+<!--
+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.
+-->
+<!--
+Template for a Claude artifact with the widgets bundle inlined; build.mjs
+writes the result to dist/examples/inline.html.
+
+Publish it with this capabilities manifest:
+ {"mcp": {"servers": [{"server": "Superset", "tools": ["query_dataset"]}]}}
+For strategy "widget-tools" (saved widgets, every inline widget) list:
+ ["get_widget_data", "get_widget_values", "get_saved_widget"]
+MCP-backed artifacts cannot be shared publicly.
+-->
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>Births by state</title>
+ <style>
+ body {
+ margin: 0;
+ font-family: system-ui, sans-serif;
+ background: #f5f4f0;
+ color: #1d1d1b;
+ }
+ .page {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 16px;
+ }
+ .toolbar,
+ .status {
+ grid-column: 1 / -1;
+ }
+ .toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+ .card {
+ box-sizing: border-box;
+ height: 340px;
+ padding: 12px;
+ border: 1px solid #e4e2dc;
+ border-radius: 8px;
+ background: #fff;
+ }
+ .card.small {
+ height: 110px;
+ }
+ .card.wide {
+ grid-column: 1 / -1;
+ }
+ .status {
+ min-height: 1.5em;
+ font-size: 13px;
+ opacity: 0.75;
+ }
+ @media (max-width: 640px) {
+ .page {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <div class="page">
+ <div class="toolbar" role="group" aria-label="Host filter on state">
+ <button type="button" data-state="CA">CA</button>
+ <button type="button" data-state="NY">NY</button>
+ <button type="button" data-state="TX">TX</button>
+ <button type="button" data-state="">All states</button>
+ </div>
+ <div id="gender" class="card small"></div>
+ <div id="total" class="card small"></div>
+ <div id="by-state" class="card wide"></div>
+ <output id="status" class="status"></output>
+ </div>
+ <!-- SUPERSET_WIDGETS_JS -->
+ <script>
+ (function () {
+ var W = window.SupersetWidgets;
+ var status = document.getElementById('status');
+ if (!W) {
+ status.textContent = 'The Superset widgets bundle did not load.';
+ return;
+ }
+
+ var DATASET_ID = 17;
+ var session = W.createSession({
+ // The Superset connector's display name in Claude settings.
+ server: 'Superset',
+ // Works against deployments that only have query_dataset.
+ strategy: 'query-dataset',
+ });
+
+ session.mount(
+ document.getElementById('gender'),
+ { type: 'filter.select', props: { datasetId: DATASET_ID, column: 'gender' } },
+ { instanceId: 'gender' },
+ );
+ session.mount(
+ document.getElementById('total'),
+ {
+ type: 'metric-tile',
+ props: {
+ dataBinding: { datasetId: DATASET_ID, metrics: ['sum__num'] },
+ label: 'Total births',
+ },
+ },
+ { instanceId: 'total' },
+ );
+ session.mount(
+ document.getElementById('by-state'),
+ {
+ type: 'echarts',
+ props: {
+ chartType: 'bar',
+ crossFilter: true,
+ dataBinding: {
+ datasetId: DATASET_ID,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 10,
+ },
+ echartsOptions: {
+ xAxis: {
+ type: 'category',
+ data: { $bind: { source: 'dimension', alias: 'state' } },
+ },
+ yAxis: { type: 'value' },
+ },
+ chrome: { titleText: 'Births by state (top 10)' },
+ },
+ },
+ { instanceId: 'by-state' },
+ );
+ // A saved widget needs strategy 'widget-tools':
+ // session.mount(element, { id: '<saved widget uuid>' });
+
+ session.on('valueChanged', function (event) {
+ var resolved = event.payload && event.payload.resolved;
+ status.textContent =
+ event.nodeId +
+ ': ' +
+ (resolved
+ ? resolved.column + ' ' + resolved.operator + ' ' + JSON.stringify(resolved.value)
+ : 'cleared');
+ });
+
+ document.querySelectorAll('[data-state]').forEach(function (button) {
+ button.addEventListener('click', function () {
+ var value = button.getAttribute('data-state');
+ session.setFilter(
+ 'state',
+ value
+ ? { datasetId: DATASET_ID, column: 'state', operator: 'IN', value: [value] }
+ : null,
+ );
+ });
+ });
+ })();
+ </script>
+ </body>
+</html>
diff --git a/superset-frontend/packages/superset-widgets/artifact/examples/react-artifact.jsx b/superset-frontend/packages/superset-widgets/artifact/examples/react-artifact.jsx
new file mode 100644
index 00000000000..e72dc6ddbb3
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/artifact/examples/react-artifact.jsx
@@ -0,0 +1,265 @@
+/**
+ * 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 claude.ai React artifact rendering real Superset widgets with no backend.
+ * The artifact keeps its own React; the widgets bundle renders into the empty
+ * elements below with its own React, and data comes from the viewer's
+ * Superset connector.
+ *
+ * Replace {{SUPERSET_WIDGETS_URL}} with the published bundle URL (a CDN the
+ * artifact host allows) and publish with this capabilities manifest:
+ * {"mcp": {"servers": [{"server": "Superset", "tools": ["query_dataset"]}]}}
+ * For strategy "widget-tools" (saved widgets, every inline widget) list:
+ * ["get_widget_data", "get_widget_values", "get_saved_widget"]
+ * MCP-backed artifacts cannot be shared publicly.
+ */
+import { useEffect, useRef, useState } from 'react';
+
+const SUPERSET_WIDGETS_URL = '{{SUPERSET_WIDGETS_URL}}';
+const SERVER = 'Superset';
+const DATASET_ID = 17;
+const STATES = ['CA', 'NY', 'TX', 'IL', 'FL'];
+
+function loadSupersetWidgets(url) {
+ if (window.SupersetWidgets) return Promise.resolve(window.SupersetWidgets);
+ return new Promise((resolve, reject) => {
+ const script = Object.assign(document.createElement('script'), {
+ src: url,
+ });
+ script.onload = () => resolve(window.SupersetWidgets);
+ script.onerror = () => reject(new Error(`Could not load ${url}`));
+ document.head.appendChild(script);
+ });
+}
+
+function summarize(payload) {
+ const resolved = payload && payload.resolved;
+ return resolved
+ ? `${resolved.column} ${resolved.operator} ${JSON.stringify(resolved.value)}`
+ : 'cleared';
+}
+
+const card = {
+ boxSizing: 'border-box',
+ padding: 12,
+ border: '1px solid #e4e2dc',
+ borderRadius: 8,
+ background: '#fff',
+};
+
+export default function BirthsDashboard() {
+ const genderRef = useRef(null);
+ const totalRef = useRef(null);
+ const byStateRef = useRef(null);
+ const sessionRef = useRef(null);
+ const [status, setStatus] = useState('loading');
+ const [problem, setProblem] = useState('');
+ const [state, setState] = useState(null);
+ const [events, setEvents] = useState([]);
+
+ useEffect(() => {
+ let cancelled = false;
+ let off = () => {};
+
+ (async () => {
+ try {
+ const W = await loadSupersetWidgets(SUPERSET_WIDGETS_URL);
+ // null outside a published artifact, or when connectors are unavailable here.
+ const mcp = window.claude?.use ? await window.claude.use('mcp') : null;
+ if (cancelled) return;
+ if (!W)
+ throw new Error('The widgets bundle loaded without SupersetWidgets.');
+ if (!mcp) {
+ setStatus('no-connectors');
+ return;
+ }
+
+ const session = W.createSession({
+ server: SERVER,
+ strategy: 'query-dataset',
+ mcp,
+ });
+ sessionRef.current = session;
+
+ session.mount(
+ genderRef.current,
+ {
+ type: 'filter.select',
+ props: { datasetId: DATASET_ID, column: 'gender' },
+ },
+ { instanceId: 'gender' },
+ );
+ session.mount(
+ totalRef.current,
+ {
+ type: 'metric-tile',
+ props: {
+ dataBinding: { datasetId: DATASET_ID, metrics: ['sum__num'] },
+ label: 'Total births',
+ },
+ },
+ { instanceId: 'total' },
+ );
+ session.mount(
+ byStateRef.current,
+ {
+ type: 'echarts',
+ props: {
+ chartType: 'bar',
+ crossFilter: true,
+ dataBinding: {
+ datasetId: DATASET_ID,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 10,
+ },
+ echartsOptions: {
+ xAxis: {
+ type: 'category',
+ data: { $bind: { source: 'dimension', alias: 'state' } },
+ },
+ yAxis: { type: 'value' },
+ },
+ chrome: { titleText: 'Births by state (top 10)' },
+ },
+ },
+ { instanceId: 'by-state' },
+ );
+
+ off = session.on('valueChanged', event => {
+ setEvents(current =>
+ [
+ {
+ key: `${Date.now()}-${Math.random()}`,
+ source: event.nodeId,
+ summary: summarize(event.payload),
+ },
+ ...current,
+ ].slice(0, 20),
+ );
+ });
+ setStatus('ready');
+ } catch (error) {
+ if (cancelled) return;
+ setProblem(error instanceof Error ? error.message : String(error));
+ setStatus('error');
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ off();
+ sessionRef.current?.dispose();
+ sessionRef.current = null;
+ };
+ }, []);
+
+ const pickState = value => {
+ setState(value);
+ sessionRef.current?.setFilter(
+ 'state',
+ value
+ ? {
+ datasetId: DATASET_ID,
+ column: 'state',
+ operator: 'IN',
+ value: [value],
+ }
+ : null,
+ );
+ };
+
+ return (
+ <div
+ style={{
+ fontFamily: 'system-ui, sans-serif',
+ padding: 16,
+ background: '#f5f4f0',
+ }}
+ >
+ <h1 style={{ fontSize: 20, margin: '0 0 12px' }}>Births</h1>
+
+ {status === 'loading' && <p>Loading Superset widgets…</p>}
+ {status === 'no-connectors' && (
+ <p>
+ Open this page as a published Claude artifact, and add the {SERVER}{' '}
+ connector in Claude settings, to see the data.
+ </p>
+ )}
+ {status === 'error' && <p role="alert">{problem}</p>}
+
+ <div
+ style={{ display: 'flex', gap: 8, flexWrap: 'wrap', margin: '12px 0' }}
+ >
+ {STATES.map(value => (
+ <button
+ key={value}
+ type="button"
+ aria-pressed={state === value}
+ disabled={status !== 'ready'}
+ onClick={() => pickState(state === value ? null : value)}
+ >
+ {value}
+ </button>
+ ))}
+ </div>
+
+ <div
+ style={{
+ display: 'grid',
+ gridTemplateColumns: 'minmax(0, 1fr) 280px',
+ gap: 16,
+ }}
+ >
+ <div
+ style={{
+ display: 'grid',
+ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
+ gap: 16,
+ }}
+ >
+ {/* The bundle owns these elements: never render children into them. */}
+ <div ref={genderRef} style={{ ...card, height: 110 }} />
+ <div ref={totalRef} style={{ ...card, height: 110 }} />
+ <div
+ ref={byStateRef}
+ style={{ ...card, height: 340, gridColumn: '1 / -1' }}
+ />
+ </div>
+ <section style={card}>
+ <h2 style={{ fontSize: 14, margin: 0 }}>Events from widgets</h2>
+ {events.length === 0 ? (
+ <p style={{ fontSize: 13, opacity: 0.7 }}>
+ Pick a value or click a bar.
+ </p>
+ ) : (
+ <ul style={{ listStyle: 'none', padding: 0, fontSize: 13 }}>
+ {events.map(entry => (
+ <li key={entry.key}>
+ <code>{entry.source}</code> {entry.summary}
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ </div>
+ </div>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/.env.example b/superset-frontend/packages/superset-widgets/example/.env.example
new file mode 100644
index 00000000000..25070d565f7
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/.env.example
@@ -0,0 +1,37 @@
+# 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.
+
+# Superset origin, used by the browser and the broker.
+SUPERSET_URL=http://localhost:8088
+
+# Broker only; never exposed to the browser.
+SUPERSET_USERNAME=admin
+SUPERSET_PASSWORD=admin
+BROKER_PORT=3001
+
+# Saved widget uuids (GET /api/v1/widget/), rendered with <Widget id>.
+EMBED_WIDGET_IDS=
+# Dataset ids inline widgets may query (the token's `datasets` claim).
+EMBED_DATASETS=17
+
+# Inline widgets and the host-owned filter use this dataset.
+FILTER_DATASET_ID=17
+FILTER_COLUMN=state
+FILTER_VALUES=CA,NY,TX,IL,FL
+
+# session (default): use your Superset login, no backend. guest: use server.mjs broker.
+EMBED_AUTH=session
diff --git a/superset-frontend/packages/superset-widgets/example/.gitignore b/superset-frontend/packages/superset-widgets/example/.gitignore
new file mode 100644
index 00000000000..9c97bbd46d2
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+.env
diff --git a/superset-frontend/packages/superset-widgets/example/README.md b/superset-frontend/packages/superset-widgets/example/README.md
new file mode 100644
index 00000000000..d1ec6a56191
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/README.md
@@ -0,0 +1,74 @@
+<!--
+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.
+-->
+
+# Widgets host example
+
+A Vite + React app that renders `@apache-superset/widgets` in its own layout:
+
+- inline widgets on `birth_names` (filter, metric tile, bar chart, pie) that
+ cross-filter each other;
+- saved widgets by id;
+- a host-owned `state` filter;
+- a log of filter and cross-filter events coming out of widgets.
+
+By default the app has **no backend**: widgets call Superset with the viewer's
+own Superset login (session cookie), so their permissions and RLS apply. Log in
+to Superset in the same browser first.
+
+`EMBED_AUTH=guest` switches to guest tokens from `server.mjs`, a local-only
+token broker for apps that do have a backend. It skips what a real broker must
+do: authenticate your app's user and add RLS rules.
+
+## Setup
+
+1. Superset config:
+ ```python
+ ENABLE_CORS = True
+ CORS_OPTIONS = {
+ "origins": ["http://localhost:5173"],
+ "resources": [r"/api/*"],
+ "supports_credentials": True, # session mode sends the login cookie
+ "allow_headers": ["Content-Type", "X-GuestToken"],
+ }
+ # guest mode only:
+ FEATURE_FLAGS = {"EMBEDDED_SUPERSET": True}
+ GUEST_ROLE_NAME = "Gamma"
+ ```
+ Session mode relies on the Superset session cookie reaching Superset from
+ the app. `localhost:5173` and `localhost:8088` are the same site, so this
+ works locally; an app on a different site needs a token flow instead.
+2. Superset frontend dependencies installed (`cd superset-frontend && npm install`):
+ this app resolves `react`, `antd`, emotion and echarts from there.
+3. Saved widget ids: `GET /api/v1/widget/`, or "Embed widget" on a widget in the
+ Dashboard v2 builder.
+4. `cp .env.example .env` and fill it in.
+
+## Run
+
+```bash
+cd superset-frontend/packages/superset-widgets/example
+npm install
+npm run dev # then open http://localhost:5173 (log in to Superset first)
+
+# guest-token mode instead (EMBED_AUTH=guest in .env):
+npm run broker # terminal 1
+npm run dev # terminal 2
+```
+
+`npm run build` type-checks and bundles without starting a server.
diff --git a/superset-frontend/packages/superset-widgets/example/index.html b/superset-frontend/packages/superset-widgets/example/index.html
new file mode 100644
index 00000000000..ebc3c1a3393
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/index.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<!--
+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.
+-->
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Superset widgets host</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
+</html>
diff --git a/superset-frontend/packages/superset-widgets/example/package-lock.json b/superset-frontend/packages/superset-widgets/example/package-lock.json
new file mode 100644
index 00000000000..fbb6a4f6c4f
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/package-lock.json
@@ -0,0 +1,2306 @@
+{
+ "name": "superset-widgets-example",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "superset-widgets-example",
+ "version": "0.0.0",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.5.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.3.5",
+ "vite-plugin-svgr": "^4.3.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
+ "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
+ "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz",
+ "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz",
+ "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz",
+ "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz",
+ "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz",
+ "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz",
+ "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz",
+ "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz",
+ "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz",
+ "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz",
+ "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz",
+ "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz",
+ "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz",
+ "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz",
+ "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz",
+ "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz",
+ "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz",
+ "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz",
+ "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz",
+ "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz",
+ "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz",
+ "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz",
+ "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz",
+ "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz",
+ "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@svgr/babel-plugin-add-jsx-attribute": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
+ "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
+ "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
+ "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
+ "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-svg-dynamic-title": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
+ "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-svg-em-dimensions": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
+ "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-transform-react-native-svg": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
+ "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-transform-svg-component": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
+ "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-preset": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
+ "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
+ "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
+ "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
+ "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
+ "@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
+ "@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
+ "@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
+ "@svgr/babel-plugin-transform-svg-component": "8.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/core": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
+ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.21.3",
+ "@svgr/babel-preset": "8.1.0",
+ "camelcase": "^6.2.0",
+ "cosmiconfig": "^8.1.3",
+ "snake-case": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ }
+ },
+ "node_modules/@svgr/hast-util-to-babel-ast": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
+ "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.21.3",
+ "entities": "^4.4.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ }
+ },
+ "node_modules/@svgr/plugin-jsx": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
+ "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.21.3",
+ "@svgr/babel-preset": "8.1.0",
+ "@svgr/hast-util-to-babel-ast": "8.0.0",
+ "svg-parser": "^2.0.4"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@svgr/core": "*"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.23",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
+ "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.9",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
+ "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.20",
+ "caniuse-lite": "^1.0.30001810",
+ "electron-to-chromium": "^1.5.420",
+ "node-releases": "^2.0.54",
+ "update-browserslist-db": "^1.3.2"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cosmiconfig": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0",
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/dot-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.427",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz",
+ "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/no-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lower-case": "^2.0.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.55",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
+ "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
+ "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.3",
+ "@rollup/rollup-android-arm64": "4.63.3",
+ "@rollup/rollup-darwin-arm64": "4.63.3",
+ "@rollup/rollup-darwin-x64": "4.63.3",
+ "@rollup/rollup-freebsd-arm64": "4.63.3",
+ "@rollup/rollup-freebsd-x64": "4.63.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.3",
+ "@rollup/rollup-linux-arm64-musl": "4.63.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.3",
+ "@rollup/rollup-linux-loong64-musl": "4.63.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.3",
+ "@rollup/rollup-linux-x64-gnu": "4.63.3",
+ "@rollup/rollup-linux-x64-musl": "4.63.3",
+ "@rollup/rollup-openbsd-x64": "4.63.3",
+ "@rollup/rollup-openharmony-arm64": "4.63.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.3",
+ "@rollup/rollup-win32-x64-gnu": "4.63.3",
+ "@rollup/rollup-win32-x64-msvc": "4.63.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/snake-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
+ "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svg-parser": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz",
+ "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-svgr": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz",
+ "integrity": "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.2.0",
+ "@svgr/core": "^8.1.0",
+ "@svgr/plugin-jsx": "^8.1.0"
+ },
+ "peerDependencies": {
+ "vite": ">=2.6.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/superset-frontend/packages/superset-widgets/example/package.json b/superset-frontend/packages/superset-widgets/example/package.json
new file mode 100644
index 00000000000..f11145670c0
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "superset-widgets-example",
+ "private": true,
+ "version": "0.0.0",
+ "description": "A host React app rendering @apache-superset/widgets in its own layout.",
+ "license": "Apache-2.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "typecheck": "tsc --noEmit",
+ "broker": "node server.mjs"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.5.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.3.5",
+ "vite-plugin-svgr": "^4.3.0"
+ }
+}
diff --git a/superset-frontend/packages/superset-widgets/example/server.mjs b/superset-frontend/packages/superset-widgets/example/server.mjs
new file mode 100644
index 00000000000..a786773891c
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/server.mjs
@@ -0,0 +1,114 @@
+/*
+ * 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.
+ */
+
+// Local-only guest-token broker. A real broker must authenticate the host
+// app's own user first and add RLS rules scoped to that user.
+import http from "node:http";
+
+try {
+ process.loadEnvFile(new URL(".env", import.meta.url));
+} catch {
+ // No .env file: use the process environment as-is.
+}
+
+const SUPERSET_URL = (process.env.SUPERSET_URL ?? "http://localhost:8088").replace(/\/+$/, "");
+const USERNAME = process.env.SUPERSET_USERNAME ?? "admin";
+const PASSWORD = process.env.SUPERSET_PASSWORD ?? "admin";
+const PORT = Number(process.env.BROKER_PORT ?? 3001);
+
+const list = (value) =>
+ (value ?? "")
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+
+async function supersetJson(path, init, what) {
+ const response = await fetch(`${SUPERSET_URL}${path}`, init);
+ const text = await response.text();
+ if (!response.ok) {
+ throw new Error(`${what} failed: ${response.status} ${text.slice(0, 300)}`);
+ }
+ return { response, body: text ? JSON.parse(text) : {} };
+}
+
+async function mintGuestToken() {
+ const { body: login } = await supersetJson(
+ "/api/v1/security/login",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: USERNAME, password: PASSWORD, provider: "db", refresh: false }),
+ },
+ "Superset login",
+ );
+ const headers = {
+ Authorization: `Bearer ${login.access_token}`,
+ "Content-Type": "application/json",
+ Referer: SUPERSET_URL,
+ };
+
+ // guest_token is CSRF-protected unless WTF_CSRF_ENABLED is off; the CSRF
+ // token is bound to the session cookie issued with it.
+ try {
+ const { response, body } = await supersetJson(
+ "/api/v1/security/csrf_token/",
+ { headers: { Authorization: headers.Authorization } },
+ "CSRF token",
+ );
+ headers["X-CSRFToken"] = body.result;
+ const cookies = response.headers.getSetCookie().map((cookie) => cookie.split(";")[0]);
+ if (cookies.length > 0) headers.Cookie = cookies.join("; ");
+ } catch (err) {
+ console.warn(`Continuing without a CSRF token: ${err.message}`);
+ }
+
+ const { body } = await supersetJson(
+ "/api/v1/security/guest_token/",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ user: { username: "widgets-demo", first_name: "Widgets", last_name: "Demo" },
+ // Saved widgets are granted by id; inline widgets by dataset.
+ resources: list(process.env.EMBED_WIDGET_IDS).map((id) => ({ type: "widget", id })),
+ datasets: list(process.env.EMBED_DATASETS).map(Number),
+ rls: [],
+ }),
+ },
+ "Guest token",
+ );
+ return body.token;
+}
+
+http
+ .createServer(async (req, res) => {
+ if (req.method !== "POST" || !req.url?.startsWith("/api/guest-token")) {
+ res.writeHead(404).end();
+ return;
+ }
+ try {
+ res.writeHead(200, { "Content-Type": "text/plain" }).end(await mintGuestToken());
+ } catch (err) {
+ console.error(err);
+ res.writeHead(502, { "Content-Type": "text/plain" }).end(err.message);
+ }
+ })
+ .listen(PORT, "127.0.0.1", () => {
+ console.log(`Guest-token broker on http://127.0.0.1:${PORT} (Superset: ${SUPERSET_URL})`);
+ });
diff --git a/superset-frontend/packages/superset-widgets/example/src/App.tsx b/superset-frontend/packages/superset-widgets/example/src/App.tsx
new file mode 100644
index 00000000000..b78540145f4
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/App.tsx
@@ -0,0 +1,309 @@
+/**
+ * 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 { useState } from 'react';
+import {
+ Chart,
+ FilterSelect,
+ MetricTile,
+ SupersetProvider,
+ Widget,
+} from '@apache-superset/widgets';
+// `?raw` loads this file's text for the source panel, not the module itself.
+// eslint-disable-next-line import/no-self-import
+import appSource from './App.tsx?raw';
+import { config, fetchGuestToken } from './config';
+import MinimalEmbed from './MinimalEmbed';
+import minimalEmbedSource from './minimalEmbedSource';
+import { EventLog } from './EventLog';
+import { Gallery } from './Gallery';
+import { HostFilterBar } from './HostFilterBar';
+import {
+ Frame,
+ StructureProvider,
+ StructureTree,
+ useStructureVisible,
+} from './structure';
+
+const withoutLicense = (source: string) =>
+ source.replace(/^\/\*\*[\s\S]*?\*\/\s*/, '');
+
+const binding = (metrics: string[], dimensions: string[], rowLimit = 1000) => ({
+ datasetId: config.datasetId,
+ metrics,
+ dimensions,
+ rowLimit,
+});
+
+function Page() {
+ const [dark, setDark] = useState(false);
+ const [showStructure, setShowStructure] = useStructureVisible();
+
+ const appClass = ['app', dark && 'app-dark', showStructure && 'app-structure']
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+ <div className={appClass}>
+ <header className="app-header">
+ <div>
+ <h1>Acme Operations</h1>
+ <p>Superset widgets rendered in this app's own React tree.</p>
+ {config.authMode === 'session' && (
+ <p className="hint">
+ Uses your Superset login, no backend. Not signed in?{' '}
+ <a
+ href={`${config.supersetUrl}/login/`}
+ target="_blank"
+ rel="noreferrer"
+ >
+ Log in to Superset
+ </a>
+ , then reload.
+ </p>
+ )}
+ </div>
+ <div className="toggles">
+ <label className="toggle">
+ <input
+ type="checkbox"
+ checked={showStructure}
+ onChange={event => setShowStructure(event.target.checked)}
+ />
+ Show structure
+ </label>
+ <label className="toggle">
+ <input
+ type="checkbox"
+ checked={dark}
+ onChange={event => setDark(event.target.checked)}
+ />
+ Dark
+ </label>
+ </div>
+ </header>
+
+ <section className="minimal-embed">
+ <h2>Embed one widget</h2>
+ <p className="hint">
+ The whole integration: a provider and a widget. This is the file
+ below, rendered live next to its source.
+ </p>
+ <div className="minimal-embed-body">
+ <pre className="code-block">
+ <code>{withoutLicense(minimalEmbedSource)}</code>
+ </pre>
+ <div className="card">
+ <MinimalEmbed />
+ </div>
+ </div>
+ </section>
+
+ <SupersetProvider
+ supersetDomain={config.supersetUrl}
+ fetchGuestToken={
+ config.authMode === 'guest' ? fetchGuestToken : undefined
+ }
+ themeMode={dark ? 'dark' : 'default'}
+ >
+ <Frame
+ label="<SupersetProvider>"
+ detail={`${config.supersetUrl} · ${
+ config.authMode === 'guest'
+ ? 'guest token from /api/guest-token'
+ : 'your Superset login'
+ }`}
+ kind="superset"
+ className="provider"
+ >
+ <Frame
+ label="<HostFilterBar>"
+ detail="useSupersetFilter('host-state')"
+ kind="host"
+ >
+ <HostFilterBar />
+ </Frame>
+ <div className="app-body">
+ <main>
+ <Frame label="Inline widgets" kind="host" as="section">
+ <h2>Inline widgets</h2>
+ <p className="hint">
+ Defined here, validated and executed by Superset. They
+ cross-filter each other.
+ </p>
+ <div className="grid">
+ <Frame
+ label="<FilterSelect>"
+ code
+ detail="datasetId=17 column=gender"
+ kind="superset"
+ className="card small"
+ >
+ <FilterSelect
+ datasetId={config.datasetId}
+ column="gender"
+ />
+ </Frame>
+ <Frame
+ label="<MetricTile>"
+ code
+ detail="sum__num"
+ kind="superset"
+ className="card small"
+ >
+ <MetricTile
+ dataBinding={binding(['sum__num'], [])}
+ label="Total births"
+ />
+ </Frame>
+ <Frame
+ label="<Chart chartType=bar>"
+ code
+ detail="sum__num by state, top 10, crossFilter"
+ kind="superset"
+ className="card"
+ >
+ <Chart
+ instanceId="births-by-state"
+ dataBinding={binding(['sum__num'], ['state'], 10)}
+ chartType="bar"
+ crossFilter
+ chrome={{ titleText: 'Births by state (top 10)' }}
+ echartsOptions={{
+ tooltip: { trigger: 'axis' },
+ xAxis: {
+ type: 'category',
+ data: {
+ $bind: { source: 'dimension', alias: 'state' },
+ },
+ },
+ yAxis: { type: 'value' },
+ }}
+ />
+ </Frame>
+ <Frame
+ label="<Chart> (pie)"
+ code
+ detail="sum__num by gender, crossFilter"
+ kind="superset"
+ className="card"
+ >
+ <Chart
+ instanceId="births-by-gender"
+ dataBinding={binding(['sum__num'], ['gender'])}
+ crossFilter
+ chrome={{ titleText: 'Births by gender' }}
+ echartsOptions={{
+ tooltip: { trigger: 'item' },
+ series: [
+ {
+ type: 'pie',
+ radius: '60%',
+ data: {
+ $bind: {
+ source: 'records',
+ fields: { name: 'gender', value: 'sum__num' },
+ },
+ },
+ },
+ ],
+ }}
+ />
+ </Frame>
+ </div>
+ </Frame>
+
+ <Frame label="Saved widgets" kind="host" as="section">
+ <h2>Saved widgets</h2>
+ {config.savedWidgetIds.length > 0 ? (
+ <div className="grid">
+ {config.savedWidgetIds.map(id => (
+ <Frame
+ key={id}
+ label="<Widget id>"
+ code
+ detail={id}
+ kind="superset"
+ className="card"
+ >
+ <Widget id={id} />
+ </Frame>
+ ))}
+ </div>
+ ) : (
+ <p className="hint">
+ Set EMBED_WIDGET_IDS to saved widget uuids.
+ </p>
+ )}
+ </Frame>
+ </main>
+ <aside>
+ <Frame
+ label="<EventLog>"
+ detail="useWidgetEvent('valueChanged')"
+ kind="host"
+ >
+ <EventLog />
+ </Frame>
+ {showStructure && (
+ <>
+ <section className="card structure-panel">
+ <h2>React tree</h2>
+ <StructureTree />
+ </section>
+ <details className="card structure-panel source">
+ <summary>App.tsx</summary>
+ <pre>{appSource}</pre>
+ </details>
+ </>
+ )}
+ </aside>
+ </div>
+ </Frame>
+
+ <Frame
+ label="<Widget id>"
+ code
+ kind="superset"
+ className="card demo-slot"
+ >
+ <Widget id="a4c570e0-6b2a-4b41-bd2c-99bc3c2c184c" />
+ </Frame>
+
+ <Frame
+ label="<Widget id>"
+ code
+ kind="superset"
+ className="card demo-slot"
+ >
+ <Widget id="a4d4c0aa-7d14-409d-81b7-8ee6266e2896" />
+ </Frame>
+ </SupersetProvider>
+
+ <Gallery themeMode={dark ? 'dark' : 'default'} />
+ </div>
+ );
+}
+
+export default function App() {
+ return (
+ <StructureProvider>
+ <Page />
+ </StructureProvider>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/EventLog.tsx b/superset-frontend/packages/superset-widgets/example/src/EventLog.tsx
new file mode 100644
index 00000000000..967aff88b88
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/EventLog.tsx
@@ -0,0 +1,75 @@
+/**
+ * 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 { useState } from 'react';
+import {
+ useWidgetEvent,
+ type FilterValueChangedPayload,
+} from '@apache-superset/widgets';
+
+interface Entry {
+ key: number;
+ time: string;
+ source: string;
+ summary: string;
+}
+
+let nextKey = 0;
+
+function summarize(payload: FilterValueChangedPayload | undefined): string {
+ const resolved = payload?.resolved;
+ if (!resolved) return 'cleared';
+ return `${resolved.column} ${resolved.operator} ${JSON.stringify(resolved.value)}`;
+}
+
+/** Filter and cross-filter events coming back out of widgets (and the host's own filter). */
+export function EventLog() {
+ const [entries, setEntries] = useState<Entry[]>([]);
+
+ useWidgetEvent('valueChanged', event => {
+ setEntries(current =>
+ [
+ {
+ key: (nextKey += 1),
+ time: new Date().toLocaleTimeString(),
+ source: event.nodeId,
+ summary: summarize(event.payload as FilterValueChangedPayload),
+ },
+ ...current,
+ ].slice(0, 30),
+ );
+ });
+
+ return (
+ <section className="card event-log">
+ <h2>Events from widgets</h2>
+ {entries.length === 0 ? (
+ <p className="hint">Pick a value in the filter or click a chart.</p>
+ ) : (
+ <ul>
+ {entries.map(entry => (
+ <li key={entry.key}>
+ <time>{entry.time}</time> <code>{entry.source}</code>
+ <div>{entry.summary}</div>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/Gallery.tsx b/superset-frontend/packages/superset-widgets/example/src/Gallery.tsx
new file mode 100644
index 00000000000..1147a3b4928
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/Gallery.tsx
@@ -0,0 +1,580 @@
+/**
+ * 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, type ReactNode } from 'react';
+import {
+ Chart,
+ FilterSelect,
+ Markdown,
+ MetricTile,
+ SupersetProvider,
+ Table,
+ Widget,
+ createWidgetBus,
+ useSupersetFilter,
+ useWidgetValue,
+ type FilterValueChangedPayload,
+ type ThemeModeName,
+} from '@apache-superset/widgets';
+import { config, fetchGuestToken } from './config';
+import { Frame } from './structure';
+
+const DATASET = config.datasetId;
+const MISSING_WIDGET_ID = '00000000-0000-0000-0000-000000000000';
+
+const byState = {
+ xAxis: {
+ type: 'category',
+ data: { $bind: { source: 'dimension', alias: 'state' } },
+ },
+ yAxis: { type: 'value' },
+};
+
+const byGender = {
+ xAxis: {
+ type: 'category',
+ data: { $bind: { source: 'dimension', alias: 'gender' } },
+ },
+ yAxis: { type: 'value' },
+};
+
+function Card({
+ title,
+ children,
+ note,
+}: {
+ title: string;
+ note: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+ <section className="gallery-card">
+ <h3>{title}</h3>
+ <p className="hint">{note}</p>
+ {children}
+ </section>
+ );
+}
+
+function Demo({
+ label,
+ className = 'gallery-widget',
+ children,
+}: {
+ label: string;
+ className?: string;
+ children: ReactNode;
+}) {
+ return (
+ <Frame label={label} kind="superset" code className={className}>
+ {children}
+ </Frame>
+ );
+}
+
+function SelectionReadout({ instanceId }: { instanceId: string }) {
+ const value = useWidgetValue(instanceId) as
+ FilterValueChangedPayload | undefined;
+ const selection = value?.resolved?.value;
+ return (
+ <p className="hint">
+ <code>useWidgetValue('{instanceId}')</code> →{' '}
+ <strong>
+ {selection === undefined
+ ? 'nothing selected'
+ : JSON.stringify(selection)}
+ </strong>
+ </p>
+ );
+}
+
+function TargetedHostFilter() {
+ const setFilter = useSupersetFilter('gallery-host-target');
+ return (
+ <div className="gallery-actions">
+ <button
+ type="button"
+ className="chip"
+ onClick={() =>
+ setFilter({
+ datasetId: DATASET,
+ column: 'state',
+ operator: 'IN',
+ value: ['NY', 'CA'],
+ targets: ['gallery-host-left'],
+ })
+ }
+ >
+ Filter left chart to NY, CA
+ </button>
+ <button type="button" className="chip" onClick={() => setFilter(null)}>
+ Clear
+ </button>
+ </div>
+ );
+}
+
+function SharedBusProviders({ themeMode }: { themeMode: ThemeModeName }) {
+ const bus = useMemo(() => createWidgetBus(), []);
+ const auth =
+ config.authMode === 'guest' ? { fetchGuestToken } : ({} as const);
+ return (
+ <div className="gallery-row">
+ <SupersetProvider
+ supersetDomain={config.supersetUrl}
+ bus={bus}
+ themeMode={themeMode}
+ {...auth}
+ >
+ <Demo label="<FilterSelect> (provider A)" className="gallery-plain">
+ <FilterSelect datasetId={DATASET} column="gender" />
+ </Demo>
+ </SupersetProvider>
+ <SupersetProvider
+ supersetDomain={config.supersetUrl}
+ bus={bus}
+ themeMode={themeMode}
+ themeConfig={{ token: { colorPrimary: '#7b3fe4' } }}
+ {...auth}
+ >
+ <Demo label="<Chart> (provider B)">
+ <Chart
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 8,
+ }}
+ echartsOptions={{
+ ...byState,
+ series: [
+ {
+ type: 'bar',
+ data: { $bind: { source: 'metric', alias: 'sum__num' } },
+ itemStyle: {
+ color: {
+ $bind: { source: 'theme', token: 'colorPrimary' },
+ },
+ },
+ },
+ ],
+ }}
+ chrome={{ titleText: 'Themed by provider B' }}
+ />
+ </Demo>
+ </SupersetProvider>
+ </div>
+ );
+}
+
+/**
+ * One small example per component and prop in the API reference. Runs under
+ * its own provider, so its filters never touch the widgets above.
+ */
+export function Gallery({ themeMode }: { themeMode: ThemeModeName }) {
+ const savedId = config.savedWidgetIds[0];
+
+ return (
+ <section className="gallery">
+ <h2>Component gallery</h2>
+ <p className="hint">
+ One example per component and prop. Every widget has a{' '}
+ <code></> Code</code> button. Not shown: <code>client</code> (the
+ Claude-artifact MCP client), <code>guestTokenHeaderName</code> and{' '}
+ <code>guestTokenFetchTimeoutMs</code> (guest-token mode only).
+ </p>
+ <SupersetProvider
+ supersetDomain={config.supersetUrl}
+ fetchGuestToken={
+ config.authMode === 'guest' ? fetchGuestToken : undefined
+ }
+ themeMode={themeMode}
+ >
+ <div className="gallery-grid">
+ <Card
+ title="Generic <Widget type props>"
+ note={
+ <>
+ Any widget type by name. <code><MetricTile …/></code> is
+ shorthand for this.
+ </>
+ }
+ >
+ <Demo label="<Widget type>" className="gallery-widget short">
+ <Widget
+ type="metric-tile"
+ props={{
+ dataBinding: {
+ datasetId: DATASET,
+ metrics: ['count'],
+ dimensions: [],
+ },
+ label: 'Rows in birth_names',
+ }}
+ />
+ </Demo>
+ </Card>
+
+ <Card
+ title="MetricTile formatting"
+ note={
+ <>
+ <code>prefix</code>, <code>suffix</code>, <code>decimals</code>,
+ with an aggregate metric object instead of a saved metric.
+ </>
+ }
+ >
+ <Demo label="<MetricTile>" className="gallery-widget short">
+ <MetricTile
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: [
+ {
+ expressionType: 'SIMPLE',
+ aggregate: 'AVG',
+ column: { column_name: 'num' },
+ label: 'avg_num',
+ },
+ ],
+ dimensions: [],
+ }}
+ prefix="~"
+ suffix=" births per row"
+ decimals={2}
+ label="Average of num"
+ />
+ </Demo>
+ </Card>
+
+ <Card
+ title="Table"
+ note={
+ <>
+ Query rows in AG Grid. <code>columnDefs</code> renames and sizes
+ columns.
+ </>
+ }
+ >
+ <Demo label="<Table>">
+ <Table
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['name'],
+ rowLimit: 25,
+ }}
+ columnDefs={[
+ { field: 'name', headerName: 'Name', flex: 1 },
+ { field: 'sum__num', headerName: 'Births', width: 120 },
+ ]}
+ />
+ </Demo>
+ </Card>
+
+ <Card title="Markdown" note={<>Static rich text, no query.</>}>
+ <Demo label="<Markdown>" className="gallery-widget short">
+ <Markdown
+ content={'### Notes\nMarkdown **inside** the host app.'}
+ />
+ </Demo>
+ </Card>
+
+ <Card
+ title="Chart customize + chrome"
+ note={
+ <>
+ <code>customize.series</code> (keyed by metric label) and{' '}
+ <code>chrome</code> legend, tooltip and axis names.
+ </>
+ }
+ >
+ <Demo label="<Chart customize chrome>">
+ <Chart
+ chartType="bar"
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num', 'count'],
+ dimensions: ['gender'],
+ }}
+ echartsOptions={byGender}
+ customize={{
+ series: {
+ sum__num: { color: '#2f5d50', displayName: 'Births' },
+ count: { visible: false },
+ },
+ }}
+ chrome={{
+ titleText: 'Births by gender',
+ legendShow: true,
+ legendPosition: 'bottom',
+ tooltipTrigger: 'axis',
+ xAxisName: 'Gender',
+ yAxisName: 'Births',
+ }}
+ />
+ </Demo>
+ </Card>
+
+ <Card
+ title="$bind sources"
+ note={
+ <>
+ <code>metric</code> values, a <code>theme</code> token color,
+ and <code>single: true</code> for one value (the first row).
+ </>
+ }
+ >
+ <Demo label="<Chart $bind>">
+ <Chart
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['gender'],
+ }}
+ echartsOptions={{
+ ...byGender,
+ title: {
+ text: 'First row',
+ subtext: {
+ $bind: {
+ source: 'metric',
+ alias: 'sum__num',
+ single: true,
+ },
+ },
+ },
+ series: [
+ {
+ type: 'bar',
+ data: { $bind: { source: 'metric', alias: 'sum__num' } },
+ itemStyle: {
+ color: {
+ $bind: { source: 'theme', token: 'colorSuccess' },
+ },
+ },
+ },
+ ],
+ }}
+ showTitle={false}
+ />
+ </Demo>
+ </Card>
+
+ <Card
+ title="Filters baked into dataBinding"
+ note={
+ <>
+ <code>dataBinding.filters</code> are part of the definition and
+ combine with any active filters.
+ </>
+ }
+ >
+ <Demo label="<Chart dataBinding.filters>">
+ <Chart
+ chartType="bar"
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ filters: [
+ {
+ expressionType: 'SIMPLE',
+ clause: 'WHERE',
+ subject: 'state',
+ operator: 'IN',
+ comparator: ['CA', 'NY', 'TX'],
+ },
+ ],
+ }}
+ echartsOptions={byState}
+ chrome={{ titleText: 'CA, NY and TX only' }}
+ />
+ </Demo>
+ </Card>
+
+ <Card
+ title="FilterSelect options, default and scope"
+ note={
+ <>
+ Fixed <code>options</code>, a <code>defaultSelection</code>, and{' '}
+ <code>scope.targets</code> so only the left chart listens. The
+ readout uses <code>useWidgetValue</code>.
+ </>
+ }
+ >
+ <Demo label="<FilterSelect scope>" className="gallery-plain">
+ <FilterSelect
+ instanceId="gallery-gender-filter"
+ datasetId={DATASET}
+ column="gender"
+ options={['boy', 'girl']}
+ defaultSelection={['girl']}
+ scope={{ targets: ['gallery-scoped'] }}
+ />
+ </Demo>
+ <SelectionReadout instanceId="gallery-gender-filter" />
+ <div className="gallery-row">
+ <Demo label="<Chart> targeted">
+ <Chart
+ instanceId="gallery-scoped"
+ chartType="bar"
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 5,
+ }}
+ echartsOptions={byState}
+ chrome={{ titleText: 'Targeted' }}
+ />
+ </Demo>
+ <Demo label="<Chart> not targeted">
+ <Chart
+ instanceId="gallery-unscoped"
+ chartType="bar"
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 5,
+ }}
+ echartsOptions={byState}
+ chrome={{ titleText: 'Not targeted' }}
+ />
+ </Demo>
+ </div>
+ </Card>
+
+ <Card
+ title="Host filter targets"
+ note={
+ <>
+ <code>useSupersetFilter</code> with <code>targets</code> narrows
+ only the listed instance ids.
+ </>
+ }
+ >
+ <TargetedHostFilter />
+ <div className="gallery-row">
+ <Demo label="<Chart> left (target)">
+ <Chart
+ instanceId="gallery-host-left"
+ chartType="bar"
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 6,
+ }}
+ echartsOptions={byState}
+ chrome={{ titleText: 'Left' }}
+ />
+ </Demo>
+ <Demo label="<Chart> right">
+ <Chart
+ instanceId="gallery-host-right"
+ chartType="bar"
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 6,
+ }}
+ echartsOptions={byState}
+ chrome={{ titleText: 'Right' }}
+ />
+ </Demo>
+ </div>
+ </Card>
+
+ <Card
+ title="Layout props"
+ note={
+ <>
+ <code>className</code>, <code>style</code> and{' '}
+ <code>showTitle</code> on the widget itself.
+ </>
+ }
+ >
+ <Demo label="<Chart className style>" className="gallery-plain">
+ <Chart
+ className="custom-widget-frame"
+ style={{ height: 220 }}
+ showTitle={false}
+ chartType="bar"
+ dataBinding={{
+ datasetId: DATASET,
+ metrics: ['sum__num'],
+ dimensions: ['gender'],
+ }}
+ echartsOptions={byGender}
+ chrome={{ titleText: 'Title hidden by showTitle' }}
+ />
+ </Demo>
+ </Card>
+
+ <Card
+ title="Loading and errors"
+ note={
+ <>
+ <code>fallback</code> while a saved widget loads, and{' '}
+ <code>renderError</code> for a widget id that does not exist.
+ </>
+ }
+ >
+ <div className="gallery-row">
+ {savedId ? (
+ <Demo label="<Widget fallback>">
+ <Widget
+ id={savedId}
+ fallback={<p className="hint">Loading saved widget…</p>}
+ />
+ </Demo>
+ ) : (
+ <p className="hint">Set EMBED_WIDGET_IDS to show fallback.</p>
+ )}
+ <Demo label="<Widget renderError>">
+ <Widget
+ id={MISSING_WIDGET_ID}
+ renderError={error => (
+ <div className="gallery-error" role="alert">
+ Custom error view: {error.message}
+ </div>
+ )}
+ />
+ </Demo>
+ </div>
+ </Card>
+
+ <Card
+ title="Providers sharing a bus, with themeConfig"
+ note={
+ <>
+ Two separate <code>SupersetProvider</code>s given the same{' '}
+ <code>bus</code> cross-filter; provider B sets{' '}
+ <code>themeConfig</code>.
+ </>
+ }
+ >
+ <SharedBusProviders themeMode={themeMode} />
+ </Card>
+ </div>
+ </SupersetProvider>
+ </section>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/HostFilterBar.tsx b/superset-frontend/packages/superset-widgets/example/src/HostFilterBar.tsx
new file mode 100644
index 00000000000..69c922f19e4
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/HostFilterBar.tsx
@@ -0,0 +1,75 @@
+/**
+ * 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 { useState } from 'react';
+import { useSupersetFilter } from '@apache-superset/widgets';
+import { config } from './config';
+
+/** A filter owned by the host app, pushed into every widget on the dataset. */
+export function HostFilterBar() {
+ const setFilter = useSupersetFilter('host-state');
+ const [selected, setSelected] = useState<string[]>([]);
+
+ const apply = (next: string[]) => {
+ setSelected(next);
+ setFilter(
+ next.length
+ ? {
+ datasetId: config.datasetId,
+ column: config.filterColumn,
+ operator: 'IN',
+ value: next,
+ }
+ : null,
+ );
+ };
+
+ return (
+ <fieldset className="filter-bar">
+ <legend className="filter-label">{config.filterColumn}</legend>
+ {config.filterValues.map(value => {
+ const active = selected.includes(value);
+ return (
+ <button
+ type="button"
+ key={value}
+ className={active ? 'chip chip-active' : 'chip'}
+ aria-pressed={active}
+ onClick={() =>
+ apply(
+ active
+ ? selected.filter(v => v !== value)
+ : [...selected, value],
+ )
+ }
+ >
+ {value}
+ </button>
+ );
+ })}
+ <button
+ type="button"
+ className="chip"
+ disabled={!selected.length}
+ onClick={() => apply([])}
+ >
+ Clear
+ </button>
+ </fieldset>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/MinimalEmbed.tsx b/superset-frontend/packages/superset-widgets/example/src/MinimalEmbed.tsx
new file mode 100644
index 00000000000..eadcb2919cc
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/MinimalEmbed.tsx
@@ -0,0 +1,44 @@
+/**
+ * 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 { Chart, SupersetProvider } from '@apache-superset/widgets';
+
+// No backend: requests carry your Superset login session.
+export default function BirthsByState() {
+ return (
+ <SupersetProvider supersetDomain="http://localhost:8088">
+ <Chart
+ chartType="bar"
+ dataBinding={{
+ datasetId: 17,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 10,
+ }}
+ echartsOptions={{
+ xAxis: {
+ type: 'category',
+ data: { $bind: { source: 'dimension', alias: 'state' } },
+ },
+ yAxis: { type: 'value' },
+ }}
+ chrome={{ titleText: 'Births by state' }}
+ />
+ </SupersetProvider>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/WidgetCode.tsx b/superset-frontend/packages/superset-widgets/example/src/WidgetCode.tsx
new file mode 100644
index 00000000000..2beb38946e1
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/WidgetCode.tsx
@@ -0,0 +1,97 @@
+/**
+ * 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, useRef, useState, type ReactNode } from 'react';
+import { config } from './config';
+import { embedSnippet } from './jsx';
+
+/**
+ * A "Code" button that opens the JSX needed to embed this one widget on its
+ * own in a modal. The widget stays mounted underneath, so opening the code
+ * does not reset its data or filters.
+ */
+export function WidgetCode({
+ element,
+ title,
+}: {
+ element: ReactNode;
+ title?: string;
+}) {
+ const dialogRef = useRef<HTMLDialogElement>(null);
+ const [open, setOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const snippet = open ? embedSnippet(element, config.supersetUrl) : '';
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (open && dialog && !dialog.open) dialog.showModal();
+ }, [open]);
+
+ const copy = () => {
+ navigator.clipboard.writeText(snippet).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ return (
+ <>
+ <button
+ type="button"
+ className="code-toggle"
+ aria-haspopup="dialog"
+ onClick={() => setOpen(true)}
+ >
+ {'</> Code'}
+ </button>
+ {open && (
+ // A dialog closes on Escape by itself; the click below is only the
+ // backdrop's dismiss, which has no keyboard equivalent to add.
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
+ <dialog
+ ref={dialogRef}
+ className="code-modal"
+ aria-label={title ? `Embed code: ${title}` : 'Embed code'}
+ onClose={() => setOpen(false)}
+ onClick={event => {
+ // Clicks on the backdrop land on the dialog element itself.
+ if (event.target === event.currentTarget)
+ dialogRef.current?.close();
+ }}
+ >
+ <header className="code-modal-header">
+ <h2>{title ?? 'Embed code'}</h2>
+ <button type="button" className="code-modal-button" onClick={copy}>
+ {copied ? 'Copied' : 'Copy'}
+ </button>
+ <button
+ type="button"
+ className="code-modal-button"
+ onClick={() => dialogRef.current?.close()}
+ >
+ Close
+ </button>
+ </header>
+ <pre>
+ <code>{snippet}</code>
+ </pre>
+ </dialog>
+ )}
+ </>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/config.ts b/superset-frontend/packages/superset-widgets/example/src/config.ts
new file mode 100644
index 00000000000..1549aba9203
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/config.ts
@@ -0,0 +1,51 @@
+/**
+ * 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.
+ */
+
+// Vite injects env only where the source literally reads `import.meta.env`,
+// so this must not be destructured (oxlint's prefer-destructuring would).
+const viteEnv = import.meta.env;
+
+const list = (value: string | undefined): string[] =>
+ (value ?? '')
+ .split(',')
+ .map(item => item.trim())
+ .filter(Boolean);
+
+export const config = {
+ supersetUrl: (viteEnv.SUPERSET_URL ?? 'http://localhost:8088').replace(
+ /\/+$/,
+ '',
+ ),
+ // `session` uses the viewer's Superset login cookie; `guest` uses the token broker.
+ authMode: (viteEnv.EMBED_AUTH === 'guest' ? 'guest' : 'session') as
+ 'guest' | 'session',
+ savedWidgetIds: list(viteEnv.EMBED_WIDGET_IDS),
+ datasetId: Number(viteEnv.FILTER_DATASET_ID ?? 17),
+ filterColumn: viteEnv.FILTER_COLUMN ?? 'state',
+ filterValues: list(viteEnv.FILTER_VALUES),
+};
+
+export async function fetchGuestToken(): Promise<string> {
+ const response = await fetch('/api/guest-token', { method: 'POST' });
+ const body = await response.text();
+ if (!response.ok) {
+ throw new Error(`Token broker responded ${response.status}: ${body}`);
+ }
+ return body;
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/jsx.ts b/superset-frontend/packages/superset-widgets/example/src/jsx.ts
new file mode 100644
index 00000000000..45dc4c800df
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/jsx.ts
@@ -0,0 +1,107 @@
+/**
+ * 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 { isValidElement, type ReactNode } from 'react';
+
+const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
+const MAX_INLINE = 60;
+
+function literal(value: unknown, indent: string): string {
+ if (value === null || value === undefined) return String(value);
+ if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ if (typeof value === 'function') return '() => {…}';
+ // Mutually recursive with elementToJsx: an element can hold props that are
+ // themselves elements.
+ // eslint-disable-next-line no-use-before-define
+ if (isValidElement(value)) return elementToJsx(value);
+
+ const inner = `${indent} `;
+ if (Array.isArray(value)) {
+ const items = value.map(item => literal(item, inner));
+ const oneLine = `[${items.join(', ')}]`;
+ if (oneLine.length <= MAX_INLINE && !oneLine.includes('\n')) return oneLine;
+ return `[\n${items.map(item => `${inner}${item}`).join(',\n')},\n${indent}]`;
+ }
+
+ const entries = Object.entries(value as Record<string, unknown>)
+ .filter(([, entry]) => entry !== undefined)
+ .map(
+ ([key, entry]) =>
+ `${IDENTIFIER.test(key) ? key : `'${key}'`}: ${literal(entry, inner)}`,
+ );
+ if (entries.length === 0) return '{}';
+ const oneLine = `{ ${entries.join(', ')} }`;
+ if (oneLine.length <= MAX_INLINE && !oneLine.includes('\n')) return oneLine;
+ return `{\n${entries.map(entry => `${inner}${entry}`).join(',\n')},\n${indent}}`;
+}
+
+function attribute(name: string, value: unknown, indent: string): string {
+ if (value === true) return name;
+ if (typeof value === 'string') return `${name}="${value}"`;
+ return `${name}={${literal(value, indent)}}`;
+}
+
+function componentName(type: unknown): string {
+ if (typeof type === 'string') return type;
+ const component = type as { displayName?: string; name?: string };
+ return component.displayName || component.name || 'Component';
+}
+
+/**
+ * The JSX that produced a React element, rebuilt from its type and props
+ * (children omitted). Showing this instead of hand-written snippets means the
+ * code on the page can never drift from what actually rendered.
+ */
+export function elementToJsx(node: ReactNode): string {
+ if (!isValidElement(node)) return '';
+ const name = componentName(node.type);
+ const props = Object.entries(node.props as Record<string, unknown>).filter(
+ ([key, value]) => key !== 'children' && value !== undefined,
+ );
+ const inline = props.map(([key, value]) => attribute(key, value, ''));
+ const oneLine = `<${name}${inline.length ? ` ${inline.join(' ')}` : ''} />`;
+ if (oneLine.length <= 72 && !oneLine.includes('\n')) return oneLine;
+ const lines = props.map(([key, value]) => ` ${attribute(key, value, ' ')}`);
+ return `<${name}\n${lines.join('\n')}\n/>`;
+}
+
+/**
+ * Everything needed to embed one widget on its own: the import, a provider,
+ * and the widget's JSX rebuilt from the element that actually rendered.
+ */
+export function embedSnippet(node: ReactNode, supersetDomain: string): string {
+ if (!isValidElement(node)) return '';
+ const imports = [
+ ...new Set([componentName(node.type), 'SupersetProvider']),
+ ].sort();
+ const body = elementToJsx(node)
+ .split('\n')
+ .map(line => ` ${line}`)
+ .join('\n');
+ return [
+ `import { ${imports.join(', ')} } from '@apache-superset/widgets';`,
+ '',
+ `<SupersetProvider supersetDomain="${supersetDomain}">`,
+ body,
+ '</SupersetProvider>',
+ ].join('\n');
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/main.tsx b/superset-frontend/packages/superset-widgets/example/src/main.tsx
new file mode 100644
index 00000000000..ffd332ce2c3
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/main.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 { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+ <StrictMode>
+ <App />
+ </StrictMode>,
+);
diff --git a/superset-frontend/packages/superset-widgets/example/src/minimalEmbedSource.ts b/superset-frontend/packages/superset-widgets/example/src/minimalEmbedSource.ts
new file mode 100644
index 00000000000..2feeacbebc1
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/minimalEmbedSource.ts
@@ -0,0 +1,21 @@
+/**
+ * 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 source text of MinimalEmbed.tsx, shown next to its live render.
+export { default } from './MinimalEmbed.tsx?raw';
diff --git a/superset-frontend/packages/superset-widgets/example/src/structure.tsx b/superset-frontend/packages/superset-widgets/example/src/structure.tsx
new file mode 100644
index 00000000000..45a7be36fb0
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/structure.tsx
@@ -0,0 +1,218 @@
+/**
+ * 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,
+ useContext,
+ useId,
+ useLayoutEffect,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+ type ReactNode,
+} from 'react';
+import { WidgetCode } from './WidgetCode';
+
+/** `superset` = a component from `@apache-superset/widgets`; `host` = this app's own code. */
+export type FrameKind = 'superset' | 'host';
+
+interface FrameNode {
+ id: string;
+ parentId: string | null;
+ label: string;
+ detail?: string;
+ kind: FrameKind;
+ order: number;
+}
+
+class FrameRegistry {
+ private nodes = new Map<string, FrameNode>();
+
+ private listeners = new Set<() => void>();
+
+ private snapshot: FrameNode[] = [];
+
+ private counter = 0;
+
+ register(node: Omit<FrameNode, 'order'>): () => void {
+ // React commits layout effects child-first in tree order, so a counter
+ // taken at first registration keeps siblings in render order.
+ const order = this.nodes.get(node.id)?.order ?? (this.counter += 1);
+ this.nodes.set(node.id, { ...node, order });
+ this.emit();
+ return () => {
+ this.nodes.delete(node.id);
+ this.emit();
+ };
+ }
+
+ subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ getSnapshot = (): FrameNode[] => this.snapshot;
+
+ private emit(): void {
+ this.snapshot = [...this.nodes.values()];
+ this.listeners.forEach(listener => listener());
+ }
+}
+
+interface StructureState {
+ registry: FrameRegistry;
+ visible: boolean;
+ setVisible: (visible: boolean) => void;
+}
+
+const StructureContext = createContext<StructureState | null>(null);
+const ParentFrameContext = createContext<string | null>(null);
+
+function useStructure(): StructureState {
+ const state = useContext(StructureContext);
+ if (!state) throw new Error('Wrap the app in <StructureProvider>');
+ return state;
+}
+
+export function StructureProvider({ children }: { children: ReactNode }) {
+ const registry = useMemo(() => new FrameRegistry(), []);
+ const [visible, setVisible] = useState(false);
+ const value = useMemo(
+ () => ({ registry, visible, setVisible }),
+ [registry, visible],
+ );
+ return (
+ <StructureContext.Provider value={value}>
+ {children}
+ </StructureContext.Provider>
+ );
+}
+
+export function useStructureVisible(): [boolean, (visible: boolean) => void] {
+ const { visible, setVisible } = useStructure();
+ return [visible, setVisible];
+}
+
+/**
+ * A layout box that also names the component inside it, so the page can show
+ * its own React structure: an outline + tag when structure is visible, an
+ * entry in the live tree either way, and — with `code` — a button that shows
+ * the JSX to embed the element it wraps.
+ */
+export function Frame({
+ label,
+ detail,
+ kind,
+ code = false,
+ as: Tag = 'div',
+ className,
+ children,
+}: {
+ label: string;
+ detail?: string;
+ kind: FrameKind;
+ code?: boolean;
+ as?: 'div' | 'section' | 'aside' | 'main';
+ className?: string;
+ children: ReactNode;
+}) {
+ const id = useId();
+ const parentId = useContext(ParentFrameContext);
+ const { registry, visible } = useStructure();
+
+ useLayoutEffect(
+ () => registry.register({ id, parentId, label, detail, kind }),
+ [registry, id, parentId, label, detail, kind],
+ );
+
+ const classes = [
+ className,
+ code && 'frame-with-code',
+ visible && 'frame',
+ visible && `frame-${kind}`,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ const content = (
+ <ParentFrameContext.Provider value={id}>
+ {children}
+ </ParentFrameContext.Provider>
+ );
+
+ if (!code) {
+ return (
+ <Tag className={classes || undefined} data-component={label}>
+ {visible && <span className="frame-tag">{label}</span>}
+ {content}
+ </Tag>
+ );
+ }
+
+ // The button gets its own row: overlaid on the widget it covers titles,
+ // inputs and table headers.
+ return (
+ <Tag className={classes} data-component={label}>
+ <div className="frame-head">
+ <code className="frame-name">{label}</code>
+ <WidgetCode element={children} title={label} />
+ </div>
+ <div className="frame-body">{content}</div>
+ </Tag>
+ );
+}
+
+function TreeLevel({
+ nodes,
+ parentId,
+}: {
+ nodes: FrameNode[];
+ parentId: string | null;
+}) {
+ const children = nodes
+ .filter(node => node.parentId === parentId)
+ .sort((a, b) => a.order - b.order);
+ if (children.length === 0) return null;
+ return (
+ <ul>
+ {children.map(node => (
+ <li key={node.id}>
+ <code className={`tree-${node.kind}`}>{node.label}</code>
+ {node.detail && <span className="tree-detail">{node.detail}</span>}
+ <TreeLevel nodes={nodes} parentId={node.id} />
+ </li>
+ ))}
+ </ul>
+ );
+}
+
+/** The component tree the page actually rendered, built from its `Frame`s. */
+export function StructureTree() {
+ const { registry } = useStructure();
+ const nodes = useSyncExternalStore(registry.subscribe, registry.getSnapshot);
+ return (
+ <div className="structure-tree">
+ <p className="legend">
+ <code className="tree-superset">Superset component</code>{' '}
+ <code className="tree-host">host app code</code>
+ </p>
+ <TreeLevel nodes={nodes} parentId={null} />
+ </div>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/styles.css b/superset-frontend/packages/superset-widgets/example/src/styles.css
new file mode 100644
index 00000000000..cb761931acf
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/styles.css
@@ -0,0 +1,549 @@
+/*
+ * 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.
+ */
+
+body {
+ margin: 0;
+ font-family: system-ui, sans-serif;
+ background: #f5f4f0;
+ color: #1d1d1b;
+}
+
+.app {
+ padding: 24px;
+ min-height: 100vh;
+ box-sizing: border-box;
+}
+
+.app-dark {
+ background: #16171a;
+ color: #e8e8e6;
+}
+
+.app-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.app-header h1 {
+ margin: 0;
+ font-size: 22px;
+}
+
+.app-header p,
+.hint {
+ margin: 4px 0 0;
+ opacity: 0.7;
+ font-size: 13px;
+}
+
+.filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ margin: 16px 0;
+}
+
+.filter-label {
+ font-weight: 600;
+ margin-right: 4px;
+}
+
+.chip {
+ border: 1px solid #d8d6cf;
+ background: transparent;
+ color: inherit;
+ border-radius: 999px;
+ padding: 4px 12px;
+ cursor: pointer;
+}
+
+.chip-active {
+ background: #2f5d50;
+ border-color: #2f5d50;
+ color: #fff;
+}
+
+.chip:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.app-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 300px;
+ gap: 24px;
+}
+
+section h2 {
+ font-size: 15px;
+ margin: 16px 0 4px;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.card {
+ background: #fff;
+ border: 1px solid #e4e2dc;
+ border-radius: 8px;
+ padding: 12px;
+ height: 320px;
+ box-sizing: border-box;
+}
+
+.app-dark .card {
+ background: #1f2024;
+ border-color: #2e3036;
+}
+
+.card.small {
+ height: 136px;
+}
+
+.card.wide {
+ grid-column: span 2;
+}
+
+.event-log {
+ height: auto;
+ min-height: 200px;
+}
+
+.event-log h2 {
+ margin-top: 0;
+}
+
+/* Line the sidebar up with the first section heading and keep it in view. */
+.app-body > aside {
+ margin-top: 16px;
+}
+
+.app:not(.app-structure) .app-body > aside {
+ position: sticky;
+ top: 24px;
+ align-self: start;
+}
+
+.event-log ul {
+ list-style: none;
+ padding: 0;
+ margin: 8px 0 0;
+ font-size: 13px;
+}
+
+.event-log li {
+ padding: 6px 0;
+ border-bottom: 1px solid #e4e2dc;
+}
+
+@media (max-width: 900px) {
+ .app-body,
+ .grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .card.wide {
+ grid-column: auto;
+ }
+}
+
+fieldset.filter-bar {
+ margin: 24px 0 0;
+ padding: 0;
+ border: 0;
+ min-width: 0;
+}
+
+legend.filter-label {
+ float: left;
+ padding: 0;
+ font-size: 13px;
+}
+
+/* structure view */
+.app-structure .app-body {
+ grid-template-columns: minmax(0, 1fr) 420px;
+}
+
+.frame {
+ position: relative;
+ outline: 2px dashed var(--frame-color);
+ outline-offset: 3px;
+}
+
+.frame-superset {
+ --frame-color: #1a85a0;
+}
+
+.frame-host {
+ --frame-color: #c46a1d;
+}
+
+.frame-tag {
+ position: absolute;
+ top: -12px;
+ left: 6px;
+ z-index: 2;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: var(--frame-color);
+ color: #fff;
+ font:
+ 11px/1.5 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+ white-space: nowrap;
+ max-width: calc(100% - 12px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+}
+
+.card {
+ position: relative;
+}
+
+.structure-panel {
+ height: auto;
+ margin-top: 16px;
+}
+
+.structure-tree ul {
+ list-style: none;
+ margin: 4px 0 0;
+ padding-left: 14px;
+ border-left: 1px solid #e4e2dc;
+ font-size: 12px;
+}
+
+.structure-tree > ul {
+ padding-left: 0;
+ border-left: 0;
+}
+
+.structure-tree li {
+ margin: 4px 0;
+}
+
+.structure-tree code {
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.tree-superset {
+ color: #1a85a0;
+}
+
+.tree-host {
+ color: #c46a1d;
+}
+
+.tree-detail {
+ display: block;
+ opacity: 0.65;
+ font-size: 11px;
+}
+
+.legend {
+ margin: 0;
+ font-size: 11px;
+}
+
+.source summary {
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.source pre {
+ max-height: 480px;
+ overflow: auto;
+ font-size: 11px;
+ line-height: 1.45;
+ margin: 8px 0 0;
+}
+
+.toggles {
+ display: flex;
+ gap: 16px;
+ font-size: 13px;
+}
+
+/* structure view spacing */
+.app-structure .frame:not(.card) {
+ padding: 18px 10px 10px;
+ margin: 18px 0 8px;
+}
+
+.app-structure .frame .frame:not(.card) {
+ margin-top: 10px;
+}
+
+.app-structure .grid {
+ row-gap: 28px;
+}
+
+.legend code {
+ margin-right: 10px;
+}
+
+/* minimal embed example */
+.minimal-embed {
+ margin: 16px 0 8px;
+}
+
+/* Stretch the live chart to the height of the source beside it. */
+.minimal-embed-body > .card {
+ height: auto;
+}
+
+.minimal-embed-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.code-block {
+ margin: 0;
+ padding: 14px 16px;
+ border-radius: 8px;
+ background: #1f2024;
+ color: #e8e8e6;
+ font:
+ 12.5px/1.55 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+ overflow: auto;
+}
+
+@media (max-width: 900px) {
+ .minimal-embed-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* per-widget header: component name + code button */
+.frame-with-code {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.frame-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: none;
+}
+
+.frame-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+ opacity: 0.55;
+}
+
+.frame-superset .frame-name {
+ color: var(--frame-color);
+ opacity: 1;
+}
+
+.frame-body {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+}
+
+.code-toggle {
+ flex: none;
+ padding: 1px 6px;
+ border: 1px solid #d8d6cf;
+ border-radius: 6px;
+ background: #fff;
+ color: #1d1d1b;
+ font:
+ 11px/1.5 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+ cursor: pointer;
+}
+
+.app-dark .code-toggle {
+ border-color: #3a3c42;
+ background: #2a2b30;
+ color: #e8e8e6;
+}
+
+.code-modal {
+ width: min(880px, calc(100vw - 32px));
+ max-height: calc(100vh - 64px);
+ padding: 0;
+ border: 1px solid #3a3c42;
+ border-radius: 10px;
+ background: #1f2024;
+ color: #e8e8e6;
+ box-shadow: 0 24px 64px rgb(0 0 0 / 35%);
+}
+
+.code-modal[open] {
+ display: flex;
+ flex-direction: column;
+}
+
+.code-modal::backdrop {
+ background: rgb(15 16 18 / 55%);
+}
+
+.code-modal-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-bottom: 1px solid #3a3c42;
+}
+
+.code-modal-header h2 {
+ flex: 1;
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.code-modal-button {
+ padding: 4px 12px;
+ border: 1px solid #3a3c42;
+ border-radius: 6px;
+ background: #2a2b30;
+ color: #e8e8e6;
+ font:
+ 12px/1.5 system-ui,
+ sans-serif;
+ cursor: pointer;
+}
+
+.code-modal-button:hover {
+ background: #34363c;
+}
+
+.code-modal pre {
+ margin: 0;
+ padding: 16px;
+ overflow: auto;
+ font:
+ 13px/1.6 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+}
+
+/* component gallery */
+.gallery {
+ margin-top: 32px;
+}
+
+.gallery-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
+ align-items: start;
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.gallery-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+ border: 1px solid #e4e2dc;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.app-dark .gallery-card {
+ border-color: #2e3036;
+ background: #1f2024;
+}
+
+.gallery-card h3 {
+ margin: 0;
+ font-size: 14px;
+}
+
+.gallery-card .hint {
+ margin: 0;
+}
+
+.gallery-widget {
+ position: relative;
+ height: 260px;
+}
+
+.gallery-widget.short {
+ height: 120px;
+}
+
+.gallery-plain {
+ position: relative;
+}
+
+.gallery-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ align-items: start;
+ gap: 12px;
+}
+
+.gallery-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.custom-widget-frame {
+ box-sizing: border-box;
+ padding: 6px;
+ border: 2px solid #2f5d50;
+ border-radius: 6px;
+}
+
+.gallery-error {
+ padding: 8px;
+ border-radius: 6px;
+ background: #fdecea;
+ color: #8a1c12;
+ font-size: 12px;
+}
+
+/* a free slot for dropping in any widget during a demo */
+.demo-slot {
+ margin-top: 24px;
+}
diff --git a/superset-frontend/packages/superset-widgets/example/src/vite-env.d.ts b/superset-frontend/packages/superset-widgets/example/src/vite-env.d.ts
new file mode 100644
index 00000000000..cac7c6072aa
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/src/vite-env.d.ts
@@ -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.
+ */
+
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+ readonly SUPERSET_URL?: string;
+ readonly EMBED_AUTH?: string;
+ readonly EMBED_WIDGET_IDS?: string;
+ readonly FILTER_DATASET_ID?: string;
+ readonly FILTER_COLUMN?: string;
+ readonly FILTER_VALUES?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/superset-frontend/packages/superset-widgets/example/tsconfig.json b/superset-frontend/packages/superset-widgets/example/tsconfig.json
new file mode 100644
index 00000000000..86162607e05
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "jsxImportSource": "@emotion/react",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "types": ["vite/client"],
+ "baseUrl": ".",
+ "paths": {
+ "@apache-superset/widgets": ["../lib/index.d.ts"],
+ "@apache-superset/core": ["../../superset-core/lib/index.d.ts"],
+ "@apache-superset/core/*": ["../../superset-core/lib/*"],
+ "@superset-ui/core": ["../../superset-ui-core/lib/index.d.ts"],
+ "@superset-ui/core/*": ["../../superset-ui-core/lib/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/superset-frontend/packages/superset-widgets/example/vite.config.ts b/superset-frontend/packages/superset-widgets/example/vite.config.ts
new file mode 100644
index 00000000000..b645041300a
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/example/vite.config.ts
@@ -0,0 +1,87 @@
+/**
+ * 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 { fileURLToPath } from 'node:url';
+import react from '@vitejs/plugin-react';
+import { defineConfig, loadEnv } from 'vite';
+import svgr from 'vite-plugin-svgr';
+
+const exampleRoot = fileURLToPath(new URL('.', import.meta.url));
+const packages = fileURLToPath(new URL('../..', import.meta.url));
+
+// Consumes the widgets package and the Superset packages it builds on from
+// source. Third-party dependencies (react, antd, emotion, echarts) resolve from
+// superset-frontend/node_modules, which is why this app declares none.
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, exampleRoot, '');
+ return {
+ plugins: [
+ react({
+ jsxImportSource: '@emotion/react',
+ babel: { plugins: ['@emotion/babel-plugin'] },
+ }),
+ // Superset imports SVGs as React components (svgr in its webpack build).
+ svgr({ include: '**/*.svg', svgrOptions: { exportType: 'default' } }),
+ ],
+ envPrefix: ['SUPERSET_URL', 'EMBED_', 'FILTER_'],
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify(
+ mode === 'production' ? 'production' : 'development',
+ ),
+ 'process.env.WEBPACK_MODE': JSON.stringify(mode),
+ 'process.env.SCARF_ANALYTICS': JSON.stringify('false'),
+ global: 'globalThis',
+ },
+ resolve: {
+ alias: [
+ {
+ find: /^@apache-superset\/widgets$/,
+ replacement: `${packages}superset-widgets/src/index.ts`,
+ },
+ {
+ find: /^@apache-superset\/core$/,
+ replacement: `${packages}superset-core/src/index.ts`,
+ },
+ {
+ find: /^@apache-superset\/core\/(.*)$/,
+ replacement: `${packages}superset-core/src/$1`,
+ },
+ {
+ find: /^@superset-ui\/core$/,
+ replacement: `${packages}superset-ui-core/src/index.ts`,
+ },
+ {
+ find: /^@superset-ui\/core\/(.*)$/,
+ replacement: `${packages}superset-ui-core/src/$1`,
+ },
+ ],
+ dedupe: ['react', 'react-dom', '@emotion/react', 'antd'],
+ },
+ server: {
+ port: 5173,
+ strictPort: true,
+ fs: { allow: [fileURLToPath(new URL('../../..', import.meta.url))] },
+ proxy: {
+ '/api/guest-token': `http://127.0.0.1:${env.BROKER_PORT || 3001}`,
+ },
+ },
+ build: {
+ chunkSizeWarningLimit: 4000,
+ },
+ };
+});
diff --git a/superset-frontend/packages/superset-widgets/src/embed/SupersetArtifactProvider.test.tsx b/superset-frontend/packages/superset-widgets/src/embed/SupersetArtifactProvider.test.tsx
new file mode 100644
index 00000000000..f6552a99ac6
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/SupersetArtifactProvider.test.tsx
@@ -0,0 +1,85 @@
+/**
+ * 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, waitFor } from 'spec/helpers/testing-library';
+import { useWidgetDataClient } from '../dataClient';
+import { SupersetArtifactProvider } from './SupersetArtifactProvider';
+
+function ClientProbe() {
+ const client = useWidgetDataClient();
+ return <div data-test="probe">{typeof client.fetchData}</div>;
+}
+
+test('explains itself when the page cannot use connectors', () => {
+ render(
+ <SupersetArtifactProvider server="Superset" mcp={null}>
+ <ClientProbe />
+ </SupersetArtifactProvider>,
+ );
+
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Superset widgets need to run inside a Claude artifact that can use the Superset connector.',
+ );
+ expect(screen.queryByTestId('probe')).not.toBeInTheDocument();
+});
+
+test('provides an MCP data client to widgets when the runtime is available', async () => {
+ const mcp = { callTool: jest.fn() };
+
+ render(
+ <SupersetArtifactProvider server="Superset" mcp={mcp}>
+ <ClientProbe />
+ </SupersetArtifactProvider>,
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('probe')).toHaveTextContent('function'),
+ );
+});
+
+test('resolves the runtime from window.claude and shows the fallback meanwhile', async () => {
+ const mcp = { callTool: jest.fn() };
+ let answer: (value: unknown) => void = () => {};
+ const use = jest.fn(
+ () =>
+ new Promise(resolve => {
+ answer = resolve;
+ }),
+ );
+ (window as unknown as { claude?: unknown }).claude = { use };
+
+ try {
+ render(
+ <SupersetArtifactProvider
+ server="Superset"
+ fallback={<div data-test="waiting">waiting</div>}
+ >
+ <ClientProbe />
+ </SupersetArtifactProvider>,
+ );
+
+ expect(screen.getByTestId('waiting')).toBeInTheDocument();
+ answer(mcp);
+ await waitFor(() =>
+ expect(screen.getByTestId('probe')).toBeInTheDocument(),
+ );
+ expect(use).toHaveBeenCalledWith('mcp');
+ } finally {
+ delete (window as unknown as { claude?: unknown }).claude;
+ }
+});
diff --git a/superset-frontend/packages/superset-widgets/src/embed/SupersetArtifactProvider.tsx b/superset-frontend/packages/superset-widgets/src/embed/SupersetArtifactProvider.tsx
new file mode 100644
index 00000000000..9e2c2c42154
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/SupersetArtifactProvider.tsx
@@ -0,0 +1,123 @@
+/**
+ * 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, useMemo, useState, type ReactNode } from 'react';
+import type { AnyThemeConfig } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import {
+ createMcpWidgetClient,
+ getArtifactMcp,
+ type ArtifactMcp,
+ type McpStrategy,
+} from '../mcp/client';
+import { SupersetProvider, type ThemeModeName } from './SupersetProvider';
+
+export interface SupersetArtifactProviderProps {
+ /** The Superset connector's display name in the viewer's Claude settings. */
+ server: string;
+ strategy?: McpStrategy;
+ proxy?: boolean;
+ themeMode?: ThemeModeName;
+ themeConfig?: AnyThemeConfig;
+ /** Shown while the artifact runtime answers (up to ~10 s). */
+ fallback?: ReactNode;
+ /** Shown when this view cannot use connectors. */
+ renderUnavailable?: (server: string) => ReactNode;
+ /** The runtime's `mcp` namespace; resolved from `window.claude` when omitted. */
+ mcp?: ArtifactMcp | null;
+ children?: ReactNode;
+}
+
+type RuntimeState =
+ | { status: 'loading' }
+ | { status: 'ready'; mcp: ArtifactMcp }
+ | { status: 'unavailable' };
+
+const toState = (mcp: ArtifactMcp | null): RuntimeState =>
+ mcp ? { status: 'ready', mcp } : { status: 'unavailable' };
+
+/**
+ * `SupersetProvider` for a Claude artifact: data comes from the viewer's own
+ * Superset connector (their permissions and row-level security), so the page
+ * needs no backend, token or network access of its own.
+ */
+export function SupersetArtifactProvider({
+ server,
+ strategy,
+ proxy,
+ themeMode,
+ themeConfig,
+ fallback = null,
+ renderUnavailable,
+ mcp,
+ children,
+}: SupersetArtifactProviderProps) {
+ const [state, setState] = useState<RuntimeState>(() =>
+ mcp === undefined ? { status: 'loading' } : toState(mcp),
+ );
+
+ useEffect(() => {
+ if (mcp !== undefined) {
+ setState(toState(mcp));
+ return undefined;
+ }
+ let cancelled = false;
+ getArtifactMcp().then(resolved => {
+ if (!cancelled) setState(toState(resolved));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [mcp]);
+
+ const client = useMemo(
+ () =>
+ state.status === 'ready'
+ ? createMcpWidgetClient({ mcp: state.mcp, server, strategy, proxy })
+ : undefined,
+ [state, server, strategy, proxy],
+ );
+
+ if (state.status === 'loading') return <>{fallback}</>;
+ if (!client) {
+ return (
+ <>
+ {renderUnavailable ? (
+ renderUnavailable(server)
+ ) : (
+ <output data-test="superset-artifact-unavailable">
+ {t(
+ 'Superset widgets need to run inside a Claude artifact that can use the %s connector.',
+ server,
+ )}
+ </output>
+ )}
+ </>
+ );
+ }
+
+ return (
+ <SupersetProvider
+ client={client}
+ themeMode={themeMode}
+ themeConfig={themeConfig}
+ >
+ {children}
+ </SupersetProvider>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/src/embed/SupersetProvider.tsx b/superset-frontend/packages/superset-widgets/src/embed/SupersetProvider.tsx
new file mode 100644
index 00000000000..bfd1679366f
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/SupersetProvider.tsx
@@ -0,0 +1,178 @@
+/**
+ * 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, useMemo, useRef, useState, type ReactNode } from 'react';
+import {
+ Theme,
+ ThemeAlgorithm,
+ type AnyThemeConfig,
+} from '@apache-superset/core/theme';
+import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
+import { WidgetBusContext, createWidgetBus } from '../bus';
+import { WidgetDataClientContext } from '../dataClient';
+import type { WidgetBus, WidgetDataClient } from '../types';
+import {
+ ExtensionWidgetLoaderContext,
+ createExtensionWidgetLoader,
+ type ExtensionWidgetLoader,
+} from './extensionLoader';
+import { createGuestTokenSource } from './guestToken';
+import { createHttpWidgetClient } from './httpClient';
+
+export type ThemeModeName = 'default' | 'dark' | 'system';
+
+export interface SupersetProviderProps {
+ /** Origin of the Superset deployment, e.g. `https://superset.example.com`. */
+ supersetDomain?: string;
+ /**
+ * A ready data client (e.g. the MCP client inside a Claude artifact); when
+ * given, `supersetDomain` and the token options are not used.
+ */
+ client?: WidgetDataClient;
+ /** A bus shared with other providers or React roots, so their widgets cross-filter. */
+ bus?: WidgetBus;
+ /**
+ * Fetches a guest token from the host's own backend. Omit to use the
+ * browser's Superset session (same-site deployments only).
+ */
+ fetchGuestToken?: () => Promise<string>;
+ guestTokenHeaderName?: string;
+ guestTokenFetchTimeoutMs?: number;
+ /**
+ * Widget types contributed by Superset extensions, loaded from
+ * `supersetDomain` the first time one is rendered. `false` turns that off;
+ * a loader of your own replaces it.
+ */
+ extensionWidgets?: boolean | ExtensionWidgetLoader;
+ themeMode?: ThemeModeName;
+ themeConfig?: AnyThemeConfig;
+ children?: ReactNode;
+}
+
+const DARK_QUERY = '(prefers-color-scheme: dark)';
+
+function useDarkMode(mode: ThemeModeName): boolean {
+ const [systemDark, setSystemDark] = useState(
+ () =>
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia(DARK_QUERY).matches,
+ );
+ useEffect(() => {
+ if (mode !== 'system' || typeof window.matchMedia !== 'function') {
+ return undefined;
+ }
+ const query = window.matchMedia(DARK_QUERY);
+ const onChange = (event: MediaQueryListEvent) =>
+ setSystemDark(event.matches);
+ query.addEventListener('change', onChange);
+ return () => query.removeEventListener('change', onChange);
+ }, [mode]);
+ return mode === 'dark' || (mode === 'system' && systemDark);
+}
+
+let agGridReady = false;
+
+/**
+ * Everything widgets under it share: one event bus (so they cross-filter),
+ * one authenticated data client, and the Superset theme. Nothing leaks into
+ * the host page's global styles.
+ */
+export function SupersetProvider({
+ supersetDomain,
+ client: providedClient,
+ bus: providedBus,
+ fetchGuestToken,
+ guestTokenHeaderName,
+ guestTokenFetchTimeoutMs,
+ extensionWidgets = true,
+ themeMode = 'default',
+ themeConfig,
+ children,
+}: SupersetProviderProps) {
+ if (!agGridReady) {
+ setupAGGridModules();
+ agGridReady = true;
+ }
+
+ const fetchRef = useRef(fetchGuestToken);
+ fetchRef.current = fetchGuestToken;
+ const usesGuestToken = fetchGuestToken !== undefined;
+
+ const ownBus = useMemo(createWidgetBus, []);
+ const bus = providedBus ?? ownBus;
+ // One token source for everything this provider fetches from Superset: the
+ // data client and, when an extension's widget turns up, its bundle.
+ const getGuestToken = useMemo(() => {
+ if (!usesGuestToken) return undefined;
+ const tokens = createGuestTokenSource(() => {
+ const fetchToken = fetchRef.current;
+ return fetchToken
+ ? fetchToken()
+ : Promise.reject(new Error('fetchGuestToken was removed'));
+ }, guestTokenFetchTimeoutMs);
+ return () => tokens.get();
+ }, [usesGuestToken, guestTokenFetchTimeoutMs]);
+
+ const client = useMemo(() => {
+ if (providedClient) return providedClient;
+ if (!supersetDomain) {
+ throw new Error(
+ 'SupersetProvider needs either supersetDomain or client.',
+ );
+ }
+ return createHttpWidgetClient({
+ supersetDomain,
+ guestTokenHeaderName,
+ getGuestToken,
+ });
+ }, [providedClient, supersetDomain, guestTokenHeaderName, getGuestToken]);
+
+ const extensionLoader = useMemo(() => {
+ if (typeof extensionWidgets === 'function') return extensionWidgets;
+ if (!extensionWidgets || !supersetDomain) return undefined;
+ return createExtensionWidgetLoader({
+ supersetDomain,
+ guestTokenHeaderName,
+ getGuestToken,
+ });
+ }, [extensionWidgets, supersetDomain, guestTokenHeaderName, getGuestToken]);
+
+ const dark = useDarkMode(themeMode);
+ const theme = useMemo(
+ () =>
+ Theme.fromConfig({
+ ...themeConfig,
+ algorithm: dark ? ThemeAlgorithm.DARK : ThemeAlgorithm.DEFAULT,
+ } as AnyThemeConfig),
+ [themeConfig, dark],
+ );
+ const { SupersetThemeProvider } = theme;
+
+ return (
+ <SupersetThemeProvider withGlobalStyles={false}>
+ <WidgetBusContext.Provider value={bus}>
+ <WidgetDataClientContext.Provider value={client}>
+ <ExtensionWidgetLoaderContext.Provider value={extensionLoader}>
+ {children}
+ </ExtensionWidgetLoaderContext.Provider>
+ </WidgetDataClientContext.Provider>
+ </WidgetBusContext.Provider>
+ </SupersetThemeProvider>
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/src/embed/Widget.test.tsx b/superset-frontend/packages/superset-widgets/src/embed/Widget.test.tsx
new file mode 100644
index 00000000000..c1803a0a0ee
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/Widget.test.tsx
@@ -0,0 +1,181 @@
+/**
+ * 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 { act } from 'react';
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import { WidgetBusContext, createWidgetBus } from '../bus';
+import { WidgetDataClientContext } from '../dataClient';
+import { registerWidgetComponent } from '../registry';
+import type { WidgetBus, WidgetDataClient, WidgetProps } from '../types';
+import { Widget } from './Widget';
+import { ExtensionWidgetLoaderContext } from './extensionLoader';
+import { useSupersetFilter } from './hooks';
+
+const received: WidgetProps[] = [];
+
+function StubWidget(widgetProps: WidgetProps) {
+ received.push(widgetProps);
+ return <div data-test="stub">{String(widgetProps.props.label ?? '')}</div>;
+}
+
+registerWidgetComponent('test.stub', StubWidget);
+
+let bus: WidgetBus;
+let client: WidgetDataClient;
+
+beforeEach(() => {
+ received.length = 0;
+ bus = createWidgetBus();
+ client = {
+ fetchData: jest.fn(),
+ fetchValues: jest.fn(),
+ getSavedWidget: jest.fn(async (id: string) => ({
+ uuid: id,
+ widget_type: 'test.stub',
+ props: { label: 'from server' },
+ })),
+ };
+});
+
+const renderInProviders = (ui: JSX.Element) =>
+ render(
+ <WidgetBusContext.Provider value={bus}>
+ <WidgetDataClientContext.Provider value={client}>
+ {ui}
+ </WidgetDataClientContext.Provider>
+ </WidgetBusContext.Provider>,
+ );
+
+test('a saved widget loads its definition, then renders with its saved id', async () => {
+ renderInProviders(
+ <Widget id="uuid-9" instanceId="w1" fallback={<span>loading</span>} />,
+ );
+
+ expect(screen.getByText('loading')).toBeInTheDocument();
+ expect(await screen.findByText('from server')).toBeInTheDocument();
+ expect(client.getSavedWidget).toHaveBeenCalledWith('uuid-9');
+ expect(received.at(-1)).toEqual({
+ instanceId: 'w1',
+ props: { label: 'from server' },
+ savedId: 'uuid-9',
+ });
+});
+
+test('an inline widget renders immediately without a lookup', () => {
+ renderInProviders(
+ <Widget type="test.stub" props={{ label: 'inline' }} instanceId="w2" />,
+ );
+
+ expect(screen.getByText('inline')).toBeInTheDocument();
+ expect(client.getSavedWidget).not.toHaveBeenCalled();
+});
+
+test('a failed lookup and an unknown type render errors', async () => {
+ (client.getSavedWidget as jest.Mock).mockRejectedValueOnce(
+ new Error('404: not found'),
+ );
+ renderInProviders(
+ <>
+ <Widget id="missing" renderError={error => <b>{error.message}</b>} />
+ <Widget type="nope" />
+ </>,
+ );
+
+ expect(await screen.findByText('404: not found')).toBeInTheDocument();
+ expect(screen.getByText('Unknown widget type "nope"')).toBeInTheDocument();
+});
+
+test('a host filter is emitted on the bus and cleared on unmount', async () => {
+ let setFilter: ReturnType<typeof useSupersetFilter> | undefined;
+ function Host() {
+ setFilter = useSupersetFilter('state');
+ return null;
+ }
+ const { unmount } = renderInProviders(<Host />);
+
+ act(() =>
+ setFilter!({
+ datasetId: 17,
+ column: 'state',
+ operator: 'IN',
+ value: ['CA'],
+ targets: ['w1'],
+ }),
+ );
+ expect(bus.getValue('host:state', dashboardApi.VALUE_CHANGED_EVENT)).toEqual({
+ selection: ['CA'],
+ resolved: {
+ column: 'state',
+ operator: 'IN',
+ value: ['CA'],
+ datasource: 17,
+ },
+ targets: ['w1'],
+ });
+
+ unmount();
+ await waitFor(() =>
+ expect(
+ bus.getValue('host:state', dashboardApi.VALUE_CHANGED_EVENT),
+ ).toEqual({ selection: null, resolved: null }),
+ );
+});
+
+test("an extension's widget type is loaded on demand, not shown as unknown", async () => {
+ const load = jest.fn(async () => StubWidget);
+ renderInProviders(
+ <ExtensionWidgetLoaderContext.Provider value={load}>
+ <Widget
+ type="extensions.acme.widgets.funnel"
+ props={{ label: 'from the extension' }}
+ instanceId="w3"
+ fallback={<span>loading</span>}
+ />
+ </ExtensionWidgetLoaderContext.Provider>,
+ );
+
+ expect(screen.getByText('loading')).toBeInTheDocument();
+ expect(await screen.findByText('from the extension')).toBeInTheDocument();
+ expect(load).toHaveBeenCalledWith('extensions.acme.widgets.funnel');
+});
+
+test('an extension that cannot be loaded says why', async () => {
+ const load = jest.fn(async () => {
+ throw new Error('Could not load the extension bundle.');
+ });
+ renderInProviders(
+ <ExtensionWidgetLoaderContext.Provider value={load}>
+ <Widget type="extensions.acme.widgets.funnel" instanceId="w4" />
+ </ExtensionWidgetLoaderContext.Provider>,
+ );
+
+ expect(
+ await screen.findByText('Could not load the extension bundle.'),
+ ).toBeInTheDocument();
+});
+
+test('without a loader, an extension type says the page cannot load it', async () => {
+ renderInProviders(
+ <Widget type="extensions.acme.widgets.funnel" instanceId="w5" />,
+ );
+
+ expect(
+ await screen.findByText(/which this page cannot load/),
+ ).toBeInTheDocument();
+});
diff --git a/superset-frontend/packages/superset-widgets/src/embed/Widget.tsx b/superset-frontend/packages/superset-widgets/src/embed/Widget.tsx
new file mode 100644
index 00000000000..c3e833e432e
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/Widget.tsx
@@ -0,0 +1,308 @@
+/**
+ * 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 {
+ Component,
+ useEffect,
+ useId,
+ useState,
+ type CSSProperties,
+ type ErrorInfo,
+ type ReactNode,
+} from 'react';
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { Flex, Loading, Typography } from '@superset-ui/core/components';
+import { useWidgetBus } from '../bus';
+import { useWidgetDataClient } from '../dataClient';
+import { getWidgetComponent } from '../registry';
+import type { DataBindingSpec, SavedWidget } from '../types';
+import { useExtensionWidget } from './extensionLoader';
+
+export interface CommonWidgetProps {
+ /** Identity on the bus; defaults to a stable generated id. */
+ instanceId?: string;
+ className?: string;
+ style?: CSSProperties;
+ /** Shown while a saved widget loads. */
+ fallback?: ReactNode;
+ renderError?: (error: Error) => ReactNode;
+ /**
+ * Render the widget's own title above it. In a dashboard the title lives in
+ * the builder's widget header, so the widget itself never draws it.
+ */
+ showTitle?: boolean;
+}
+
+export type WidgetElementProps = CommonWidgetProps &
+ (
+ | { type: string; props?: Record<string, unknown>; id?: never }
+ | { id: string; type?: never; props?: never }
+ );
+
+function widgetTitle(
+ type: string,
+ props: Record<string, unknown>,
+): string | undefined {
+ if (type !== 'echarts') return undefined;
+ const chromeText = (props.chrome as { titleText?: unknown } | undefined)
+ ?.titleText;
+ const rawTitle = (props.echartsOptions as { title?: unknown } | undefined)
+ ?.title;
+ const first = Array.isArray(rawTitle) ? rawTitle[0] : rawTitle;
+ const text =
+ typeof chromeText === 'string' && chromeText.trim() !== ''
+ ? chromeText
+ : (first as { text?: unknown } | undefined)?.text;
+ return typeof text === 'string' && text.trim() !== ''
+ ? text.trim()
+ : undefined;
+}
+
+const toError = (error: unknown): Error =>
+ error instanceof Error ? error : new Error(String(error));
+
+function DefaultError({ error }: { error: Error }) {
+ return (
+ <Flex
+ align="center"
+ justify="center"
+ style={{ width: '100%', height: '100%' }}
+ >
+ <Typography.Text type="danger">{error.message}</Typography.Text>
+ </Flex>
+ );
+}
+
+class WidgetErrorBoundary extends Component<
+ { renderError?: (error: Error) => ReactNode; children: ReactNode },
+ { error?: Error }
+> {
+ state: { error?: Error } = {};
+
+ static getDerivedStateFromError(error: unknown) {
+ return { error: toError(error) };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ // eslint-disable-next-line no-console
+ console.error('[superset-widgets]', error, info.componentStack);
+ }
+
+ render() {
+ const { error } = this.state;
+ if (error) {
+ return this.props.renderError?.(error) ?? <DefaultError error={error} />;
+ }
+ return this.props.children;
+ }
+}
+
+/**
+ * One Superset widget, rendered in place: either an inline definition
+ * (`type` + `props`) or a widget saved in Superset (`id`). Must be inside a
+ * `SupersetProvider`.
+ */
+export function Widget(widgetProps: WidgetElementProps) {
+ const {
+ instanceId,
+ className,
+ style,
+ fallback,
+ renderError,
+ showTitle = true,
+ id,
+ } = widgetProps;
+ const generatedId = useId();
+ const busId = instanceId ?? `widget${generatedId}`;
+ const bus = useWidgetBus();
+ const client = useWidgetDataClient();
+ const [saved, setSaved] = useState<SavedWidget>();
+ const [loadError, setLoadError] = useState<Error>();
+
+ useEffect(() => {
+ if (!id) return undefined;
+ let cancelled = false;
+ setSaved(undefined);
+ setLoadError(undefined);
+ if (!client.getSavedWidget) {
+ setLoadError(new Error('This data client cannot load saved widgets.'));
+ return undefined;
+ }
+ client.getSavedWidget(id).then(
+ widget => {
+ if (!cancelled) setSaved(widget);
+ },
+ error => {
+ if (!cancelled) setLoadError(toError(error));
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [client, id]);
+
+ // A widget that leaves must not keep filtering the ones that stay.
+ useEffect(
+ () => () => {
+ const value = bus.getValue(busId, dashboardApi.VALUE_CHANGED_EVENT) as
+ { resolved?: unknown } | undefined;
+ if (value?.resolved) {
+ bus.emit(busId, dashboardApi.VALUE_CHANGED_EVENT, {
+ selection: null,
+ resolved: null,
+ });
+ }
+ },
+ [bus, busId],
+ );
+
+ const type = id ? saved?.widget_type : widgetProps.type;
+ const props = id ? saved?.props : (widgetProps.props ?? {});
+ const registered = type ? getWidgetComponent(type) : undefined;
+ // A type the registry does not have may still belong to an extension, whose
+ // frontend is fetched from Superset the first time one of its widgets renders.
+ const extension = useExtensionWidget(registered ? undefined : type);
+ const WidgetComponent = registered ?? extension.component;
+
+ let content: ReactNode;
+ if (loadError) {
+ content = renderError?.(loadError) ?? <DefaultError error={loadError} />;
+ } else if (extension.error) {
+ content = renderError?.(extension.error) ?? (
+ <DefaultError error={extension.error} />
+ );
+ } else if (!type || !props || extension.loading) {
+ content = fallback ?? <Loading position="inline-centered" size="s" />;
+ } else if (!WidgetComponent) {
+ const error = new Error(`Unknown widget type "${type}"`);
+ content = renderError?.(error) ?? <DefaultError error={error} />;
+ } else {
+ content = (
+ <WidgetErrorBoundary renderError={renderError}>
+ <WidgetComponent instanceId={busId} props={props} savedId={id} />
+ </WidgetErrorBoundary>
+ );
+ }
+
+ const title =
+ showTitle && type && props ? widgetTitle(type, props) : undefined;
+
+ return (
+ <Flex
+ vertical
+ gap="small"
+ className={className}
+ style={{ position: 'relative', width: '100%', height: '100%', ...style }}
+ data-superset-widget={type}
+ >
+ {title && (
+ <Typography.Text strong ellipsis style={{ flex: 'none' }}>
+ {title}
+ </Typography.Text>
+ )}
+ <div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
+ {content}
+ </div>
+ </Flex>
+ );
+}
+
+function splitCommon<T extends CommonWidgetProps>(
+ all: T,
+): [CommonWidgetProps, Omit<T, keyof CommonWidgetProps>] {
+ const {
+ instanceId,
+ className,
+ style,
+ fallback,
+ renderError,
+ showTitle,
+ ...rest
+ } = all;
+ return [
+ { instanceId, className, style, fallback, renderError, showTitle },
+ rest,
+ ];
+}
+
+export interface ChartProps extends CommonWidgetProps {
+ dataBinding: DataBindingSpec;
+ /** A near-raw ECharts option; `$bind` markers splice in query results. */
+ echartsOptions?: Record<string, unknown>;
+ chartType?: 'bar' | 'line' | 'scatter';
+ chrome?: Record<string, unknown>;
+ customize?: Record<string, unknown>;
+ crossFilter?: boolean;
+}
+
+export interface MetricTileProps extends CommonWidgetProps {
+ dataBinding: DataBindingSpec;
+ label?: string;
+ prefix?: string;
+ suffix?: string;
+ decimals?: number;
+}
+
+export interface TableProps extends CommonWidgetProps {
+ dataBinding: DataBindingSpec;
+ columnDefs?: Record<string, unknown>[];
+}
+
+export interface FilterSelectProps extends CommonWidgetProps {
+ datasetId: number;
+ column: string;
+ options?: string[];
+ defaultSelection?: string[];
+ scope?: { targets?: string[] };
+}
+
+export interface MarkdownProps extends CommonWidgetProps {
+ content: string;
+}
+
+export function Chart(chartProps: ChartProps) {
+ const [common, props] = splitCommon(chartProps);
+ return <Widget {...common} type="echarts" props={{ ...props }} />;
+}
+
+export function MetricTile(tileProps: MetricTileProps) {
+ const [common, props] = splitCommon(tileProps);
+ return <Widget {...common} type="metric-tile" props={{ ...props }} />;
+}
+
+export function Table(tableProps: TableProps) {
+ const [common, props] = splitCommon(tableProps);
+ return <Widget {...common} type="ag-grid-table" props={{ ...props }} />;
+}
+
+export function FilterSelect(filterProps: FilterSelectProps) {
+ const [common, props] = splitCommon(filterProps);
+ return <Widget {...common} type="filter.select" props={{ ...props }} />;
+}
+
+export function Markdown(markdownProps: MarkdownProps) {
+ const [common, props] = splitCommon(markdownProps);
+ return <Widget {...common} type="markdown" props={{ ...props }} />;
+}
+
+Widget.displayName = 'Widget';
+Chart.displayName = 'Chart';
+MetricTile.displayName = 'MetricTile';
+Table.displayName = 'Table';
+FilterSelect.displayName = 'FilterSelect';
+Markdown.displayName = 'Markdown';
diff --git a/superset-frontend/packages/superset-widgets/src/embed/guestToken.ts b/superset-frontend/packages/superset-widgets/src/embed/guestToken.ts
new file mode 100644
index 00000000000..c5fae82e685
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/guestToken.ts
@@ -0,0 +1,96 @@
+/**
+ * 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.
+ */
+
+const REFRESH_MARGIN_MS = 60_000;
+const FALLBACK_LIFETIME_MS = 5 * 60_000;
+export const DEFAULT_GUEST_TOKEN_FETCH_TIMEOUT_MS = 30_000;
+
+export interface GuestTokenSource {
+ /** A token valid for at least the refresh margin, fetching a new one when needed. */
+ get(): Promise<string>;
+}
+
+/** Epoch milliseconds the token expires at, from its `exp` claim. */
+export function guestTokenExpiry(token: string): number | undefined {
+ const payload = token.split('.')[1];
+ if (!payload) return undefined;
+ try {
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
+ const claims = JSON.parse(atob(padded)) as { exp?: unknown };
+ return typeof claims.exp === 'number' ? claims.exp * 1000 : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
+ if (timeoutMs <= 0) return promise;
+ return new Promise<T>((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error(`fetchGuestToken timed out after ${timeoutMs}ms`)),
+ timeoutMs,
+ );
+ promise.then(
+ value => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ error => {
+ clearTimeout(timer);
+ reject(error);
+ },
+ );
+ });
+}
+
+/**
+ * Caches the host's guest token and fetches a fresh one shortly before it
+ * expires. Concurrent callers share a single in-flight fetch, so a page of
+ * widgets asks the host's broker once.
+ */
+export function createGuestTokenSource(
+ fetchGuestToken: () => Promise<string>,
+ timeoutMs: number = DEFAULT_GUEST_TOKEN_FETCH_TIMEOUT_MS,
+): GuestTokenSource {
+ let token: string | undefined;
+ let expiresAt = 0;
+ let pending: Promise<string> | undefined;
+
+ return {
+ get() {
+ if (token && Date.now() < expiresAt - REFRESH_MARGIN_MS) {
+ return Promise.resolve(token);
+ }
+ if (!pending) {
+ pending = withTimeout(fetchGuestToken(), timeoutMs)
+ .then(next => {
+ token = next;
+ expiresAt =
+ guestTokenExpiry(next) ?? Date.now() + FALLBACK_LIFETIME_MS;
+ return next;
+ })
+ .finally(() => {
+ pending = undefined;
+ });
+ }
+ return pending;
+ },
+ };
+}
diff --git a/superset-frontend/packages/superset-widgets/src/embed/hooks.ts b/superset-frontend/packages/superset-widgets/src/embed/hooks.ts
new file mode 100644
index 00000000000..ee6ef2ac62d
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/hooks.ts
@@ -0,0 +1,104 @@
+/**
+ * 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 { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { HOST_SOURCE_PREFIX, useWidgetBus } from '../bus';
+import type { FilterValueChangedPayload } from '../filterVocabulary';
+import type { HostFilter, WidgetEvent } from '../types';
+
+const CLEARED: FilterValueChangedPayload = { selection: null, resolved: null };
+
+/** The bus payload for a host-owned filter; `null` clears it. */
+export function hostFilterPayload(
+ filter: HostFilter | null,
+): FilterValueChangedPayload {
+ return filter
+ ? {
+ selection: filter.value,
+ resolved: {
+ column: filter.column,
+ operator: filter.operator,
+ value: filter.value,
+ datasource: filter.datasetId,
+ },
+ targets: filter.targets,
+ }
+ : CLEARED;
+}
+
+/**
+ * A stable setter for a host-owned filter under `key`. Widgets on
+ * `filter.datasetId` (or only `filter.targets`) AND it onto their queries.
+ * The filter is cleared when the calling component unmounts.
+ */
+export function useSupersetFilter(
+ key: string,
+): (filter: HostFilter | null) => void {
+ const bus = useWidgetBus();
+ const sourceId = `${HOST_SOURCE_PREFIX}${key}`;
+ const active = useRef(false);
+
+ useEffect(
+ () => () => {
+ if (active.current) {
+ bus.emit(sourceId, dashboardApi.VALUE_CHANGED_EVENT, CLEARED);
+ active.current = false;
+ }
+ },
+ [bus, sourceId],
+ );
+
+ return useCallback(
+ (filter: HostFilter | null) => {
+ active.current = filter !== null;
+ bus.emit(
+ sourceId,
+ dashboardApi.VALUE_CHANGED_EVENT,
+ hostFilterPayload(filter),
+ );
+ },
+ [bus, sourceId],
+ );
+}
+
+/** Subscribes to every `eventType` emitted by widgets (or the host) under the provider. */
+export function useWidgetEvent(
+ eventType: string,
+ listener: (event: WidgetEvent) => void,
+): void {
+ const bus = useWidgetBus();
+ const listenerRef = useRef(listener);
+ listenerRef.current = listener;
+
+ useEffect(() => {
+ const subscription = bus.on(eventType, event => listenerRef.current(event));
+ return () => subscription.dispose();
+ }, [bus, eventType]);
+}
+
+/** The last payload `instanceId` emitted for `eventType` (the current selection, by default). */
+export function useWidgetValue(
+ instanceId: string,
+ eventType: string = dashboardApi.VALUE_CHANGED_EVENT,
+): unknown {
+ const bus = useWidgetBus();
+ return useSyncExternalStore(bus.subscribe, () =>
+ bus.getValue(instanceId, eventType),
+ );
+}
diff --git a/superset-frontend/packages/superset-widgets/src/embed/httpClient.test.ts b/superset-frontend/packages/superset-widgets/src/embed/httpClient.test.ts
new file mode 100644
index 00000000000..5c034ea085c
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/httpClient.test.ts
@@ -0,0 +1,148 @@
+/**
+ * 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 { createGuestTokenSource, guestTokenExpiry } from './guestToken';
+import { createHttpWidgetClient } from './httpClient';
+
+const DOMAIN = 'https://superset.example.com/';
+
+const jsonResponse = (status: number, body: unknown) =>
+ ({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Error',
+ json: async () => body,
+ }) as Response;
+
+const tokenWithExp = (exp: number) =>
+ `header.${btoa(JSON.stringify({ exp })).replace(/=+$/, '')}.signature`;
+
+let fetchMock: jest.Mock;
+const originalFetch = global.fetch;
+
+beforeEach(() => {
+ fetchMock = jest.fn();
+ global.fetch = fetchMock;
+});
+
+afterAll(() => {
+ global.fetch = originalFetch;
+});
+
+test('an inline widget posts its type and props with the guest token', async () => {
+ fetchMock.mockResolvedValue(
+ jsonResponse(200, { result: { columns: ['n'], rows: [{ n: 1 }] } }),
+ );
+ const client = createHttpWidgetClient({
+ supersetDomain: DOMAIN,
+ getGuestToken: async () => 'token-1',
+ });
+
+ const result = await client.fetchData({
+ instanceId: 'a',
+ widget: { type: 'metric-tile', props: { dataBinding: { datasetId: 17 } } },
+ filters: [
+ { column: 'state', operator: 'IN', value: ['CA'], datasource: 17 },
+ ],
+ });
+
+ expect(result).toEqual({ columns: ['n'], rows: [{ n: 1 }] });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://superset.example.com/api/v1/widget/data');
+ expect(init.method).toBe('POST');
+ expect(init.mode).toBe('cors');
+ expect(init.credentials).toBe('omit');
+ expect(init.headers['X-GuestToken']).toBe('token-1');
+ expect(JSON.parse(init.body)).toEqual({
+ widget: { type: 'metric-tile', props: { dataBinding: { datasetId: 17 } } },
+ filters: [{ column: 'state', operator: 'IN', value: ['CA'] }],
+ });
+});
+
+test('a saved widget is selected by id only, and session mode sends cookies', async () => {
+ fetchMock.mockResolvedValue(jsonResponse(200, { result: ['boy', 'girl'] }));
+ const client = createHttpWidgetClient({ supersetDomain: DOMAIN });
+
+ const values = await client.fetchValues({
+ instanceId: 'a',
+ widget: { id: 'uuid-1', type: 'filter.select', props: { column: 'x' } },
+ });
+
+ expect(values).toEqual(['boy', 'girl']);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://superset.example.com/api/v1/widget/values');
+ expect(init.credentials).toBe('include');
+ expect(init.headers).not.toHaveProperty('X-GuestToken');
+ expect(JSON.parse(init.body)).toEqual({ id: 'uuid-1' });
+});
+
+test("the server's message surfaces as the error", async () => {
+ fetchMock.mockResolvedValue(
+ jsonResponse(403, { message: 'Guest may not access this widget' }),
+ );
+ const client = createHttpWidgetClient({
+ supersetDomain: DOMAIN,
+ getGuestToken: async () => 'token-1',
+ });
+
+ await expect(client.getSavedWidget!('uuid-1')).rejects.toThrow(
+ '403: Guest may not access this widget',
+ );
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ 'https://superset.example.com/api/v1/widget/uuid-1',
+ );
+});
+
+test('without a guest token, a 401 asks the viewer to sign in to Superset', async () => {
+ fetchMock.mockResolvedValue(jsonResponse(401, { msg: 'Not authorized' }));
+ const client = createHttpWidgetClient({ supersetDomain: DOMAIN });
+
+ await expect(client.getSavedWidget!('uuid-1')).rejects.toThrow(
+ 'Sign in to Superset at https://superset.example.com to see this widget.',
+ );
+});
+
+test('without a guest token, a 403 says the account cannot access the data', async () => {
+ fetchMock.mockResolvedValue(jsonResponse(403, { message: 'Forbidden' }));
+ const client = createHttpWidgetClient({ supersetDomain: DOMAIN });
+
+ await expect(client.getSavedWidget!('uuid-1')).rejects.toThrow(
+ 'Your Superset account cannot access this data.',
+ );
+});
+
+test('guest tokens are reused until close to expiry, with one fetch for concurrent callers', async () => {
+ const inAnHour = Math.floor(Date.now() / 1000) + 3600;
+ const almostExpired = Math.floor(Date.now() / 1000) + 30;
+ const fetchToken = jest
+ .fn()
+ .mockResolvedValueOnce(tokenWithExp(almostExpired))
+ .mockResolvedValueOnce(tokenWithExp(inAnHour));
+ const source = createGuestTokenSource(fetchToken);
+
+ const [first, second] = await Promise.all([source.get(), source.get()]);
+ expect(first).toBe(second);
+ expect(fetchToken).toHaveBeenCalledTimes(1);
+
+ // Inside the refresh margin, so the next call fetches a fresh token.
+ const refreshed = await source.get();
+ expect(refreshed).toBe(tokenWithExp(inAnHour));
+ await source.get();
+ expect(fetchToken).toHaveBeenCalledTimes(2);
+ expect(guestTokenExpiry(refreshed)).toBe(inAnHour * 1000);
+});
diff --git a/superset-frontend/packages/superset-widgets/src/embed/httpClient.ts b/superset-frontend/packages/superset-widgets/src/embed/httpClient.ts
new file mode 100644
index 00000000000..f804edee1f7
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/httpClient.ts
@@ -0,0 +1,136 @@
+/**
+ * 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 type { ResolvedFilter } from '../filterVocabulary';
+import type {
+ DataRow,
+ SavedWidget,
+ WidgetDataClient,
+ WidgetRef,
+} from '../types';
+
+export interface ApiFetchOptions {
+ /** Origin (and optional path prefix) of the Superset deployment. */
+ supersetDomain: string;
+ /** Omit to call with the browser's own Superset session instead. */
+ getGuestToken?: () => Promise<string>;
+ guestTokenHeaderName?: string;
+}
+
+export type HttpWidgetClientOptions = ApiFetchOptions;
+
+/** Calls a Superset JSON API and unwraps its `result`. */
+export type ApiFetch = <T>(path: string, init?: RequestInit) => Promise<T>;
+
+async function responseMessage(response: Response): Promise<string> {
+ try {
+ const body = (await response.json()) as {
+ message?: unknown;
+ errors?: { message?: string }[];
+ };
+ if (typeof body.message === 'string') return body.message;
+ if (Array.isArray(body.errors)) {
+ return body.errors.map(error => error.message).join('; ');
+ }
+ } catch {
+ // Not JSON; fall back to the status text.
+ }
+ return response.statusText;
+}
+
+const selectorOf = (widget: WidgetRef) =>
+ 'id' in widget
+ ? { id: widget.id }
+ : { widget: { type: widget.type, props: widget.props } };
+
+export function createApiFetch({
+ supersetDomain,
+ getGuestToken,
+ guestTokenHeaderName = 'X-GuestToken',
+}: ApiFetchOptions): ApiFetch {
+ const base = supersetDomain.replace(/\/+$/, '');
+
+ return async function call<T>(
+ path: string,
+ init: RequestInit = {},
+ ): Promise<T> {
+ const token = getGuestToken ? await getGuestToken() : undefined;
+ const response = await fetch(`${base}${path}`, {
+ ...init,
+ mode: 'cors',
+ credentials: token ? 'omit' : 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token ? { [guestTokenHeaderName]: token } : {}),
+ },
+ });
+ if (!response.ok) {
+ // Without a guest token the viewer's Superset session is the credential,
+ // so an auth failure means "sign in", not a broken embed.
+ if (!token && response.status === 401) {
+ throw new Error(`Sign in to Superset at ${base} to see this widget.`);
+ }
+ if (!token && response.status === 403) {
+ throw new Error('Your Superset account cannot access this data.');
+ }
+ throw new Error(`${response.status}: ${await responseMessage(response)}`);
+ }
+ const body = (await response.json()) as { result: T };
+ return body.result;
+ };
+}
+
+/**
+ * Talks to Superset's widget API with `fetch` rather than the app-wide
+ * `SupersetClient` singleton, so several providers (and several Superset
+ * deployments) can live on one page.
+ */
+export function createHttpWidgetClient(
+ options: HttpWidgetClientOptions,
+): WidgetDataClient {
+ const call = createApiFetch(options);
+
+ return {
+ async fetchData({ widget, filters }) {
+ const result = await call<{ columns?: string[]; rows?: DataRow[] }>(
+ '/api/v1/widget/data',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ ...selectorOf(widget),
+ filters: filters.map(
+ ({ column, operator, value }: ResolvedFilter) => ({
+ column,
+ operator,
+ value,
+ }),
+ ),
+ }),
+ },
+ );
+ return { columns: result.columns ?? [], rows: result.rows ?? [] };
+ },
+ fetchValues: ({ widget }) =>
+ call<unknown[]>('/api/v1/widget/values', {
+ method: 'POST',
+ body: JSON.stringify(selectorOf(widget)),
+ }),
+ getSavedWidget: id =>
+ call<SavedWidget>(`/api/v1/widget/${encodeURIComponent(id)}`),
+ };
+}
diff --git a/superset-frontend/packages/superset-widgets/src/embed/session.test.tsx b/superset-frontend/packages/superset-widgets/src/embed/session.test.tsx
new file mode 100644
index 00000000000..d8e4e158745
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/session.test.tsx
@@ -0,0 +1,237 @@
+/**
+ * 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 } from 'react';
+import { act, waitFor } from 'spec/helpers/testing-library';
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import { useWidgetBus } from '../bus';
+import { useWidgetDataClient } from '../dataClient';
+import { registerWidgetComponent } from '../registry';
+import type { WidgetBus, WidgetDataClient, WidgetProps } from '../types';
+import { createSession } from './session';
+
+interface Seen {
+ instanceId: string;
+ bus: WidgetBus;
+ client: WidgetDataClient;
+}
+
+const seen: Seen[] = [];
+
+function Probe({ instanceId, props }: WidgetProps) {
+ const bus = useWidgetBus();
+ const client = useWidgetDataClient();
+ useEffect(() => {
+ seen.push({ instanceId, bus, client });
+ }, [instanceId, bus, client]);
+ return <span>{String(props.label ?? '')}</span>;
+}
+
+registerWidgetComponent('session-probe', Probe);
+
+const stubClient: WidgetDataClient = {
+ fetchData: jest.fn(async () => ({ columns: [], rows: [] })),
+ fetchValues: jest.fn(async () => []),
+};
+
+function hostElement(): HTMLElement {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ return element;
+}
+
+beforeEach(() => {
+ seen.length = 0;
+ document.body.innerHTML = '';
+ document.head.innerHTML = '';
+});
+
+test('mounts widgets into host elements that share one bus and data client', async () => {
+ const session = createSession({ client: stubClient, loadFonts: false });
+ const first = hostElement();
+ const second = hostElement();
+
+ act(() => {
+ session.mount(
+ first,
+ { type: 'session-probe', props: { label: 'one' } },
+ { instanceId: 'a' },
+ );
+ session.mount(
+ second,
+ { type: 'session-probe', props: { label: 'two' } },
+ { instanceId: 'b' },
+ );
+ });
+
+ await waitFor(() => expect(seen).toHaveLength(2));
+ expect(first).toHaveTextContent('one');
+ expect(second).toHaveTextContent('two');
+ expect(seen[0].bus).toBe(seen[1].bus);
+ expect(seen[0].client).toBe(stubClient);
+ expect(seen[1].client).toBe(stubClient);
+
+ act(() => session.dispose());
+});
+
+test('host filters reach the bus and listeners can unsubscribe', () => {
+ const session = createSession({ client: stubClient, loadFonts: false });
+ const listener = jest.fn();
+ const off = session.on(dashboardApi.VALUE_CHANGED_EVENT, listener);
+
+ session.setFilter('state', {
+ datasetId: 17,
+ column: 'state',
+ operator: 'IN',
+ value: ['CA'],
+ });
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({
+ nodeId: 'host:state',
+ payload: expect.objectContaining({
+ resolved: expect.objectContaining({
+ column: 'state',
+ value: ['CA'],
+ datasource: 17,
+ }),
+ }),
+ }),
+ );
+
+ off();
+ session.setFilter('state', null);
+ expect(listener).toHaveBeenCalledTimes(1);
+ session.dispose();
+});
+
+test('dispose clears host filters, unmounts widgets and refuses new mounts', async () => {
+ const session = createSession({ client: stubClient, loadFonts: false });
+ const element = hostElement();
+ const listener = jest.fn();
+ session.on(dashboardApi.VALUE_CHANGED_EVENT, listener);
+
+ act(() => {
+ session.mount(element, { type: 'session-probe', props: { label: 'x' } });
+ });
+ await waitFor(() => expect(element).toHaveTextContent('x'));
+ session.setFilter('state', {
+ datasetId: 17,
+ column: 'state',
+ operator: 'EQUALS',
+ value: 'CA',
+ });
+
+ act(() => session.dispose());
+
+ expect(element).toBeEmptyDOMElement();
+ expect(listener).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ nodeId: 'host:state',
+ payload: { selection: null, resolved: null },
+ }),
+ );
+ expect(() =>
+ session.mount(hostElement(), { type: 'session-probe', props: {} }),
+ ).toThrow('disposed');
+});
+
+test('uses the viewer connector through MCP when no client is given', async () => {
+ const callTool = jest.fn(async () => ({
+ structuredContent: { result: { columns: ['x'], rows: [{ x: 1 }] } },
+ }));
+ const session = createSession({
+ server: 'Superset',
+ mcp: { callTool },
+ loadFonts: false,
+ });
+
+ act(() => {
+ session.mount(hostElement(), { type: 'session-probe', props: {} });
+ });
+ await waitFor(() => expect(seen).toHaveLength(1));
+
+ await seen[0].client
+ .fetchData({
+ instanceId: seen[0].instanceId,
+ widget: { type: 'echarts', props: {} },
+ filters: [],
+ })
+ .catch(() => undefined);
+
+ expect(callTool).toHaveBeenCalledWith(
+ 'Superset',
+ 'get_widget_data',
+ expect.anything(),
+ );
+ act(() => session.dispose());
+});
+
+test('needs a connector name or a client', () => {
+ expect(() => createSession({})).toThrow('server');
+});
+
+test('rejects a duplicate instanceId', () => {
+ const session = createSession({ client: stubClient, loadFonts: false });
+ act(() => {
+ session.mount(
+ hostElement(),
+ { type: 'session-probe', props: {} },
+ {
+ instanceId: 'same',
+ },
+ );
+ });
+ expect(() =>
+ session.mount(
+ hostElement(),
+ { type: 'session-probe', props: {} },
+ {
+ instanceId: 'same',
+ },
+ ),
+ ).toThrow('already mounted');
+ act(() => session.dispose());
+});
+
+test('adds the font stylesheet once across sessions', () => {
+ const first = createSession({ client: stubClient });
+ const second = createSession({ client: stubClient });
+
+ expect(document.querySelectorAll('#superset-widgets-fonts')).toHaveLength(1);
+
+ first.dispose();
+ second.dispose();
+});
+
+test('theme changes re-render mounted widgets in place', async () => {
+ const session = createSession({ client: stubClient, loadFonts: false });
+ const element = hostElement();
+ act(() => {
+ session.mount(element, {
+ type: 'session-probe',
+ props: { label: 'kept' },
+ });
+ });
+ await waitFor(() => expect(element).toHaveTextContent('kept'));
+
+ act(() => session.setThemeMode('dark'));
+
+ expect(element).toHaveTextContent('kept');
+ act(() => session.dispose());
+});
diff --git a/superset-frontend/packages/superset-widgets/src/embed/session.tsx b/superset-frontend/packages/superset-widgets/src/embed/session.tsx
new file mode 100644
index 00000000000..5d826e0577a
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/embed/session.tsx
@@ -0,0 +1,239 @@
+/**
+ * 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 type { CSSProperties } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { dashboard as dashboardApi } from '@apache-superset/core';
+import type { AnyThemeConfig } from '@apache-superset/core/theme';
+import { HOST_SOURCE_PREFIX, createWidgetBus } from '../bus';
+import {
+ createMcpWidgetClient,
+ getArtifactMcp,
+ type ArtifactMcp,
+ type McpStrategy,
+} from '../mcp/client';
+import type { HostFilter, WidgetDataClient, WidgetEvent } from '../types';
+import { hostFilterPayload } from './hooks';
+import type { ExtensionWidgetLoader } from './extensionLoader';
+import { SupersetProvider, type ThemeModeName } from './SupersetProvider';
+import { Widget } from './Widget';
+
+export interface WidgetSessionOptions {
+ /** The Superset connector's display name in the viewer's Claude settings. */
+ server?: string;
+ strategy?: McpStrategy;
+ /** Call through the connector's `call_tool` meta-tool. */
+ proxy?: boolean;
+ themeMode?: ThemeModeName;
+ themeConfig?: AnyThemeConfig;
+ /** The artifact runtime's `mcp` namespace; resolved from `window.claude` when omitted. */
+ mcp?: ArtifactMcp | null | Promise<ArtifactMcp | null>;
+ /** Any other data client (e.g. `createHttpWidgetClient`), used instead of MCP. */
+ client?: WidgetDataClient;
+ /**
+ * Loads widget types contributed by Superset extensions (see
+ * `createExtensionWidgetLoader`). A session has no Superset origin of its
+ * own, so without one an extension's widget cannot be drawn.
+ */
+ extensionWidgets?: ExtensionWidgetLoader;
+ /**
+ * Adds the Google Fonts stylesheet for Inter and IBM Plex Mono. The bundle
+ * ships no font files, and Google Fonts is the one stylesheet host
+ * artifacts allow. Defaults to `true`.
+ */
+ loadFonts?: boolean;
+}
+
+export type MountTarget =
+ { type: string; props?: Record<string, unknown> } | { id: string };
+
+export interface MountOptions {
+ /** Identity on the bus, used by filter `targets`; generated when omitted. */
+ instanceId?: string;
+ className?: string;
+ style?: CSSProperties;
+ showTitle?: boolean;
+}
+
+export interface MountedWidget {
+ instanceId: string;
+ unmount(): void;
+}
+
+export interface WidgetSession {
+ /** Renders one widget into `element`, which the session then owns. */
+ mount(
+ element: Element,
+ target: MountTarget,
+ options?: MountOptions,
+ ): MountedWidget;
+ /** A host-owned filter under `key`; `null` clears it. */
+ setFilter(key: string, filter: HostFilter | null): void;
+ /** Subscribes to widget (and host) events, e.g. `valueChanged`. */
+ on(eventType: string, listener: (event: WidgetEvent) => void): () => void;
+ setThemeMode(mode: ThemeModeName): void;
+ /** Unmounts every widget and clears the session's host filters. */
+ dispose(): void;
+}
+
+const FONTS_LINK_ID = 'superset-widgets-fonts';
+const FONTS_URL =
+ 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@200;400;500;600&display=swap';
+
+function ensureFonts(): void {
+ if (typeof document === 'undefined') return;
+ if (document.getElementById(FONTS_LINK_ID)) return;
+ const link = document.createElement('link');
+ link.id = FONTS_LINK_ID;
+ link.rel = 'stylesheet';
+ link.href = FONTS_URL;
+ document.head.appendChild(link);
+}
+
+let generatedIds = 0;
+
+/**
+ * Widgets for hosts that do not render them through React context, such as a
+ * plain HTML page or a Claude React artifact that ships its own React. Every
+ * mount is a separate React root of the bundled React, but all of a
+ * session's widgets share one event bus and one data client, so they
+ * cross-filter like widgets under one `SupersetProvider`.
+ */
+export function createSession(options: WidgetSessionOptions): WidgetSession {
+ const {
+ server,
+ strategy,
+ proxy,
+ themeConfig,
+ extensionWidgets,
+ loadFonts = true,
+ } = options;
+ let themeMode: ThemeModeName = options.themeMode ?? 'default';
+
+ let client: WidgetDataClient;
+ if (options.client) {
+ ({ client } = options);
+ } else if (server) {
+ client = createMcpWidgetClient({
+ mcp: options.mcp !== undefined ? options.mcp : getArtifactMcp(),
+ server,
+ strategy,
+ proxy,
+ });
+ } else {
+ throw new Error(
+ 'createSession needs the Superset connector name (`server`) or a `client`.',
+ );
+ }
+
+ const bus = createWidgetBus();
+ const mounts = new Map<string, { root: Root; render: () => void }>();
+ const hostFilterKeys = new Set<string>();
+ let disposed = false;
+
+ if (loadFonts) ensureFonts();
+
+ const emitHostFilter = (key: string, filter: HostFilter | null) =>
+ bus.emit(
+ `${HOST_SOURCE_PREFIX}${key}`,
+ dashboardApi.VALUE_CHANGED_EVENT,
+ hostFilterPayload(filter),
+ );
+
+ return {
+ mount(element, target, mountOptions = {}) {
+ if (disposed) {
+ throw new Error('This widget session has been disposed.');
+ }
+ generatedIds += 1;
+ const instanceId = mountOptions.instanceId ?? `widget-${generatedIds}`;
+ if (mounts.has(instanceId)) {
+ throw new Error(
+ `A widget with instanceId "${instanceId}" is already mounted.`,
+ );
+ }
+
+ const root = createRoot(element);
+ const common = {
+ instanceId,
+ className: mountOptions.className,
+ style: mountOptions.style,
+ showTitle: mountOptions.showTitle,
+ };
+ const render = () =>
+ root.render(
+ <SupersetProvider
+ client={client}
+ bus={bus}
+ themeMode={themeMode}
+ themeConfig={themeConfig}
+ extensionWidgets={extensionWidgets ?? false}
+ >
+ {'id' in target ? (
+ <Widget id={target.id} {...common} />
+ ) : (
+ <Widget
+ type={target.type}
+ props={target.props ?? {}}
+ {...common}
+ />
+ )}
+ </SupersetProvider>,
+ );
+ render();
+ mounts.set(instanceId, { root, render });
+
+ return {
+ instanceId,
+ unmount() {
+ const mounted = mounts.get(instanceId);
+ if (!mounted) return;
+ mounts.delete(instanceId);
+ mounted.root.unmount();
+ },
+ };
+ },
+
+ setFilter(key, filter) {
+ if (disposed) return;
+ if (filter) hostFilterKeys.add(key);
+ else hostFilterKeys.delete(key);
+ emitHostFilter(key, filter);
+ },
+
+ on(eventType, listener) {
+ const subscription = bus.on(eventType, listener);
+ return () => subscription.dispose();
+ },
+
+ setThemeMode(mode) {
+ if (disposed || mode === themeMode) return;
+ themeMode = mode;
+ mounts.forEach(mounted => mounted.render());
+ },
+
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ mounts.forEach(mounted => mounted.root.unmount());
+ mounts.clear();
+ hostFilterKeys.forEach(key => emitHostFilter(key, null));
+ hostFilterKeys.clear();
+ },
+ };
+}
diff --git a/superset-frontend/packages/superset-widgets/src/index.ts b/superset-frontend/packages/superset-widgets/src/index.ts
new file mode 100644
index 00000000000..3744d22a699
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/index.ts
@@ -0,0 +1,150 @@
+/**
+ * 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.
+ */
+
+// Embedding
+export { SupersetProvider } from './embed/SupersetProvider';
+export type {
+ SupersetProviderProps,
+ ThemeModeName,
+} from './embed/SupersetProvider';
+export {
+ Chart,
+ FilterSelect,
+ Markdown,
+ MetricTile,
+ Table,
+ Widget,
+} from './embed/Widget';
+export type {
+ ChartProps,
+ CommonWidgetProps,
+ FilterSelectProps,
+ MarkdownProps,
+ MetricTileProps,
+ TableProps,
+ WidgetElementProps,
+} from './embed/Widget';
+export {
+ hostFilterPayload,
+ useSupersetFilter,
+ useWidgetEvent,
+ useWidgetValue,
+} from './embed/hooks';
+export { createGuestTokenSource, guestTokenExpiry } from './embed/guestToken';
+export type { GuestTokenSource } from './embed/guestToken';
+export { createApiFetch, createHttpWidgetClient } from './embed/httpClient';
+export {
+ ExtensionWidgetLoaderContext,
+ createExtensionWidgetLoader,
+ isExtensionWidgetType,
+ parseExtensionWidgetType,
+ useExtensionWidget,
+} from './embed/extensionLoader';
+export type {
+ ExtensionWidgetLoader,
+ ExtensionWidgetLoaderOptions,
+ ExtensionWidgetState,
+} from './embed/extensionLoader';
+export {
+ adaptExtensionWidget,
+ createExtensionCore,
+} from './embed/extensionHost';
+export type { ExtensionInfo } from './embed/extensionHost';
+export { SupersetArtifactProvider } from './embed/SupersetArtifactProvider';
+export type { SupersetArtifactProviderProps } from './embed/SupersetArtifactProvider';
+export { createSession } from './embed/session';
+export type {
+ MountOptions,
+ MountTarget,
+ MountedWidget,
+ WidgetSession,
+ WidgetSessionOptions,
+} from './embed/session';
+export {
+ MCP_TOOLS,
+ createMcpWidgetClient,
+ extractPayload,
+ getArtifactMcp,
+} from './mcp/client';
+export type {
+ ArtifactMcp,
+ McpStrategy,
+ McpWidgetClientOptions,
+} from './mcp/client';
+export { WidgetMcpError, toWidgetMcpError } from './mcp/errors';
+export type { WidgetMcpErrorCode } from './mcp/errors';
+export type {
+ ApiFetch,
+ ApiFetchOptions,
+ HttpWidgetClientOptions,
+} from './embed/httpClient';
+
+// Host contracts (the dashboard builder implements these too)
+export {
+ HOST_SOURCE_PREFIX,
+ WidgetBusContext,
+ createWidgetBus,
+ useWidgetBus,
+ useWidgetBusRevision,
+} from './bus';
+export {
+ WidgetDataClientContext,
+ sessionDataClient,
+ useWidgetDataClient,
+ widgetRef,
+} from './dataClient';
+export {
+ WidgetApplySourceContext,
+ useWidgetApplySource,
+} from './deferredApply';
+export {
+ DASHBOARD_WIDGETS_LOCATION,
+ builtInWidgetComponents,
+ getWidgetComponent,
+ registerWidgetComponent,
+ unregisterWidgetComponent,
+} from './registry';
+export {
+ getActiveAdhocFilters,
+ getActiveResolvedFilters,
+ toAdhocFilters,
+} from './activeFilters';
+export { FILTER_BAR_APPLY_EVENT } from './filterVocabulary';
+export type {
+ FilterOperator,
+ FilterValueChangedPayload,
+ ResolvedFilter,
+} from './filterVocabulary';
+export { describeFetchError, fetchQueryData } from './chartData';
+export { resolveBindings } from './resolveBindings';
+export type {
+ DataBindingSpec,
+ DataRow,
+ HostFilter,
+ InlineWidgetRef,
+ QueryDataResult,
+ SavedWidget,
+ SavedWidgetRef,
+ WidgetBus,
+ WidgetComponent,
+ WidgetDataClient,
+ WidgetEvent,
+ WidgetProps,
+ WidgetRef,
+} from './types';
diff --git a/superset-frontend/packages/superset-widgets/src/mcp/client.test.ts b/superset-frontend/packages/superset-widgets/src/mcp/client.test.ts
new file mode 100644
index 00000000000..2309cc7d21f
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/mcp/client.test.ts
@@ -0,0 +1,393 @@
+/**
+ * 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 {
+ createMcpWidgetClient,
+ extractPayload,
+ getArtifactMcp,
+ type ArtifactMcp,
+} from './client';
+import { WidgetMcpError } from './errors';
+
+const SERVER = 'Superset';
+
+const chart = {
+ type: 'echarts',
+ props: {
+ dataBinding: {
+ datasetId: 17,
+ metrics: ['sum__num'],
+ dimensions: ['state'],
+ rowLimit: 10,
+ filters: [
+ {
+ expressionType: 'SIMPLE',
+ clause: 'WHERE',
+ subject: 'gender',
+ operator: '==',
+ comparator: 'girl',
+ },
+ ],
+ },
+ },
+};
+
+const stubMcp = (...results: unknown[]) => {
+ const callTool = jest.fn();
+ results.forEach(result =>
+ result instanceof Error || (result as { code?: string })?.code
+ ? callTool.mockRejectedValueOnce(result)
+ : callTool.mockResolvedValueOnce(result),
+ );
+ return { callTool } satisfies ArtifactMcp;
+};
+
+test('widget-tools sends inline widgets and host filters to get_widget_data', async () => {
+ const mcp = stubMcp({
+ payload: { columns: ['state', 'sum__num'], rows: [{ state: 'CA' }] },
+ });
+ const client = createMcpWidgetClient({ mcp, server: SERVER });
+
+ const result = await client.fetchData({
+ instanceId: 'bar',
+ widget: chart,
+ filters: [
+ { column: 'state', operator: 'IN', value: ['CA'], datasource: 17 },
+ ],
+ });
+
+ expect(mcp.callTool).toHaveBeenCalledWith(SERVER, 'get_widget_data', {
+ request: {
+ widget: chart,
+ filters: [{ column: 'state', operator: 'IN', value: ['CA'] }],
+ },
+ });
+ expect(result).toEqual({
+ columns: ['state', 'sum__num'],
+ rows: [{ state: 'CA' }],
+ });
+});
+
+test('widget-tools addresses saved widgets by id', async () => {
+ const mcp = stubMcp(
+ { payload: { uuid: 'w1', widget_type: 'echarts', props: {} } },
+ { payload: { values: ['boy', 'girl'] } },
+ );
+ const client = createMcpWidgetClient({ mcp, server: SERVER });
+
+ await expect(client.getSavedWidget?.('w1')).resolves.toMatchObject({
+ uuid: 'w1',
+ });
+ await expect(
+ client.fetchValues({
+ instanceId: 'f',
+ widget: { id: 'w2', type: 'filter.select', props: {} },
+ }),
+ ).resolves.toEqual(['boy', 'girl']);
+
+ expect(mcp.callTool).toHaveBeenNthCalledWith(1, SERVER, 'get_saved_widget', {
+ request: { id: 'w1' },
+ });
+ expect(mcp.callTool).toHaveBeenNthCalledWith(2, SERVER, 'get_widget_values', {
+ request: { id: 'w2' },
+ });
+});
+
+test('proxy mode goes through call_tool and unwraps the nested tool result', async () => {
+ const inner = { columns: ['state'], rows: [{ state: 'NY' }] };
+ const mcp = stubMcp({
+ payload: { content: [{ type: 'text', text: JSON.stringify(inner) }] },
+ });
+ const client = createMcpWidgetClient({ mcp, server: SERVER, proxy: true });
+
+ const result = await client.fetchData({
+ instanceId: 'bar',
+ widget: chart,
+ filters: [],
+ });
+
+ expect(mcp.callTool).toHaveBeenCalledWith(SERVER, 'call_tool', {
+ name: 'get_widget_data',
+ arguments: { request: { widget: chart, filters: [] } },
+ });
+ expect(result).toEqual(inner);
+});
+
+test('query-dataset maps the binding, authored filters and host filters', async () => {
+ const mcp = stubMcp({
+ payload: {
+ columns: [{ name: 'state' }, { name: 'sum__num' }],
+ data: [{ state: 'CA', sum__num: 1 }],
+ },
+ });
+ const client = createMcpWidgetClient({
+ mcp,
+ server: SERVER,
+ strategy: 'query-dataset',
+ });
+
+ const result = await client.fetchData({
+ instanceId: 'bar',
+ widget: chart,
+ filters: [
+ { column: 'state', operator: 'NOT_EQUALS', value: 'TX' },
+ { column: 'num', operator: 'RANGE', value: { min: 5, max: 10 } },
+ { column: 'ds', operator: 'TIME_RANGE', value: { start: '2020' } },
+ ],
+ });
+
+ expect(mcp.callTool).toHaveBeenCalledWith(SERVER, 'query_dataset', {
+ request: {
+ dataset_id: 17,
+ metrics: ['sum__num'],
+ columns: ['state'],
+ filters: [
+ { col: 'gender', op: '==', val: 'girl' },
+ { col: 'state', op: '!=', val: 'TX' },
+ { col: 'num', op: '>=', val: 5 },
+ { col: 'num', op: '<=', val: 10 },
+ { col: 'ds', op: '>=', val: '2020' },
+ ],
+ row_limit: 10,
+ },
+ });
+ expect(result).toEqual({
+ columns: ['state', 'sum__num'],
+ rows: [{ state: 'CA', sum__num: 1 }],
+ });
+});
+
+test('query-dataset rejects what only the widget tools can run', async () => {
+ const mcp = stubMcp();
+ const client = createMcpWidgetClient({
+ mcp,
+ server: SERVER,
+ strategy: 'query-dataset',
+ });
+ const adhocMetric = {
+ ...chart,
+ props: {
+ dataBinding: { datasetId: 17, metrics: [{ expressionType: 'SQL' }] },
+ },
+ };
+ const sqlFilter = {
+ ...chart,
+ props: {
+ dataBinding: {
+ datasetId: 17,
+ metrics: ['count'],
+ filters: [{ expressionType: 'SQL', sqlExpression: '1=1' }],
+ },
+ },
+ };
+
+ await expect(
+ client.fetchData({ instanceId: 'a', widget: adhocMetric, filters: [] }),
+ ).rejects.toMatchObject({ code: 'unsupported' });
+ await expect(
+ client.fetchData({ instanceId: 'b', widget: sqlFilter, filters: [] }),
+ ).rejects.toMatchObject({ code: 'unsupported' });
+ await expect(
+ client.fetchData({
+ instanceId: 'c',
+ widget: { id: 'w1', ...chart },
+ filters: [],
+ }),
+ ).rejects.toMatchObject({ code: 'unsupported' });
+ await expect(client.getSavedWidget?.('w1')).rejects.toMatchObject({
+ code: 'unsupported',
+ });
+ expect(mcp.callTool).not.toHaveBeenCalled();
+});
+
+test('query-dataset reads filter values as distinct column values', async () => {
+ const mcp = stubMcp({
+ payload: {
+ columns: [{ name: 'gender' }],
+ data: [{ gender: 'boy' }, { gender: 'girl' }, { gender: 'boy' }],
+ },
+ });
+ const client = createMcpWidgetClient({
+ mcp,
+ server: SERVER,
+ strategy: 'query-dataset',
+ });
+
+ const values = await client.fetchValues({
+ instanceId: 'f',
+ widget: {
+ type: 'filter.select',
+ props: { datasetId: 17, column: 'gender' },
+ },
+ });
+
+ expect(values).toEqual(['boy', 'girl']);
+ expect(mcp.callTool).toHaveBeenCalledWith(SERVER, 'query_dataset', {
+ request: { dataset_id: 17, columns: ['gender'], row_limit: 1000 },
+ });
+});
+
+test('widget-tools reads payloads wrapped as {result}', async () => {
+ const mcp = stubMcp(
+ {
+ payload: {
+ result: { columns: ['state'], rows: [{ state: 'CA' }] },
+ },
+ },
+ {
+ structuredContent: {
+ result: {
+ uuid: 'w1',
+ widget_type: 'echarts',
+ title: null,
+ props: {},
+ dataset_id: 17,
+ changed_on: null,
+ },
+ },
+ content: [],
+ },
+ );
+ const client = createMcpWidgetClient({ mcp, server: SERVER });
+
+ await expect(
+ client.fetchData({ instanceId: 'bar', widget: chart, filters: [] }),
+ ).resolves.toEqual({ columns: ['state'], rows: [{ state: 'CA' }] });
+ await expect(client.getSavedWidget?.('w1')).resolves.toMatchObject({
+ uuid: 'w1',
+ dataset_id: 17,
+ });
+});
+
+test.each([
+ ['NotFound', 'not_found', 'Not found in Superset: Widget w1 not found'],
+ ['Forbidden', 'forbidden', 'does not have access'],
+ [
+ 'ValidationError',
+ 'invalid_widget',
+ 'Superset rejected this widget definition: Widget w1 not found; metrics: required',
+ ],
+ ['CommandError', 'tool_error', 'Widget w1 not found'],
+ ['UnexpectedError', 'tool_error', 'Widget w1 not found'],
+])('an in-band %s result becomes %s', async (errorType, code, message) => {
+ const mcp = stubMcp({
+ payload: {
+ result: {
+ error: 'Widget w1 not found',
+ error_type: errorType,
+ errors: [{ message: 'metrics: required' }],
+ timestamp: '2026-09-15T00:00:00Z',
+ },
+ },
+ });
+ const client = createMcpWidgetClient({ mcp, server: SERVER });
+
+ const error = await client
+ .fetchData({ instanceId: 'bar', widget: chart, filters: [] })
+ .catch((caught: unknown) => caught);
+
+ expect(error).toMatchObject({ code });
+ expect((error as Error).message).toContain(message);
+});
+
+test('query-dataset surfaces in-band tool errors too', async () => {
+ const mcp = stubMcp({
+ payload: { error: 'Dataset 17 not found', error_type: 'NotFound' },
+ });
+ const client = createMcpWidgetClient({
+ mcp,
+ server: SERVER,
+ strategy: 'query-dataset',
+ });
+
+ await expect(
+ client.fetchData({ instanceId: 'bar', widget: chart, filters: [] }),
+ ).rejects.toMatchObject({ code: 'not_found' });
+});
+
+test.each([
+ ['needs_reauth', 'Reconnect the Superset connector'],
+ ['server_not_connected', 'Add the Superset connector'],
+ ['server_not_found', 'Add the Superset connector'],
+ ['selection_required', 'Choose which Superset connector'],
+ ['not_in_manifest', 'not allowed to call Superset'],
+ ['approval_required', 'Approve access to Superset'],
+ ['consent_required', 'Approve access to Superset'],
+ ['not_granted', 'Approve access to Superset'],
+ ['blocked_by_policy', 'policy blocks Superset'],
+ ['upstream_error', 'Superset is not responding'],
+ ['rate_limited', 'Superset is not responding'],
+ ['cancelled', 'cancelled'],
+ ['capability_disabled', 'Reload the page'],
+ ['capability_removed', 'Reload the page'],
+ ['user_changed', 'Reload the page'],
+ ['transform_error', 'Could not read the response from Superset'],
+ ['bad_request', 'input rejected'],
+ ['tool_error', 'input rejected'],
+ ['something_new', 'Superset is not responding'],
+])('%s rejections map to a viewer-facing message', async (code, text) => {
+ const mcp = stubMcp({ code, server: SERVER, message: 'input rejected' });
+ const client = createMcpWidgetClient({ mcp, server: SERVER });
+
+ const error = await client
+ .fetchData({ instanceId: 'bar', widget: chart, filters: [] })
+ .catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(WidgetMcpError);
+ expect((error as WidgetMcpError).message).toContain(text);
+ expect(mcp.callTool).toHaveBeenCalledTimes(1);
+});
+
+test('a retryable failure is retried once', async () => {
+ const mcp = stubMcp(
+ { code: 'server_unavailable', message: 'timeout', retryable: true },
+ { payload: { columns: ['state'], rows: [] } },
+ );
+ const client = createMcpWidgetClient({
+ mcp,
+ server: SERVER,
+ retryDelayMs: 0,
+ });
+
+ await expect(
+ client.fetchData({ instanceId: 'bar', widget: chart, filters: [] }),
+ ).resolves.toEqual({ columns: ['state'], rows: [] });
+ expect(mcp.callTool).toHaveBeenCalledTimes(2);
+});
+
+test('a missing runtime rejects with capability_unavailable', async () => {
+ const client = createMcpWidgetClient({ mcp: null, server: SERVER });
+
+ await expect(
+ client.fetchData({ instanceId: 'bar', widget: chart, filters: [] }),
+ ).rejects.toMatchObject({ code: 'capability_unavailable' });
+});
+
+test('getArtifactMcp is null outside an artifact', async () => {
+ await expect(getArtifactMcp()).resolves.toBeNull();
+});
+
+test('extractPayload reads structuredContent, text blocks and {result} wrappers', () => {
+ expect(extractPayload({ structuredContent: { result: [1, 2] } })).toEqual([
+ 1, 2,
+ ]);
+ expect(
+ extractPayload({ content: [{ type: 'text', text: '{"a":1}' }] }),
+ ).toEqual({ a: 1 });
+ expect(extractPayload({ payload: 'plain text' })).toBe('plain text');
+});
diff --git a/superset-frontend/packages/superset-widgets/src/mcp/client.ts b/superset-frontend/packages/superset-widgets/src/mcp/client.ts
new file mode 100644
index 00000000000..0ac3a6c9eca
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/mcp/client.ts
@@ -0,0 +1,370 @@
+/**
+ * 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 type { ResolvedFilter } from '../filterVocabulary';
+import type {
+ DataBindingSpec,
+ DataRow,
+ QueryDataResult,
+ SavedWidget,
+ WidgetDataClient,
+ WidgetRef,
+} from '../types';
+import {
+ WidgetMcpError,
+ toWidgetMcpError,
+ toolErrorFromPayload,
+ widgetMcpError,
+} from './errors';
+
+/** The slice of the artifact runtime's `mcp` capability this client uses. */
+export interface ArtifactMcp {
+ callTool(
+ server: string,
+ tool: string,
+ input?: unknown,
+ options?: { signal?: AbortSignal },
+ ): Promise<unknown>;
+}
+
+/**
+ * `widget-tools`: Superset's widget MCP tools, which run saved and inline
+ * widgets exactly like the REST widget API.
+ * `query-dataset`: the long-standing `query_dataset` tool, so inline widgets
+ * work against deployments that predate the widget tools.
+ */
+export type McpStrategy = 'widget-tools' | 'query-dataset';
+
+export interface McpWidgetClientOptions {
+ mcp: ArtifactMcp | null | Promise<ArtifactMcp | null>;
+ /** The connector's display name, as named in the artifact's manifest. */
+ server: string;
+ strategy?: McpStrategy;
+ /** Call through the server's `call_tool` meta-tool instead of the tool itself. */
+ proxy?: boolean;
+ /** Delay before the single retry of a retryable read; defaults to the server's hint or ~0.5 s. */
+ retryDelayMs?: number;
+}
+
+/** Tool names and argument shapes in one place, so a server-side rename is a one-line change. */
+export const MCP_TOOLS = {
+ widgetData: 'get_widget_data',
+ widgetValues: 'get_widget_values',
+ savedWidget: 'get_saved_widget',
+ queryDataset: 'query_dataset',
+ proxy: 'call_tool',
+} as const;
+
+/** `null` outside an artifact, or when this view may not use connectors. */
+export async function getArtifactMcp(): Promise<ArtifactMcp | null> {
+ const { claude } = globalThis as {
+ claude?: { use?: (name: string) => Promise<unknown> };
+ };
+ if (!claude || typeof claude.use !== 'function') return null;
+ try {
+ const mcp = (await claude.use('mcp')) as ArtifactMcp | null;
+ return mcp && typeof mcp.callTool === 'function' ? mcp : null;
+ } catch {
+ return null;
+ }
+}
+
+const isRecord = (value: unknown): value is Record<string, unknown> =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const DATA_KEYS = [
+ 'columns',
+ 'rows',
+ 'data',
+ 'values',
+ 'uuid',
+ 'error',
+ 'error_type',
+];
+
+function parseText(text: string): unknown {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+}
+
+/**
+ * The tool's JSON payload out of a `callTool` resolution: `payload` when the
+ * runtime provides it, otherwise `structuredContent` or the first text block.
+ * A proxied call nests the real tool's result one level deeper, and FastMCP
+ * wraps non-object results as `{result: ...}`; both are peeled here.
+ */
+export function extractPayload(value: unknown, depth = 0): unknown {
+ if (depth > 4 || !isRecord(value)) return value;
+ if ('payload' in value && value.payload !== undefined) {
+ return extractPayload(value.payload, depth + 1);
+ }
+ if ('structuredContent' in value && value.structuredContent !== undefined) {
+ return extractPayload(value.structuredContent, depth + 1);
+ }
+ if (Array.isArray(value.content)) {
+ const text = value.content.find(
+ (block): block is { type: string; text: string } =>
+ isRecord(block) && typeof block.text === 'string',
+ );
+ return text ? extractPayload(parseText(text.text), depth + 1) : undefined;
+ }
+ // Tools with a union return type have their structured output wrapped as
+ // {result: payload}; data and error payloads never carry a `result` key.
+ if ('result' in value && !DATA_KEYS.some(key => key in value)) {
+ return extractPayload(value.result, depth + 1);
+ }
+ return value;
+}
+
+const QUERY_DATASET_OPS = new Set([
+ '==',
+ '!=',
+ '>',
+ '<',
+ '>=',
+ '<=',
+ 'LIKE',
+ 'NOT LIKE',
+ 'ILIKE',
+ 'NOT ILIKE',
+ 'IN',
+ 'NOT IN',
+ 'IS NULL',
+ 'IS NOT NULL',
+ 'IS TRUE',
+ 'IS FALSE',
+ 'TEMPORAL_RANGE',
+]);
+
+interface DatasetFilter {
+ col: string;
+ op: string;
+ val?: unknown;
+}
+
+function resolvedToDatasetFilters(filter: ResolvedFilter): DatasetFilter[] {
+ const { column: col, operator, value } = filter;
+ switch (operator) {
+ case 'EQUALS':
+ return [{ col, op: '==', val: value }];
+ case 'NOT_EQUALS':
+ return [{ col, op: '!=', val: value }];
+ case 'IN':
+ return [{ col, op: 'IN', val: value }];
+ case 'NOT_IN':
+ return [{ col, op: 'NOT IN', val: value }];
+ case 'RANGE': {
+ const { min, max } = (value ?? {}) as { min?: unknown; max?: unknown };
+ return [
+ ...(min != null ? [{ col, op: '>=', val: min }] : []),
+ ...(max != null ? [{ col, op: '<=', val: max }] : []),
+ ];
+ }
+ case 'TIME_RANGE': {
+ const { start, end } = (value ?? {}) as {
+ start?: unknown;
+ end?: unknown;
+ };
+ return [
+ ...(start != null ? [{ col, op: '>=', val: start }] : []),
+ ...(end != null ? [{ col, op: '<', val: end }] : []),
+ ];
+ }
+ default:
+ return [];
+ }
+}
+
+export function createMcpWidgetClient({
+ mcp,
+ server,
+ strategy = 'widget-tools',
+ proxy = false,
+ retryDelayMs,
+}: McpWidgetClientOptions): WidgetDataClient {
+ const mcpReady = Promise.resolve(mcp);
+
+ async function invoke(tool: string, args: unknown): Promise<unknown> {
+ const runtime = await mcpReady;
+ if (!runtime) throw widgetMcpError('capability_unavailable', server);
+ const [name, input] = proxy
+ ? [MCP_TOOLS.proxy, { name: tool, arguments: args }]
+ : [tool, args];
+
+ let result: unknown;
+ try {
+ result = await runtime.callTool(server, name, input);
+ } catch (error) {
+ const first = toWidgetMcpError(error, server);
+ // Reads only, and only what the runtime marked retryable: once.
+ if (!first.retryable) throw first;
+ const wait =
+ retryDelayMs ?? first.retryAfterMs ?? 400 + Math.random() * 400;
+ await new Promise(resolve => setTimeout(resolve, wait));
+ try {
+ result = await runtime.callTool(server, name, input);
+ } catch (retryError) {
+ throw toWidgetMcpError(retryError, server);
+ }
+ }
+
+ const payload = extractPayload(result);
+ const toolError = toolErrorFromPayload(payload, server);
+ if (toolError) throw toolError;
+ if (payload === undefined) {
+ throw widgetMcpError('invalid_response', server);
+ }
+ return payload;
+ }
+
+ const selectorOf = (widget: WidgetRef) =>
+ 'id' in widget
+ ? { id: widget.id }
+ : { widget: { type: widget.type, props: widget.props } };
+
+ const toResult = (payload: unknown): QueryDataResult => {
+ if (!isRecord(payload)) throw widgetMcpError('invalid_response', server);
+ const columns = Array.isArray(payload.columns)
+ ? payload.columns.map(column =>
+ isRecord(column)
+ ? String(column.name ?? column.column_name ?? '')
+ : String(column),
+ )
+ : [];
+ const rawRows = payload.rows ?? payload.data;
+ const rows = Array.isArray(rawRows) ? (rawRows as DataRow[]) : [];
+ return { columns, rows };
+ };
+
+ const widgetTools: WidgetDataClient = {
+ async fetchData({ widget, filters }) {
+ const payload = await invoke(MCP_TOOLS.widgetData, {
+ request: {
+ ...selectorOf(widget),
+ filters: filters.map(({ column, operator, value }) => ({
+ column,
+ operator,
+ value,
+ })),
+ },
+ });
+ return toResult(payload);
+ },
+ async fetchValues({ widget }) {
+ const payload = await invoke(MCP_TOOLS.widgetValues, {
+ request: selectorOf(widget),
+ });
+ if (Array.isArray(payload)) return payload;
+ if (isRecord(payload) && Array.isArray(payload.values)) {
+ return payload.values;
+ }
+ throw widgetMcpError('invalid_response', server);
+ },
+ async getSavedWidget(id) {
+ const payload = await invoke(MCP_TOOLS.savedWidget, { request: { id } });
+ const widget =
+ isRecord(payload) && isRecord(payload.widget)
+ ? payload.widget
+ : payload;
+ if (!isRecord(widget) || typeof widget.widget_type !== 'string') {
+ throw widgetMcpError('invalid_response', server);
+ }
+ return widget as unknown as SavedWidget;
+ },
+ };
+
+ const unsupported = (detail: string) =>
+ widgetMcpError('unsupported', server, detail);
+
+ const authoredFilters = (binding: DataBindingSpec): DatasetFilter[] =>
+ (binding.filters ?? []).map(filter => {
+ const { expressionType, subject, operator, comparator } = filter as {
+ expressionType?: unknown;
+ subject?: unknown;
+ operator?: unknown;
+ comparator?: unknown;
+ };
+ if (
+ expressionType !== 'SIMPLE' ||
+ typeof subject !== 'string' ||
+ typeof operator !== 'string' ||
+ !QUERY_DATASET_OPS.has(operator)
+ ) {
+ throw unsupported(
+ 'This widget has a SQL or unsupported filter, which needs the "widget-tools" strategy.',
+ );
+ }
+ return { col: subject, op: operator, val: comparator };
+ });
+
+ const queryDataset: WidgetDataClient = {
+ async fetchData({ widget, filters }) {
+ if ('id' in widget) {
+ throw unsupported('Saved widgets need the "widget-tools" strategy.');
+ }
+ const binding = widget.props.dataBinding as DataBindingSpec | undefined;
+ if (!binding) throw unsupported('This widget has no dataBinding.');
+ const metrics = binding.metrics ?? [];
+ if (!metrics.every(metric => typeof metric === 'string')) {
+ throw unsupported(
+ 'Ad hoc metrics need the "widget-tools" strategy; use saved metric names.',
+ );
+ }
+ const payload = await invoke(MCP_TOOLS.queryDataset, {
+ request: {
+ dataset_id: binding.datasetId,
+ metrics,
+ columns: (binding.dimensions ?? []).filter(Boolean),
+ filters: [
+ ...authoredFilters(binding),
+ ...filters.flatMap(resolvedToDatasetFilters),
+ ],
+ row_limit: binding.rowLimit ?? 1000,
+ },
+ });
+ return toResult(payload);
+ },
+ async fetchValues({ widget }) {
+ if ('id' in widget) {
+ throw unsupported('Saved widgets need the "widget-tools" strategy.');
+ }
+ const { datasetId, column } = widget.props as {
+ datasetId?: number;
+ column?: string;
+ };
+ if (datasetId == null || !column) return [];
+ const payload = await invoke(MCP_TOOLS.queryDataset, {
+ request: { dataset_id: datasetId, columns: [column], row_limit: 1000 },
+ });
+ const { rows } = toResult(payload);
+ return [...new Set(rows.map(row => row[column]))];
+ },
+ getSavedWidget() {
+ return Promise.reject(
+ unsupported('Saved widgets need the "widget-tools" strategy.'),
+ );
+ },
+ };
+
+ return strategy === 'query-dataset' ? queryDataset : widgetTools;
+}
+
+export { WidgetMcpError };
diff --git a/superset-frontend/packages/superset-widgets/src/mcp/errors.ts b/superset-frontend/packages/superset-widgets/src/mcp/errors.ts
new file mode 100644
index 00000000000..3ecc401a0bc
--- /dev/null
+++ b/superset-frontend/packages/superset-widgets/src/mcp/errors.ts
@@ -0,0 +1,251 @@
+/**
+ * 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 { t } from '@apache-superset/core/translation';
+
+/**
+ * Codes a connector call can fail with (the artifact runtime's own codes),
+ * plus the client's: `capability_unavailable` (not inside an artifact that
+ * may use connectors), `unsupported` (the chosen strategy cannot serve this
+ * widget) and `invalid_response` (the tool answered with something unreadable).
+ */
+export type WidgetMcpErrorCode =
+ | 'needs_reauth'
+ | 'server_not_connected'
+ | 'server_not_found'
+ | 'selection_required'
+ | 'server_unavailable'
+ | 'not_in_manifest'
+ | 'blocked_by_policy'
+ | 'approval_required'
+ | 'consent_required'
+ | 'not_granted'
+ | 'tool_error'
+ | 'bad_request'
+ | 'cancelled'
+ | 'rate_limited'
+ | 'upstream_error'
+ | 'capability_disabled'
+ | 'capability_removed'
+ | 'transform_error'
+ | 'user_changed'
+ | 'capability_unavailable'
+ | 'unsupported'
+ | 'invalid_response'
+ | 'not_found'
+ | 'forbidden'
+ | 'invalid_widget';
+
+export class WidgetMcpError extends Error {
+ readonly code: WidgetMcpErrorCode;
+
+ readonly server?: string;
+
+ /** Only set when repeating the call unattended may succeed. */
+ readonly retryable: boolean;
+
+ readonly retryAfterMs?: number;
+
+ constructor(
+ code: WidgetMcpErrorCode,
+ message: string,
+ options: {
+ server?: string;
+ retryable?: boolean;
+ retryAfterMs?: number;
+ } = {},
+ ) {
+ super(message);
+ this.name = 'WidgetMcpError';
+ this.code = code;
+ this.server = options.server;
+ this.retryable = options.retryable ?? false;
+ this.retryAfterMs = options.retryAfterMs;
+ }
+}
+
+const KNOWN_CODES = new Set<string>([
+ 'needs_reauth',
+ 'server_not_connected',
+ 'server_not_found',
+ 'selection_required',
+ 'server_unavailable',
+ 'not_in_manifest',
+ 'blocked_by_policy',
+ 'approval_required',
+ 'consent_required',
+ 'not_granted',
+ 'tool_error',
+ 'bad_request',
+ 'cancelled',
+ 'rate_limited',
+ 'upstream_error',
+ 'capability_disabled',
+ 'capability_removed',
+ 'transform_error',
+ 'user_changed',
+]);
+
+/** What the viewer can do about each failure; one message per distinct fix. */
+function viewerMessage(
+ code: WidgetMcpErrorCode,
+ server: string,
+ detail: string,
+): string {
+ switch (code) {
+ case 'needs_reauth':
+ return t(
+ 'Reconnect the %s connector in Claude settings, then reload this page.',
+ server,
+ );
+ case 'server_not_connected':
+ case 'server_not_found':
+ return t(
+ 'Add the %s connector in Claude settings to see this data.',
+ server,
+ );
+ case 'selection_required':
+ return t('Choose which %s connector this page should use.', server);
+ case 'not_in_manifest':
+ return t(
+ 'This page is not allowed to call %s. Republish it with the connector in its manifest.',
+ server,
+ );
+ case 'approval_required':
+ case 'consent_required':
+ case 'not_granted':
+ return t('Approve access to %s to load this widget.', server);
+ case 'blocked_by_policy':
+ return t("Your organization's policy blocks %s on this page.", server);
+ case 'server_unavailable':
+ case 'upstream_error':
+ case 'rate_limited':
+ return t('%s is not responding right now. Try again shortly.', server);
+ case 'cancelled':
+ return t('The request was cancelled.');
+ case 'capability_disabled':
+ case 'capability_removed':
+ case 'user_changed':
+ return t('Connector access for this page changed. Reload the page.');
+ case 'transform_error':
+ case 'invalid_response':
+ return t('Could not read the response from %s.', server);
+ case 'capability_unavailable':
+ return t(
+ 'Superset widgets need to run inside a Claude artifact with the %s connector.',
+ server,
+ );
+ case 'not_found':
+ return detail
+ ? t('Not found in %s: %s', server, detail)
+ : t('This widget or its dataset was not found in %s.', server);
+ case 'forbidden':
+ return t('Your %s account does not have access to this data.', server);
+ case 'invalid_widget':
+ return detail
+ ? t('%s rejected this widget definition: %s', server, detail)
+ : t('%s rejected this widget definition.', server);
+ case 'bad_request':
+ case 'tool_error':
+ case 'unsupported':
+ default:
+ return detail || t('%s could not load this widget.', server);
+ }
+}
+
+/** A rejection from `callTool` (or anything thrown around it) as a typed error. */
+export function toWidgetMcpError(
+ error: unknown,
+ server: string,
+): WidgetMcpError {
+ if (error instanceof WidgetMcpError) return error;
+ const raw = (error ?? {}) as {
+ code?: unknown;
+ message?: unknown;
+ retryable?: unknown;
+ retryAfterMs?: unknown;
+ };
+ const code: WidgetMcpErrorCode =
+ typeof raw.code === 'string' && KNOWN_CODES.has(raw.code)
+ ? (raw.code as WidgetMcpErrorCode)
+ : 'upstream_error';
+ const detail = typeof raw.message === 'string' ? raw.message : '';
+ return new WidgetMcpError(code, viewerMessage(code, server, detail), {
+ server,
+ retryable: raw.retryable === true,
+ retryAfterMs:
+ typeof raw.retryAfterMs === 'number' ? raw.retryAfterMs : undefined,
+ });
+}
+
+/** A client-side failure (`unsupported`, `invalid_response`, ...) with the viewer-facing message. */
+export function widgetMcpError(
+ code: WidgetMcpErrorCode,
+ server: string,
+ detail = '',
+): WidgetMcpError {
+ return new WidgetMcpError(code, viewerMessage(code, server, detail), {
+ server,
+ });
+}
+
+/**
+ * Superset's MCP tools report failures in-band, as a normal result shaped
+ * `{error, error_type, errors?}`; `null` when `payload` is data.
+ */
+export function toolErrorFromPayload(
+ payload: unknown,
+ server: string,
+): WidgetMcpError | null {
+ if (typeof payload !== 'object' || payload === null) return null;
+ const {
+ error,
+ error_type: errorType,
+ errors,
+ } = payload as {
+ error?: unknown;
+ error_type?: unknown;
+ errors?: unknown;
+ };
+ if (typeof error !== 'string' && typeof errorType !== 'string') return null;
+
+ const details = Array.isArray(errors)
+ ? errors
+ .map(item =>
+ typeof item === 'string'
+ ? item
+ : typeof (item as { message?: unknown })?.message === 'string'
+ ? (item as { message: string }).message
+ : '',
+ )
+ .filter(Boolean)
+ : [];
+ const detail = [typeof error === 'string' ? error : '', ...details]
+ .filter(Boolean)
+ .join('; ');
+
+ const code: WidgetMcpErrorCode =
+ errorType === 'NotFound'
+ ? 'not_found'
+ : errorType === 'Forbidden'
+ ? 'forbidden'
+ : errorType === 'ValidationError'
+ ? 'invalid_widget'
+ : 'tool_error';
+ return widgetMcpError(code, server, detail);
+}
diff --git a/superset-frontend/src/core/dashboard/EmbedWidgetModal.tsx b/superset-frontend/src/core/dashboard/EmbedWidgetModal.tsx
new file mode 100644
index 00000000000..eaa4b0593df
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/EmbedWidgetModal.tsx
@@ -0,0 +1,140 @@
+/**
+ * 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 { useState, type ReactElement } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { Button, Flex, Modal, Typography } from '@superset-ui/core/components';
+import { describeFetchError } from '@apache-superset/widgets/chartData';
+import { isExtensionWidgetType } from '@apache-superset/widgets/embed/extensionLoader';
+import type { SavedWidget } from '@apache-superset/widgets/types';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { useDashboardStore } from './store';
+import { widgetLabel } from './widgetLabel';
+
+/** Where the saved widget's uuid is kept on the node, so re-embedding updates it. */
+export const EMBED_WIDGET_ID_PROP = 'embedWidgetId';
+
+function datasetIdOf(props: Record<string, unknown>): number | undefined {
+ const binding = props.dataBinding as { datasetId?: number } | undefined;
+ return binding?.datasetId ?? (props.datasetId as number | undefined);
+}
+
+export default function EmbedWidgetModal({
+ nodeId,
+ show,
+ onHide,
+}: {
+ nodeId: string;
+ show: boolean;
+ onHide: () => void;
+}): ReactElement | null {
+ const store = useDashboardStore();
+ const { addDangerToast, addSuccessToast } = useToasts();
+ const node = store.getNode(nodeId);
+ const [saving, setSaving] = useState(false);
+
+ if (!node) return null;
+
+ const { [EMBED_WIDGET_ID_PROP]: storedId, ...props } = node.props ?? {};
+ const widgetId = typeof storedId === 'string' ? storedId : undefined;
+ const datasetId = datasetIdOf(props);
+
+ const save = async () => {
+ setSaving(true);
+ try {
+ const title = widgetLabel(node.type, props) || undefined;
+ const { json } = widgetId
+ ? await SupersetClient.put({
+ endpoint: `/api/v1/widget/${widgetId}`,
+ jsonPayload: { props, title },
+ })
+ : await SupersetClient.post({
+ endpoint: '/api/v1/widget/',
+ jsonPayload: { widget_type: node.type, props, title },
+ });
+ const saved = json.result as SavedWidget;
+ store.updateProps(nodeId, { [EMBED_WIDGET_ID_PROP]: saved.uuid });
+ addSuccessToast(t('Widget saved for embedding'));
+ } catch (error) {
+ addDangerToast(await describeFetchError(error));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const savedSnippet = widgetId
+ ? `<Widget id="${widgetId}" />\n\n` +
+ `// guest token\n{ resources: [{ type: "widget", id: "${widgetId}" }], rls: [] }`
+ : '';
+ const inlineSnippet = `<Widget\n type="${node.type}"\n props={${JSON.stringify(props, null, 2)}}\n/>${
+ datasetId !== undefined
+ ? `\n\n// guest token\n{ resources: [], datasets: [${datasetId}], rls: [] }`
+ : ''
+ }`;
+
+ return (
+ <Modal
+ show={show}
+ onHide={onHide}
+ title={t('Embed widget')}
+ name={t('Embed widget')}
+ footer={
+ <Flex justify="flex-end" gap="small">
+ <Button buttonStyle="secondary" onClick={onHide}>
+ {t('Close')}
+ </Button>
+ <Button buttonStyle="primary" loading={saving} onClick={save}>
+ {widgetId ? t('Update saved widget') : t('Save widget')}
+ </Button>
+ </Flex>
+ }
+ >
+ <Flex vertical gap="middle">
+ <Typography.Text>
+ {t(
+ 'Render this widget in any React app with @apache-superset/widgets, inside a SupersetProvider.',
+ )}
+ </Typography.Text>
+ {isExtensionWidgetType(node.type) && (
+ <Typography.Text type="secondary">
+ {t(
+ 'This widget comes from an extension. The host page loads that extension from Superset as it renders; with guest tokens the deployment also needs EMBEDDED_EXTENSION_ASSETS_PUBLIC.',
+ )}
+ </Typography.Text>
+ )}
+ <Typography.Text strong>{t('By saved id')}</Typography.Text>
+ {widgetId ? (
+ <Typography.Paragraph copyable={{ text: savedSnippet }}>
+ <pre data-test="embed-widget-saved-snippet">{savedSnippet}</pre>
+ </Typography.Paragraph>
+ ) : (
+ <Typography.Text type="secondary">
+ {t(
+ 'Save the widget to get an id. The server then runs the saved definition and the host cannot change it.',
+ )}
+ </Typography.Text>
+ )}
+ <Typography.Text strong>{t('Inline')}</Typography.Text>
+ <Typography.Paragraph copyable={{ text: inlineSnippet }}>
+ <pre data-test="embed-widget-inline-snippet">{inlineSnippet}</pre>
+ </Typography.Paragraph>
+ </Flex>
+ </Modal>
+ );
+}