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&apos;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(&apos;{instanceId}&apos;)</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>&lt;/&gt; 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>&lt;MetricTile …/&gt;</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>
+  );
+}