You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/08/11 17:24:29 UTC

(superset) branch dashboard-v2 updated: feat: Initial design of containers (#43065)

This is an automated email from the ASF dual-hosted git repository.

michael-s-molina pushed a commit to branch dashboard-v2
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/dashboard-v2 by this push:
     new 46be6a032d3 feat: Initial design of containers (#43065)
46be6a032d3 is described below

commit 46be6a032d371908c60a9e560823b78bff7725e1
Author: Michael S. Molina <70...@users.noreply.github.com>
AuthorDate: Tue Aug 11 14:24:21 2026 -0300

    feat: Initial design of containers (#43065)
---
 .../src/core/dashboard/BuildingBlockView.test.tsx  |   6 +-
 .../src/core/dashboard/BuildingBlockView.tsx       | 241 ++++++---
 .../src/core/dashboard/DashboardProvider.test.ts   |  86 ++--
 .../src/core/dashboard/DashboardProvider.ts        |  58 ++-
 .../CanvasBlock.test.tsx => RootGrid.test.tsx}     |  44 +-
 superset-frontend/src/core/dashboard/RootGrid.tsx  | 549 +++++++++++++++++++++
 .../src/core/dashboard/blockHeaderControl.tsx      | 144 ++++++
 .../src/core/dashboard/blockLabel.test.ts          |  20 +-
 superset-frontend/src/core/dashboard/blockLabel.ts |  47 +-
 .../src/core/dashboard/blocks/AgGridTableBlock.tsx |   4 +-
 .../src/core/dashboard/blocks/CanvasBlock.tsx      | 302 ------------
 .../src/core/dashboard/blocks/CarouselBlock.tsx    | 246 +++++++++
 .../src/core/dashboard/blocks/ChartBlock.tsx       |   4 +-
 .../src/core/dashboard/blocks/CollapsibleBlock.tsx |  97 ++++
 .../src/core/dashboard/blocks/MarkdownBlock.tsx    |   9 +-
 .../src/core/dashboard/blocks/MetricTileBlock.tsx  |   7 +-
 .../src/core/dashboard/blocks/TabsBlock.test.tsx   | 226 +++++++++
 .../src/core/dashboard/blocks/TabsBlock.tsx        | 246 +++++++++
 .../src/core/dashboard/blocks/flowContent.tsx      | 344 +++++++++++++
 .../src/core/dashboard/gridPacking.ts              |  77 ++-
 .../src/core/dashboard/layoutStyle.ts              |   2 +-
 superset-frontend/src/core/dashboard/placement.ts  | 113 ++++-
 .../dashboard/registerBuiltInBuildingBlocks.ts     |  70 ++-
 .../src/core/dashboard/resolveBuildingBlockView.ts |  10 +-
 .../pages/DashboardBuilderV2/EditorPanel.test.tsx  |  23 +-
 .../src/pages/DashboardBuilderV2/EditorPanel.tsx   |   2 +-
 .../pages/DashboardBuilderV2/Inspector.test.tsx    |   2 +-
 .../src/pages/DashboardBuilderV2/Palette.tsx       |  59 ++-
 .../src/pages/DashboardBuilderV2/index.test.tsx    |  57 ++-
 .../src/pages/DashboardBuilderV2/index.tsx         | 102 +++-
 30 files changed, 2628 insertions(+), 569 deletions(-)

diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
index eb01deb10bd..3686f18351b 100644
--- a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
+++ b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
@@ -34,8 +34,8 @@ beforeEach(() => {
 const withBlock = () => {
   const rootId = provider.getRoot().id;
   const id = provider.addBuildingBlock(rootId, 0, {
-    type: 'markdown',
-    props: { content: 'Quarterly notes' },
+    type: 'metric-tile',
+    props: { label: 'Quarterly notes' },
   });
   render(<BuildingBlockView nodeId={id} />);
   return { rootId, id };
@@ -45,7 +45,7 @@ test('a block says which one it is', () => {
   const { id } = withBlock();
 
   // Named by the same call the Outline names its rows by, so a block is not
-  // "Quarterly notes" in one place and "Markdown" in the other.
+  // "Quarterly notes" in one place and "Metric Tile" in the other.
   expect(screen.getByTestId(`block-title-${id}`)).toHaveTextContent(
     'Quarterly notes',
   );
diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
index d0afe24fc45..db75dad6a7e 100644
--- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
+++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
@@ -25,6 +25,8 @@ import { ErrorBoundary } from 'src/components';
 import { provider, useDashboardRevision } from './store';
 import { resolveBuildingBlockView } from './resolveBuildingBlockView';
 import { blockLabel } from './blockLabel';
+import { blockHeaderControl } from './blockHeaderControl';
+import RootGrid from './RootGrid';
 
 function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) {
   const theme = useTheme();
@@ -66,16 +68,53 @@ const BlockHeader = styled.div`
     align-items: center;
     gap: ${theme.sizeUnit}px;
     height: ${theme.controlHeightSM}px;
-    /* Aligned with the inset every block's own content sits at, so a block's
-       name reads as the head of the box beneath it rather than as something
-       floating loose to its left. The right side stays tight: the remove
-       button is a square target of its own and centres its icon, which is the
-       inset it needs. */
-    padding-left: ${theme.padding}px;
-    padding-right: ${theme.sizeUnit}px;
+    flex: 0 0 auto;
   `}
 `;
 
+/**
+ * What the remove control (and a type's own extra header control, if it has
+ * one — see `blockHeaderControl`) sit inside together, pushed to the end of
+ * the header as one group.
+ *
+ * Grouped rather than each carrying its own `margin-left: auto`: the two
+ * controls have to land beside each other with nothing but the header's own
+ * gap between them, which a shared wrapper gives for free and two
+ * independently-pushed elements would not (each would land flush against
+ * the header's own right edge, stacking on top of one another instead of
+ * sitting side by side). Pushed to the end whether or not a name is there
+ * to share the row with — an unnamed type (see blockLabel's UNNAMED set)
+ * leaves nothing on the other side to grow and do this instead.
+ */
+const HeaderTrailingControls = styled.span`
+  ${({ theme }) => css`
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit}px;
+    flex: 0 0 auto;
+    margin-left: auto;
+  `}
+`;
+
+/**
+ * What a type's own extra header control (see `blockHeaderControl`) is
+ * wrapped in, and why — the identical reasoning `RemoveSlot`, below, is
+ * wrapped for: the control itself is an `ActionButton` (or built from one)
+ * whose `onClick` carries no event, so the two gestures this sits inside are
+ * stopped here instead — a press on it must act rather than select the
+ * block it is drawn on, and a pointer down on it must not start a
+ * react-grid-layout drag.
+ *
+ * `data-block-header-control` is the other half of that second one —
+ * `RootGrid` names it in `draggableCancel`, and react-grid-layout matches
+ * the selector up the ancestors, so carrying it here covers the control
+ * inside.
+ */
+const HeaderControlSlot = styled.span`
+  display: flex;
+  flex: 0 0 auto;
+`;
+
 /**
  * What the remove control is wrapped in, and why it is wrapped at all.
  *
@@ -86,7 +125,7 @@ const BlockHeader = styled.div`
  * the bin must remove rather than select the block it is drawn on, and a
  * pointer down on it must not start a react-grid-layout drag.
  *
- * `data-block-remove` is the other half of that second one — `CanvasBlock`
+ * `data-block-remove` is the other half of that second one — `RootGrid`
  * names it in `draggableCancel`, and react-grid-layout matches the selector up
  * the ancestors, so carrying it here covers the button inside.
  */
@@ -102,13 +141,16 @@ interface BuildingBlockViewProps extends HTMLAttributes<HTMLDivElement> {
 /**
  * The single entry point for rendering a dashboard node. A node's `type` is
  * resolved against `dashboard.buildingBlocks` views — built-in types
- * (canvas/markdown/echarts) and extension-contributed ones are registered
+ * (markdown/echarts/...) and extension-contributed ones are registered
  * identically (see `registerBuiltInBuildingBlocks`), so nothing here knows
  * or cares which kind it's rendering. Falls back to a placeholder if the
  * node doesn't exist, or nothing is registered for its `type`.
  *
- * A `canvas` parent (`CanvasBlock`) renders its children through
- * `react-grid-layout`, which positions/sizes each child by cloning it and
+ * The root is the one exception: it is not a Building Block (see the
+ * composition/layout design doc), so there is nothing to look up for it in
+ * that registry — its renderer, `RootGrid`, is resolved directly instead.
+ * `RootGrid` renders its children through `react-grid-layout`, which
+ * positions/sizes each child by cloning it and
  * injecting `ref`/`style`/drag-and-resize handlers directly onto whatever
  * element it renders — hence `forwardRef` and spreading `...rest` onto this
  * component's own root div, rather than each block doing that itself. That's
@@ -135,14 +177,21 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
     const node = provider.getNode(nodeId);
     if (!node) return null;
 
-    const resolved = resolveBuildingBlockView(node.type, nodeId);
     const selected = provider.getSelection() === nodeId;
     // The root is the dashboard itself rather than something on it: it has no
     // name of its own to show, and removing it is refused by the provider, so
-    // a header there would be a label saying "Canvas" over a button that only
+    // a header there would be a label saying "Grid" over a button that only
     // ever raises an error.
     const chrome = nodeId !== provider.getRoot().id;
     const isRoot = !chrome;
+    // The root's renderer is not looked up in the building-block registry —
+    // see this component's own doc comment — since the root was never
+    // registered there in the first place.
+    const resolved = isRoot ? (
+      <RootGrid nodeId={nodeId} />
+    ) : (
+      resolveBuildingBlockView(node.type, nodeId)
+    );
     // The same token `BlockHeader` is drawn at: the content box below is this
     // element's height minus the band, so the two have to be one number.
     const headerHeight = theme.controlHeightSM;
@@ -192,6 +241,17 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
           // is kept wherever it set one and `relative` only fills the gap
           // when it did not.
           position: rest.style?.position ?? 'relative',
+          // Sized the same way every other block already is — everything
+          // that is not the root gets its width/height from
+          // react-grid-layout, which clones the grid item and injects both
+          // directly (captured above through `...rest.style`). Nothing
+          // clones the root; it is rendered directly, with no props, so
+          // without this it has no width or height at all and shrinks to
+          // whatever its own content happens to be — which is exactly one
+          // block tall, with nothing below it to drop onto and a scrollbar
+          // that flickers in and out as that one block resizes against it.
+          width: isRoot ? '100%' : rest.style?.width,
+          height: isRoot ? '100%' : rest.style?.height,
           // The card, drawn around the whole of a block rather than around
           // part of it.
           //
@@ -207,26 +267,39 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
           // Opaque for the same reason it is one card: on a free canvas
           // blocks overlap, and anything a block does not paint is a window
           // onto whatever is behind it.
+          //
+          // None of this is true of the root. The root is not a block on the
+          // dashboard, it *is* the dashboard — the surface everything else is
+          // arranged on, not a card among them — so it gets none of a card's
+          // trappings: no fill, no border, no rounded corners of its own.
           backgroundColor: isRoot ? undefined : theme.colorBgContainer,
-          border: `1px solid ${theme.colorBorderSecondary}`,
-          borderRadius: theme.borderRadiusLG,
+          border: isRoot
+            ? undefined
+            : `1px solid ${theme.colorBorderSecondary}`,
+          borderRadius: isRoot ? undefined : theme.borderRadiusLG,
           // Nothing reaches past the corners this rounds — a block's content
-          // is square and would otherwise fill them back in.
+          // is square and would otherwise fill them back in. Moot on the
+          // root, which rounds nothing.
           overflow: isRoot ? undefined : 'hidden',
-          // The dashboard's own gutter, and what makes it clickable.
-          //
-          // The root is drawn by a grid that fills its box edge to edge
-          // (`containerPadding={[0, 0]}` in CanvasBlock), which left the
-          // dashboard with no pixels of its own. The inset is what an author
-          // aims at to select the dashboard rather than something on it — and
-          // it is the same inset a block's content sits at, so the two read as
-          // one scale rather than two. It carries no surface of its own: this
-          // is what everything else is arranged *on*, not a card among them.
-          padding: isRoot ? theme.padding : undefined,
+          // One inset for the whole card — the name and the content both
+          // sit inside it, rather than each drawing its own. `border-box`
+          // keeps it inside the pixel box `RootGrid`/`FlowItem` gave this
+          // element (a chart resizes to what's left after this is
+          // subtracted) instead of adding to it. The root gets none: it is
+          // not a card, and RootGrid already fills it exactly.
+          padding: isRoot ? undefined : theme.padding,
+          boxSizing: 'border-box',
           // Drawn over the block rather than around it: an outline takes no
           // space, so nothing on screen shifts when a selection moves.
-          outline: selected ? `2px solid ${theme.colorPrimary}` : undefined,
-          outlineOffset: selected ? -2 : undefined,
+          //
+          // Never on the root: it can still be selected (see `EditorPanel`'s
+          // own Properties for it), but the root is the canvas itself, not a
+          // block sitting on it, and an outline meant to mark one block out
+          // from its neighbors instead reads as a frame around the entire
+          // dashboard when it is the root wearing it.
+          outline:
+            selected && !isRoot ? `2px solid ${theme.colorPrimary}` : undefined,
+          outlineOffset: selected && !isRoot ? -2 : undefined,
         }}
       >
         {/* What this block is, and how to be rid of it.
@@ -237,7 +310,7 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
             once instead of twice.
 
             `data-block-remove` is what keeps a press on the button from
-            starting a react-grid-layout drag; see CanvasBlock's
+            starting a react-grid-layout drag; see RootGrid's
             `draggableCancel`. The propagation stops are the same idea for the
             two gestures it sits inside: a click here removes rather than
             selects, and a pointer down here grabs nothing.
@@ -249,49 +322,73 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
             semantics and Properties carries the same Delete. */}
         {chrome && (
           <BlockHeader data-test={`block-header-${nodeId}`}>
-            <Typography.Text
-              ellipsis
-              data-test={`block-title-${nodeId}`}
-              style={{
-                flex: '1 1 auto',
-                // The name of the thing below it, not a note about it. At the
-                // small size in the secondary colour it read as a caption
-                // hanging over the block — and this is the first thing anyone
-                // scanning a canvas uses to tell one block from the next, so
-                // it is drawn at the weight that job deserves.
-                fontSize: theme.fontSize,
-                fontWeight: theme.fontWeightStrong,
-                color: theme.colorText,
-              }}
-            >
-              {blockLabel(node.type, node.props)}
-            </Typography.Text>
-            <RemoveSlot
-              data-block-remove
-              onMouseDown={event => event.stopPropagation()}
-              onPointerDown={event => event.stopPropagation()}
-              onClick={event => event.stopPropagation()}
-            >
-              <ActionButton
-                label={t('Remove block')}
-                tooltip={t('Remove block')}
-                placement="bottom"
-                dataTest={`block-remove-${nodeId}`}
-                onClick={() => provider.removeBuildingBlock(nodeId)}
-                // A bin rather than a cross. A cross on a card is the gesture
-                // for dismissing the card — closing it, putting it away — and
-                // this does not put the block away, it takes it off the
-                // dashboard. The bin is what the rest of the app uses to say
-                // so, and it is the same act the panel offers as Delete.
-                //
-                // Quiet at rest and primary under the pointer, which is
-                // `ActionButton`'s own behaviour and the same answer the
-                // dashboard list gives for its Delete: a bin on every block,
-                // all of them lit red, would make a canvas read as a row of
-                // things about to be deleted.
-                icon={<Icons.DeleteOutlined iconSize="s" />}
-              />
-            </RemoveSlot>
+            {/* Skipped entirely for a type `blockLabel` leaves unnamed
+                (markdown, whose rendered body is right below this and needs
+                no caption repeating it) — an empty `Typography.Text` would
+                still be a blank strip claiming the header's whole left
+                side, not nothing. */}
+            {blockLabel(node.type, node.props) && (
+              <Typography.Text
+                ellipsis
+                data-test={`block-title-${nodeId}`}
+                style={{
+                  flex: '1 1 auto',
+                  // The name of the thing below it, not a note about it. At the
+                  // small size in the secondary colour it read as a caption
+                  // hanging over the block — and this is the first thing anyone
+                  // scanning a canvas uses to tell one block from the next, so
+                  // it is drawn at the weight that job deserves.
+                  fontSize: theme.fontSize,
+                  fontWeight: theme.fontWeightStrong,
+                  color: theme.colorText,
+                }}
+              >
+                {blockLabel(node.type, node.props)}
+              </Typography.Text>
+            )}
+            <HeaderTrailingControls>
+              {/* A type's own extra header control — e.g. `collapsible`'s
+                  expand/collapse toggle — sits beside Remove rather than
+                  below the header, so a block with one of these is still
+                  just a title and its content, not a title, a second bar,
+                  and its content. See `blockHeaderControl`. */}
+              {blockHeaderControl(node.type, nodeId) && (
+                <HeaderControlSlot
+                  data-block-header-control
+                  onMouseDown={event => event.stopPropagation()}
+                  onPointerDown={event => event.stopPropagation()}
+                  onClick={event => event.stopPropagation()}
+                >
+                  {blockHeaderControl(node.type, nodeId)}
+                </HeaderControlSlot>
+              )}
+              <RemoveSlot
+                data-block-remove
+                onMouseDown={event => event.stopPropagation()}
+                onPointerDown={event => event.stopPropagation()}
+                onClick={event => event.stopPropagation()}
+              >
+                <ActionButton
+                  label={t('Remove block')}
+                  tooltip={t('Remove block')}
+                  placement="bottom"
+                  dataTest={`block-remove-${nodeId}`}
+                  onClick={() => provider.removeBuildingBlock(nodeId)}
+                  // A bin rather than a cross. A cross on a card is the gesture
+                  // for dismissing the card — closing it, putting it away — and
+                  // this does not put the block away, it takes it off the
+                  // dashboard. The bin is what the rest of the app uses to say
+                  // so, and it is the same act the panel offers as Delete.
+                  //
+                  // Quiet at rest and primary under the pointer, which is
+                  // `ActionButton`'s own behaviour and the same answer the
+                  // dashboard list gives for its Delete: a bin on every block,
+                  // all of them lit red, would make a canvas read as a row of
+                  // things about to be deleted.
+                  icon={<Icons.DeleteOutlined iconSize="s" />}
+                />
+              </RemoveSlot>
+            </HeaderTrailingControls>
           </BlockHeader>
         )}
         {/* The block's own box, which is the whole of this element's minus
diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
index 07b887d2b5a..9c6f1ed1a36 100644
--- a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
+++ b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
@@ -16,7 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import DashboardProvider from './DashboardProvider';
+import DashboardProvider, { registerContainerType } from './DashboardProvider';
+
+// A stand-in container type for exercising generic container mechanics
+// (holding children, being a valid move/collision target) — 'canvas' itself
+// is no longer addable (it's reserved for the root, see `addBuildingBlock`),
+// and this suite tests `DashboardProvider` in isolation, without the
+// registration (`registerBuiltInBuildingBlocks`) that gives 'tabs'/'tab'
+// their own container status.
+const TEST_CONTAINER_TYPE = 'container';
+
+beforeAll(() => {
+  registerContainerType(TEST_CONTAINER_TYPE);
+});
 
 beforeEach(() => {
   DashboardProvider.getInstance().reset();
@@ -26,12 +38,12 @@ test('returns the singleton instance', () => {
   expect(DashboardProvider.getInstance()).toBe(DashboardProvider.getInstance());
 });
 
-test('starts with a blank root canvas', () => {
+test('starts with a blank root grid', () => {
   const provider = DashboardProvider.getInstance();
 
   expect(provider.getRoot()).toEqual({
     id: 'root',
-    type: 'canvas',
+    type: 'grid',
     layout: { columns: 24, gap: 16 },
     children: [],
   });
@@ -60,11 +72,13 @@ test('addBuildingBlock clamps an out-of-range index', () => {
   expect(provider.getRoot().children).toEqual([id]);
 });
 
-test('addBuildingBlock gives canvas nodes an empty children array', () => {
+test('addBuildingBlock gives container nodes an empty children array', () => {
   const provider = DashboardProvider.getInstance();
   const rootId = provider.getRoot().id;
 
-  const id = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
+  const id = provider.addBuildingBlock(rootId, 0, {
+    type: TEST_CONTAINER_TYPE,
+  });
 
   expect(provider.getNode(id)?.children).toEqual([]);
 });
@@ -83,7 +97,7 @@ test('addBuildingBlock throws when the parent cannot hold children', () => {
   const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
 
   expect(() => provider.addBuildingBlock(leafId, 0, { type: 'text' })).toThrow(
-    /not a canvas/,
+    /not a container/,
   );
 });
 
@@ -98,15 +112,17 @@ test('removeBuildingBlock detaches the node from its parent', () => {
   expect(provider.getRoot().children).toEqual([]);
 });
 
-test('removeBuildingBlock removes an entire canvas subtree', () => {
+test('removeBuildingBlock removes an entire container subtree', () => {
   const provider = DashboardProvider.getInstance();
   const rootId = provider.getRoot().id;
-  const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
-  const childId = provider.addBuildingBlock(canvasId, 0, { type: 'text' });
+  const containerId = provider.addBuildingBlock(rootId, 0, {
+    type: TEST_CONTAINER_TYPE,
+  });
+  const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
 
-  provider.removeBuildingBlock(canvasId);
+  provider.removeBuildingBlock(containerId);
 
-  expect(provider.getNode(canvasId)).toBeUndefined();
+  expect(provider.getNode(containerId)).toBeUndefined();
   expect(provider.getNode(childId)).toBeUndefined();
 });
 
@@ -131,13 +147,15 @@ test('removeBuildingBlock is a no-op for an unknown id', () => {
 test('moveBuildingBlock relocates a node to a new parent at the given index', () => {
   const provider = DashboardProvider.getInstance();
   const rootId = provider.getRoot().id;
-  const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
+  const containerId = provider.addBuildingBlock(rootId, 0, {
+    type: TEST_CONTAINER_TYPE,
+  });
   const id = provider.addBuildingBlock(rootId, 1, { type: 'text' });
 
-  provider.moveBuildingBlock(id, canvasId, 0);
+  provider.moveBuildingBlock(id, containerId, 0);
 
-  expect(provider.getRoot().children).toEqual([canvasId]);
-  expect(provider.getNode(canvasId)?.children).toEqual([id]);
+  expect(provider.getRoot().children).toEqual([containerId]);
+  expect(provider.getNode(containerId)?.children).toEqual([id]);
 });
 
 test('moveBuildingBlock keeps an explicit position when the parent is unchanged', () => {
@@ -159,14 +177,16 @@ test('moveBuildingBlock keeps an explicit position when the parent is unchanged'
   });
 });
 
-test('getParentId returns the canvas holding a node', () => {
+test('getParentId returns the container holding a node', () => {
   const provider = DashboardProvider.getInstance();
   const rootId = provider.getRoot().id;
-  const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
-  const childId = provider.addBuildingBlock(canvasId, 0, { type: 'text' });
+  const containerId = provider.addBuildingBlock(rootId, 0, {
+    type: TEST_CONTAINER_TYPE,
+  });
+  const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
 
-  expect(provider.getParentId(childId)).toBe(canvasId);
-  expect(provider.getParentId(canvasId)).toBe(rootId);
+  expect(provider.getParentId(childId)).toBe(containerId);
+  expect(provider.getParentId(containerId)).toBe(rootId);
 });
 
 test('getParentId returns undefined for the root and for an unknown id', () => {
@@ -179,9 +199,11 @@ test('getParentId returns undefined for the root and for an unknown id', () => {
 test('moveBuildingBlock throws when moving the root node', () => {
   const provider = DashboardProvider.getInstance();
   const rootId = provider.getRoot().id;
-  const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
+  const containerId = provider.addBuildingBlock(rootId, 0, {
+    type: TEST_CONTAINER_TYPE,
+  });
 
-  expect(() => provider.moveBuildingBlock(rootId, canvasId, 0)).toThrow(
+  expect(() => provider.moveBuildingBlock(rootId, containerId, 0)).toThrow(
     /Cannot move the root node/,
   );
 });
@@ -193,21 +215,23 @@ test('moveBuildingBlock throws when the target cannot hold children', () => {
   const otherId = provider.addBuildingBlock(rootId, 1, { type: 'text' });
 
   expect(() => provider.moveBuildingBlock(otherId, leafId, 0)).toThrow(
-    /not a canvas/,
+    /not a container/,
   );
 });
 
 test('moveBuildingBlock throws when moving a node into its own subtree', () => {
   const provider = DashboardProvider.getInstance();
   const rootId = provider.getRoot().id;
-  const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
-  const childCanvasId = provider.addBuildingBlock(canvasId, 0, {
-    type: 'canvas',
+  const containerId = provider.addBuildingBlock(rootId, 0, {
+    type: TEST_CONTAINER_TYPE,
+  });
+  const childContainerId = provider.addBuildingBlock(containerId, 0, {
+    type: TEST_CONTAINER_TYPE,
   });
 
-  expect(() => provider.moveBuildingBlock(canvasId, childCanvasId, 0)).toThrow(
-    /into itself or one of its own descendants/,
-  );
+  expect(() =>
+    provider.moveBuildingBlock(containerId, childContainerId, 0),
+  ).toThrow(/into itself or one of its own descendants/);
 });
 
 test("updateLayout merges into the node's existing layout", () => {
@@ -249,7 +273,7 @@ test('updateLayout displaces an explicitly placed sibling it now collides with',
   // Growing `first` down into row 2 now overlaps `second`, which is also
   // explicitly placed — this mirrors what an AI tool call (not a mouse
   // drag) can do, since it goes through this method directly rather than
-  // through CanvasBlock/react-grid-layout's own collision handling.
+  // through RootGrid/react-grid-layout's own collision handling.
   provider.updateLayout(firstId, { rowSpan: 2 });
 
   expect(provider.getNode(firstId)?.layout).toEqual({
@@ -401,7 +425,7 @@ test('removing the selected node clears the selection', () => {
 test('removing a container clears a selection inside its subtree', () => {
   const provider = DashboardProvider.getInstance();
   const sectionId = provider.addBuildingBlock(provider.getRoot().id, 0, {
-    type: 'canvas',
+    type: TEST_CONTAINER_TYPE,
   });
   const childId = provider.addBuildingBlock(sectionId, 0, { type: 'markdown' });
   provider.setSelection(childId);
diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.ts b/superset-frontend/src/core/dashboard/DashboardProvider.ts
index 4bd44eb0384..adc2b6d3c59 100644
--- a/superset-frontend/src/core/dashboard/DashboardProvider.ts
+++ b/superset-frontend/src/core/dashboard/DashboardProvider.ts
@@ -32,26 +32,44 @@ type StoredNode = Omit<DashboardNode, 'id'>;
 const ROOT_ID = 'root';
 
 /**
- * The one node type that holds other nodes.
- *
- * Named here because two things need to agree on it and neither should learn
- * it by string comparison of its own: this provider, deciding whether a new
- * node gets a `children` array at all, and the palette, deciding whether a
- * block an author places is a container or something to put in one.
+ * The root's own type — the one container every dashboard always has,
+ * whether or not anything has registered itself as one. Named here because
+ * two things need to agree on it and neither should learn it by string
+ * comparison of its own: this provider, deciding whether a new node gets a
+ * `children` array at all, and the palette, deciding that placing this
+ * specific type is not an authored feature (it's reserved for the root).
+ * Rendered by `Grid` — see `BuildingBlockView`, which resolves the root's
+ * renderer to it directly rather than through the building-block registry.
  */
-export const CONTAINER_TYPE = 'canvas';
+export const GRID_TYPE = 'grid';
+
+/**
+ * Container types beyond the root's own — each registered by whatever adds
+ * that building block (see `registerBuiltInBuildingBlocks`), the same way a
+ * type registers its renderer. A container's own arrangement of its
+ * children is that type's business, not this provider's (see the
+ * composition/layout design doc) — this set only ever answers the one
+ * question the provider itself needs: whether a freshly added node of this
+ * type gets a `children` array to hold them in.
+ */
+const registeredContainerTypes = new Set<string>();
+
+/** Marks `type` as a container, alongside the root's own `grid`. */
+export function registerContainerType(type: string): void {
+  registeredContainerTypes.add(type);
+}
 
 /** Whether placing this type produces something other nodes can go inside. */
 export const isContainerType = (type: string): boolean =>
-  type === CONTAINER_TYPE;
+  type === GRID_TYPE || registeredContainerTypes.has(type);
 
 function createBlankNodes(): Record<string, StoredNode> {
   return {
     [ROOT_ID]: {
-      type: 'canvas',
+      type: GRID_TYPE,
       // No total height is set here, and none is needed — the root grid's
       // rows are created on demand (see resolveContainerGridStyle), so the
-      // canvas is always exactly as tall as its content.
+      // grid is always exactly as tall as its content.
       layout: { columns: DEFAULT_COLUMNS, gap: 16 },
       children: [],
     },
@@ -157,8 +175,8 @@ class DashboardProvider {
   public getNode = (id: string): DashboardNode | undefined => this.toNode(id);
 
   /**
-   * The canvas a node sits in, or `undefined` for the root and for a node
-   * that is not in the tree.
+   * The container a node sits in, or `undefined` for the root and for a
+   * node that is not in the tree.
    *
    * {@link moveBuildingBlock} takes the destination parent as an argument, so
    * every caller that moves a node already has to know which parent it is in
@@ -193,7 +211,7 @@ class DashboardProvider {
    * collide with one another (see {@link resolveExplicitCollisions}) and
    * folds the result into `nodes`. `addBuildingBlock`/`updateLayout` are the
    * two ways an extension's AI tools place a node without going through
-   * `CanvasBlock`'s interactive drag/resize at all — this gives that
+   * `RootGrid`'s interactive drag/resize at all — this gives that
    * programmatic path the same "nothing ends up stuck overlapping"
    * guarantee a mouse-driven resize gets for free from `react-grid-layout`,
    * rather than leaving it to whatever the renderer happens to paper over
@@ -231,13 +249,19 @@ class DashboardProvider {
     index: number,
     spec: BuildingBlockSpec,
   ): string {
+    if (spec.type === GRID_TYPE) {
+      throw new Error(
+        `[dashboard] Cannot add a "${GRID_TYPE}" node — it is reserved for the dashboard root, not a Building Block`,
+      );
+    }
+
     const parent = this.nodes[parentId];
     if (!parent) {
       throw new Error(`[dashboard] Unknown parent node "${parentId}"`);
     }
     if (!parent.children) {
       throw new Error(
-        `[dashboard] Node "${parentId}" cannot hold children (not a canvas)`,
+        `[dashboard] Node "${parentId}" cannot hold children (not a container)`,
       );
     }
 
@@ -303,7 +327,7 @@ class DashboardProvider {
     }
     if (!this.nodes[newParentId]?.children) {
       throw new Error(
-        `[dashboard] Node "${newParentId}" cannot hold children (not a canvas)`,
+        `[dashboard] Node "${newParentId}" cannot hold children (not a container)`,
       );
     }
     if (this.isNodeOrDescendant(id, newParentId)) {
@@ -334,7 +358,7 @@ class DashboardProvider {
     // column count) was only ever meaningful in the *old* parent's grid —
     // carrying it over verbatim into the new one is how a moved node ends
     // up silently overlapping or overflowing its new siblings. Interactive
-    // drag-based reparenting (see `CanvasBlock`'s `handleDragStop`) already
+    // drag-based reparenting (see `RootGrid`'s `handleDragStop`) already
     // resets exactly these two things on drop; this is that same reset,
     // applied here so the programmatic path gives the same guarantee.
     //
@@ -381,7 +405,7 @@ class DashboardProvider {
 
   /**
    * Merges a `layout` update into each of several nodes at once, in a single
-   * commit. A drag or resize that displaces siblings (see `CanvasBlock`)
+   * commit. A drag or resize that displaces siblings (see `RootGrid`)
    * resolves *all* of their new positions together — committing them one
    * {@link updateLayout} call at a time would tick the revision counter, and
    * so re-render every subscriber, once per displaced sibling instead of
diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/RootGrid.test.tsx
similarity index 80%
rename from superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx
rename to superset-frontend/src/core/dashboard/RootGrid.test.tsx
index d0ec4b723ef..5889e4aa24b 100644
--- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx
+++ b/superset-frontend/src/core/dashboard/RootGrid.test.tsx
@@ -17,16 +17,15 @@
  * under the License.
  */
 import { fireEvent, render, screen } from 'spec/helpers/testing-library';
-import DashboardProvider from '../DashboardProvider';
-import CanvasBlock from './CanvasBlock';
+import DashboardProvider from './DashboardProvider';
+import RootGrid from './RootGrid';
 
 /**
  * What a gesture on the grid commits.
  *
- * `react-grid-layout` is mocked down to the props CanvasBlock feeds it.
- * Everything about how it draws is its own business and covered by its own
- * tests; what matters here is that CanvasBlock always compacts and never
- * allows overlap.
+ * `react-grid-layout` is mocked down to the props RootGrid feeds it. Everything
+ * about how it draws is its own business and covered by its own tests; what
+ * matters here is that RootGrid always compacts and never allows overlap.
  */
 jest.mock('react-grid-layout/legacy', () => ({
   __esModule: true,
@@ -67,7 +66,7 @@ const mount = () => {
   const rootId = provider.getRoot().id;
   const first = provider.addBuildingBlock(rootId, 0, { type: 'markdown' });
   const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
-  render(<CanvasBlock nodeId={rootId} />);
+  render(<RootGrid nodeId={rootId} />);
   return { rootId, first, second };
 };
 
@@ -79,17 +78,17 @@ test('a grid compacts its children and does not let them overlap', () => {
   expect(grid).toHaveAttribute('data-allow-overlap', 'false');
 });
 
-test('the corner a block removes itself from is not also a resize handle', () => {
+test('a block resizes from all four corners', () => {
   mount();
 
-  // react-grid-layout appends its handles after a block's own content, so the
-  // north-east one landed on the remove control and took every click aimed at
-  // it -- `elementFromPoint` at that button's centre returned the handle. A
-  // handle nobody can grab is worse than no handle: the corner still looks
-  // resizable, and answers a drag that starts a pixel to either side.
+  // Used to exclude the north-east corner, which sat under the remove
+  // control -- react-grid-layout appends its handles after a block's own
+  // content, so a handle there took every click aimed at the button beneath
+  // it. The single card-wide inset (see `BuildingBlockView`) moved the
+  // button far enough from the true corner that both now fit.
   expect(screen.getByTestId('rgl')).toHaveAttribute(
     'data-resize-handles',
-    'se,sw,nw',
+    'se,sw,nw,ne',
   );
 });
 
@@ -108,7 +107,7 @@ const paletteTransfer = (type: string) => {
 test('dropping a palette block on a container places it there', () => {
   const { rootId } = mount();
 
-  fireEvent.drop(screen.getByTestId('canvas-container'), {
+  fireEvent.drop(screen.getByTestId('grid-container'), {
     dataTransfer: paletteTransfer('markdown'),
   });
 
@@ -121,7 +120,7 @@ test('a drop carrying something else is not read as a block', () => {
   const { rootId } = mount();
   const before = provider.getNode(rootId)?.children?.length;
 
-  fireEvent.drop(screen.getByTestId('canvas-container'), {
+  fireEvent.drop(screen.getByTestId('grid-container'), {
     dataTransfer: {
       types: ['text/plain'],
       getData: () => '',
@@ -176,6 +175,19 @@ test('the grid is told not to start a drag from the remove control', () => {
   ).not.toBeNull();
 });
 
+test('the grid is also told not to start a drag from a nested block resize handle', () => {
+  mount();
+
+  // The same guard as the remove control, for the same reason — a `tabs`
+  // block sitting on this grid renders its own resize handle (see
+  // `TabsBlock`'s `FlowItem`) for a block flowed into one of its panes, and
+  // without this, resizing that block also drags the `tabs` item holding it.
+  const cancel = screen
+    .getByTestId('rgl')
+    .getAttribute('data-draggable-cancel');
+  expect(cancel).toContain('[data-block-resize]');
+});
+
 test('clicking the remove control removes rather than selects', () => {
   const { first, second } = mount();
 
diff --git a/superset-frontend/src/core/dashboard/RootGrid.tsx b/superset-frontend/src/core/dashboard/RootGrid.tsx
new file mode 100644
index 00000000000..f616626dd83
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/RootGrid.tsx
@@ -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.
+ */
+import { useCallback } from 'react';
+import type { DragEvent as ReactDragEvent } from 'react';
+// The v1-compatible flat-props API (this component's own model: a plain
+// `cols`/`rowHeight`/`margin` grid, no responsive breakpoints — see the
+// design doc's "Explicitly deferred" section) lives at this subpath in v2;
+// the package's main entry exports only the newer composable API.
+import GridLayoutLegacy, { WidthProvider } from 'react-grid-layout/legacy';
+import type { Layout, LayoutItem } from 'react-grid-layout/legacy';
+import 'react-grid-layout/css/styles.css';
+import type { dashboard as dashboardApi } from '@apache-superset/core';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
+import { provider, useDashboardRevision } from './store';
+import { resolveGridMetrics } from './layoutStyle';
+import { availableDropSpan, packChildLayout } from './gridPacking';
+import {
+  FALLBACK_ROW_SPAN,
+  PALETTE_MIME,
+  placeBlock,
+  placeBlockAt,
+} from './placement';
+import BuildingBlockView from './BuildingBlockView';
+
+type LayoutProps = dashboardApi.LayoutProps;
+
+const ResizableGridLayout = WidthProvider(GridLayoutLegacy);
+
+/** `react-grid-layout`'s own item id for the live drop preview — see `handleDropDragOver`. Distinct from `DashboardProvider`'s own `node_<n>` ids by construction, so it can never collide with a real node while a drag is in progress. */
+const DROPPING_ITEM_ID = '__dropping-block__';
+
+/**
+ * Where a dragged or (via `isDroppable`, below) freshly dropped block is
+ * about to land — the one on-screen answer to "where, exactly" for a
+ * gesture that otherwise only shows the cursor.
+ *
+ * `react-grid-layout/css/styles.css` (imported above) styles its
+ * `.react-grid-placeholder` red at 0.2 opacity by default — a rectangle this
+ * restyles in the app's own primary colour instead, rather than editing the
+ * package's own stylesheet (not this codebase's to change, and gone on the
+ * next install). A descendant selector under this element's own generated
+ * class outweighs the plain two-class rule it is overriding, so this wins
+ * without needing `!important`.
+ *
+ * `react-grid-layout` renders the identical placeholder for a *resize* too,
+ * carrying its own extra `placeholder-resizing` class — resizing an
+ * existing block already shows that block's own edges moving live under the
+ * pointer, so a second box drawn on top of it would only repeat what is
+ * already on screen. The extra class is what tells the two apart; the base
+ * rule is left for the drag/drop case this was actually added for.
+ */
+const GridSurface = styled.div`
+  ${({ theme }) => css`
+    width: 100%;
+    height: 100%;
+    overflow-x: hidden;
+    overflow-y: auto;
+
+    .react-grid-item.react-grid-placeholder {
+      background-color: ${theme.colorPrimaryBg};
+      border: 2px dashed ${theme.colorPrimary};
+      border-radius: ${theme.borderRadiusLG}px;
+      opacity: 1;
+
+      &.placeholder-resizing {
+        background: transparent;
+        border: none;
+        opacity: 0;
+      }
+    }
+  `}
+`;
+
+// DEBUG INSTRUMENTATION — kept in place on purpose while the drag/resize
+// interaction is still being worked out; remove only when explicitly asked
+// to. Logs which node id/type a gesture actually started on (so it's
+// obvious, e.g., when a click lands on a nested container instead of the
+// chart the user meant to grab) and what RootGrid ends up committing at the end
+// of it.
+const DASHBOARD_DRAG_DEBUG = true;
+function debugLog(event: string, detail: Record<string, unknown>) {
+  if (!DASHBOARD_DRAG_DEBUG) return;
+  // eslint-disable-next-line no-console
+  console.debug(`[dashboard-drag] ${event}`, detail);
+}
+function describeNode(id: string | undefined) {
+  if (!id) return undefined;
+  const node = provider.getNode(id);
+  return { id, type: node?.type };
+}
+
+/**
+ * Finds the deepest dashboard container actually under a screen point,
+ * ignoring `excludeEl`'s own subtree — the block being dragged might itself
+ * be, or contain, a container, and a drop "onto itself" isn't a valid
+ * reparent target. Every container's outer element carries
+ * `data-container-id` (this component's own does; others set it on whatever
+ * DOM they render a drop target from), so this is the one piece of
+ * cross-container awareness a drag needs: which container the pointer is
+ * over right now, at any nesting depth, without any container needing to
+ * know about any other container's existence.
+ *
+ * `excludeEl`'s `pointer-events` is toggled off for the single synchronous
+ * `elementFromPoint` call so the hit-test sees through the dragged element
+ * to whatever is actually underneath it on screen (otherwise the dragged
+ * element — positioned directly under the cursor by definition — would
+ * always be its own top hit).
+ */
+function findContainerIdAt(
+  clientX: number,
+  clientY: number,
+  excludeEl: HTMLElement,
+): string | null {
+  const previousPointerEvents = excludeEl.style.pointerEvents;
+  excludeEl.style.pointerEvents = 'none';
+  const hit = document.elementFromPoint(clientX, clientY);
+  excludeEl.style.pointerEvents = previousPointerEvents;
+  return (
+    hit?.closest<HTMLElement>('[data-container-id]')?.dataset.containerId ??
+    null
+  );
+}
+
+/**
+ * The dashboard's own grid — not a Building Block (see the composition/
+ * layout design doc), which is why it lives here rather than in `blocks/`
+ * alongside the things that get placed on it. There is exactly one of these
+ * per dashboard, rendered for the root and only the root: `BuildingBlockView`
+ * resolves the root's renderer to this component directly, rather than
+ * through the `dashboard.buildingBlocks` registry every real building block
+ * goes through — nothing places a `RootGrid`, and nothing ever will, the same
+ * way nothing places the dashboard itself.
+ *
+ * Backed by `react-grid-layout`. All position/size math — including
+ * collision handling — is `react-grid-layout`'s: resizing a block never
+ * shrinks a sibling, only displaces it to the next open slot (clamped to
+ * `minW`/`minH: 1`), which leaves every block's own authored (hand- or
+ * AI-set) span untouched. This component's job is translating between the
+ * stored `col`/`row`/`colSpan`/`rowSpan` schema (which allows a child to
+ * omit its position entirely, to be auto-placed) and `react-grid-layout`'s
+ * own `{x, y, w, h}` — see `packChildLayout` for the auto-placement piece RGL
+ * has no concept of — and committing back to the store only once a gesture
+ * ends (`onDragStop`/`onResizeStop`), never on every intermediate frame.
+ *
+ * Dragging a block into a *different* container (reparenting, as opposed to
+ * repositioning within this one) is handled by hit-testing which
+ * `data-container-id` is under the pointer when the drag ends — deliberately
+ * not something `react-grid-layout` (scoped to one grid instance) handles on
+ * its own.
+ */
+export default function RootGrid({ nodeId }: { nodeId: string }) {
+  useDashboardRevision();
+  const theme = useTheme();
+  const node = provider.getNode(nodeId);
+
+  const commitLayout = useCallback(
+    (rglLayout: Layout) => {
+      const updates: Record<string, Partial<LayoutProps>> = {};
+      rglLayout.forEach(item => {
+        updates[item.i] = {
+          col: item.x + 1,
+          row: item.y + 1,
+          colSpan: item.w,
+          rowSpan: item.h,
+        };
+      });
+      debugLog('commitLayout', { containerId: nodeId, updates });
+      provider.updateLayouts(updates);
+    },
+    [nodeId],
+  );
+
+  const handleDragStart = useCallback(
+    (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
+      debugLog('dragStart', {
+        containerId: nodeId,
+        dragged: describeNode(newItem?.i),
+      });
+    },
+    [nodeId],
+  );
+
+  const handleResizeStart = useCallback(
+    (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
+      debugLog('resizeStart', {
+        containerId: nodeId,
+        resized: describeNode(newItem?.i),
+      });
+    },
+    [nodeId],
+  );
+
+  const handleResizeStop = useCallback(
+    (rglLayout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
+      debugLog('resizeStop', {
+        containerId: nodeId,
+        resized: describeNode(newItem?.i),
+      });
+      commitLayout(rglLayout);
+    },
+    [nodeId, commitLayout],
+  );
+
+  const handleDragStop = useCallback(
+    (
+      rglLayout: Layout,
+      _oldItem: unknown,
+      newItem: { i: string } | null,
+      _placeholder: unknown,
+      event: Event,
+      element: HTMLElement | null,
+    ) => {
+      const { clientX, clientY } = event as MouseEvent;
+      const targetContainerId =
+        newItem && element && clientX != null && clientY != null
+          ? findContainerIdAt(clientX, clientY, element)
+          : null;
+
+      debugLog('dragStop', {
+        containerId: nodeId,
+        dragged: describeNode(newItem?.i),
+        targetContainerId,
+        reparenting: !!targetContainerId && targetContainerId !== nodeId,
+      });
+
+      if (targetContainerId && targetContainerId !== nodeId && newItem) {
+        try {
+          // moveBuildingBlock itself clears col/row and clamps colSpan to
+          // the destination's own column count — old coordinates were only
+          // ever meaningful in *this* container's grid.
+          const destIndex =
+            provider.getNode(targetContainerId)?.children?.length ?? 0;
+          provider.moveBuildingBlock(newItem.i, targetContainerId, destIndex);
+          return;
+        } catch {
+          // Dropped onto itself or one of its own descendants — not a valid
+          // reparent target. Fall through and keep it in this container.
+        }
+      }
+
+      commitLayout(rglLayout);
+    },
+    [nodeId, commitLayout],
+  );
+
+  if (!node) return null;
+
+  const { columns, gap, rowUnitPx } = resolveGridMetrics(node.layout, theme);
+  const children = node.children ?? [];
+
+  const packed = packChildLayout(children, columns, provider.getNode);
+  const layout: Layout = children.map(id => ({
+    i: id,
+    minW: 1,
+    minH: 1,
+    ...packed[id],
+  }));
+
+  /**
+   * How wide a block being dragged in from the palette should preview at,
+   * for wherever the pointer currently is — react-grid-layout's own
+   * `isDroppable` calls this on every dragover and merges the `w`/`h` it
+   * returns into the live placeholder, so this is the one place that decides
+   * "how much room is actually here" rather than the block always claiming
+   * a fixed span regardless of what it is being dropped next to.
+   *
+   * Not a palette drag at all (a file from the desktop, dragged text, …) is
+   * `false`: nothing else on the grid should react to it, and returning
+   * anything else would show a preview for a drop this grid never accepts
+   * in the first place.
+   *
+   * Hovering over a *nested* container (a `tabs`/`collapsible`/`carousel`
+   * block sitting on this grid, or anything a third party contributes) is
+   * `false` for the identical reason: that container has its own drop
+   * target (`FlowContent`/`EmptyArea`, tagged `data-container-id`, the same
+   * way this component's own `GridSurface` is) and is where the block
+   * actually belongs, not beside or on top of the container's own card on
+   * *this* grid. Without this, both react to the same hover — this preview
+   * showing a push-down where the container sits, the container's own
+   * unaware that anything is above it — and only one of them ever runs its
+   * cleanup on drop (`FlowContent`'s own `onDrop` stops the event there),
+   * leaving this one's placeholder on screen with nothing left to clear it.
+   *
+   * The width and height themselves are `availableDropSpan`'s own call (see
+   * `gridPacking.ts`) — this only has to translate the cursor's pixel
+   * position into the grid cell underneath it, the same conversion
+   * `react-grid-layout` itself does internally to place every other item. A
+   * gap between two existing blocks that is already tall enough on its own
+   * is exactly where that matters: without shrinking to fit it, the preview
+   * would keep asking for its own full, unshrunk height regardless of how
+   * little of it the gap actually has room for, and collide its way into
+   * shoving the block below down to clear space that was never short to
+   * begin with.
+   *
+   * `dragOffsetX`/`dragOffsetY` correct for something react-grid-layout does
+   * on its own that reads wrong for this gesture: left uncorrected, it
+   * centers the preview *on* the cursor, so a block six rows tall starts
+   * three rows above wherever the pointer actually is. Dropped "just below"
+   * a row of blocks, that is enough to land back on top of them instead —
+   * exactly what this offsets away, by feeding back half of whatever pixel
+   * size the preview is about to be (`react-grid-layout`'s own
+   * `calcGridItemWHPx` math, reproduced here since nothing shorter reaches
+   * it) so the centering it does and the anchoring this undoes cancel out,
+   * and the preview's top-left corner ends up *at* the cursor instead.
+   */
+  const handleDropDragOver = (
+    event: ReactDragEvent<HTMLDivElement>,
+  ):
+    | {
+        w?: number;
+        h?: number;
+        dragOffsetX?: number;
+        dragOffsetY?: number;
+      }
+    | false => {
+    if (!event.dataTransfer.types.includes(PALETTE_MIME)) {
+      return false;
+    }
+
+    const hoveredContainerId = (
+      event.target as HTMLElement
+    ).closest<HTMLElement>('[data-container-id]')?.dataset.containerId;
+    if (hoveredContainerId && hoveredContainerId !== nodeId) {
+      return false;
+    }
+
+    const gridRect = event.currentTarget.getBoundingClientRect();
+    const colWidth = (gridRect.width - gap * (columns - 1)) / columns;
+    const colStride = colWidth + gap;
+    const rowStride = rowUnitPx + gap;
+    const cursorCol = Math.min(
+      columns - 1,
+      Math.max(0, Math.floor((event.clientX - gridRect.left) / colStride)),
+    );
+    const cursorRow = Math.max(
+      0,
+      Math.floor((event.clientY - gridRect.top) / rowStride),
+    );
+
+    const { w, h } = availableDropSpan(
+      packed,
+      columns,
+      cursorCol,
+      cursorRow,
+      FALLBACK_ROW_SPAN,
+    );
+    const pixelWidth = Math.round(colWidth * w + Math.max(0, w - 1) * gap);
+    const pixelHeight = Math.round(rowUnitPx * h + Math.max(0, h - 1) * gap);
+
+    return {
+      w,
+      h,
+      dragOffsetX: pixelWidth / 2,
+      dragOffsetY: pixelHeight / 2,
+    };
+  };
+
+  /**
+   * Where a palette drag actually lands, once released — the counterpart to
+   * `GridSurface`'s own plain `onDrop` (below, for a drop that missed the
+   * grid entirely, into the leftover space past its last row): this one
+   * fires when react-grid-layout's own `isDroppable` resolved a real x/y/w/h
+   * for the drop, live, during the drag itself (see `handleDropDragOver`),
+   * so there is no position left to compute here — only a type to read off
+   * the browser's own drag data, and where among the existing children this
+   * new one's own row/col actually falls (see `placeBlockAt`'s own doc
+   * comment for why that has to be figured out here rather than left to
+   * default to "at the end").
+   */
+  const handleExternalDrop = (
+    _rglLayout: Layout,
+    item: LayoutItem | undefined,
+    event: Event,
+  ) => {
+    const type = (event as DragEvent).dataTransfer?.getData(PALETTE_MIME);
+    if (!type || !item) return;
+
+    const readingOrderIndex = children.findIndex(id => {
+      const rect = packed[id];
+      return rect.y > item.y || (rect.y === item.y && rect.x >= item.x);
+    });
+    const index =
+      readingOrderIndex === -1 ? children.length : readingOrderIndex;
+
+    debugLog('dropOnGrid', { containerId: nodeId, type, item, index });
+    placeBlockAt(nodeId, type, index, {
+      col: item.x + 1,
+      row: item.y + 1,
+      colSpan: item.w,
+      rowSpan: item.h,
+    });
+  };
+
+  return (
+    <GridSurface
+      data-container-id={nodeId}
+      data-test="grid-container"
+      // The grid itself (`ResizableGridLayout`, below) now answers a drop
+      // anywhere over its own rendered rows via `isDroppable` — this pair
+      // only still fires for the leftover space past its last row (the
+      // grid is exactly as tall as its content, and this element is not),
+      // where appending full-width at the end is still the right answer,
+      // the same one it always was.
+      //
+      // Every container is a drop target, not just the root: a nested
+      // section is exactly where an author means to put something when they
+      // drag it there, and the stop (in `onDrop`, below) is what makes the
+      // innermost container under the pointer the one that takes it rather
+      // than every ancestor claiming the same drop.
+      onDragOver={event => {
+        if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+          event.preventDefault();
+          event.dataTransfer.dropEffect = 'copy';
+        }
+      }}
+      onDrop={event => {
+        const type = event.dataTransfer.getData(PALETTE_MIME);
+        if (type !== '') {
+          event.preventDefault();
+          event.stopPropagation();
+          placeBlock(nodeId, type);
+        }
+      }}
+    >
+      <ResizableGridLayout
+        layout={layout}
+        cols={columns}
+        // Left to its own `autoSize`, this element is only ever as tall as
+        // its own rows — meaning dropping "below" sparse content means
+        // finding the few remaining pixels this element actually renders
+        // across, past which there's no element left to fire a dragover on
+        // at all (`GridSurface`'s own plain fallback, below, takes over
+        // there instead — no live preview, an append at the end). A CSS
+        // floor is deliberately as far as this goes: it changes nothing
+        // about `layout` or what `isDroppable` tracks internally, only how
+        // tall the element hovered over already is.
+        //
+        // A live drop preview can still push the *content* height briefly
+        // past what this floor alone guarantees (the synthetic dropping
+        // item can land below everything currently on screen), and this
+        // element's own width is measured (by `WidthProvider`) on a
+        // `ResizeObserver` callback, not synchronously — so for a frame or
+        // two after that height change forces `GridSurface` to grow a
+        // vertical scrollbar, this element can still think it has the width
+        // from before that scrollbar took some of it back, and render
+        // wider than the space actually left. `GridSurface`'s own
+        // `overflow-x: hidden` (below) is what keeps that transient
+        // mismatch from ever showing up as a horizontal scrollbar rather
+        // than trying to keep the two measurements in perfect lockstep.
+        style={{ minHeight: '100%' }}
+        rowHeight={rowUnitPx}
+        margin={[gap, gap] as const}
+        containerPadding={[0, 0] as const}
+        // Explicit rather than relying on the (identical) default, since the
+        // obvious-looking alternative doesn't work: `compactType={null}`
+        // (no compaction) looks like it should mean "displace only the
+        // sibling actually being collided with," but react-grid-layout's
+        // own collision-resolution branch for `compactType === null`
+        // (`moveElementAwayFromCollision`'s "collisionNorth" case) re-adds
+        // a colliding sibling's full height on *every* drag frame the
+        // collision persists rather than settling once it's clear —
+        // confirmed live: dragging one tile so it merely brushes a
+        // neighbor's edge sent that neighbor hundreds of pixels away within
+        // a few mouse-move events. `"vertical"` resolves the same collision
+        // by moving the sibling down exactly once, by exactly its own
+        // height, every time.
+        compactType="vertical"
+        allowOverlap={false}
+        preventCollision={false}
+        // A palette drag is react-grid-layout's own concern from here on,
+        // not `GridSurface`'s: `isDroppable` is what makes it track the drag
+        // live and shows the preview `GridSurface`'s own CSS now styles
+        // (see that component's doc comment), with `handleDropDragOver`
+        // answering "how wide, here" on every frame and `handleExternalDrop`
+        // reading the result back out once the drag actually ends.
+        // `droppingItem` is only the shape's starting point — its `i` is
+        // never a real node, and its `w`/`h` are overwritten by
+        // `handleDropDragOver` before the preview is ever drawn — but
+        // react-grid-layout still wants one up front to size the very first
+        // frame against.
+        isDroppable
+        droppingItem={{
+          i: DROPPING_ITEM_ID,
+          x: 0,
+          y: 0,
+          w: columns,
+          h: FALLBACK_ROW_SPAN,
+        }}
+        onDropDragOver={handleDropDragOver}
+        onDrop={handleExternalDrop}
+        // All four corners. The top-right one used to sit under the remove
+        // control — react-grid-layout appends its handles after the block's
+        // own content, so a 20px handle sat over that button and took every
+        // click aimed at it. The single card-wide inset (see
+        // `BuildingBlockView`) moved the button further from the true
+        // corner than it sat before, which is what makes room for both here.
+        resizeHandles={['se', 'sw', 'nw', 'ne']}
+        // This is the only grid a dashboard has (see this component's own
+        // doc comment) — a nested container never renders another `RootGrid` —
+        // but a nested container (e.g. a `tabs` block) still renders its own
+        // `[data-container-id]` drop target, and a click anywhere inside one
+        // of those (on a leaf grandchild, or on the empty space between them)
+        // must never start dragging it as a single item here, or "drag one
+        // chart out of a tab" and "the whole tabs block comes with it"
+        // become indistinguishable at the DOM level. A leaf child
+        // (chart/markdown) never renders `data-container-id` itself, so this
+        // never affects dragging a leaf.
+        //
+        // `[data-block-resize]` is the same guard for a different gesture —
+        // `TabsBlock`'s own resize handle on a block flowed into a pane
+        // (see `FlowItem`). Without it, resizing that block and dragging the
+        // `tabs` item that holds it are indistinguishable for the same
+        // reason: both start with a pointer-down somewhere inside this grid
+        // item, and this component has no way to tell them apart except by
+        // where, exactly, that press landed.
+        //
+        // `[data-block-header-control]` is the same guard again for a
+        // type's own extra header control (see `blockHeaderControl`) — a
+        // press on `collapsible`'s expand/collapse toggle must toggle it,
+        // not start dragging the block it sits on.
+        draggableCancel="[data-container-id],[data-block-remove],[data-block-resize],[data-block-header-control]"
+        onDragStart={handleDragStart}
+        onDragStop={handleDragStop}
+        onResizeStart={handleResizeStart}
+        onResizeStop={handleResizeStop}
+      >
+        {children.map(childId => (
+          <BuildingBlockView key={childId} nodeId={childId} />
+        ))}
+      </ResizableGridLayout>
+    </GridSurface>
+  );
+}
diff --git a/superset-frontend/src/core/dashboard/blockHeaderControl.tsx b/superset-frontend/src/core/dashboard/blockHeaderControl.tsx
new file mode 100644
index 00000000000..7f2a5a83b1a
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blockHeaderControl.tsx
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+
+/**
+ * @fileoverview A second control in a block's own header, beside the remove
+ * button — the header-side counterpart to `blockLabel`. Most block types
+ * have nothing to put there and get nothing rendered. `collapsible` needs an
+ * expand/collapse toggle next to its remove control rather than a second bar
+ * of its own further down the card (see `CollapsibleBlock`); `carousel`
+ * needs a way to add a slide that isn't the dot strip itself, since the dot
+ * strip is meant to read as a plain position indicator rather than a row of
+ * controls (see `CarouselBlock`).
+ */
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { ActionButton } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { provider } from './store';
+import { SLIDE_TYPE, untitledSlideLabel } from './blocks/CarouselBlock';
+
+/**
+ * How tall a collapsed block stays — just enough for `BuildingBlockView`'s
+ * own header, plus a little room around it, rather than the bare minimum
+ * (`1`) either unit accepts: at exactly the header's own height a collapsed
+ * block reads as clipped rather than deliberately shut. Read in
+ * `layout.rowSpan`'s own unit, whatever this node's container happens to
+ * interpret that as (a grid row on the root's own grid, a pixel inside a
+ * flow area — see the composition/layout design doc).
+ */
+const COLLAPSED_ROW_SPAN = 2;
+
+/**
+ * The height restored on expanding, when nothing narrower was ever
+ * authored to begin with — the same default a freshly placed container
+ * arrives with (see `placeBlock`), so expanding a block nobody has resized
+ * yet returns it to exactly the size it was placed at.
+ */
+const DEFAULT_EXPANDED_ROW_SPAN = 4;
+
+function CollapsibleToggle({ nodeId }: { nodeId: string }): ReactElement {
+  const node = provider.getNode(nodeId);
+  const collapsed = Boolean(node?.props?.collapsed);
+
+  const toggle = (): void => {
+    const current = provider.getNode(nodeId);
+    if (!current) return;
+    if (collapsed) {
+      const restored =
+        (current.props?.expandedRowSpan as number | undefined) ??
+        DEFAULT_EXPANDED_ROW_SPAN;
+      provider.updateLayout(nodeId, { rowSpan: restored });
+      provider.updateProps(nodeId, { collapsed: false });
+    } else {
+      // The height about to be given up is saved so expanding again
+      // returns to it rather than always to the default — an author who
+      // grew a collapsible before collapsing it should not find it back at
+      // its original size on the way out.
+      provider.updateProps(nodeId, {
+        collapsed: true,
+        expandedRowSpan: current.layout?.rowSpan ?? DEFAULT_EXPANDED_ROW_SPAN,
+      });
+      provider.updateLayout(nodeId, { rowSpan: COLLAPSED_ROW_SPAN });
+    }
+  };
+
+  return (
+    <ActionButton
+      label={collapsed ? t('Expand block') : t('Collapse block')}
+      tooltip={collapsed ? t('Expand') : t('Collapse')}
+      placement="bottom"
+      dataTest={`block-collapse-toggle-${nodeId}`}
+      onClick={toggle}
+      icon={
+        collapsed ? (
+          <Icons.CaretRightOutlined iconSize="s" />
+        ) : (
+          <Icons.CaretDownOutlined iconSize="s" />
+        )
+      }
+    />
+  );
+}
+
+/**
+ * Appends a new slide and selects nothing itself — `CarouselBlock` notices
+ * the growth on its own next render and switches to it (see its own
+ * comment). This component can't do that switching directly: it renders as
+ * `CarouselBlock`'s sibling in `BuildingBlockView`'s header, not as
+ * anything that could hold or reach the active-slide state living inside
+ * `CarouselBlock`.
+ */
+function CarouselAddSlide({ nodeId }: { nodeId: string }): ReactElement {
+  const addSlide = (): void => {
+    const index = provider.getNode(nodeId)?.children?.length ?? 0;
+    provider.addBuildingBlock(nodeId, index, {
+      type: SLIDE_TYPE,
+      props: { label: untitledSlideLabel(index) },
+    });
+  };
+
+  return (
+    <ActionButton
+      label={t('Add slide')}
+      tooltip={t('Add slide')}
+      placement="bottom"
+      dataTest={`carousel-add-${nodeId}`}
+      onClick={addSlide}
+      icon={<Icons.PlusOutlined iconSize="s" />}
+    />
+  );
+}
+
+const HEADER_CONTROLS: Record<string, (nodeId: string) => ReactElement> = {
+  collapsible: nodeId => <CollapsibleToggle nodeId={nodeId} />,
+  carousel: nodeId => <CarouselAddSlide nodeId={nodeId} />,
+};
+
+/**
+ * A second control for the block of `type` to show in its own header,
+ * beside the remove button — or `null` for every type that has nothing to
+ * put there, which is nearly all of them.
+ */
+export function blockHeaderControl(
+  type: string,
+  nodeId: string,
+): ReactElement | null {
+  return HEADER_CONTROLS[type]?.(nodeId) ?? null;
+}
diff --git a/superset-frontend/src/core/dashboard/blockLabel.test.ts b/superset-frontend/src/core/dashboard/blockLabel.test.ts
index 2b7b74f8e38..63c78ba8824 100644
--- a/superset-frontend/src/core/dashboard/blockLabel.test.ts
+++ b/superset-frontend/src/core/dashboard/blockLabel.test.ts
@@ -47,10 +47,25 @@ test('a metric tile is named by the label it displays', () => {
   );
 });
 
-test('markdown is named by its opening words', () => {
+test('a tab pane is named by its own label', () => {
+  expect(blockLabel('tab', { label: 'Overview' })).toBe('Overview');
+});
+
+test('a tabs block with no panes yet falls back to its registered name', () => {
+  expect(blockLabel('tabs', {})).toBe('Tabs');
+});
+
+test('markdown goes unnamed — its rendered body is already its name', () => {
+  // Unlike a chart's title or a tile's label, markdown's `content` is the
+  // whole of what the block renders rather than a field carved out of it,
+  // and its registered name ("Markdown") says only what it is, not which
+  // one — worth nothing sitting right above the content itself. Both would
+  // repeat what a reader is already looking at, so this returns '' rather
+  // than falling back to either.
   expect(
     blockLabel('markdown', { content: '# Acme Corp\n\nGenerated November' }),
-  ).toBe('# Acme Corp Generated November');
+  ).toBe('');
+  expect(blockLabel('markdown', {})).toBe('');
 });
 
 test('a block with no name of its own is named by what it is', () => {
@@ -64,7 +79,6 @@ test('a name of nothing but spaces is no name', () => {
   expect(
     blockLabel('echarts', { echartsOptions: { title: { text: '  ' } } }),
   ).toBe('ECharts');
-  expect(blockLabel('markdown', { content: '\n\n' })).toBe('Markdown');
 });
 
 test('a type nothing registered still says something', () => {
diff --git a/superset-frontend/src/core/dashboard/blockLabel.ts b/superset-frontend/src/core/dashboard/blockLabel.ts
index 1eea5ea1adf..9faa0727980 100644
--- a/superset-frontend/src/core/dashboard/blockLabel.ts
+++ b/superset-frontend/src/core/dashboard/blockLabel.ts
@@ -46,32 +46,61 @@ const echartsTitle = (props: Props): unknown => {
 };
 
 /**
- * Where a block type carries a name of its own.
+ * Where a block type carries a name of its own, distinct from what it
+ * renders.
  *
- * Only the types that have one: everything else is named by its registration,
- * and an unlisted type — an extension's — falls through to that without
- * needing to be known here.
+ * `markdown` is deliberately not here. A chart's title or a tile's label is
+ * a field the block reads once and renders once — naming the block by it
+ * and having `ChartBlock` skip drawing its own copy (see `ChartBlock`'s own
+ * comment) is what keeps it appearing exactly once, in the header, rather
+ * than twice. Markdown's `content` is not that: it is the whole of what the
+ * block renders, not a field carved out of it, so echoing it into the
+ * header would print the same words a second time right above the ones the
+ * author actually wrote — most visibly when that content is nothing but a
+ * heading, where the two would read as identical. Everything else here is
+ * named by its registration.
  */
 const NAMED_BY: Record<string, (props: Props) => unknown> = {
-  markdown: props => props?.content,
   echarts: echartsTitle,
   'metric-tile': props => props?.label,
+  tab: props => props?.label,
+  collapsible: props => props?.label,
+  slide: props => props?.label,
 };
 
 /**
- * What to call the block of `type` holding `props`.
+ * Types that go unnamed rather than falling back to their registered name.
+ *
+ * Every other type says something a reader cannot already see just by
+ * looking at the block — "Table" for a grid with no title of its own,
+ * "ECharts" for a chart nobody has titled yet. A markdown block has no such
+ * gap to fill: its entire rendered body sits right below the header, so
+ * "Markdown" would be one more label repeating what the reader is already
+ * looking at, rather than standing in for something otherwise missing.
+ * `carousel` is here for a different reason: it is meant to read as just a
+ * slide's own content and the dots beside it, not as a slide sitting inside
+ * a captioned card — the same idea `CarouselBlock`'s own missing title bar
+ * carries further.
+ */
+const UNNAMED: ReadonlySet<string> = new Set(['markdown', 'carousel']);
+
+/**
+ * What to call the block of `type` holding `props`, or `''` for one that
+ * goes unnamed (see `UNNAMED`) — callers skip the header's name entirely
+ * for those rather than rendering an empty label.
  *
  * A name the block's own content carries wins, because that is the name its
  * author gave it and the one they will look for. Only when there is none does
- * this fall back to the registered block name — "Markdown", "Table" — which
- * says what a block is rather than which one it is, and is worth nothing at
- * all when five of them sit in a column.
+ * this fall back to the registered block name — "Table" — which says what a
+ * block is rather than which one it is, and is worth nothing at all when
+ * five of them sit in a column.
  *
  * Returned whole: how much of a long name fits is the caller's business,
  * since a row in a panel and a header on a wide chart cut at different
  * points.
  */
 export function blockLabel(type: string, props: Props): string {
+  if (UNNAMED.has(type)) return '';
   const own = NAMED_BY[type]?.(props);
   if (typeof own === 'string' && own.trim() !== '') {
     return own.trim().replace(/\s+/g, ' ');
diff --git a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx b/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
index 9b1b6f8d039..89a489be903 100644
--- a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
@@ -18,7 +18,6 @@
  */
 import { useEffect, useState } from 'react';
 import type { dashboard as dashboardApi } from '@apache-superset/core';
-import { useTheme } from '@apache-superset/core/theme';
 import {
   Flex,
   Loading,
@@ -54,7 +53,6 @@ function deriveColumnDefs(columns: string[]): ColDef[] {
  */
 export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
   useDashboardRevision();
-  const theme = useTheme();
   const [rows, setRows] = useState<DataRow[] | null>(null);
   const [columns, setColumns] = useState<string[] | null>(null);
   const [error, setError] = useState<string | null>(null);
@@ -115,7 +113,7 @@ export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
         <Flex
           align="center"
           justify="center"
-          style={{ width: '100%', height: '100%', padding: theme.padding }}
+          style={{ width: '100%', height: '100%' }}
         >
           <Typography.Text type="danger">{error}</Typography.Text>
         </Flex>
diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx
deleted file mode 100644
index 79486bf01dc..00000000000
--- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-/**
- * 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 } from 'react';
-// The v1-compatible flat-props API (this component's own model: a plain
-// `cols`/`rowHeight`/`margin` grid, no responsive breakpoints — see the
-// design doc's "Explicitly deferred" section) lives at this subpath in v2;
-// the package's main entry exports only the newer composable API.
-import GridLayoutLegacy, { WidthProvider } from 'react-grid-layout/legacy';
-import type { Layout } from 'react-grid-layout/legacy';
-import 'react-grid-layout/css/styles.css';
-import type { dashboard as dashboardApi } from '@apache-superset/core';
-import { useTheme } from '@apache-superset/core/theme';
-import { provider, useDashboardRevision } from '../store';
-import { resolveGridMetrics } from '../layoutStyle';
-import { packChildLayout } from '../gridPacking';
-import { PALETTE_MIME, placeBlock } from '../placement';
-import BuildingBlockView from '../BuildingBlockView';
-
-type LayoutProps = dashboardApi.LayoutProps;
-
-const ResizableGridLayout = WidthProvider(GridLayoutLegacy);
-
-// DEBUG INSTRUMENTATION — kept in place on purpose while the drag/resize
-// interaction is still being worked out; remove only when explicitly asked
-// to. Logs which node id/type a gesture actually started on (so it's
-// obvious, e.g., when a click lands on a `canvas` row container instead of
-// the chart the user meant to grab) and what CanvasBlock ends up committing
-// at the end of it.
-const DASHBOARD_DRAG_DEBUG = true;
-function debugLog(event: string, detail: Record<string, unknown>) {
-  if (!DASHBOARD_DRAG_DEBUG) return;
-  // eslint-disable-next-line no-console
-  console.debug(`[dashboard-drag] ${event}`, detail);
-}
-function describeNode(id: string | undefined) {
-  if (!id) return undefined;
-  const node = provider.getNode(id);
-  return { id, type: node?.type };
-}
-
-/**
- * Finds the deepest dashboard container actually under a screen point,
- * ignoring `excludeEl`'s own subtree — the block being dragged might itself
- * be, or contain, a canvas, and a drop "onto itself" isn't a valid reparent
- * target. Every canvas's outer element carries `data-container-id`, so this
- * is the one piece of cross-container awareness a drag needs: which
- * container the pointer is over right now, at any nesting depth, without
- * any container needing to know about any other container's existence.
- *
- * `excludeEl`'s `pointer-events` is toggled off for the single synchronous
- * `elementFromPoint` call so the hit-test sees through the dragged element
- * to whatever is actually underneath it on screen (otherwise the dragged
- * element — positioned directly under the cursor by definition — would
- * always be its own top hit).
- */
-function findContainerIdAt(
-  clientX: number,
-  clientY: number,
-  excludeEl: HTMLElement,
-): string | null {
-  const previousPointerEvents = excludeEl.style.pointerEvents;
-  excludeEl.style.pointerEvents = 'none';
-  const hit = document.elementFromPoint(clientX, clientY);
-  excludeEl.style.pointerEvents = previousPointerEvents;
-  return (
-    hit?.closest<HTMLElement>('[data-container-id]')?.dataset.containerId ??
-    null
-  );
-}
-
-/**
- * The built-in `canvas` building block: a grid container for child nodes,
- * backed by `react-grid-layout` — same recursive resolution its children
- * use (see `BuildingBlockView`), so nesting works identically whether a
- * child is another canvas, a leaf built-in block, or an extension's.
- *
- * All position/size math — including collision handling — is
- * `react-grid-layout`'s: resizing a block never shrinks a sibling, only
- * displaces it to the next open slot (clamped to `minW`/`minH: 1`), which
- * leaves every block's own authored (hand- or AI-set) span untouched. This
- * component's job is translating between the stored `col`/`row`/`colSpan`/
- * `rowSpan` schema (which allows a child to omit its position entirely, to
- * be auto-placed) and `react-grid-layout`'s own `{x, y, w, h}` — see
- * `packChildLayout` for the auto-placement piece RGL has no concept of — and
- * committing back to the store only once a gesture ends
- * (`onDragStop`/`onResizeStop`), never on every intermediate frame.
- *
- * Dragging a block into a *different* container (reparenting, as opposed to
- * repositioning within this one) is handled by hit-testing which
- * `data-container-id` is under the pointer when the drag ends — deliberately
- * not something `react-grid-layout` (scoped to one grid instance) handles on
- * its own.
- */
-export default function CanvasBlock({ nodeId }: { nodeId: string }) {
-  useDashboardRevision();
-  const theme = useTheme();
-  const node = provider.getNode(nodeId);
-
-  const commitLayout = useCallback(
-    (rglLayout: Layout) => {
-      const updates: Record<string, Partial<LayoutProps>> = {};
-      rglLayout.forEach(item => {
-        updates[item.i] = {
-          col: item.x + 1,
-          row: item.y + 1,
-          colSpan: item.w,
-          rowSpan: item.h,
-        };
-      });
-      debugLog('commitLayout', { containerId: nodeId, updates });
-      provider.updateLayouts(updates);
-    },
-    [nodeId],
-  );
-
-  const handleDragStart = useCallback(
-    (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
-      debugLog('dragStart', {
-        containerId: nodeId,
-        dragged: describeNode(newItem?.i),
-      });
-    },
-    [nodeId],
-  );
-
-  const handleResizeStart = useCallback(
-    (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
-      debugLog('resizeStart', {
-        containerId: nodeId,
-        resized: describeNode(newItem?.i),
-      });
-    },
-    [nodeId],
-  );
-
-  const handleResizeStop = useCallback(
-    (rglLayout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
-      debugLog('resizeStop', {
-        containerId: nodeId,
-        resized: describeNode(newItem?.i),
-      });
-      commitLayout(rglLayout);
-    },
-    [nodeId, commitLayout],
-  );
-
-  const handleDragStop = useCallback(
-    (
-      rglLayout: Layout,
-      _oldItem: unknown,
-      newItem: { i: string } | null,
-      _placeholder: unknown,
-      event: Event,
-      element: HTMLElement | null,
-    ) => {
-      const { clientX, clientY } = event as MouseEvent;
-      const targetContainerId =
-        newItem && element && clientX != null && clientY != null
-          ? findContainerIdAt(clientX, clientY, element)
-          : null;
-
-      debugLog('dragStop', {
-        containerId: nodeId,
-        dragged: describeNode(newItem?.i),
-        targetContainerId,
-        reparenting: !!targetContainerId && targetContainerId !== nodeId,
-      });
-
-      if (targetContainerId && targetContainerId !== nodeId && newItem) {
-        try {
-          // moveBuildingBlock itself clears col/row and clamps colSpan to
-          // the destination's own column count — old coordinates were only
-          // ever meaningful in *this* container's grid.
-          const destIndex =
-            provider.getNode(targetContainerId)?.children?.length ?? 0;
-          provider.moveBuildingBlock(newItem.i, targetContainerId, destIndex);
-          return;
-        } catch {
-          // Dropped onto itself or one of its own descendants — not a valid
-          // reparent target. Fall through and keep it in this container.
-        }
-      }
-
-      commitLayout(rglLayout);
-    },
-    [nodeId, commitLayout],
-  );
-
-  if (!node) return null;
-
-  const { columns, gap, rowUnitPx } = resolveGridMetrics(node.layout, theme);
-  const children = node.children ?? [];
-
-  const packed = packChildLayout(children, columns, provider.getNode);
-  const layout: Layout = children.map(id => ({
-    i: id,
-    minW: 1,
-    minH: 1,
-    ...packed[id],
-  }));
-
-  return (
-    <div
-      data-container-id={nodeId}
-      data-test="canvas-container"
-      // Every container is a drop target, not just the root: a nested
-      // section is exactly where an author means to put something when they
-      // drag it there, and the stop is what makes the innermost container
-      // under the pointer the one that takes it rather than every ancestor
-      // claiming the same drop.
-      onDragOver={event => {
-        if (event.dataTransfer.types.includes(PALETTE_MIME)) {
-          event.preventDefault();
-          event.dataTransfer.dropEffect = 'copy';
-        }
-      }}
-      onDrop={event => {
-        const type = event.dataTransfer.getData(PALETTE_MIME);
-        if (type !== '') {
-          event.preventDefault();
-          event.stopPropagation();
-          placeBlock(nodeId, type);
-        }
-      }}
-      style={{ width: '100%', height: '100%', overflow: 'auto' }}
-    >
-      <ResizableGridLayout
-        layout={layout}
-        cols={columns}
-        rowHeight={rowUnitPx}
-        margin={[gap, gap] as const}
-        containerPadding={[0, 0] as const}
-        // Explicit rather than relying on the (identical) default, since the
-        // obvious-looking alternative doesn't work: `compactType={null}`
-        // (no compaction) looks like it should mean "displace only the
-        // sibling actually being collided with," but react-grid-layout's
-        // own collision-resolution branch for `compactType === null`
-        // (`moveElementAwayFromCollision`'s "collisionNorth" case) re-adds
-        // a colliding sibling's full height on *every* drag frame the
-        // collision persists rather than settling once it's clear —
-        // confirmed live: dragging one tile so it merely brushes a
-        // neighbor's edge sent that neighbor hundreds of pixels away within
-        // a few mouse-move events. `"vertical"` resolves the same collision
-        // by moving the sibling down exactly once, by exactly its own
-        // height, every time.
-        compactType="vertical"
-        allowOverlap={false}
-        preventCollision={false}
-        // Every corner but the top-right one, which a block spends on its
-        // remove control. react-grid-layout appends its handles after the
-        // block's own content, so a 20px handle sat over that button and took
-        // every click aimed at it — `elementFromPoint` at the button's centre
-        // returned the handle. A handle nobody can grab is worse than no
-        // handle: the corner looks resizable and answers a drag that starts
-        // one pixel away. Three corners still size a block, and the fourth
-        // does the one thing that corner is labelled for.
-        resizeHandles={['se', 'sw', 'nw']}
-        // A nested canvas that declares its own `rowUnit` independently of
-        // the outer `rowSpan` that placed it can end up needing more (or
-        // less) height than that outer placement actually reserves — see
-        // the `overflow: 'auto'` above, which is what keeps a too-tall
-        // nested grid from bleeding into whatever comes after it instead of
-        // scrolling internally. This selector is the drag-side half of the
-        // same nesting concern: `[data-container-id]` matches only this
-        // node's own children that are themselves a `canvas` — a click
-        // anywhere inside one of those (on a leaf grandchild, or on the
-        // empty space between them) must never start dragging that nested
-        // canvas as a single item here, or "drag one chart in a KPI row"
-        // and "the whole row comes with it" become indistinguishable at the
-        // DOM level. A leaf child (chart/markdown) never renders
-        // `data-container-id` itself, so this never affects dragging a
-        // leaf — only a nested canvas becomes un-draggable as a whole via
-        // a body click (it's still resizable via its own corner handles).
-        draggableCancel="[data-container-id],[data-block-remove]"
-        onDragStart={handleDragStart}
-        onDragStop={handleDragStop}
-        onResizeStart={handleResizeStart}
-        onResizeStop={handleResizeStop}
-      >
-        {children.map(childId => (
-          <BuildingBlockView key={childId} nodeId={childId} />
-        ))}
-      </ResizableGridLayout>
-    </div>
-  );
-}
diff --git a/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx
new file mode 100644
index 00000000000..d643b8732ca
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx
@@ -0,0 +1,246 @@
+/**
+ * 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 { useRef, useState } from 'react';
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { EmptyState } from '@superset-ui/core/components';
+import { provider, useDashboardRevision } from '../store';
+import { PALETTE_MIME, placeBlock } from '../placement';
+import { FlowContent } from './flowContent';
+
+/**
+ * A slide's own child type — the vertical-navigation counterpart to
+ * `TabsBlock`'s `TAB_TYPE`, and not registered as a building block for the
+ * identical reason: nothing ever resolves one through
+ * `resolveBuildingBlockView`, since `CarouselBlock` renders a slide's
+ * children directly. It only needs to be a recognized container type so
+ * `addBuildingBlock` gives it a `children` array (see `registerContainerType`
+ * in `DashboardProvider`).
+ */
+export const SLIDE_TYPE = 'slide';
+
+/**
+ * The negative margin is what keeps the nav column full-height — see
+ * `TabsBlock`'s own `Root`, which this mirrors for the identical reason: the
+ * card's own padding (`BuildingBlockView`) is right for a single thing
+ * filling the card, but wrong for chrome that has to reach the card's own
+ * edges to read as one. The top is left alone: `carousel` has no *title* in
+ * its header (see `blockLabel`'s `UNNAMED` set), but the header itself —
+ * carrying at least the remove control — is still there for every non-root
+ * node, so this box starts below it rather than at the card's true top edge
+ * regardless.
+ */
+const Root = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: row;
+    width: 100%;
+    height: 100%;
+    margin: 0 -${theme.padding}px -${theme.padding}px;
+  `}
+`;
+
+/**
+ * The dot strip itself — shown only once there is something to navigate
+ * between (see `CarouselBlock`'s own render). A dot rather than a labelled
+ * button: this is the one built-in container whose own switching control is
+ * meant to read as a lightweight indicator of position among slides, the
+ * way a carousel's dots do elsewhere, rather than as a row of named
+ * destinations the way `TabsBlock`'s tab bar is.
+ */
+const NavColumn = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    flex: 0 0 auto;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 3}px;
+    border-right: 1px solid ${theme.colorBorderSecondary};
+    overflow-y: auto;
+  `}
+`;
+
+const Dot = styled.button<{ $active: boolean }>`
+  ${({ theme, $active }) => css`
+    appearance: none;
+    border: none;
+    padding: 0;
+    flex: 0 0 auto;
+    width: ${theme.sizeUnit * 2}px;
+    height: ${theme.sizeUnit * 2}px;
+    border-radius: 50%;
+    background-color: ${$active ? theme.colorPrimary : theme.colorBorder};
+    cursor: pointer;
+    transition: background-color ${theme.motionDurationMid};
+
+    &:hover {
+      background-color: ${
+        $active ? theme.colorPrimary : theme.colorPrimaryBorder
+      };
+    }
+
+    &:focus-visible {
+      outline: 2px solid ${theme.colorPrimaryBorder};
+      outline-offset: 2px;
+    }
+  `}
+`;
+
+/**
+ * Where a carousel with no slides yet still has to take a drop — a fresh
+ * `FlowContent` area has a `containerId` to drop into from the moment it
+ * exists (see its own comment), but a carousel with zero slides has no
+ * slide node at all yet for one to be the content of. This is that same
+ * drop target one level up: it makes the first `slide` itself, then hands
+ * the dropped type to `placeBlock` the same way `FlowContent` would have.
+ */
+const EmptyArea = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+/** What a slide is called before an author (or the assistant) names it. */
+export const untitledSlideLabel = (index: number): string =>
+  t('Slide %s', index + 1);
+
+/**
+ * The built-in `carousel` building block — a container whose own children
+ * (each a `slide`, itself a container) are switchable one at a time through
+ * a vertical strip of dots, rather than the horizontal tab bar `TabsBlock`
+ * uses for the same idea. Registered like any other block (see
+ * `registerBuiltInBuildingBlocks`), and like `tabs`, it has no grid of its
+ * own: which slide is showing is this component's own concern, not a
+ * `layout` fact the document carries (composition/layout design doc).
+ *
+ * Unlike `tabs`, nothing here fills a carousel in on its own — a fresh one
+ * shows the same empty state a fresh pane would, rather than one slide
+ * already made for it, which is what keeps the dots from ever needing to
+ * appear over a single slide nobody asked for. They join the moment a first
+ * slide actually exists.
+ *
+ * Which slide is *active* is intentionally not persisted, for the identical
+ * reason `TabsBlock`'s active pane is not: it is a fact about who is looking
+ * at the dashboard right now, not about the dashboard itself. It resets to
+ * the first slide whenever the previously active one no longer exists.
+ */
+export default function CarouselBlock({
+  nodeId,
+}: {
+  nodeId: string;
+}): ReactElement | null {
+  useDashboardRevision();
+  const node = provider.getNode(nodeId);
+  const slides = node?.children ?? [];
+
+  const [activeSlideId, setActiveSlideId] = useState<string | undefined>(
+    slides[0],
+  );
+  const activeIsValid =
+    activeSlideId !== undefined && slides.includes(activeSlideId);
+  if (!activeIsValid && activeSlideId !== slides[0]) {
+    setActiveSlideId(slides[0]);
+  }
+
+  // A slide added since the last render — whether from `blockHeaderControl`'s
+  // "+" (see its own comment) or a palette drop into an empty carousel above
+  // — is one nobody has seen yet, so it becomes the one shown rather than
+  // landing silently behind whichever slide was already active. Both of
+  // those additions always append, so the newest slide is always the last
+  // one; a ref rather than a prop is what lets this component notice the
+  // growth at all, since the button that causes it renders as this one's
+  // sibling in `BuildingBlockView`'s header, not as anything that could pass
+  // it a callback.
+  const previousSlideCount = useRef(slides.length);
+  if (slides.length > previousSlideCount.current) {
+    setActiveSlideId(slides[slides.length - 1]);
+  }
+  previousSlideCount.current = slides.length;
+
+  if (!node) return null;
+
+  return (
+    <Root data-test={`carousel-${nodeId}`}>
+      {slides.length > 0 && (
+        <NavColumn
+          role="tablist"
+          aria-label={t('Carousel slides')}
+          aria-orientation="vertical"
+        >
+          {slides.map((slideId, index) => {
+            const active = slideId === activeSlideId;
+            return (
+              <Dot
+                key={slideId}
+                type="button"
+                role="tab"
+                tabIndex={0}
+                aria-selected={active}
+                aria-label={t('Slide %s', index + 1)}
+                $active={active}
+                data-test={`slide-${slideId}`}
+                onClick={() => setActiveSlideId(slideId)}
+              />
+            );
+          })}
+        </NavColumn>
+      )}
+      {activeSlideId ? (
+        <FlowContent
+          containerId={activeSlideId}
+          emptyTitle={t('Nothing on this slide yet')}
+          emptyDescription={t('Ask the assistant to add something here.')}
+          dataTest={`carousel-slide-${nodeId}`}
+        />
+      ) : (
+        <EmptyArea
+          data-test={`carousel-empty-${nodeId}`}
+          data-container-id={nodeId}
+          onDragOver={event => {
+            if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+              event.preventDefault();
+              event.dataTransfer.dropEffect = 'copy';
+            }
+          }}
+          onDrop={event => {
+            const type = event.dataTransfer.getData(PALETTE_MIME);
+            if (type !== '') {
+              event.preventDefault();
+              event.stopPropagation();
+              const slideId = provider.addBuildingBlock(nodeId, 0, {
+                type: SLIDE_TYPE,
+                props: { label: untitledSlideLabel(0) },
+              });
+              placeBlock(slideId, type);
+            }
+          }}
+        >
+          <EmptyState
+            size="small"
+            image="empty.svg"
+            title={t('Nothing in this carousel yet')}
+            description={t('Ask the assistant to add a slide.')}
+          />
+        </EmptyArea>
+      )}
+    </Root>
+  );
+}
diff --git a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
index b54007936da..48edfff97c0 100644
--- a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
@@ -109,7 +109,7 @@ echarts.use([
  * renders it owns measuring the DOM. This measures both dimensions: a grid
  * item's cell is always a definite pixel box (its column share of the
  * container's width, its `rowSpan × rowUnit` height, both enforced by the
- * parent grid — see `CanvasBlock`), so there's no case here where a
+ * parent grid — see `RootGrid`), so there's no case here where a
  * measured size is ambiguous or collapses to zero the way an unconstrained
  * flex height could.
  */
@@ -260,7 +260,7 @@ export default function ChartBlock({ nodeId }: { nodeId: string }) {
         <Flex
           align="center"
           justify="center"
-          style={{ width: '100%', height: '100%', padding: theme.padding }}
+          style={{ width: '100%', height: '100%' }}
         >
           <Typography.Text type="danger">{error}</Typography.Text>
         </Flex>
diff --git a/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx
new file mode 100644
index 00000000000..52bc83d3186
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.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 type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { provider, useDashboardRevision } from '../store';
+import { FlowContent } from './flowContent';
+
+/**
+ * The negative margin is what lets `FlowContent`'s own inset (see its own
+ * comment) reach the card's true edges instead of sitting inside it twice —
+ * see `TabsBlock`'s identical `Root`, which this mirrors for the identical
+ * reason. The top is left alone, since this box already starts below the
+ * card's header rather than at its true top edge.
+ */
+const Root = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    height: 100%;
+    margin: 0 -${theme.padding}px -${theme.padding}px;
+  `}
+`;
+
+/**
+ * The built-in `collapsible` building block — a container that holds a
+ * single child, shown or hidden behind one toggle. Registered like any
+ * other block (see `registerBuiltInBuildingBlocks`), and like `tabs`, it has
+ * no grid of its own.
+ *
+ * The toggle itself is not drawn here: `BuildingBlockView`'s own header
+ * already carries this block's name and its remove control for every block
+ * type, and a second bar in the content below repeating the same idea would
+ * make this the only block type with two header-shaped rows stacked on top
+ * of each other. `blockHeaderControl` puts the toggle in that same header,
+ * beside the remove control, so a collapsible block is — per its own name —
+ * a title and its content, nothing else. This component's whole job is
+ * therefore just the content half: nothing at all while collapsed (the
+ * header above still reads fine on its own), the flowed child once
+ * expanded.
+ *
+ * `props.collapsed` is what `blockHeaderControl`'s toggle flips, and it also
+ * resizes this node's own `layout.rowSpan` down to a header-only height
+ * while collapsed (see its own comment) — a fact about the dashboard's own
+ * state an author sets deliberately, not a transient fact about who is
+ * looking at it right now, so it is persisted rather than kept the way
+ * `TabsBlock`'s active pane is.
+ *
+ * There is no intermediate pane node the way `tabs` has one per tab — one
+ * child is already the simplest container `FlowContent` can hold, so
+ * `nodeId` itself is the flow area's own `containerId`. `accepts` closes
+ * the drop target the moment that one child exists, which is what makes
+ * "single child" an actual constraint rather than a suggestion.
+ */
+export default function CollapsibleBlock({
+  nodeId,
+}: {
+  nodeId: string;
+}): ReactElement | null {
+  useDashboardRevision();
+  const node = provider.getNode(nodeId);
+  if (!node) return null;
+
+  const collapsed = Boolean(node.props?.collapsed);
+  if (collapsed) return null;
+
+  const children = node.children ?? [];
+
+  return (
+    <Root data-test={`collapsible-${nodeId}`}>
+      <FlowContent
+        containerId={nodeId}
+        accepts={children.length === 0}
+        emptyTitle={t('Nothing here yet')}
+        emptyDescription={t('Ask the assistant to add something here.')}
+        dataTest={`collapsible-content-${nodeId}`}
+      />
+    </Root>
+  );
+}
diff --git a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx b/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
index 0a6c3910518..c2f2ad1ac38 100644
--- a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
@@ -16,7 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useTheme } from '@apache-superset/core/theme';
 import { SafeMarkdown } from '@superset-ui/core/components';
 import { provider, useDashboardRevision } from '../store';
 
@@ -28,7 +27,6 @@ import { provider, useDashboardRevision } from '../store';
  */
 export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
   useDashboardRevision();
-  const theme = useTheme();
   const node = provider.getNode(nodeId);
   if (!node) return null;
 
@@ -37,10 +35,9 @@ export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
       style={{
         width: '100%',
         height: '100%',
-        // Surface, border and corners belong to the card `BuildingBlockView`
-        // draws around this block and the name above it, so that the name is
-        // inside the frame rather than over it.
-        padding: theme.padding,
+        // Surface, border, corners and inset all belong to the card
+        // `BuildingBlockView` draws around this block and the name above
+        // it, so that the name is inside the frame rather than over it.
         overflow: 'auto',
       }}
     >
diff --git a/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx b/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx
index e5a45c6ac95..5d11e0677a6 100644
--- a/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx
@@ -153,10 +153,9 @@ export default function MetricTileBlock({ nodeId }: { nodeId: string }) {
         // block — always a definite pixel box, same as `ChartBlock`.
         width: '100%',
         height: '100%',
-        // Surface, border and corners belong to the card `BuildingBlockView`
-        // draws around this block and the name above it, so that the name is
-        // inside the frame rather than over it.
-        padding: theme.padding,
+        // Surface, border, corners and inset all belong to the card
+        // `BuildingBlockView` draws around this block and the name above
+        // it, so that the name is inside the frame rather than over it.
         overflow: 'hidden',
       }}
     >
diff --git a/superset-frontend/src/core/dashboard/blocks/TabsBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/TabsBlock.test.tsx
new file mode 100644
index 00000000000..bf25c8b11f1
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/TabsBlock.test.tsx
@@ -0,0 +1,226 @@
+/**
+ * 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, fireEvent, render, screen } from 'spec/helpers/testing-library';
+import DashboardProvider from '../DashboardProvider';
+import { registerBuiltInBuildingBlocks } from '../registerBuiltInBuildingBlocks';
+import TabsBlock from './TabsBlock';
+
+const provider = DashboardProvider.getInstance();
+
+beforeAll(() => {
+  registerBuiltInBuildingBlocks();
+});
+
+beforeEach(() => {
+  provider.reset();
+});
+
+/** Creates a bare `tabs` node under the root — rendering is each test's own call, made once its setup (if any) is done. */
+const createTabs = (): string => {
+  const rootId = provider.getRoot().id;
+  return provider.addBuildingBlock(rootId, 0, { type: 'tabs' });
+};
+
+test('a freshly placed tabs block already has one tab, selected', () => {
+  const tabsId = createTabs();
+  render(<TabsBlock nodeId={tabsId} />);
+
+  // A tabs block with nothing to switch between is not a useful starting
+  // point, so this fills it in rather than leaving it for the "+" — see
+  // TabsBlock's own layout effect.
+  const tab = screen.getByRole('tab', { name: 'Tab 1' });
+  expect(tab).toHaveAttribute('aria-selected', 'true');
+  expect(screen.getByText('Nothing in this tab yet')).toBeVisible();
+
+  const paneId = provider.getNode(tabsId)?.children?.[0] as string;
+  expect(provider.getNode(paneId)?.props?.label).toBe('Tab 1');
+  expect(screen.queryByTestId(`tab-remove-${paneId}`)).not.toBeInTheDocument();
+});
+
+test('adding a tab is named after its own position, not the count at click time', () => {
+  const tabsId = createTabs();
+  render(<TabsBlock nodeId={tabsId} />);
+
+  // Tab 1 already exists (see the test above) — each click adds the next.
+  fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
+  fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
+
+  expect(screen.getByRole('tab', { name: 'Tab 1' })).toBeVisible();
+  expect(screen.getByRole('tab', { name: 'Tab 2' })).toBeVisible();
+  expect(screen.getByRole('tab', { name: 'Tab 3' })).toBeVisible();
+});
+
+test('the only tab offers no way to remove itself', () => {
+  const tabsId = createTabs();
+  render(<TabsBlock nodeId={tabsId} />);
+
+  const paneId = provider.getNode(tabsId)?.children?.[0] as string;
+
+  // Removing it would leave a blank tabs block the layout effect would
+  // immediately refill anyway, which reads as the control having silently
+  // done nothing.
+  expect(screen.queryByTestId(`tab-remove-${paneId}`)).not.toBeInTheDocument();
+});
+
+test('a second tab can be removed, falling back to the remaining one', () => {
+  const tabsId = createTabs();
+  render(<TabsBlock nodeId={tabsId} />);
+  const firstPaneId = provider.getNode(tabsId)?.children?.[0] as string;
+
+  fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
+  const secondTab = screen.getByRole('tab', { name: 'Tab 2' });
+  fireEvent.click(secondTab);
+  fireEvent.click(
+    screen.getByTestId(`tab-remove-${provider.getNode(tabsId)?.children?.[1]}`),
+  );
+
+  expect(screen.queryByRole('tab', { name: 'Tab 2' })).not.toBeInTheDocument();
+  expect(provider.getNode(tabsId)?.children).toEqual([firstPaneId]);
+  expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveAttribute(
+    'aria-selected',
+    'true',
+  );
+});
+
+test('dropping a palette block onto the active pane places it there', () => {
+  const tabsId = createTabs();
+  const paneId = provider.addBuildingBlock(tabsId, 0, {
+    type: 'tab',
+    props: { label: 'Overview' },
+  });
+  render(<TabsBlock nodeId={tabsId} />);
+
+  const data = new Map([
+    ['application/x-dashboard-building-block', 'markdown'],
+  ]);
+  fireEvent.drop(screen.getByTestId(`tabs-panes-${tabsId}`), {
+    dataTransfer: {
+      types: [...data.keys()],
+      getData: (key: string) => data.get(key) ?? '',
+      dropEffect: '',
+      effectAllowed: '',
+    },
+  });
+
+  expect(provider.getNode(paneId)?.children).toHaveLength(1);
+  const droppedId = provider.getNode(paneId)?.children?.[0] as string;
+  expect(provider.getNode(droppedId)?.type).toBe('markdown');
+});
+
+test('clicking a tab shows its own content and hides the other panes', async () => {
+  const tabsId = createTabs();
+  const firstPane = provider.addBuildingBlock(tabsId, 0, {
+    type: 'tab',
+    props: { label: 'Overview' },
+  });
+  const secondPane = provider.addBuildingBlock(tabsId, 1, {
+    type: 'tab',
+    props: { label: 'Detail' },
+  });
+  provider.addBuildingBlock(firstPane, 0, {
+    type: 'markdown',
+    props: { content: 'Overview content' },
+  });
+  provider.addBuildingBlock(secondPane, 0, {
+    type: 'markdown',
+    props: { content: 'Detail content' },
+  });
+  render(<TabsBlock nodeId={tabsId} />);
+
+  // The first pane is active by default. `findByText` rather than
+  // `getByText`: `SafeMarkdown` lazy-loads `react-markdown` itself and
+  // renders nothing until that resolves, so the text is not necessarily
+  // there yet on the tick right after `render`.
+  expect(await screen.findByText('Overview content')).toBeVisible();
+  expect(screen.queryByText('Detail content')).not.toBeInTheDocument();
+
+  fireEvent.click(screen.getByRole('tab', { name: 'Detail' }));
+
+  expect(screen.queryByText('Overview content')).not.toBeInTheDocument();
+  expect(await screen.findByText('Detail content')).toBeVisible();
+});
+
+test('a flowed block with no height of its own flexes to fill the area, and fixes to a number once grown from the keyboard', () => {
+  const tabsId = createTabs();
+  const pane = provider.addBuildingBlock(tabsId, 0, {
+    type: 'tab',
+    props: { label: 'Overview' },
+  });
+  const chartId = provider.addBuildingBlock(pane, 0, {
+    type: 'markdown',
+    props: { content: 'Chart stand-in' },
+  });
+  render(<TabsBlock nodeId={tabsId} />);
+
+  const handle = screen.getByTestId(`flow-resize-${chartId}`);
+  // No `rowSpan` of its own yet, so there is no number to report — the
+  // block is flexing to fill the area rather than sitting at a fixed size.
+  expect(handle).not.toHaveAttribute('aria-valuenow');
+
+  handle.focus();
+  fireEvent.keyDown(handle, { key: 'ArrowDown' });
+
+  // The first resize is what fixes it to an explicit size — measured off
+  // the rendered box in a real browser, or `DEFAULT_FLOW_ITEM_HEIGHT` here,
+  // where nothing is actually laid out to measure.
+  expect(handle).toHaveAttribute('aria-valuenow', '376');
+  expect(provider.getNode(chartId)?.layout?.rowSpan).toBe(376);
+});
+
+test('a flowed block cannot be shrunk past the minimum height', () => {
+  const tabsId = createTabs();
+  const pane = provider.addBuildingBlock(tabsId, 0, {
+    type: 'tab',
+    props: { label: 'Overview' },
+  });
+  const chartId = provider.addBuildingBlock(pane, 0, {
+    type: 'markdown',
+    layout: { rowSpan: 124 },
+    props: { content: 'Chart stand-in' },
+  });
+  render(<TabsBlock nodeId={tabsId} />);
+
+  const handle = screen.getByTestId(`flow-resize-${chartId}`);
+  handle.focus();
+  fireEvent.keyDown(handle, { key: 'ArrowUp' });
+  fireEvent.keyDown(handle, { key: 'ArrowUp' });
+
+  expect(provider.getNode(chartId)?.layout?.rowSpan).toBe(120);
+});
+
+test('removing the active pane falls back to the first remaining tab', () => {
+  const tabsId = createTabs();
+  provider.addBuildingBlock(tabsId, 0, {
+    type: 'tab',
+    props: { label: 'Overview' },
+  });
+  const detailPane = provider.addBuildingBlock(tabsId, 1, {
+    type: 'tab',
+    props: { label: 'Detail' },
+  });
+  render(<TabsBlock nodeId={tabsId} />);
+
+  fireEvent.click(screen.getByRole('tab', { name: 'Detail' }));
+  act(() => provider.removeBuildingBlock(detailPane));
+
+  expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
+    'aria-selected',
+    'true',
+  );
+});
diff --git a/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx b/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx
new file mode 100644
index 00000000000..fec50add9e7
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx
@@ -0,0 +1,246 @@
+/**
+ * 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 { useLayoutEffect, useState } from 'react';
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { ActionButton } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { provider, useDashboardRevision } from '../store';
+import { FlowContent } from './flowContent';
+
+/**
+ * A pane's own child type — not registered as a building block in its own
+ * right (see `registerBuiltInBuildingBlocks`), since nothing ever resolves
+ * one through `resolveBuildingBlockView`: this component renders a pane's
+ * `children` directly rather than rendering the pane node itself through
+ * `BuildingBlockView`. It only needs to be a *container* type (so
+ * `addBuildingBlock` gives it a `children` array) — see
+ * `registerContainerType` in `DashboardProvider`.
+ */
+export const TAB_TYPE = 'tab';
+
+/**
+ * The negative margin is what keeps the tab bar full-width.
+ *
+ * `BuildingBlockView` now insets every block's content by the card's own
+ * padding (see its own comment) — right for a chart or a table, which is a
+ * single thing filling the card, but wrong for a strip of tabs, which reads
+ * as cut short the moment it does not reach the card's edges the way a
+ * header would. `FlowContent` keeps its own inset (see its own comment) for
+ * the content flowed into a pane, which is the single-thing case the
+ * padding was written for; this cancels that same padding for the chrome
+ * around it — on three sides only. The top is not the card's own padding to
+ * begin with:
+ * this box starts right where the header already ends, not at the card's
+ * true top edge, so cancelling it as well pulled the tab bar up past the
+ * header instead of just to the card's left/right/bottom edges, overlapping
+ * the two.
+ */
+const Root = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    height: 100%;
+    margin: 0 -${theme.padding}px -${theme.padding}px;
+  `}
+`;
+
+const TabBar = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit}px;
+    flex: 0 0 auto;
+    padding: 0 ${theme.sizeUnit * 2}px;
+    border-bottom: 1px solid ${theme.colorBorderSecondary};
+    overflow-x: auto;
+  `}
+`;
+
+const TabButton = styled.button<{ $active: boolean }>`
+  ${({ theme, $active }) => css`
+    appearance: none;
+    border: none;
+    background: none;
+    flex: 0 0 auto;
+    padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
+    font-size: ${theme.fontSizeSM}px;
+    font-weight: ${$active ? theme.fontWeightStrong : theme.fontWeightNormal};
+    color: ${$active ? theme.colorPrimaryText : theme.colorTextSecondary};
+    border-bottom: 2px solid ${$active ? theme.colorPrimary : 'transparent'};
+    white-space: nowrap;
+    cursor: pointer;
+    transition:
+      color ${theme.motionDurationMid},
+      border-color ${theme.motionDurationMid};
+
+    &:hover {
+      color: ${theme.colorPrimaryText};
+    }
+
+    &:focus-visible {
+      outline: 2px solid ${theme.colorPrimaryBorder};
+      outline-offset: -2px;
+    }
+  `}
+`;
+
+/**
+ * A tab and its own remove control, as siblings rather than one nested
+ * inside the other.
+ *
+ * `TabButton` is a real `<button>` — switching tabs is what most presses on
+ * it mean, and a button is what answers Enter/Space and reads as one to a
+ * screen reader. The remove control is `ActionButton`, itself a `<button>`,
+ * and a button inside a button is invalid HTML that nothing downstream can
+ * reliably navigate into. Wrapped here as two controls sharing a row
+ * instead, the same shape `BuildingBlockView`'s own header takes for a name
+ * and its own remove control.
+ */
+const TabItem = styled.span`
+  display: flex;
+  align-items: center;
+  flex: 0 0 auto;
+`;
+
+/** What a pane is called before an author (or the assistant) names it. */
+const untitledLabel = (index: number): string => t('Tab %s', index + 1);
+
+/**
+ * The built-in `tabs` building block — a container whose own children (each
+ * a `tab` pane, itself a container) are switchable rather than all shown at
+ * once. Registered like any other block (see `registerBuiltInBuildingBlocks`),
+ * it holds children of its own like the root's own grid does — but unlike
+ * the root, it has no grid: which pane is showing is this component's own
+ * concern, not a `layout` fact the document carries. Per the
+ * composition/layout design doc, that's the point — a container answers
+ * "how do I arrange what's inside me" for itself, and this is simply one
+ * answer among many, no more privileged than the root's own grid.
+ *
+ * Which pane is *active* is intentionally not persisted: it's a fact about
+ * who's looking at the dashboard right now, not about the dashboard itself
+ * (the same reasoning `DashboardProvider`'s own `selection` field is
+ * host-internal rather than part of a node). It resets to the first pane
+ * whenever the previously active one no longer exists — most commonly right
+ * after that pane is removed, or on a first render with no pane yet.
+ */
+export default function TabsBlock({
+  nodeId,
+}: {
+  nodeId: string;
+}): ReactElement | null {
+  useDashboardRevision();
+  const node = provider.getNode(nodeId);
+  const panes = node?.children ?? [];
+
+  const [activeTabId, setActiveTabId] = useState<string | undefined>(panes[0]);
+  const activeIsValid =
+    activeTabId !== undefined && panes.includes(activeTabId);
+  if (!activeIsValid && activeTabId !== panes[0]) {
+    setActiveTabId(panes[0]);
+  }
+
+  const addTab = (): void => {
+    const id = provider.addBuildingBlock(nodeId, panes.length, {
+      type: TAB_TYPE,
+      props: { label: untitledLabel(panes.length) },
+    });
+    setActiveTabId(id);
+  };
+
+  // A tabs block with nothing in it yet has nothing to switch between —
+  // the first thing anyone would do with one is press + once anyway, so
+  // this does it for them. An effect rather than done inline during render:
+  // render must not mutate the document itself, only read it. Laid out
+  // rather than a plain effect so the fill-in happens before the browser
+  // ever paints the empty state, which would otherwise flash for one frame
+  // on every block placed from the palette.
+  useLayoutEffect(() => {
+    if (node && panes.length === 0) {
+      addTab();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [nodeId, node, panes.length]);
+
+  if (!node) return null;
+
+  return (
+    <Root data-test={`tabs-${nodeId}`}>
+      <TabBar role="tablist" aria-label={t('Tabs')}>
+        {panes.map((paneId, index) => {
+          const pane = provider.getNode(paneId);
+          const label =
+            (pane?.props?.label as string | undefined) || untitledLabel(index);
+          const active = paneId === activeTabId;
+          return (
+            <TabItem key={paneId}>
+              <TabButton
+                type="button"
+                role="tab"
+                tabIndex={0}
+                aria-selected={active}
+                $active={active}
+                data-test={`tab-${paneId}`}
+                onClick={() => setActiveTabId(paneId)}
+              >
+                {label}
+              </TabButton>
+              {/* Offered once there is a second tab to fall back to —
+                  removing the only one just left a blank tabs block the
+                  effect above would immediately refill, which reads as the
+                  control having silently done nothing. */}
+              {panes.length > 1 && (
+                <ActionButton
+                  label={t('Remove tab')}
+                  tooltip={t('Remove tab')}
+                  placement="bottom"
+                  dataTest={`tab-remove-${paneId}`}
+                  icon={<Icons.CloseOutlined iconSize="s" />}
+                  onClick={() => provider.removeBuildingBlock(paneId)}
+                />
+              )}
+            </TabItem>
+          );
+        })}
+        <ActionButton
+          label={t('Add tab')}
+          tooltip={t('Add tab')}
+          placement="bottom"
+          dataTest={`tabs-add-${nodeId}`}
+          icon={<Icons.PlusOutlined iconSize="s" />}
+          onClick={addTab}
+        />
+      </TabBar>
+      {/* `activeTabId` is only briefly undefined, on the very first render
+          before the layout effect above fills the tabs block in — skipping
+          `FlowContent` for that one tick is what keeps this from ever
+          needing a pane id it does not have yet. */}
+      {activeTabId && (
+        <FlowContent
+          containerId={activeTabId}
+          emptyTitle={t('Nothing in this tab yet')}
+          emptyDescription={t('Ask the assistant to add something here.')}
+          dataTest={`tabs-panes-${nodeId}`}
+        />
+      )}
+    </Root>
+  );
+}
diff --git a/superset-frontend/src/core/dashboard/blocks/flowContent.tsx b/superset-frontend/src/core/dashboard/blocks/flowContent.tsx
new file mode 100644
index 00000000000..bbbd8207528
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/flowContent.tsx
@@ -0,0 +1,344 @@
+/**
+ * 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.
+ */
+
+/**
+ * @fileoverview A container's "flow" — one child under the next, each
+ * resizable and the whole thing a drop target — shared by every built-in
+ * container that is not the root's own grid (`tabs`, `collapsible`,
+ * `carousel`). Each of those still decides for itself how many flow areas
+ * it has and which one is currently showing (a single one for
+ * `collapsible`, one of several panes for `tabs`/`carousel`) — that part is
+ * genuinely each container's own business (composition/layout design doc).
+ * What they do not each reimplement is what a *single* flow area is once
+ * you have picked one: a resizable stack of blocks, droppable from the
+ * palette, exactly like `RootGrid`'s own drop target for the reason given
+ * on `FlowContent` below.
+ */
+import { useEffect, useRef, useState } from 'react';
+import type { KeyboardEvent, PointerEvent, ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { EmptyState } from '@superset-ui/core/components';
+import { provider } from '../store';
+import { PALETTE_MIME, placeBlock } from '../placement';
+import BuildingBlockView from '../BuildingBlockView';
+
+/**
+ * The height `FlowItem` falls back to only when it has to measure *something*
+ * and nothing has rendered yet to measure (see `currentHeight` there) — not
+ * a block's actual starting height any more. A block with no `layout.rowSpan`
+ * of its own (nothing has resized it yet) flexes to fill whatever room the
+ * flow area actually has instead, which is a real available-height number,
+ * not a guess at one.
+ */
+export const DEFAULT_FLOW_ITEM_HEIGHT = 360;
+
+/** How short a resize may make a flowed block — short of this and there is nothing left to grab the handle off of. */
+export const MIN_FLOW_ITEM_HEIGHT = 120;
+
+/** How far one arrow press resizes a block. */
+const RESIZE_STEP = 16;
+
+/**
+ * A flowed block's own resize handle.
+ *
+ * `BuildingBlockView` renders whatever it is given as `children` last, after
+ * its own header and content — the slot `react-grid-layout` injects its
+ * handles through on the root's own grid. A flowed block has no grid, and so
+ * no react-grid-layout handle of its own, but the slot serves the same
+ * purpose for the same reason: this needs to sit inside the block's own box,
+ * on top of it, without becoming part of what the block itself renders.
+ *
+ * A strip along the whole bottom edge rather than a corner square: this
+ * resizes one axis, not two (a flow's blocks are already full width, so
+ * there is no second dimension to change), and a full-width strip is a
+ * wider target than a handful of pixels in a corner would be.
+ */
+const ResizeGrip = styled.div`
+  ${({ theme }) => css`
+    position: absolute;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    height: ${theme.sizeUnit * 2}px;
+    cursor: row-resize;
+    touch-action: none;
+    z-index: 1;
+
+    &::after {
+      content: '';
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      height: 2px;
+      background-color: transparent;
+      transition: background-color ${theme.motionDurationMid};
+    }
+
+    &:hover::after,
+    &:focus-visible::after,
+    &:active::after {
+      background-color: ${theme.colorPrimary};
+    }
+
+    &:focus-visible {
+      outline: none;
+    }
+  `}
+`;
+
+/**
+ * One block, flowed into an area, with its own height and a way to change
+ * it.
+ *
+ * The height rendered is `layout.rowSpan` once an author has set one —
+ * reused rather than a field of its own, for the same reason `colSpan` is
+ * meaningless outside a grid and nobody invented a second name for "this
+ * many columns" to go with it: `rowSpan` already means "how tall," and a
+ * flow container is free to read it in its own unit (a pixel, here) the same
+ * way a grid container reads it in row tracks (see the composition/layout
+ * design doc — a container's own arrangement is its own business).
+ *
+ * `height` (and `liveHeight`, its local draft) is `undefined` for a block
+ * nobody has resized yet — rather than defaulting it to some fixed number,
+ * this flexes (`flex: 1 1 auto`) to fill whatever the flow area actually has
+ * available, the same way the very first block dropped into an empty area
+ * should read as filling it rather than sitting in a corner of it. The
+ * moment an author (or the resize handle below) sets an explicit height,
+ * that becomes authoritative and this switches to a fixed one instead
+ * (`flex: 0 0 auto`) — a size someone chose is never overridden by whatever
+ * space happens to be around it.
+ *
+ * The drag is tracked locally and committed with `provider.updateLayout`
+ * only once it ends, the same reason `RootGrid` commits a resize on
+ * `onResizeStop` rather than on every intermediate frame: a revision tick —
+ * and the re-render of everything subscribed to it — per pixel dragged
+ * would make the drag itself the slow part of resizing.
+ */
+export function FlowItem({
+  nodeId,
+  height,
+}: {
+  nodeId: string;
+  height: number | undefined;
+}): ReactElement {
+  const [liveHeight, setLiveHeight] = useState(height);
+  // What was accepted replaces the draft, because the draft was a view of
+  // it: a resize the assistant makes while this is on screen has to show.
+  useEffect(() => setLiveHeight(height), [height]);
+  const from = useRef<{ y: number; height: number } | null>(null);
+  const wrapperRef = useRef<HTMLDivElement>(null);
+
+  // A resize gesture always needs a starting height to measure a delta
+  // against — for a block that flexed to fill its space rather than being
+  // given one, that is only ever knowable by measuring what got rendered,
+  // never by reading `liveHeight` (which is `undefined` for exactly this
+  // block). `|| DEFAULT_FLOW_ITEM_HEIGHT` rather than `??`: a measured `0`
+  // (nothing painted yet to measure, or a collapsed flex box) is exactly as
+  // unusable a starting point for a resize as no measurement at all.
+  const currentHeight = (): number => {
+    if (liveHeight !== undefined) return liveHeight;
+    return (
+      wrapperRef.current?.getBoundingClientRect().height ||
+      DEFAULT_FLOW_ITEM_HEIGHT
+    );
+  };
+
+  const startDrag = (event: PointerEvent<HTMLDivElement>): void => {
+    // This block sits inside the root's own grid (the flow's own container
+    // is a grid item like any other), whose react-draggable instance
+    // otherwise reads this same pointer-down as the start of a drag on that
+    // item — the whole container moving on the root grid instead of this
+    // one block resizing inside it. `data-block-resize` is `RootGrid`'s own
+    // `draggableCancel` half of the same guard (see `BuildingBlockView`'s
+    // `data-block-remove`, which exists for the identical reason).
+    event.stopPropagation();
+    from.current = { y: event.clientY, height: currentHeight() };
+    event.currentTarget.setPointerCapture?.(event.pointerId);
+  };
+
+  const drag = (event: PointerEvent<HTMLDivElement>): void => {
+    if (from.current !== null) {
+      setLiveHeight(
+        Math.max(
+          MIN_FLOW_ITEM_HEIGHT,
+          from.current.height + event.clientY - from.current.y,
+        ),
+      );
+    }
+  };
+
+  const endDrag = (event: PointerEvent<HTMLDivElement>): void => {
+    // `liveHeight` only turns into a real number once `drag` has actually
+    // fired at least once — a press and release with no movement in between
+    // is not a resize, and must not fix a block that was flexing in place
+    // to whatever `currentHeight` happened to measure at that instant.
+    if (from.current !== null && liveHeight !== undefined) {
+      provider.updateLayout(nodeId, { rowSpan: liveHeight });
+    }
+    from.current = null;
+    event.currentTarget.releasePointerCapture?.(event.pointerId);
+  };
+
+  const resize = (event: KeyboardEvent<HTMLDivElement>): void => {
+    const moves: Record<string, (current: number) => number> = {
+      ArrowDown: current => current + RESIZE_STEP,
+      ArrowUp: current => Math.max(MIN_FLOW_ITEM_HEIGHT, current - RESIZE_STEP),
+    };
+    const move = moves[event.key];
+    if (move !== undefined) {
+      event.preventDefault();
+      const next = move(currentHeight());
+      setLiveHeight(next);
+      provider.updateLayout(nodeId, { rowSpan: next });
+    }
+  };
+
+  return (
+    <div
+      ref={wrapperRef}
+      style={
+        liveHeight === undefined
+          ? {
+              width: '100%',
+              flex: '1 1 auto',
+              minHeight: MIN_FLOW_ITEM_HEIGHT,
+              position: 'relative',
+            }
+          : {
+              width: '100%',
+              height: liveHeight,
+              flex: '0 0 auto',
+              position: 'relative',
+            }
+      }
+    >
+      <BuildingBlockView
+        nodeId={nodeId}
+        style={{ width: '100%', height: '100%' }}
+      >
+        <ResizeGrip
+          // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
+          role="separator"
+          aria-orientation="horizontal"
+          aria-label={t('Resize block')}
+          aria-valuenow={liveHeight}
+          aria-valuemin={MIN_FLOW_ITEM_HEIGHT}
+          tabIndex={0}
+          data-test={`flow-resize-${nodeId}`}
+          data-block-resize
+          onPointerDown={startDrag}
+          onPointerMove={drag}
+          onPointerUp={endDrag}
+          onKeyDown={resize}
+        />
+      </BuildingBlockView>
+    </div>
+  );
+}
+
+const FlowArea = styled.div`
+  ${({ theme }) => css`
+    flex: 1 1 auto;
+    min-height: 0;
+    overflow: auto;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 2}px;
+    /* The area's own inset — the same gutter the root gives its own
+       children (see BuildingBlockView), so a block flowed in here reads as
+       sitting a comfortable distance inside the container rather than
+       pressed against its edges. */
+    padding: ${theme.sizeUnit * 4}px;
+  `}
+`;
+
+/**
+ * One flow area's worth of content: a resizable stack of blocks, a drop
+ * target for the palette, and an empty state when there is nothing in it
+ * yet.
+ *
+ * `accepts` gates the drop rather than the caller doing it before ever
+ * rendering this — `collapsible`'s one area holds a single block, and
+ * disabling the drop once that block exists (rather than never offering a
+ * drop target at all) is what lets the empty state's own instruction stay
+ * honest right up until the moment it stops applying.
+ *
+ * `data-container-id` is what makes this a valid reparent target for an
+ * *existing* block being dragged on the root's own grid, not just new ones
+ * from the palette — `RootGrid`'s hit-testing looks for this attribute at
+ * any nesting depth (see its own doc comment), and a flow area answers it
+ * the same way `RootGrid`'s own grid does. Stopping propagation on drop is
+ * what keeps the same event from also reaching `RootGrid`'s handler on its
+ * way up the tree — without it, a block dropped here would be placed twice,
+ * once in this area and once on the root.
+ */
+export function FlowContent({
+  containerId,
+  emptyTitle,
+  emptyDescription,
+  accepts = true,
+  dataTest,
+}: {
+  containerId: string;
+  emptyTitle: string;
+  emptyDescription: string;
+  accepts?: boolean;
+  dataTest?: string;
+}): ReactElement {
+  const children = provider.getNode(containerId)?.children ?? [];
+
+  return (
+    <FlowArea
+      data-test={dataTest}
+      data-container-id={containerId}
+      onDragOver={event => {
+        if (accepts && event.dataTransfer.types.includes(PALETTE_MIME)) {
+          event.preventDefault();
+          event.dataTransfer.dropEffect = 'copy';
+        }
+      }}
+      onDrop={event => {
+        const type = event.dataTransfer.getData(PALETTE_MIME);
+        if (accepts && type !== '') {
+          event.preventDefault();
+          event.stopPropagation();
+          placeBlock(containerId, type);
+        }
+      }}
+    >
+      {children.length === 0 && (
+        <EmptyState
+          size="small"
+          image="empty.svg"
+          title={emptyTitle}
+          description={emptyDescription}
+        />
+      )}
+      {children.map(childId => (
+        <FlowItem
+          key={childId}
+          nodeId={childId}
+          height={provider.getNode(childId)?.layout?.rowSpan}
+        />
+      ))}
+    </FlowArea>
+  );
+}
diff --git a/superset-frontend/src/core/dashboard/gridPacking.ts b/superset-frontend/src/core/dashboard/gridPacking.ts
index 19a985c3a05..cb70fef7afd 100644
--- a/superset-frontend/src/core/dashboard/gridPacking.ts
+++ b/superset-frontend/src/core/dashboard/gridPacking.ts
@@ -110,6 +110,81 @@ export function packChildLayout(
   return result;
 }
 
+/** A rectangular span a block being dropped in has room for — see `availableDropSpan`. */
+export interface AvailableDropSpan {
+  w: number;
+  h: number;
+}
+
+/**
+ * How big a block being dropped in at `(cursorCol, cursorRow)` has room for
+ * — up to `maxRowSpan` tall — given `packed`'s existing occupancy: the pure
+ * geometry half of `RootGrid`'s own live drop preview (see its own
+ * `handleDropDragOver`), split out here so it can be exercised without a
+ * real drag gesture, the same reason `packChildLayout`'s own placement math
+ * lives here rather than inside a component.
+ *
+ * Open space returns exactly as wide a span as that row has free, capped at
+ * `columns` — a wholly empty grid included, since nothing anywhere is
+ * occupied there either — and exactly as tall a span as the rows below it,
+ * within that same width, stay just as free, capped at `maxRowSpan`. A gap
+ * that turns out to be as tall as it is wide is not pushing anything out of
+ * the way at all: it was already free on every side. No minimum width of
+ * its own beyond that: every block's own `minW` is 1 (see `RootGrid`'s own
+ * `layout` construction), so a single free column is already as legitimate
+ * a place to drop one as a whole free row is — narrower than that and there
+ * is no width left to report at all.
+ *
+ * Directly over another block there is no *beside* to speak of, only
+ * *above* or *below* it (which `compactType="vertical"` resolves the same
+ * way it already does for repositioning an existing block), so that returns
+ * the full row at `maxRowSpan` instead.
+ */
+export function availableDropSpan(
+  packed: Record<string, PackedRect>,
+  columns: number,
+  cursorCol: number,
+  cursorRow: number,
+  maxRowSpan: number,
+): AvailableDropSpan {
+  const occupied = new Set<string>();
+  Object.values(packed).forEach(rect => {
+    for (let dy = 0; dy < rect.h; dy += 1) {
+      for (let dx = 0; dx < rect.w; dx += 1) {
+        occupied.add(`${rect.x + dx},${rect.y + dy}`);
+      }
+    }
+  });
+
+  if (occupied.has(`${cursorCol},${cursorRow}`)) {
+    return { w: columns, h: maxRowSpan };
+  }
+
+  let left = cursorCol;
+  while (left > 0 && !occupied.has(`${left - 1},${cursorRow}`)) {
+    left -= 1;
+  }
+  let right = cursorCol;
+  while (right < columns - 1 && !occupied.has(`${right + 1},${cursorRow}`)) {
+    right += 1;
+  }
+  const w = right - left + 1;
+
+  const rowIsFree = (row: number): boolean => {
+    for (let dx = 0; dx < w; dx += 1) {
+      if (occupied.has(`${left + dx},${row}`)) return false;
+    }
+    return true;
+  };
+  let bottom = cursorRow;
+  while (bottom - cursorRow + 1 < maxRowSpan && rowIsFree(bottom + 1)) {
+    bottom += 1;
+  }
+  const h = bottom - cursorRow + 1;
+
+  return { w, h };
+}
+
 function rectsOverlap(
   a: { col: number; row: number; colSpan: number; rowSpan: number },
   b: { col: number; row: number; colSpan: number; rowSpan: number },
@@ -127,7 +202,7 @@ function rectsOverlap(
  * (both `col` and `row` set) by pushing a later-declared one straight down,
  * one row at a time, until it no longer overlaps an earlier one — the same
  * "displace, never shrink" rule interactive resize/drag already gets from
- * `react-grid-layout` (see `CanvasBlock`), applied here for the
+ * `react-grid-layout` (see `RootGrid`), applied here for the
  * programmatic placement path (`DashboardProvider.addBuildingBlock`/
  * `updateLayout`, which an extension's AI tools call directly) so both give
  * the same "nothing ends up stuck overlapping" guarantee, not just the one
diff --git a/superset-frontend/src/core/dashboard/layoutStyle.ts b/superset-frontend/src/core/dashboard/layoutStyle.ts
index f151c26db34..9d356e4ab0f 100644
--- a/superset-frontend/src/core/dashboard/layoutStyle.ts
+++ b/superset-frontend/src/core/dashboard/layoutStyle.ts
@@ -27,7 +27,7 @@ export const DEFAULT_COLUMNS = 24;
 
 const DEFAULT_GAP = 16;
 
-/** A container's resolved grid geometry, in the plain numbers `CanvasBlock` feeds to `react-grid-layout` (`cols`/`rowHeight`/`margin`). */
+/** A container's resolved grid geometry, in the plain numbers `RootGrid` feeds to `react-grid-layout` (`cols`/`rowHeight`/`margin`). */
 export interface GridMetrics {
   columns: number;
   gap: number;
diff --git a/superset-frontend/src/core/dashboard/placement.ts b/superset-frontend/src/core/dashboard/placement.ts
index 80830d06b96..61109f29eb0 100644
--- a/superset-frontend/src/core/dashboard/placement.ts
+++ b/superset-frontend/src/core/dashboard/placement.ts
@@ -28,7 +28,7 @@
  * it, and the difference is invisible until someone hits it.
  */
 
-import { isContainerType } from './DashboardProvider';
+import { GRID_TYPE, isContainerType } from './DashboardProvider';
 import { DEFAULT_COLUMNS } from './layoutStyle';
 import { provider } from './store';
 
@@ -41,6 +41,50 @@ import { provider } from './store';
  */
 export const PALETTE_MIME = 'application/x-dashboard-building-block';
 
+/**
+ * A type's own starting `rowSpan` on the root grid, in row tracks (a row is
+ * `rowUnitPx` tall, 32px by default — see `layoutStyle.ts`). Left unset
+ * (`gridPacking.ts`'s own fallback of 1 row) a freshly placed block sits
+ * shorter than `BuildingBlockView`'s header-plus-padding chrome alone, before
+ * any content of its own — every leaf type would open already clipped or
+ * scrolling. Tuned per type instead of one shared number because what fills
+ * that box varies enough that one height leaves half of them cramped and the
+ * other half wasting space: a metric tile is a few lines centered in a card,
+ * a table wants room for a header row and several data rows, a fresh `tabs`
+ * pays for a tab bar and a flow area's own padding before its empty state
+ * even starts. An author who wants a block smaller still has the resize
+ * handle for that — this is only the size nobody has touched it at yet.
+ *
+ * Root-grid-only: a `rowSpan` off this table is meaningless (and, worse,
+ * silently wrong) for a block placed into anything else — a `tabs` pane, a
+ * `collapsible`, a `carousel` slide — since those read `rowSpan` in their
+ * own unit, a flow area's own pixel (see `FlowContent`'s own comment), not
+ * a grid row track. `placeBlock`/`placeBlockAt` only reach into this table
+ * once they've confirmed the parent actually is the root's own grid.
+ */
+const DEFAULT_ROW_SPAN: Record<string, number> = {
+  markdown: 5,
+  echarts: 8,
+  'ag-grid-table': 9,
+  'metric-tile': 4,
+  tabs: 6,
+  collapsible: 5,
+  carousel: 5,
+};
+
+/**
+ * Row span for a type this module has no specific tuning for — an
+ * extension-contributed block, most likely — and also `RootGrid`'s own
+ * starting height for a block whose *position* came from a palette drag
+ * rather than this module's per-type table (see `placeBlockAt`): a drag
+ * already answers where a block lands, live, as the gesture happens, and
+ * asking it to also settle on a bespoke height per type at the same time is
+ * more than one gesture should have to carry. Generous rather than tight:
+ * better an unfamiliar block opens a little taller than it needed to than
+ * clipped or scrolling before anyone has seen what it renders.
+ */
+export const FALLBACK_ROW_SPAN = 6;
+
 /**
  * Places a new block of `type` at the end of `parentId`'s children and
  * selects it, returning its id.
@@ -50,21 +94,68 @@ export const PALETTE_MIME = 'application/x-dashboard-building-block';
  * set before anything can go inside it. Selecting what was just placed is
  * what brings its properties forward: placing something is the moment you
  * want to configure it.
+ *
+ * `rowSpan` is only ever set here when `parentId` is the root's own grid —
+ * everywhere else (a `tabs` pane, a `collapsible`, a `carousel` slide) it's
+ * left unset entirely, on purpose, so `FlowItem` (see `flowContent.tsx`)
+ * reads that as "no height chosen yet" and flexes the block to fill
+ * whatever room the container actually has, rather than a grid-row number
+ * misread as a pixel count.
  */
 export function placeBlock(parentId: string, type: string): string {
   const index = provider.getNode(parentId)?.children?.length ?? 0;
+  const onRootGrid = provider.getNode(parentId)?.type === GRID_TYPE;
+  const rowSpan = onRootGrid
+    ? (DEFAULT_ROW_SPAN[type] ?? FALLBACK_ROW_SPAN)
+    : undefined;
+  const id = provider.addBuildingBlock(parentId, index, {
+    type,
+    layout: isContainerType(type)
+      ? { columns: DEFAULT_COLUMNS, gap: 16, colSpan: DEFAULT_COLUMNS, rowSpan }
+      : { rowSpan },
+  });
+  provider.setSelection(id);
+  return id;
+}
+
+/**
+ * Places a new block of `type` at an explicit grid cell and an explicit
+ * spot in `parentId`'s own reading order, rather than appending it
+ * full-width at the end the way `placeBlock` does — `RootGrid`'s own
+ * counterpart for a palette block dropped onto the root's grid, where
+ * *where* (and how wide, next to whatever it landed beside) was the entire
+ * point of the gesture.
+ *
+ * `position` arrives already resolved: react-grid-layout ran its own
+ * collision avoidance live, during the drag itself, the same as it does for
+ * repositioning an existing block (see `RootGrid`'s own `isDroppable`
+ * wiring) — nothing here recomputes a position, only writes the one already
+ * shown as the drop's own preview.
+ *
+ * `index`, unlike `placeBlock`'s own implicit "at the end," is the caller's
+ * to get right: `DashboardProvider`'s own collision resolution (see
+ * `resolveExplicitCollisions`) settles a tie between two explicitly placed
+ * siblings by pushing down whichever comes *later* in `children` — so a
+ * block dropped, say, between two existing rows has to land earlier in that
+ * order than the row it is displacing, or collision resolution reads it
+ * backwards and pushes the new block itself down past everything instead of
+ * making room for it where it was actually dropped. `RootGrid` is what
+ * already knows every sibling's own current position (it just packed them,
+ * to draw this render's preview in the first place), so it is the one that
+ * resolves reading order too, rather than this module re-deriving it from
+ * scratch here.
+ */
+export function placeBlockAt(
+  parentId: string,
+  type: string,
+  index: number,
+  position: { col: number; row: number; colSpan: number; rowSpan: number },
+): string {
   const id = provider.addBuildingBlock(parentId, index, {
     type,
-    ...(isContainerType(type)
-      ? {
-          layout: {
-            columns: DEFAULT_COLUMNS,
-            gap: 16,
-            colSpan: DEFAULT_COLUMNS,
-            rowSpan: 4,
-          },
-        }
-      : {}),
+    layout: isContainerType(type)
+      ? { columns: DEFAULT_COLUMNS, gap: 16, ...position }
+      : { ...position },
   });
   provider.setSelection(id);
   return id;
diff --git a/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts b/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
index 5352ec5f72e..06f6d8a3b85 100644
--- a/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
+++ b/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
@@ -18,34 +18,34 @@
  */
 import { views } from 'src/core/views';
 import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
-import CanvasBlock from './blocks/CanvasBlock';
+import { registerContainerType } from './DashboardProvider';
 import MarkdownBlock from './blocks/MarkdownBlock';
 import ChartBlock from './blocks/ChartBlock';
 import AgGridTableBlock from './blocks/AgGridTableBlock';
 import MetricTileBlock from './blocks/MetricTileBlock';
+import TabsBlock, { TAB_TYPE } from './blocks/TabsBlock';
+import CollapsibleBlock from './blocks/CollapsibleBlock';
+import CarouselBlock, { SLIDE_TYPE } from './blocks/CarouselBlock';
 
 let registered = false;
 
 /**
  * Registers the built-in block types through the exact same `views` call an
- * extension uses to contribute one of its own — canvas/markdown/echarts/
- * ag-grid-table/metric-tile have no special status in the render path (see
- * `BuildingBlockView`), they're just pre-registered here before anything
- * else has a chance to render a dashboard node.
+ * extension uses to contribute one of its own — markdown/echarts/
+ * ag-grid-table/metric-tile/tabs have no special status in the render path
+ * (see `BuildingBlockView`), they're just pre-registered here before
+ * anything else has a chance to render a dashboard node.
+ *
+ * `grid` — the root's own type — is deliberately not among them. The root
+ * is not a Building Block (see the composition/layout design doc): nothing
+ * ever places one, and `BuildingBlockView` resolves the root's renderer
+ * directly rather than through this registry (the same reason `tab`, below,
+ * isn't registered either — see `TabsBlock`).
  */
 export function registerBuiltInBuildingBlocks(): void {
   if (registered) return;
   registered = true;
 
-  views.registerView(
-    {
-      id: 'canvas',
-      name: 'Canvas',
-      description: 'A flex container for other building blocks.',
-    },
-    DASHBOARD_BUILDING_BLOCKS_LOCATION,
-    CanvasBlock,
-  );
   views.registerView(
     {
       id: 'markdown',
@@ -82,4 +82,46 @@ export function registerBuiltInBuildingBlocks(): void {
     DASHBOARD_BUILDING_BLOCKS_LOCATION,
     MetricTileBlock,
   );
+  views.registerView(
+    {
+      id: 'tabs',
+      name: 'Tabs',
+      description: 'Groups building blocks into switchable tabs.',
+    },
+    DASHBOARD_BUILDING_BLOCKS_LOCATION,
+    TabsBlock,
+  );
+  views.registerView(
+    {
+      id: 'collapsible',
+      name: 'Collapsible',
+      description: 'Holds a single building block behind a show/hide toggle.',
+    },
+    DASHBOARD_BUILDING_BLOCKS_LOCATION,
+    CollapsibleBlock,
+  );
+  views.registerView(
+    {
+      id: 'carousel',
+      name: 'Carousel',
+      description:
+        'Groups building blocks into slides, navigated vertically one at a time.',
+    },
+    DASHBOARD_BUILDING_BLOCKS_LOCATION,
+    CarouselBlock,
+  );
+
+  // A tab pane / carousel slide holds its own children (in flow — see
+  // `TabsBlock`/`CarouselBlock`), but neither is registered as a view:
+  // nothing ever resolves one through `resolveBuildingBlockView` — each
+  // renders its pane's/slide's children directly rather than rendering the
+  // node itself. They only need to be recognized container types so
+  // `addBuildingBlock` gives them a `children` array. `collapsible` needs no
+  // such private type: its one child is held directly, with no intermediate
+  // pane (see `CollapsibleBlock`).
+  registerContainerType('tabs');
+  registerContainerType(TAB_TYPE);
+  registerContainerType('collapsible');
+  registerContainerType('carousel');
+  registerContainerType(SLIDE_TYPE);
 }
diff --git a/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts b/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
index 0f14285f35d..a0ed9d62060 100644
--- a/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
+++ b/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
@@ -21,10 +21,12 @@ import { resolveView, views } from 'src/core/views';
 
 /**
  * The `views` location a dashboard node's `type` must be registered at to
- * be renderable as a building block — built-in types (canvas/markdown/
- * echarts, see `registerBuiltInBuildingBlocks`) and extension-contributed
- * ones register here identically, through the same `views.registerView`
- * call.
+ * be renderable as a building block — built-in types (markdown/echarts/...,
+ * see `registerBuiltInBuildingBlocks`) and extension-contributed ones
+ * register here identically, through the same `views.registerView` call.
+ * The root's own type (`grid`) is deliberately not among them — it is not
+ * a Building Block, and `BuildingBlockView` resolves its renderer directly
+ * rather than through this location.
  */
 export const DASHBOARD_BUILDING_BLOCKS_LOCATION = 'dashboard.buildingBlocks';
 
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
index 5bfb026a34a..ab598741096 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
@@ -55,17 +55,18 @@ test('building blocks is what you start on, and it lists what is registered', ()
   expect(screen.getByTestId('palette-echarts')).toBeVisible();
 });
 
-test('canvas is not offered in the palette, since nesting one is not an authored feature', () => {
+test('the root grid is not offered in the palette, since nesting one is not an authored feature', () => {
   mount();
 
-  // Canvas stays registered so the root node has something to render
-  // through, but placing one is not offered.
+  // The root's own type is resolved directly by `BuildingBlockView`, never
+  // through the `dashboard.buildingBlocks` registry this palette lists from
+  // (see `registerBuiltInBuildingBlocks`) — so there is nothing registered
+  // under either name to ever show up here in the first place.
   expect(screen.queryByTestId('palette-canvas')).not.toBeInTheDocument();
-  // With nothing left on it, the shelf it would have sat on does not render
-  // either.
-  expect(
-    screen.queryByTestId('palette-shelf-structure'),
-  ).not.toBeInTheDocument();
+  expect(screen.queryByTestId('palette-grid')).not.toBeInTheDocument();
+  // The structure shelf still renders — it holds the genuine containers
+  // (tabs/collapsible/carousel), which the root is not one of.
+  expect(screen.getByTestId('palette-shelf-structure')).toBeInTheDocument();
 });
 
 test('clicking a block asks the page to place it', async () => {
@@ -163,7 +164,7 @@ const widthOf = () =>
 test('the panel opens wide enough to edit a block in', () => {
   mount();
 
-  expect(widthOf()).toBe(500);
+  expect(widthOf()).toBe(350);
 });
 
 test('the handle resizes from the keyboard, so a drag is not the only way', () => {
@@ -172,7 +173,7 @@ test('the handle resizes from the keyboard, so a drag is not the only way', () =
   handle.focus();
 
   fireEvent.keyDown(handle, { key: 'ArrowRight' });
-  expect(widthOf()).toBe(516);
+  expect(widthOf()).toBe(366);
 
   fireEvent.keyDown(handle, { key: 'End' });
   expect(widthOf()).toBe(800);
@@ -188,7 +189,7 @@ test('the handle reports the width it actually has', () => {
   // control is lying about the only thing it does.
   expect(screen.getByTestId('panel-resize')).toHaveAttribute(
     'aria-valuenow',
-    '500',
+    '350',
   );
 });
 
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
index 8c99dbcb4ee..ed292de8f60 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
@@ -37,7 +37,7 @@ type PanelTab = 'blocks' | 'properties' | 'outline';
  * narrow whatever they are given. The ceiling leaves a usable canvas on a
  * small screen.
  */
-const DEFAULT_WIDTH = 500;
+const DEFAULT_WIDTH = 350;
 const MIN_WIDTH = 280;
 const MAX_WIDTH = 800;
 /** How far one arrow press moves the edge. */
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
index 256a8cd0b2c..09f1e74c8e3 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
@@ -333,7 +333,7 @@ test('the dashboard is not a block, so it is not placed and cannot be deleted',
 
 test('the panel counts what is on the dashboard', () => {
   const rootId = provider.getRoot().id;
-  const section = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
+  const section = provider.addBuildingBlock(rootId, 0, { type: 'tabs' });
   provider.addBuildingBlock(section, 0, { type: 'markdown' });
   provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
   selectRoot();
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
index f7f5a0018a0..725cc34be66 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
@@ -18,18 +18,18 @@
  */
 import { useMemo, useState } from 'react';
 import type { ReactElement, ReactNode } from 'react';
+import type { views as viewsApi } from '@apache-superset/core';
 import { t } from '@apache-superset/core/translation';
 import { css, styled } from '@apache-superset/core/theme';
 import { EmptyState, Input } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
-import { views } from 'src/core/views';
+import { useViews } from 'src/core/views';
 import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
-import {
-  CONTAINER_TYPE,
-  isContainerType,
-} from 'src/core/dashboard/DashboardProvider';
+import { isContainerType } from 'src/core/dashboard/DashboardProvider';
 import { PALETTE_MIME } from 'src/core/dashboard/placement';
 
+type View = viewsApi.View;
+
 /**
  * Which shelf a block sits on.
  *
@@ -60,16 +60,23 @@ export interface PaletteEntry {
   readonly shelf: 'structure' | 'content';
 }
 
-/** Everything registered as a building block, in the order it was registered. */
-export const paletteEntries = (): readonly PaletteEntry[] =>
-  (views.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION) ?? [])
-    .filter(view => view.id !== CONTAINER_TYPE)
-    .map(view => ({
-      type: view.id,
-      label: view.name,
-      description: view.description,
-      shelf: isContainerType(view.id) ? 'structure' : 'content',
-    }));
+/**
+ * Everything registered as a building block, in the order it was registered.
+ *
+ * No filtering needed for the root's own type: `grid` is not registered
+ * here at all (see `registerBuiltInBuildingBlocks`), since the root is not a
+ * Building Block — nothing to exclude by name, because it was never in this
+ * list to begin with.
+ */
+export const paletteEntries = (
+  registered: readonly View[] | undefined,
+): readonly PaletteEntry[] =>
+  (registered ?? []).map(view => ({
+    type: view.id,
+    label: view.name,
+    description: view.description,
+    shelf: isContainerType(view.id) ? 'structure' : 'content',
+  }));
 
 const matches = (entry: PaletteEntry, query: string): boolean => {
   if (query === '') {
@@ -325,12 +332,19 @@ const Disclosure = ({
 /**
  * The building blocks, as things to place.
  *
- * The list is the registry's — `views.getViews('dashboard.buildingBlocks')`,
- * the same call `BuildingBlockView` resolves a renderer through. Registering
- * a block makes it placeable and unregistering one removes it, with no list
- * here to keep in agreement — except canvas, which stays registered so the
- * root node has something to render through but is filtered out below, since
- * placing one is not an authored feature.
+ * The list is the registry's — `useViews('dashboard.buildingBlocks')`, the
+ * same location `BuildingBlockView` resolves a renderer through for anything
+ * other than the root. Registering a block makes it placeable and
+ * unregistering one removes it, with no list here to keep in agreement.
+ *
+ * `useViews` rather than a one-time read: an extension's own building block
+ * registers itself only once its remote module has actually loaded, which
+ * is asynchronous (a network fetch for its bundle, then Module Federation's
+ * own init) and near-certain to still be in flight on this component's first
+ * render. A snapshot taken then would permanently miss every
+ * extension-contributed block that hadn't finished loading yet — built-ins
+ * never hit this because `registerBuiltInBuildingBlocks` runs synchronously
+ * at import time, well before anything here renders.
  */
 export default function Palette({
   onAdd,
@@ -340,7 +354,8 @@ export default function Palette({
   const [query, setQuery] = useState('');
   const [closed, setClosed] = useState<ReadonlySet<string>>(new Set());
 
-  const entries = useMemo(paletteEntries, []);
+  const registered = useViews(DASHBOARD_BUILDING_BLOCKS_LOCATION);
+  const entries = useMemo(() => paletteEntries(registered), [registered]);
   const found = entries.filter(entry => matches(entry, query));
   const searching = query.trim() !== '';
   const isOpen = (key: string): boolean => searching || !closed.has(key);
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
index 46c364fb4f4..0b757d4be3e 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 import userEvent from '@testing-library/user-event';
-import { act, render, screen } from 'spec/helpers/testing-library';
+import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
 import DashboardProvider from 'src/core/dashboard/DashboardProvider';
 import DashboardBuilderV2 from '.';
 
@@ -73,24 +73,55 @@ test('placing a block from the palette puts it on the dashboard and selects it',
   expect(provider.getSelection()).toBe(children[0]);
 });
 
+/** A drag payload jsdom's synthetic events do not carry on their own. */
+const paletteTransfer = (type: string) => {
+  const data = new Map([['application/x-dashboard-building-block', type]]);
+  return {
+    types: [...data.keys()],
+    getData: (key: string) => data.get(key) ?? '',
+    setData: (key: string, value: string) => data.set(key, value),
+    dropEffect: '',
+    effectAllowed: '',
+  };
+};
+
+test('dropping a palette block on a blank dashboard places it there', () => {
+  renderPage();
+
+  // `RootGrid`'s own drop target does not exist yet on a blank dashboard —
+  // this is the one that is actually on screen at that point, and it needs
+  // the identical handling or the empty state's own instruction to "drag a
+  // building block from the panel" is one this element cannot answer.
+  fireEvent.drop(screen.getByTestId('empty-canvas'), {
+    dataTransfer: paletteTransfer('markdown'),
+  });
+
+  const children = provider.getRoot().children ?? [];
+  expect(children).toHaveLength(1);
+  expect(provider.getNode(children[0])?.type).toBe('markdown');
+});
+
 test('a block placed while a container is selected goes inside it', async () => {
   renderPage();
-  // Canvas is not on the palette, but a container can still exist — placed
-  // by the assistant, or carried over from before this UI stopped offering
-  // it — and a block placed into a selected one still goes inside it.
-  const sectionId = provider.addBuildingBlock(provider.getRoot().id, 0, {
-    type: 'canvas',
+  // A 'tabs' block itself is not the container to select for this — its own
+  // children are always 'tab' panes, never a leaf placed directly — so this
+  // selects the pane, which is exactly where a leaf placed from the palette
+  // belongs.
+  const tabsId = provider.addBuildingBlock(provider.getRoot().id, 0, {
+    type: 'tabs',
+  });
+  const paneId = provider.addBuildingBlock(tabsId, 0, {
+    type: 'tab',
+    props: { label: 'Overview' },
   });
-  act(() => provider.setSelection(sectionId));
+  act(() => provider.setSelection(paneId));
 
   await userEvent.click(screen.getByTestId('palette-markdown'));
 
-  // An author who has just selected a section and reaches for a block means
-  // to put it in that section.
-  expect(provider.getNode(sectionId)?.children).toEqual([
-    provider.getSelection(),
-  ]);
-  expect(provider.getRoot().children).toEqual([sectionId]);
+  // An author who has just selected a pane and reaches for a block means to
+  // put it in that pane.
+  expect(provider.getNode(paneId)?.children).toEqual([provider.getSelection()]);
+  expect(provider.getNode(tabsId)?.children).toEqual([paneId]);
 });
 
 test('a block placed while a leaf is selected goes beside it, not inside it', async () => {
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
index 4ed2ce1d8ce..a2e6b375385 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
@@ -16,12 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { useState } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { css, styled } from '@apache-superset/core/theme';
 import { EmptyState, Flex } from '@superset-ui/core/components';
 import { dashboard, useDashboardRevision } from 'src/core/dashboard';
 import { provider } from 'src/core/dashboard/store';
-import { placeBlock } from 'src/core/dashboard/placement';
+import { PALETTE_MIME, placeBlock } from 'src/core/dashboard/placement';
 import BuildingBlockView from 'src/core/dashboard/BuildingBlockView';
 import DashboardHeader from './DashboardHeader';
 import EditorPanel from './EditorPanel';
@@ -63,28 +64,19 @@ const EmptyCanvasWrapper = styled.div`
 /**
  * The dashboard with nothing on it, as something to aim at.
  *
- * A dashed frame is what the rest of the app draws around a place a thing can
- * be dropped, and this is one — the palette drags land here. It is also the
- * only way to select the root on a blank canvas, so it answers a pointer and a
- * Tab the way anything clickable does: the frame firms up and the surface
- * lifts, rather than a dashed box that never reacts to being pressed.
+ * No border and no hover fill of its own — the root draws directly onto the
+ * grid, same as every block on it (see `BuildingBlockView`) — but it is
+ * still the only way to select the root on a blank canvas and the palette's
+ * own drop target, so a Tab still lands on it and takes a visible outline,
+ * rather than the control being unreachable from the keyboard entirely.
  */
 const CanvasPlaceholder = styled(Flex)`
   ${({ theme }) => css`
     width: 100%;
     height: 100%;
-    border: 2px dashed ${theme.colorBorder};
     border-radius: ${theme.borderRadiusLG}px;
     color: ${theme.colorTextTertiary};
     cursor: pointer;
-    transition:
-      border-color ${theme.motionDurationMid},
-      background-color ${theme.motionDurationMid};
-
-    &:hover {
-      border-color: ${theme.colorPrimaryBorderHover};
-      background-color: ${theme.colorFillQuaternary};
-    }
 
     &:focus-visible {
       outline: 2px solid ${theme.colorPrimaryBorder};
@@ -93,6 +85,26 @@ const CanvasPlaceholder = styled(Flex)`
   `}
 `;
 
+/**
+ * The same live drop indicator `RootGrid` draws once the root has a grid of
+ * its own to draw one onto (see its own `GridSurface` doc comment) — this is
+ * that same answer for the one moment there is no grid yet to ask. A blank
+ * dashboard is a single open target with only one possible outcome (the
+ * first block, full width, at the top) rather than a cell to resolve, so
+ * filling the whole placeholder is exactly right here — there is no
+ * "beside" or "below" anything yet to be more specific than "here" about,
+ * and no react-grid-layout instance underneath for this to coordinate with.
+ */
+const DropPreview = styled.div`
+  ${({ theme }) => css`
+    width: 100%;
+    height: 100%;
+    background-color: ${theme.colorPrimaryBg};
+    border: 2px dashed ${theme.colorPrimary};
+    border-radius: ${theme.borderRadiusLG}px;
+  `}
+`;
+
 /**
  * Prototype entry point for SIP item 7.1 (AI-Native Dashboards, section 7.1
  * of the design doc): a canvas paired with the chat panel, so the
@@ -119,6 +131,17 @@ export default function DashboardBuilderV2() {
   const root = dashboard.getRoot();
   const isEmpty = !root.children || root.children.length === 0;
 
+  // Whether a palette drag is currently over the empty-canvas placeholder —
+  // drives `DropPreview`, below. A counter rather than a plain boolean
+  // toggled on enter/leave: the placeholder isn't a single element, it's the
+  // wrapper plus whatever `EmptyState`/`DropPreview` renders inside it, and
+  // the pointer crossing from the wrapper onto one of those fires a `leave`
+  // on the outer element immediately followed by an `enter` on the inner one
+  // — a plain boolean would read that as leaving entirely and flicker the
+  // preview off for a frame. Only reaching zero really means "gone".
+  const [dragOverCount, setDragOverCount] = useState(0);
+  const isDragOver = dragOverCount > 0;
+
   /**
    * Places a block from the palette.
    *
@@ -184,14 +207,49 @@ export default function DashboardBuilderV2() {
                     provider.setSelection(root.id);
                   }
                 }}
+                // The same drop target `RootGrid` offers once the root has
+                // at least one child — this stands in for it beforehand,
+                // since a dashboard with nothing on it yet is exactly when
+                // this placeholder (rather than `RootGrid`) is what's on
+                // screen to drop onto. Without this, the empty state's own
+                // "Drag a building block from the panel" is an instruction
+                // this element cannot actually answer.
+                onDragEnter={event => {
+                  if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+                    setDragOverCount(count => count + 1);
+                  }
+                }}
+                onDragLeave={event => {
+                  if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+                    setDragOverCount(count => Math.max(0, count - 1));
+                  }
+                }}
+                onDragOver={event => {
+                  if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+                    event.preventDefault();
+                    event.dataTransfer.dropEffect = 'copy';
+                  }
+                }}
+                onDrop={event => {
+                  const type = event.dataTransfer.getData(PALETTE_MIME);
+                  setDragOverCount(0);
+                  if (type !== '') {
+                    event.preventDefault();
+                    placeBlock(root.id, type);
+                  }
+                }}
               >
-                <EmptyState
-                  image="empty-dashboard.svg"
-                  title={t('Start building')}
-                  description={t(
-                    'Drag a building block from the panel, or ask the assistant for one.',
-                  )}
-                />
+                {isDragOver ? (
+                  <DropPreview data-test="empty-canvas-drop-preview" />
+                ) : (
+                  <EmptyState
+                    image="empty-dashboard.svg"
+                    title={t('Start building')}
+                    description={t(
+                      'Drag a building block from the panel, or ask the assistant for one.',
+                    )}
+                  />
+                )}
               </CanvasPlaceholder>
             </EmptyCanvasWrapper>
           ) : (