You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2026/08/06 21:09:36 UTC

(superset) branch enxdev/feat/ai-native-dashboards-ui-v1 updated: style(dashboard-v2): improve the style of the controls

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

EnxDev pushed a commit to branch enxdev/feat/ai-native-dashboards-ui-v1
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/enxdev/feat/ai-native-dashboards-ui-v1 by this push:
     new 80a58e3f506 style(dashboard-v2): improve the style of the controls
80a58e3f506 is described below

commit 80a58e3f50650f798dd4cbc6d19bcf9b7e84d0a0
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Thu Aug 6 23:09:02 2026 +0200

    style(dashboard-v2): improve the style of the controls
---
 .../src/core/dashboard/BuildingBlockView.tsx       | 130 +++----
 .../src/core/dashboard/blocks/CanvasBlock.test.tsx |  13 +-
 .../pages/DashboardBuilderV2/CanvasControls.tsx    |   7 +-
 .../pages/DashboardBuilderV2/DashboardHeader.tsx   | 105 ++++--
 .../DashboardBuilderV2/DashboardProperties.tsx     |  16 +-
 .../src/pages/DashboardBuilderV2/EditorPanel.tsx   | 148 +++++---
 .../src/pages/DashboardBuilderV2/InertControl.tsx  |  38 +-
 .../src/pages/DashboardBuilderV2/Inspector.tsx     | 243 +++++++-----
 .../DashboardBuilderV2/LayoutModeSwitcher.tsx      |  65 ++--
 .../src/pages/DashboardBuilderV2/Outline.tsx       | 197 ++++++++--
 .../src/pages/DashboardBuilderV2/Palette.tsx       | 406 +++++++++++++++------
 .../src/pages/DashboardBuilderV2/PropsForm.tsx     | 145 +++++++-
 .../src/pages/DashboardBuilderV2/index.test.tsx    |   4 +-
 .../src/pages/DashboardBuilderV2/index.tsx         |  40 +-
 14 files changed, 1070 insertions(+), 487 deletions(-)

diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
index 10078eec1bc..d0afe24fc45 100644
--- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
+++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
@@ -19,7 +19,7 @@
 import { forwardRef, type HTMLAttributes } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
-import { Flex, Typography } from '@superset-ui/core/components';
+import { ActionButton, Flex, Typography } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { ErrorBoundary } from 'src/components';
 import { provider, useDashboardRevision } from './store';
@@ -53,46 +53,48 @@ function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) {
 }
 
 /**
- * The control that takes a block off the dashboard.
+ * A block's name, and what can be done to the block.
  *
- * Written as a styled component rather than inlined with the rest of the
- * header because what it needs is a hover state, and an inline style cannot
- * express one.
- *
- * It sits quiet until pointed at: a bin on every block, all of them lit,
- * would make a canvas read as a row of things about to be deleted. Under the
- * pointer the icon takes the colour the app gives a destructive action
- * everywhere else, so what it does is answered before the click rather than
- * after it. `:focus-visible` gets the same treatment, because someone
- * arriving by Tab is owed the same warning as someone arriving by mouse.
- *
- * The icon and nothing else. The button is a 24px square only so there is
- * enough of it to hit, and filling that square would light a shape the eye
- * reads as a new control appearing in the header rather than as the one
- * already there answering.
+ * Carries no surface of its own and no rule under it. The card behind this is
+ * opaque and unbroken, so a second background here would only draw a seam
+ * across it a hand's width below the top edge — the block would read as a
+ * strip and a box rather than as one card with a name on it.
  */
-const RemoveButton = styled.button`
+const BlockHeader = styled.div`
   ${({ theme }) => css`
     display: flex;
     align-items: center;
-    justify-content: center;
-    flex: 0 0 auto;
-    width: ${theme.controlHeightSM}px;
+    gap: ${theme.sizeUnit}px;
     height: ${theme.controlHeightSM}px;
-    padding: 0;
-    border: none;
-    background: none;
-    color: ${theme.colorTextTertiary};
-    cursor: pointer;
-    transition: color ${theme.motionDurationMid} ease-in-out;
-
-    &:hover,
-    &:focus-visible {
-      color: ${theme.colorError};
-    }
+    /* 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;
   `}
 `;
 
+/**
+ * What the remove control is wrapped in, and why it is wrapped at all.
+ *
+ * The control itself is `ActionButton` — the shared component for an icon
+ * action carried on a surface that is already something else, and the one the
+ * dashboard list uses for its own Delete. It takes an `onClick` with no event,
+ * so the two gestures this sits inside are stopped here instead: a click on
+ * 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`
+ * names it in `draggableCancel`, and react-grid-layout matches the selector up
+ * the ancestors, so carrying it here covers the button inside.
+ */
+const RemoveSlot = styled.span`
+  display: flex;
+  flex: 0 0 auto;
+`;
+
 interface BuildingBlockViewProps extends HTMLAttributes<HTMLDivElement> {
   nodeId: string;
 }
@@ -141,6 +143,8 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
     // ever raises an error.
     const chrome = nodeId !== provider.getRoot().id;
     const isRoot = !chrome;
+    // 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;
 
     return (
@@ -244,27 +248,7 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
             is not this button: the Outline selects any block with proper tree
             semantics and Properties carries the same Delete. */}
         {chrome && (
-          <div
-            data-test={`block-header-${nodeId}`}
-            style={{
-              display: 'flex',
-              alignItems: 'center',
-              gap: theme.sizeUnit,
-              height: headerHeight,
-              // 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.
-              paddingLeft: theme.padding,
-              paddingRight: theme.sizeUnit,
-              // No surface of its own, and no rule under it. The card behind
-              // this is opaque and unbroken, so a second background here
-              // would only draw a seam across it a hand's width below the
-              // top edge — the block would read as a strip and a box rather
-              // than as one card with a name on it.
-            }}
-          >
+          <BlockHeader data-test={`block-header-${nodeId}`}>
             <Typography.Text
               ellipsis
               data-test={`block-title-${nodeId}`}
@@ -282,27 +266,33 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
             >
               {blockLabel(node.type, node.props)}
             </Typography.Text>
-            <RemoveButton
-              type="button"
+            <RemoveSlot
               data-block-remove
-              data-test={`block-remove-${nodeId}`}
-              aria-label={t('Remove block')}
-              title={t('Remove block')}
               onMouseDown={event => event.stopPropagation()}
               onPointerDown={event => event.stopPropagation()}
-              onClick={event => {
-                event.stopPropagation();
-                provider.removeBuildingBlock(nodeId);
-              }}
+              onClick={event => event.stopPropagation()}
             >
-              {/* 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. */}
-              <Icons.DeleteOutlined iconSize="s" />
-            </RemoveButton>
-          </div>
+              <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>
+          </BlockHeader>
         )}
         {/* The block's own box, which is the whole of this element's minus
             the band above it. Subtracted in pixels off a percentage rather
diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx
index 46ec604cb4f..2e44be78c98 100644
--- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx
@@ -290,15 +290,20 @@ test('the grid is told not to start a drag from the remove control', () => {
   // react-grid-layout begins a drag on a press anywhere in the block it is
   // positioning, and the button sits inside that block. `draggableCancel` is
   // what it reads to exclude a region, so the selector and the attribute the
-  // button carries have to agree — aiming at the X would otherwise drag the
+  // control carries have to agree — aiming at the bin would otherwise drag the
   // block it is attached to.
   const cancel = screen
     .getByTestId('rgl')
     .getAttribute('data-draggable-cancel');
   expect(cancel).toContain('[data-block-remove]');
-  expect(screen.getByTestId(`block-remove-${first}`)).toHaveAttribute(
-    'data-block-remove',
-  );
+  // On the control or above it: react-draggable matches the selector against
+  // the pressed element and then walks its ancestors up to the grid item, so
+  // the attribute excludes the whole region it is set on. The bin is the
+  // shared `ActionButton`, which renders its own element and forwards no
+  // arbitrary attributes to it — the region is what carries this.
+  expect(
+    screen.getByTestId(`block-remove-${first}`).closest('[data-block-remove]'),
+  ).not.toBeNull();
 });
 
 test('clicking the remove control removes rather than selects', () => {
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx b/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx
index d161a53891b..c1875965211 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx
@@ -22,7 +22,7 @@ import { useTheme } from '@apache-superset/core/theme';
 import { Button } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { provider } from 'src/core/dashboard/store';
-import Inert, { compact } from './InertControl';
+import Inert from './InertControl';
 
 /**
  * What acts on the canvas as a whole, in the canvas's own corner.
@@ -74,13 +74,12 @@ export default function CanvasControls(): ReactElement {
       }}
     >
       <Button
-        size="small"
+        buttonSize="xsmall"
         buttonStyle="link"
         aria-label={t('Arrange dashboard')}
         data-test="canvas-arrange"
         tooltip={t('Arrange dashboard — choose how the canvas lays blocks out')}
         placement="bottom"
-        style={{ ...compact(theme), paddingInline: theme.sizeUnit }}
         onClick={() => provider.setSelection(provider.getRoot().id)}
       >
         <Icons.LayoutOutlined iconSize="s" />
@@ -94,7 +93,7 @@ export default function CanvasControls(): ReactElement {
         // styles give a wrapped button a left margin meant for a row of them
         // — so this one sat further from its neighbour than the neighbour sat
         // from anything, and the pair's spacing stopped being the `gap` above.
-        style={{ paddingInline: theme.sizeUnit, marginLeft: 0 }}
+        style={{ marginLeft: 0 }}
       >
         <Icons.ReloadOutlined iconSize="s" />
       </Inert>
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx
index 27d6464de14..96021ed0931 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx
@@ -20,8 +20,8 @@ import { useEffect, useState } from 'react';
 import type { ReactElement } from 'react';
 import { useSelector } from 'react-redux';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
-import { Input, PublishedLabel } from '@superset-ui/core/components';
+import { css, styled } from '@apache-superset/core/theme';
+import { Divider, Input, PublishedLabel } from '@superset-ui/core/components';
 import MetadataBar, {
   MetadataType,
 } from '@superset-ui/core/components/MetadataBar';
@@ -89,6 +89,39 @@ const Metadata = (): ReactElement => {
   );
 };
 
+/**
+ * The name, drawn as a name rather than as a field.
+ *
+ * A bordered box on a bar of small controls read as one more control, and the
+ * one thing on the bar that says what you are looking at was the hardest thing
+ * on it to find. Borderless at the heading weight, it reads as the title it
+ * is; the surface arriving under the pointer and on focus is what still says
+ * it can be typed into, which is the same trade the editable titles elsewhere
+ * in the app make.
+ */
+const TitleInput = styled(Input)`
+  ${({ theme }) => css`
+    max-width: ${theme.sizeUnit * 60}px;
+    height: ${theme.controlHeightSM}px;
+    padding-inline: ${theme.sizeUnit}px;
+    font-size: ${theme.fontSizeLG}px;
+    font-weight: ${theme.fontWeightStrong};
+    color: ${theme.colorText};
+    background-color: transparent;
+    transition: background-color ${theme.motionDurationMid};
+
+    &:hover,
+    &:focus {
+      background-color: ${theme.colorFillQuaternary};
+    }
+
+    &::placeholder {
+      font-weight: ${theme.fontWeightNormal};
+      color: ${theme.colorTextTertiary};
+    }
+  `}
+`;
+
 /**
  * The dashboard's name, edited where it is read.
  *
@@ -108,16 +141,15 @@ const Metadata = (): ReactElement => {
  * tick per character for everything subscribed to the store.
  */
 const Title = ({ nodeId, title }: { nodeId: string; title: string }) => {
-  const theme = useTheme();
   const [draft, setDraft] = useState(title);
   // What was accepted replaces the draft, because the draft was a view of it:
   // a rename the assistant makes while this is on screen has to show.
   useEffect(() => setDraft(title), [title]);
 
   return (
-    <Input
+    <TitleInput
       size="small"
-      style={{ maxWidth: 220, height: theme.controlHeightSM }}
+      variant="borderless"
       value={draft}
       aria-label={t('Dashboard title')}
       placeholder={t('Untitled dashboard')}
@@ -138,6 +170,37 @@ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => {
   );
 };
 
+/**
+ * The bar itself.
+ *
+ * Inset horizontally the way the rest of the app insets a page header, so the
+ * left edge of the bar and the left edge of the work below it are one line
+ * rather than two a few pixels apart. The rule beneath is `colorSplit` — what
+ * this app draws a separator with — rather than the heavier border it shares
+ * with the boxes that hold things.
+ */
+const Bar = styled.header`
+  ${({ theme }) => css`
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    flex: 0 0 auto;
+    padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
+    border-bottom: 1px solid ${theme.colorSplit};
+    background-color: ${theme.colorBgContainer};
+  `}
+`;
+
+/** What an author does to the whole dashboard, at the end they read last. */
+const Actions = styled.span`
+  ${({ theme }) => css`
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    margin-left: auto;
+  `}
+`;
+
 /**
  * The dashboard's header: what this dashboard is, and what can be done to it.
  *
@@ -156,22 +219,10 @@ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => {
  */
 export default function DashboardHeader(): ReactElement {
   useDashboardRevision();
-  const theme = useTheme();
   const root = provider.getRoot();
 
   return (
-    <header
-      data-test="dashboard-header"
-      style={{
-        display: 'flex',
-        alignItems: 'center',
-        gap: theme.sizeUnit * 2,
-        flex: '0 0 auto',
-        padding: theme.sizeUnit * 2,
-        borderBottom: `1px solid ${theme.colorBorder}`,
-        background: theme.colorBgContainer,
-      }}
-    >
+    <Bar data-test="dashboard-header">
       {/* Where this dashboard came from: a starting point to build on, asked
           before the work rather than during it, which is why it leads the
           bar. History used to sit beside it on that reasoning and has gone to
@@ -196,14 +247,7 @@ export default function DashboardHeader(): ReactElement {
           one question — what state is this in — and they are read together. */}
       <Metadata />
 
-      <span
-        style={{
-          marginLeft: 'auto',
-          display: 'flex',
-          alignItems: 'center',
-          gap: theme.sizeUnit * 2,
-        }}
-      >
+      <Actions>
         {/* Icons, not words, because these two are reached by muscle memory
             far more often than they are read. The name stays on them for
             anyone not reading with their eyes. */}
@@ -213,6 +257,11 @@ export default function DashboardHeader(): ReactElement {
         <Inert label={t('Redo')} test="header-redo">
           <Icons.RedoOutlined iconSize="s" />
         </Inert>
+        {/* Stepping back through the work and writing it down are two
+            different acts on the bar's one crowded end, and at an even gap
+            the four of them read as one run of controls. The rule is what
+            says where one pair stops and the other starts. */}
+        <Divider type="vertical" />
         {/* Saving commits a version; History is the versions already
             committed. One concern, read in one place — so the record sits
             immediately before the button that produces what it lists, rather
@@ -223,7 +272,7 @@ export default function DashboardHeader(): ReactElement {
         <Inert label={t('Save')} test="header-save" reads>
           {t('Save')}
         </Inert>
-      </span>
-    </header>
+      </Actions>
+    </Bar>
   );
 }
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx
index d6fd3359d33..803051557f6 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx
@@ -77,15 +77,23 @@ const asString = (value: unknown): string =>
  *
  * So the sections are kept and their wrapper is not: this matches `Section`
  * in the Inspector, which is what a group of fields is called everywhere else
- * in this panel. The ticks go with it — they report on a save that this page
- * cannot do, and each section still says what is wrong with it where the
- * wrong thing is.
+ * in this panel — the same size as the labels it introduces, carrying the
+ * difference in weight rather than in size, so a heading is not set in less
+ * than the fields beneath it. The ticks go with the wrapper — they report on a
+ * save that this page cannot do, and each section still says what is wrong
+ * with it where the wrong thing is.
  */
 const sectionLabel = (
   theme: ReturnType<typeof useTheme>,
   title: string,
 ): ReactElement => (
-  <span style={{ fontSize: theme.fontSizeSM, color: theme.colorTextSecondary }}>
+  <span
+    style={{
+      fontSize: theme.fontSize,
+      fontWeight: theme.fontWeightStrong,
+      color: theme.colorText,
+    }}
+  >
     {title}
   </span>
 );
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
index 523bd2152cf..aafe7c08f71 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
@@ -19,7 +19,7 @@
 import { useCallback, useEffect, useRef, useState } from 'react';
 import type { KeyboardEvent, PointerEvent, ReactElement } from 'react';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
+import { css, styled } from '@apache-superset/core/theme';
 import { Button, Tabs } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { provider, useDashboardRevision } from 'src/core/dashboard/store';
@@ -46,6 +46,84 @@ const KEYBOARD_STEP = 16;
 const clampWidth = (width: number): number =>
   Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width));
 
+/**
+ * The rail, open or shut.
+ *
+ * Separated from the canvas with `colorSplit`, the same hairline the header
+ * rules itself off with, so the three edges of the authoring shell are one
+ * line and not three weights of one.
+ */
+const Rail = styled.aside`
+  ${({ theme }) => css`
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    position: relative;
+    /* The panel is a fixed-height column and the scrolling happens inside the
+       tab body, so the tab bar stays put however long a form gets. */
+    overflow: hidden;
+    padding: ${theme.sizeUnit * 2}px;
+    border-right: 1px solid ${theme.colorSplit};
+    background-color: ${theme.colorBgContainer};
+  `}
+`;
+
+const ClosedRail = styled.aside`
+  ${({ theme }) => css`
+    flex-shrink: 0;
+    display: flex;
+    justify-content: center;
+    padding: ${theme.sizeUnit}px;
+    border-right: 1px solid ${theme.colorSplit};
+    background-color: ${theme.colorBgContainer};
+  `}
+`;
+
+/**
+ * The edge, as something to take hold of.
+ *
+ * The hit area is wide enough to aim at and the line inside it is not: a band
+ * of colour the width of the target announced itself as a bar being added to
+ * the layout rather than as the edge answering. What lights is a rule down the
+ * middle, which is the edge the pointer is already on.
+ *
+ * Coloured on focus as well as on hover, because focus is the state with no
+ * cursor to read — and the width of the authoring surface must be reachable
+ * without a pointer at all.
+ */
+const Grip = styled.div<{ $active: boolean }>`
+  ${({ theme, $active }) => css`
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    /* Wide enough to be worth aiming at, sitting over the panel's own border
+       so the edge is the target rather than a strip beside it. */
+    width: ${theme.sizeUnit * 2}px;
+    z-index: 1;
+    cursor: col-resize;
+    touch-action: none;
+
+    /* Sat at the edge itself rather than a few pixels inside it: what lights
+       has to be the line the panel already ends on, or it reads as a second
+       rule appearing beside the first. */
+    &::after {
+      content: '';
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      right: 0;
+      width: 2px;
+      background-color: ${$active ? theme.colorPrimary : 'transparent'};
+      transition: background-color ${theme.motionDurationMid};
+    }
+
+    &:focus-visible {
+      outline: none;
+    }
+  `}
+`;
+
 /**
  * The authoring panel: one rail, three ways of working on a dashboard.
  *
@@ -60,7 +138,6 @@ export default function EditorPanel({
   onAdd: (type: string) => void;
 }): ReactElement {
   useDashboardRevision();
-  const theme = useTheme();
   const [tab, setTab] = useState<PanelTab>('blocks');
   const [width, setWidth] = useState(DEFAULT_WIDTH);
   /** Whether the rail is out of the way. The width it had is kept either way. */
@@ -164,20 +241,9 @@ export default function EditorPanel({
    */
   if (closed) {
     return (
-      <aside
-        data-test="editor-panel"
-        aria-label={t('Editor panel')}
-        style={{
-          flexShrink: 0,
-          display: 'flex',
-          justifyContent: 'center',
-          padding: theme.sizeUnit,
-          borderRight: `1px solid ${theme.colorBorder}`,
-          background: theme.colorBgContainer,
-        }}
-      >
+      <ClosedRail data-test="editor-panel" aria-label={t('Editor panel')}>
         <Button
-          size="small"
+          buttonSize="xsmall"
           buttonStyle="link"
           data-test="panel-expand"
           aria-label={t('Show the editor panel')}
@@ -185,35 +251,21 @@ export default function EditorPanel({
           tooltip={t('Show the editor panel')}
           placement="right"
           onClick={() => setClosed(false)}
-          style={{
-            height: theme.controlHeightSM,
-            paddingInline: theme.sizeUnit,
-          }}
         >
           <Icons.MenuUnfoldOutlined iconSize="m" />
         </Button>
-      </aside>
+      </ClosedRail>
     );
   }
 
   return (
-    <aside
+    <Rail
       ref={panel}
       data-test="editor-panel"
       aria-label={t('Editor panel')}
-      style={{
-        width,
-        flexShrink: 0,
-        display: 'flex',
-        flexDirection: 'column',
-        position: 'relative',
-        // The panel is a fixed-height column and the scrolling happens inside
-        // the tab body, so the tab bar stays put however long a form gets.
-        overflow: 'hidden',
-        padding: theme.sizeUnit * 2,
-        borderRight: `1px solid ${theme.colorBorder}`,
-        background: theme.colorBgContainer,
-      }}
+      // The one thing that cannot be a class: it is a value the author sets by
+      // dragging, and a class per pixel is a stylesheet per drag.
+      style={{ width }}
     >
       <Tabs
         activeKey={tab}
@@ -226,7 +278,7 @@ export default function EditorPanel({
         tabBarExtraContent={{
           right: (
             <Button
-              size="small"
+              buttonSize="xsmall"
               buttonStyle="link"
               data-test="panel-collapse"
               aria-label={t('Hide the editor panel')}
@@ -234,10 +286,6 @@ export default function EditorPanel({
               tooltip={t('Hide the editor panel')}
               placement="bottom"
               onClick={() => setClosed(true)}
-              style={{
-                height: theme.controlHeightSM,
-                paddingInline: theme.sizeUnit,
-              }}
             >
               <Icons.MenuFoldOutlined iconSize="m" />
             </Button>
@@ -264,7 +312,7 @@ export default function EditorPanel({
       {/* The rule's suggested `hr` is the static kind of separator: it takes
           neither focus nor a value, and both are what make this one a
           splitter an author can reach without a pointer. */}
-      <div
+      <Grip
         // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
         role="separator"
         tabIndex={0}
@@ -274,6 +322,7 @@ export default function EditorPanel({
         aria-valuenow={width}
         aria-valuemin={MIN_WIDTH}
         aria-valuemax={MAX_WIDTH}
+        $active={gripped}
         onPointerDown={startDrag}
         onPointerMove={drag}
         onPointerUp={endDrag}
@@ -282,24 +331,7 @@ export default function EditorPanel({
         onPointerLeave={() => setGripped(from.current !== null)}
         onFocus={() => setGripped(true)}
         onBlur={() => setGripped(false)}
-        style={{
-          position: 'absolute',
-          top: 0,
-          right: 0,
-          bottom: 0,
-          // Wide enough to be worth aiming at, sitting over the panel's own
-          // border so the edge is the target rather than a strip beside it.
-          width: theme.sizeUnit * 2,
-          zIndex: 1,
-          cursor: 'col-resize',
-          // The cursor alone only answers an author who already suspected the
-          // edge could move. Colouring it under the pointer — and on focus,
-          // where there is no cursor to read — is what says so first.
-          background: gripped ? theme.colorPrimaryBorder : 'transparent',
-          transition: `background ${theme.motionDurationMid}`,
-          touchAction: 'none',
-        }}
       />
-    </aside>
+    </Rail>
   );
 }
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx b/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx
index 072372dcfa1..c94aec238ad 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx
@@ -18,36 +18,27 @@
  */
 import type { ReactElement, ReactNode } from 'react';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
 import { Button, type ButtonProps } from '@superset-ui/core/components';
 
 const NOT_AVAILABLE = t('Not available yet');
 
 /**
- * This prototype's controls, at two sizes.
+ * This prototype's controls, at two of the shared Button's own sizes.
  *
- * Nothing here is drawn at full size: these bars are chrome around the work
- * rather than the work itself, and every pixel they take is one the canvas
- * does not get. But the two kinds of control on them are not read the same
- * way. A word is read, and one squeezed to the smallest step the theme has is
+ * Nothing here is drawn at full size: the bars and rails are chrome around the
+ * work rather than the work itself, and every pixel they take is one the
+ * canvas does not get. But the two kinds of control on them are not read the
+ * same way. A word is read, and one squeezed to the smallest step there is is
  * read slowly; an icon is recognised by its shape, and loses nothing there.
  *
- * Both are driven off the theme's own control scale rather than literals, so
- * they track the size the rest of the app is built on instead of drifting.
+ * Said in `buttonSize`, which is the prop the shared `Button` actually reads.
+ * `size` is antd's, and the wrapper writes its own height over whatever antd
+ * does with it — so every control here asked for `size="small"`, got the full
+ * 32px default, and was then pushed back down by a hand-written height,
+ * padding and font size at each site. Those helpers were a copy of this scale
+ * maintained beside it, free to drift from it and answering to no theme
+ * override; `buttonSize` is the scale itself.
  */
-export const named = (theme: ReturnType<typeof useTheme>) => ({
-  height: theme.controlHeightSM,
-  paddingInline: theme.sizeUnit * 2.5,
-  fontSize: theme.fontSize,
-  lineHeight: 1,
-});
-
-export const compact = (theme: ReturnType<typeof useTheme>) => ({
-  height: theme.controlHeightXS,
-  paddingInline: theme.sizeUnit * 1.5,
-  fontSize: theme.fontSizeSM,
-  lineHeight: 1,
-});
 
 /**
  * An affordance that is present, named and honest about not working.
@@ -81,17 +72,16 @@ export default function Inert({
   style?: ButtonProps['style'];
   children: ReactNode;
 }): ReactElement {
-  const theme = useTheme();
   return (
     <Button
-      size="small"
+      buttonSize={reads ? 'small' : 'xsmall'}
       buttonStyle={buttonStyle}
       disabled
       aria-label={label}
       data-test={test}
       tooltip={`${label} — ${NOT_AVAILABLE}`}
       placement="bottom"
-      style={{ ...(reads ? named(theme) : compact(theme)), ...style }}
+      style={style}
     >
       {children}
     </Button>
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx
index 4d4d756d3f8..04eb6c8e0fe 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx
@@ -20,9 +20,10 @@ import { useEffect, useState } from 'react';
 import type { ReactElement, ReactNode } from 'react';
 import type { dashboard as dashboardApi } from '@apache-superset/core';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
 import {
   Button,
+  EmptyState,
   Form,
   Input,
   InputNumber,
@@ -33,6 +34,7 @@ import {
 import { Icons } from '@superset-ui/core/components/Icons';
 import copyTextToClipboard from 'src/utils/copy';
 import { provider, useDashboardRevision } from 'src/core/dashboard/store';
+import { blockLabel } from 'src/core/dashboard/blockLabel';
 import { resolveLayoutMode } from 'src/core/dashboard/layoutStyle';
 import DashboardProperties from './DashboardProperties';
 import LayoutModeSwitcher from './LayoutModeSwitcher';
@@ -194,20 +196,68 @@ const FlexFields = ({
 };
 
 /**
- * The panel's own buttons, sized down.
+ * A group of fields, and where one stops.
  *
- * Everything here acts on one block, in a rail whose width is spent on the
- * fields rather than on the controls that commit them — and a panel of
- * full-height buttons reads as a row of decisions before you have read what
- * any of them apply to. Driven off the theme's smallest control step, the
- * same one the header's icon controls sit at, so the two rails agree.
+ * The panel is a single column that can run several screens deep, and the
+ * headings alone were doing all the work of dividing it — set at the same
+ * weight as the field labels beneath them, they read as one more label rather
+ * than as the top of a group. The rule above each section is what actually
+ * separates them; the heading is bolder so a scan finds it first.
  */
-const minor = (theme: ReturnType<typeof useTheme>) => ({
-  height: theme.controlHeightXS,
-  paddingInline: theme.sizeUnit * 2,
-  fontSize: theme.fontSizeSM,
-  lineHeight: 1,
-});
+const Group = styled.section`
+  ${({ theme }) => css`
+    margin-top: ${theme.sizeUnit * 4}px;
+    padding-top: ${theme.sizeUnit * 4}px;
+    border-top: 1px solid ${theme.colorSplit};
+  `}
+`;
+
+/**
+ * At the size the fields under it are labelled, and heavier.
+ *
+ * Smaller and greyer than the labels it introduces, a section heading reads as
+ * a caption belonging to the field above rather than as the top of the group
+ * below — the hierarchy inverted, with "Content" the section set in less than
+ * "Content" the field. Weight carries the difference instead, with the rule
+ * above doing the separating.
+ */
+const GroupTitle = styled.h4`
+  ${({ theme }) => css`
+    margin: 0 0 ${theme.sizeUnit * 2}px;
+    font-size: ${theme.fontSize}px;
+    font-weight: ${theme.fontWeightStrong};
+    color: ${theme.colorText};
+  `}
+`;
+
+/** Where the panel ends, and the one control that ends the block with it. */
+const Footer = styled.div`
+  ${({ theme }) => css`
+    margin-top: ${theme.sizeUnit * 4}px;
+    padding-top: ${theme.sizeUnit * 4}px;
+    border-top: 1px solid ${theme.colorSplit};
+  `}
+`;
+
+/** What is selected, named the way the canvas and the Outline name it. */
+const IdentityName = styled.h3`
+  ${({ theme }) => css`
+    margin: 0;
+    font-size: ${theme.fontSize}px;
+    font-weight: ${theme.fontWeightStrong};
+    color: ${theme.colorText};
+    overflow-wrap: anywhere;
+  `}
+`;
+
+const IdentityMeta = styled.p`
+  ${({ theme }) => css`
+    margin: ${theme.sizeUnit}px 0 0;
+    font-size: ${theme.fontSizeSM}px;
+    color: ${theme.colorTextTertiary};
+    word-break: break-all;
+  `}
+`;
 
 const Section = ({
   title,
@@ -217,23 +267,12 @@ const Section = ({
   title: string;
   test: string;
   children: ReactNode;
-}): ReactElement => {
-  const theme = useTheme();
-  return (
-    <section data-test={test} style={{ marginTop: theme.sizeUnit * 4 }}>
-      <h4
-        style={{
-          margin: `0 0 ${theme.sizeUnit * 2}px`,
-          fontSize: theme.fontSizeSM,
-          color: theme.colorTextSecondary,
-        }}
-      >
-        {title}
-      </h4>
-      {children}
-    </section>
-  );
-};
+}): ReactElement => (
+  <Group data-test={test}>
+    <GroupTitle>{title}</GroupTitle>
+    {children}
+  </Group>
+);
 
 /**
  * A number that may be absent, and stays absent when cleared.
@@ -294,10 +333,10 @@ const ContentField = ({
   useEffect(() => setDraft(content), [content, nodeId]);
 
   return (
-    <Form.Item
-      label={t('Content')}
-      style={{ marginBottom: theme.sizeUnit * 2 }}
-    >
+    // "Text", not "Content": the section this sits in is already called
+    // Content, and the two stacked read as the same word said twice. What the
+    // box holds is prose, which is what the label should say.
+    <Form.Item label={t('Text')} style={{ marginBottom: theme.sizeUnit * 2 }}>
       <Input.TextArea
         size="small"
         rows={4}
@@ -337,18 +376,23 @@ const StackingControls = ({ nodeId }: { nodeId: string }): ReactElement => {
   const theme = useTheme();
   return (
     <div style={{ display: 'flex', gap: theme.sizeUnit }}>
+      {/* `secondary`, which is what this app calls a button that is not the
+          headline action — the style Cancel takes beside Save. Left unsaid the
+          shared Button draws every one of them `primary`, which is two filled
+          headline buttons for what is a pair of nudges. Neither of these
+          commits anything, and neither leads. */}
       <Button
-        size="small"
+        buttonSize="xsmall"
+        buttonStyle="secondary"
         data-test="inspector-bring-to-front"
-        style={minor(theme)}
         onClick={() => provider.bringToFront(nodeId)}
       >
         {t('Bring to front')}
       </Button>
       <Button
-        size="small"
+        buttonSize="xsmall"
+        buttonStyle="secondary"
         data-test="inspector-send-to-back"
-        style={minor(theme)}
         onClick={() => provider.sendToBack(nodeId)}
       >
         {t('Send to back')}
@@ -453,10 +497,9 @@ const PropsJsonEditor = ({
       )}
       <div style={{ display: 'flex', gap: theme.sizeUnit }}>
         <Button
-          size="small"
+          buttonSize="xsmall"
           buttonStyle="primary"
           data-test="inspector-props-apply"
-          style={minor(theme)}
           disabled={parsed === undefined || !dirty}
           onClick={() => {
             if (parsed === undefined) {
@@ -473,10 +516,13 @@ const PropsJsonEditor = ({
         >
           {t('Apply')}
         </Button>
+        {/* `secondary` beside the primary Apply — the pairing this app uses
+            wherever one button commits and the one next to it does not. Two
+            `primary` buttons side by side say both are the thing to press. */}
         <Button
-          size="small"
+          buttonSize="xsmall"
+          buttonStyle="secondary"
           data-test="inspector-props-revert"
-          style={minor(theme)}
           disabled={!dirty}
           onClick={() => setDraft(accepted)}
         >
@@ -490,13 +536,13 @@ const PropsJsonEditor = ({
             message, and a copy that says nothing leaves you pressing it
             again to be sure. */}
         <Button
-          size="small"
+          buttonSize="xsmall"
           buttonStyle="link"
           data-test="inspector-props-copy"
           aria-label={t('Copy properties as JSON')}
           tooltip={copied ? t('Copied') : t('Copy properties as JSON')}
           placement="bottom"
-          style={{ ...minor(theme), marginLeft: 'auto' }}
+          style={{ marginLeft: 'auto' }}
           onClick={() => {
             copyTextToClipboard(() => Promise.resolve(draft));
             setCopied(true);
@@ -614,17 +660,16 @@ export default function Inspector(): ReactElement {
 
   if (!node) {
     return (
-      <p
-        data-test="inspector-empty"
-        style={{
-          ...inset,
-          margin: 0,
-          color: theme.colorTextTertiary,
-          fontSize: theme.fontSizeSM,
-        }}
-      >
-        {t('Select a block to edit its properties.')}
-      </p>
+      <div data-test="inspector-empty" style={inset}>
+        <EmptyState
+          size="small"
+          image="empty.svg"
+          title={t('Nothing selected')}
+          description={t(
+            'Pick a block on the canvas, or a row in the Outline, to edit it here.',
+          )}
+        />
+      </div>
     );
   }
 
@@ -653,16 +698,18 @@ export default function Inspector(): ReactElement {
       {isRoot ? (
         <DashboardProperties />
       ) : (
-        <p
-          data-test="inspector-identity"
-          style={{
-            margin: 0,
-            color: theme.colorTextSecondary,
-            wordBreak: 'break-all',
-          }}
-        >
-          {node.type} · {node.id}
-        </p>
+        // The same shape the dashboard's own panel opens with: what this is,
+        // then the smaller print about it. The name is `blockLabel`'s — the
+        // one the canvas header and the Outline row already use — so a block
+        // is called one thing in all three places, and the type and id sit
+        // under it as what they are, a fact about the block rather than its
+        // name.
+        <div data-test="inspector-identity">
+          <IdentityName>{blockLabel(node.type, node.props)}</IdentityName>
+          <IdentityMeta>
+            {node.type} · {node.id}
+          </IdentityMeta>
+        </div>
       )}
 
       {/* Outside the `Form` below, and each half of it wrapping its own —
@@ -690,23 +737,24 @@ export default function Inspector(): ReactElement {
             title={t('Arrangement')}
             test="inspector-section-arrangement"
           >
+            {/* One run of fields: the mode is asked as a field now, so it
+                spaces itself against the rest rather than needing a gap put
+                between it and them. */}
             <LayoutModeSwitcher nodeId={node.id} />
-            <div style={{ marginTop: theme.sizeUnit * 3 }}>
-              {CONTAINER_FIELDS.map(field => (
-                <NumberField
-                  key={field.key}
-                  label={field.label}
-                  test={`inspector-${field.key}`}
-                  value={node.layout?.[field.key] as number | undefined}
-                  onChange={next =>
-                    provider.updateLayout(node.id, { [field.key]: next })
-                  }
-                />
-              ))}
-              {resolveLayoutMode(node.layout) === 'flex' && (
-                <FlexFields nodeId={node.id} layout={node.layout} />
-              )}
-            </div>
+            {CONTAINER_FIELDS.map(field => (
+              <NumberField
+                key={field.key}
+                label={field.label}
+                test={`inspector-${field.key}`}
+                value={node.layout?.[field.key] as number | undefined}
+                onChange={next =>
+                  provider.updateLayout(node.id, { [field.key]: next })
+                }
+              />
+            ))}
+            {resolveLayoutMode(node.layout) === 'flex' && (
+              <FlexFields nodeId={node.id} layout={node.layout} />
+            )}
           </Section>
         )}
 
@@ -738,17 +786,30 @@ export default function Inspector(): ReactElement {
       </Form>
 
       {/* `removeBuildingBlock` refuses the root, so offering it here would be
-          a button that only ever raises. */}
+          a button that only ever raises.
+
+          Ruled off from the fields above it rather than following them at a
+          gap: everything else in this column changes the block, and this is
+          the one control that ends it. The rule is the same one that divides
+          the sections, so the panel reads as ending here rather than as
+          having one more field. */}
       {!isRoot && (
-        <Button
-          size="small"
-          danger
-          data-test="inspector-delete"
-          style={{ ...minor(theme), marginTop: theme.sizeUnit * 3 }}
-          onClick={() => provider.removeBuildingBlock(node.id)}
-        >
-          {t('Delete')}
-        </Button>
+        <Footer>
+          <Button
+            buttonSize="xsmall"
+            // `buttonStyle`, not antd's own `danger`: the shared Button reads
+            // the former and derives the latter from it, so a bare `danger`
+            // is dropped and the control falls back to `primary` — which drew
+            // the one destructive thing in this panel as its filled headline
+            // action.
+            buttonStyle="danger"
+            icon={<Icons.DeleteOutlined iconSize="s" />}
+            data-test="inspector-delete"
+            onClick={() => provider.removeBuildingBlock(node.id)}
+          >
+            {t('Delete block')}
+          </Button>
+        </Footer>
       )}
     </div>
   );
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx
index 7f9a20a3fcf..ae61f668827 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx
@@ -19,8 +19,8 @@
 import type { ReactElement } from 'react';
 import type { dashboard as dashboardApi } from '@apache-superset/core';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
-import { Radio, Tooltip } from '@superset-ui/core/components';
+import { css, styled } from '@apache-superset/core/theme';
+import { Form, Radio, Tooltip } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { provider, useDashboardRevision } from 'src/core/dashboard/store';
 import { resolveLayoutMode } from 'src/core/dashboard/layoutStyle';
@@ -61,6 +61,21 @@ const MODES: readonly {
   },
 ];
 
+/** Spaced like the number fields it sits above, so the section reads evenly. */
+const ModeField = styled(Form.Item)`
+  ${({ theme }) => css`
+    margin-bottom: ${theme.sizeUnit * 2}px;
+  `}
+`;
+
+const ModeLabel = styled.span`
+  ${({ theme }) => css`
+    display: inline-flex;
+    align-items: center;
+    gap: ${theme.sizeUnit}px;
+  `}
+`;
+
 /**
  * How one container arranges its children.
  *
@@ -80,7 +95,6 @@ export default function LayoutModeSwitcher({
   nodeId: string;
 }): ReactElement | null {
   useDashboardRevision();
-  const theme = useTheme();
   const node = provider.getNode(nodeId);
   if (!node?.children) {
     return null;
@@ -88,27 +102,18 @@ export default function LayoutModeSwitcher({
   const mode = resolveLayoutMode(node.layout);
 
   return (
-    <div
-      data-test="layout-mode-switcher"
-      style={{
-        display: 'flex',
-        alignItems: 'center',
-        gap: theme.sizeUnit * 2,
-      }}
-    >
-      <span
-        id={`layout-mode-label-${nodeId}`}
-        style={{
-          fontSize: theme.fontSizeSM,
-          color: theme.colorTextTertiary,
-        }}
-      >
-        {t('Layout')}
-      </span>
+    // Asked the way every other field in this section is asked — label above,
+    // control beneath. Beside its control it was the one question in the
+    // Arrangement section reading left to right, which made a setting that
+    // belongs with the columns and the gap look like chrome sitting over them.
+    <ModeField label={t('Layout')} data-test="layout-mode-switcher">
       <Radio.Group
         size="small"
         value={mode}
-        aria-labelledby={`layout-mode-label-${nodeId}`}
+        // The `Form.Item` label has no control id to point at without a
+        // `name`, so the group carries its own name rather than going
+        // unannounced.
+        aria-label={t('Layout')}
         onChange={event =>
           provider.updateLayout(nodeId, {
             mode: event.target.value as LayoutMode,
@@ -120,21 +125,17 @@ export default function LayoutModeSwitcher({
             <Radio.Button
               value={option.key}
               data-test={`layout-mode-${option.key}`}
-              // Sized with the rest of the header rather than left at antd's
-              // small step: a control that stands taller than everything
-              // beside it reads as a different kind of thing.
-              style={{
-                height: theme.controlHeightSM,
-                paddingInline: theme.sizeUnit * 2,
-                fontSize: theme.fontSizeSM,
-                lineHeight: `${theme.controlHeightSM - 2}px`,
-              }}
             >
-              {option.icon} {option.label}
+              {/* The icon and the name are one label, spaced by the layout
+                  rather than by a text node holding a space. */}
+              <ModeLabel>
+                {option.icon}
+                {option.label}
+              </ModeLabel>
             </Radio.Button>
           </Tooltip>
         ))}
       </Radio.Group>
-    </div>
+    </ModeField>
   );
 }
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx
index 609d83228f3..2cd27aa0b95 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx
@@ -18,7 +18,8 @@
  */
 import type { ReactElement } from 'react';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
+import { css, styled } from '@apache-superset/core/theme';
+import { EmptyState } from '@superset-ui/core/components';
 import { views } from 'src/core/views';
 import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
 import { provider, useDashboardRevision } from 'src/core/dashboard/store';
@@ -69,6 +70,140 @@ const select = (nodeId: string): void => {
     ?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
 };
 
+/**
+ * A node, as a tile to read and to reach through.
+ *
+ * The same tile the palette is built from, because the two panels are the same
+ * kind of thing seen twice: a tree of blocks, one of blocks you could place
+ * and one of blocks you did. A block that is a bordered tile with a name in
+ * the Building blocks tab and a bare line of text in the Outline reads as two
+ * different kinds of object.
+ *
+ * What it does not borrow is the grip: these do not drag. What it adds is
+ * selection, which the palette has no equivalent of — the accent border and
+ * fill, kept through hover so a pointer passing over the selected tile does
+ * not read as unselecting it.
+ */
+const OutlineTile = styled.div<{ $selected: boolean }>`
+  ${({ theme, $selected }) => css`
+    position: relative;
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    padding: ${theme.sizeUnit * 2}px;
+    border: 1px solid ${$selected ? theme.colorPrimary : theme.colorBorder};
+    border-radius: ${theme.borderRadiusSM}px;
+    background-color: ${
+      $selected ? theme.colorPrimaryBg : theme.colorFillQuaternary
+    };
+    font-size: ${theme.fontSizeSM}px;
+    color: ${$selected ? theme.colorPrimaryText : theme.colorText};
+    cursor: pointer;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    transition:
+      border-color ${theme.motionDurationMid},
+      background-color ${theme.motionDurationMid};
+
+    &:hover {
+      border-color: ${
+        $selected ? theme.colorPrimary : theme.colorPrimaryBorderHover
+      };
+      background-color: ${
+        $selected ? theme.colorPrimaryBgHover : theme.colorFillTertiary
+      };
+    }
+
+    &:focus-visible {
+      outline: 2px solid ${theme.colorPrimaryBorder};
+      outline-offset: -2px;
+    }
+  `}
+`;
+
+/** The tree itself: the reset, and the space between what is at its top. */
+const List = styled.ul`
+  ${({ theme }) => css`
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit}px;
+  `}
+`;
+
+/**
+ * A node's children, and the guide that says they are its.
+ *
+ * The same treatment the palette gives a shelf, for the same reason and at the
+ * same measurements: indentation alone leaves the eye to infer the grouping
+ * from an edge that is not drawn, and here the nesting can run deeper than the
+ * palette's single level, so there is that much more to infer.
+ *
+ * Drawn from here rather than from the tile, because unlike the palette a tile
+ * in this tree may itself hold a branch — and the guide has to clear that
+ * whole subtree to reach the sibling below it. So the vertical is drawn on the
+ * list item, which is the tile *and* everything under it; only the last item
+ * draws it on its own tile instead, stopping at the stub. A guide that carries
+ * on past the final tile reads as a branch with something still to come; one
+ * drawn on every tile would break wherever a node had children.
+ */
+const Branch = styled(List)`
+  ${({ theme }) => css`
+    margin-top: ${theme.sizeUnit}px;
+    margin-left: ${theme.sizeUnit * 2}px;
+    padding-left: ${theme.sizeUnit * 3}px;
+
+    & > li {
+      position: relative;
+    }
+
+    /* The vertical, past everything this item holds, to the one below it. */
+    & > li:not(:last-child)::before,
+    /* The last item's, stopping where its own stub meets it. */
+    & > li:last-child > [role='treeitem']::before,
+    /* Every item's stub back to the guide. */
+    & > li > [role='treeitem']::after {
+      content: '';
+      position: absolute;
+      left: -${theme.sizeUnit * 3}px;
+      background-color: ${theme.colorBorder};
+    }
+
+    & > li:not(:last-child)::before {
+      top: -${theme.sizeUnit}px;
+      bottom: -${theme.sizeUnit}px;
+      width: 1px;
+    }
+
+    & > li:last-child > [role='treeitem']::before {
+      top: -${theme.sizeUnit}px;
+      bottom: 50%;
+      width: 1px;
+    }
+
+    & > li > [role='treeitem']::after {
+      top: 50%;
+      width: ${theme.sizeUnit * 3}px;
+      height: 1px;
+    }
+  `}
+`;
+
+/**
+ * Set down from the tab bar and in from the panel edge, the same step the
+ * palette and the inspector take from theirs. Flush against the tabs, the
+ * first row read as a caption hanging off them rather than as the top of a
+ * list — and the three tabs of one panel should start on one line.
+ */
+const Panel = styled.div`
+  ${({ theme }) => css`
+    padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit}px 0;
+  `}
+`;
+
 const Row = ({
   nodeId,
   depth,
@@ -76,7 +211,6 @@ const Row = ({
   nodeId: string;
   depth: number;
 }): ReactElement | null => {
-  const theme = useTheme();
   const node = provider.getNode(nodeId);
   if (!node) {
     return null;
@@ -86,12 +220,13 @@ const Row = ({
 
   return (
     <li role="none">
-      <div
+      <OutlineTile
         role="treeitem"
         aria-level={depth + 1}
         aria-selected={selected}
         tabIndex={selected ? 0 : -1}
         data-test={`outline-row-${nodeId}`}
+        $selected={selected}
         onClick={() => select(nodeId)}
         onKeyDown={event => {
           if (event.key === 'Enter' || event.key === ' ') {
@@ -99,38 +234,22 @@ const Row = ({
             select(nodeId);
           }
         }}
-        style={{
-          display: 'flex',
-          alignItems: 'center',
-          gap: theme.sizeUnit,
-          padding: `${theme.sizeUnit / 2}px ${theme.sizeUnit}px`,
-          paddingLeft: theme.sizeUnit * (1 + depth * 3),
-          borderRadius: theme.borderRadius,
-          fontSize: theme.fontSizeSM,
-          color: selected ? theme.colorPrimaryText : theme.colorText,
-          background: selected ? theme.colorPrimaryBg : undefined,
-          cursor: 'pointer',
-          whiteSpace: 'nowrap',
-          overflow: 'hidden',
-          textOverflow: 'ellipsis',
-        }}
       >
         {labelOf(node.type, node.props)}
-      </div>
+      </OutlineTile>
       {children.length > 0 && (
-        <ul
+        <Branch
           // The tags the rule suggests are document sections, not tree
           // structure. `group` inside `tree` is the pattern WAI-ARIA
           // specifies for a treeitem's children, and a screen reader's tree
           // navigation reads it — no semantic element means this.
           // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
           role="group"
-          style={{ listStyle: 'none', margin: 0, padding: 0 }}
         >
           {children.map(childId => (
             <Row key={childId} nodeId={childId} depth={depth + 1} />
           ))}
-        </ul>
+        </Branch>
       )}
     </li>
   );
@@ -150,31 +269,31 @@ const Row = ({
  */
 export default function Outline(): ReactElement {
   useDashboardRevision();
-  const theme = useTheme();
   const root = provider.getRoot();
   const children = root.children ?? [];
 
   if (children.length === 0) {
     return (
-      <p
-        data-test="outline-empty"
-        style={{ color: theme.colorTextTertiary, fontSize: theme.fontSizeSM }}
-      >
-        {t('Nothing on the dashboard yet.')}
-      </p>
+      <Panel data-test="outline-empty">
+        <EmptyState
+          size="small"
+          image="empty-dashboard.svg"
+          title={t('Nothing on the dashboard yet')}
+          description={t(
+            'Blocks you place show up here, in the order they sit on the canvas.',
+          )}
+        />
+      </Panel>
     );
   }
 
   return (
-    <ul
-      role="tree"
-      aria-label={t('Dashboard outline')}
-      data-test="outline"
-      style={{ listStyle: 'none', margin: 0, padding: 0 }}
-    >
-      {children.map(childId => (
-        <Row key={childId} nodeId={childId} depth={0} />
-      ))}
-    </ul>
+    <Panel>
+      <List role="tree" aria-label={t('Dashboard outline')} data-test="outline">
+        {children.map(childId => (
+          <Row key={childId} nodeId={childId} depth={0} />
+        ))}
+      </List>
+    </Panel>
   );
 }
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
index e9ccbf94576..79e69dfa332 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
@@ -19,8 +19,8 @@
 import { useMemo, useState } from 'react';
 import type { ReactElement, ReactNode } from 'react';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
-import { Input } from '@superset-ui/core/components';
+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 { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
@@ -77,6 +77,209 @@ const matches = (entry: PaletteEntry, query: string): boolean => {
   );
 };
 
+/**
+ * The panel's own scroll column.
+ *
+ * The search field stays put and the shelves move under it: a list long enough
+ * to scroll is exactly when the field that filters it must not scroll away.
+ */
+const Column = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    /* The field is not the first item of the list it filters, and at a tighter
+       gap it read as one — the shelf below it sat as close to it as its own
+       tiles sit to each other. The space is what separates searching the
+       palette from reading it. */
+    gap: ${theme.sizeUnit * 5}px;
+    min-height: 0;
+    /* Set down from the tab bar and in from the panel edge: a search field
+       flush against both reads as part of the chrome around the list rather
+       than the way into it. */
+    padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit}px 0;
+  `}
+`;
+
+const Shelves = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit}px;
+    overflow-y: auto;
+    min-height: 0;
+  `}
+`;
+
+/**
+ * A shelf's name, heavier than what is on the shelf.
+ *
+ * Set in the secondary colour it came out lighter than the rows beneath it,
+ * which reads as the shelf belonging to the list rather than the list to the
+ * shelf. Same size as its rows and heavier, the same trade the Inspector's
+ * section headings make.
+ *
+ * The hover is a wash rather than the fill a row takes, because a shelf that
+ * lights the way a row lights is a row: this one opens and closes a group, and
+ * should not look like something to place.
+ */
+const ShelfButton = styled.button`
+  ${({ theme }) => css`
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    width: 100%;
+    padding: ${theme.sizeUnit}px;
+    border: 0;
+    border-radius: ${theme.borderRadiusSM}px;
+    background: none;
+    color: ${theme.colorText};
+    font-size: ${theme.fontSizeSM}px;
+    font-weight: ${theme.fontWeightStrong};
+    text-align: left;
+    cursor: pointer;
+    transition: background-color ${theme.motionDurationMid};
+
+    /* The toggle is the shelf's state made visible — plus for shut, minus for
+       open — and it is quieter than the name it sits beside, which is what is
+       actually being read. */
+    .palette-toggle {
+      display: flex;
+      flex: 0 0 auto;
+      color: ${theme.colorTextTertiary};
+    }
+
+    &:hover {
+      background-color: ${theme.colorFillQuaternary};
+    }
+
+    &:focus-visible {
+      outline: 2px solid ${theme.colorPrimaryBorder};
+      outline-offset: -2px;
+    }
+  `}
+`;
+
+/**
+ * What ties a shelf to the blocks on it.
+ *
+ * The tiles are indented under their shelf, and indentation alone leaves the
+ * eye to infer the grouping from an edge that is not drawn. The guide down the
+ * left is that edge, and each tile reaches back to it with a stub — so a tile
+ * belongs to the shelf above it visibly rather than by inference.
+ *
+ * The guide is drawn by the tiles rather than here (see `BlockTile`), because
+ * where it has to stop is the middle of the last tile and this element cannot
+ * know where that is. Drawn in `colorBorder`, which is what this app draws one
+ * thing off from another with — the same one the tiles are drawn with, so the
+ * guide and what it holds are one weight of line.
+ */
+const Branch = styled.div`
+  ${({ theme }) => css`
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit}px;
+    margin-left: ${theme.sizeUnit * 2}px;
+    padding-left: ${theme.sizeUnit * 3}px;
+  `}
+`;
+
+/**
+ * A block, as a tile to pick up.
+ *
+ * A bordered tile rather than a bare row: what these are is a set of things
+ * that get dragged onto a canvas and become boxes there, and a tile with an
+ * edge is a thing you can take hold of in a way a line of text is not. The
+ * grip states the same thing in the same place on every one of them.
+ *
+ * The stub reaching left is what joins the tile to its shelf's guide — see
+ * `Branch`. It is drawn from the tile rather than by the shelf because only
+ * the tile knows where its own middle is.
+ *
+ * `grab` becoming `grabbing`, and the border taking the accent under the
+ * pointer, are the two halves of saying this can be dragged. The focus ring is
+ * the same answer for a keyboard, which the row had no visible reply to at all.
+ * Every colour here is a token: the tile has to hold up in both themes, and a
+ * literal only ever suits the one it was picked in.
+ */
+const BlockTile = styled.button`
+  ${({ theme }) => css`
+    position: relative;
+    display: flex;
+    align-items: center;
+    gap: ${theme.sizeUnit * 2}px;
+    width: 100%;
+    padding: ${theme.sizeUnit * 2}px;
+    border: 1px solid ${theme.colorBorder};
+    border-radius: ${theme.borderRadiusSM}px;
+    background-color: ${theme.colorFillQuaternary};
+    color: ${theme.colorText};
+    font-size: ${theme.fontSizeSM}px;
+    text-align: left;
+    cursor: grab;
+    transition:
+      border-color ${theme.motionDurationMid},
+      background-color ${theme.motionDurationMid};
+
+    /* The shelf's guide, and this tile's stub back to it.
+       The vertical runs from above the tile — bridging the gap to the one
+       before it — down to the tile's own bottom, so the segments meet and read
+       as one line. The last tile stops it at the stub: a guide that carries on
+       past the final tile reads as a shelf with something still to come. */
+    &::before,
+    &::after {
+      content: '';
+      position: absolute;
+      left: -${theme.sizeUnit * 3}px;
+      background-color: ${theme.colorBorder};
+    }
+
+    &::before {
+      top: -${theme.sizeUnit}px;
+      bottom: 0;
+      width: 1px;
+    }
+
+    &:last-child::before {
+      bottom: 50%;
+    }
+
+    &::after {
+      top: 50%;
+      width: ${theme.sizeUnit * 3}px;
+      height: 1px;
+    }
+
+    &:hover {
+      border-color: ${theme.colorPrimaryBorderHover};
+      background-color: ${theme.colorFillTertiary};
+    }
+
+    &:active {
+      cursor: grabbing;
+      border-color: ${theme.colorPrimary};
+    }
+
+    &:focus-visible {
+      outline: 2px solid ${theme.colorPrimaryBorder};
+      outline-offset: -2px;
+    }
+
+    /* The grip is part of the tile's answer rather than a control of its own,
+       so it strengthens with the tile rather than on its own hover. */
+    .palette-grip {
+      display: flex;
+      flex: 0 0 auto;
+      color: ${theme.colorTextQuaternary};
+      transition: color ${theme.motionDurationMid};
+    }
+
+    &:hover .palette-grip,
+    &:focus-visible .palette-grip {
+      color: ${theme.colorTextTertiary};
+    }
+  `}
+`;
+
 /**
  * A disclosure the palette drives rather than the browser, so a search can
  * reveal through a shelf the author collapsed and give it back on clearing.
@@ -91,42 +294,28 @@ const Disclosure = ({
   open: boolean;
   onToggle: () => void;
   children: ReactNode;
-}): ReactElement => {
-  const theme = useTheme();
-  return (
-    <div data-test={`palette-shelf-${name.toLowerCase()}`}>
-      <button
-        type="button"
-        aria-expanded={open}
-        // The caret carries an `aria-label` of its own, which would otherwise
-        // join the shelf's name and announce the shape of the arrow first.
-        aria-label={name}
-        onClick={onToggle}
-        style={{
-          display: 'flex',
-          alignItems: 'center',
-          gap: theme.sizeUnit,
-          width: '100%',
-          padding: `${theme.sizeUnit}px 0`,
-          border: 0,
-          background: 'none',
-          color: theme.colorTextSecondary,
-          fontSize: theme.fontSizeSM,
-          textAlign: 'left',
-          cursor: 'pointer',
-        }}
-      >
+}): ReactElement => (
+  <div data-test={`palette-shelf-${name.toLowerCase()}`}>
+    <ShelfButton
+      type="button"
+      aria-expanded={open}
+      // The toggle carries an `aria-label` of its own, which would otherwise
+      // join the shelf's name and announce the shape of the glyph first.
+      aria-label={name}
+      onClick={onToggle}
+    >
+      <span className="palette-toggle" aria-hidden>
         {open ? (
-          <Icons.CaretDownOutlined iconSize="s" />
+          <Icons.MinusSquareOutlined iconSize="s" />
         ) : (
-          <Icons.CaretRightOutlined iconSize="s" />
+          <Icons.PlusSquareOutlined iconSize="s" />
         )}
-        {name}
-      </button>
-      {open && <div style={{ marginLeft: theme.sizeUnit * 3 }}>{children}</div>}
-    </div>
-  );
-};
+      </span>
+      {name}
+    </ShelfButton>
+    {open && <Branch>{children}</Branch>}
+  </div>
+);
 
 /**
  * The building blocks, as things to place.
@@ -141,7 +330,6 @@ export default function Palette({
 }: {
   onAdd: (type: string) => void;
 }): ReactElement {
-  const theme = useTheme();
   const [query, setQuery] = useState('');
   const [closed, setClosed] = useState<ReadonlySet<string>>(new Set());
 
@@ -159,102 +347,78 @@ export default function Palette({
     });
 
   return (
-    <div
-      data-test="palette"
-      style={{
-        display: 'flex',
-        flexDirection: 'column',
-        gap: theme.sizeUnit,
-        // Set down from the tab bar and in from the panel edge: a search
-        // field flush against both reads as part of the chrome around the
-        // list rather than the way into it.
-        padding: `${theme.sizeUnit * 3}px ${theme.sizeUnit}px 0`,
-      }}
-    >
+    <Column data-test="palette">
+      {/* At the default height rather than `small`. This is the way into the
+          whole palette and the only thing on the tab that is typed into, and
+          at the smallest step it was shorter than the tiles it filters — the
+          one control on the panel read as the least of them. */}
       <Input
-        size="small"
         allowClear
         value={query}
         aria-label={t('Search components')}
         placeholder={t('Search components…')}
         data-test="palette-search"
         prefix={<Icons.SearchOutlined iconSize="s" />}
-        style={{ marginBottom: theme.sizeUnit }}
         onChange={event => setQuery(event.target.value)}
       />
       {found.length === 0 ? (
-        <p
-          data-test="palette-empty"
-          style={{
-            margin: `${theme.sizeUnit * 2}px 0 0`,
-            fontSize: theme.fontSizeSM,
-            color: theme.colorTextTertiary,
-          }}
-        >
-          {t('No building block matches “%s”.', query)}
-        </p>
+        <div data-test="palette-empty">
+          <EmptyState
+            size="small"
+            image="filter-results.svg"
+            title={t('No matching blocks')}
+            description={t('Nothing here is called “%s”.', query)}
+          />
+        </div>
       ) : (
-        SHELVES.map(shelf => {
-          const onShelf = found.filter(entry => entry.shelf === shelf.key);
-          // An empty shelf is not a shelf: it would imply something failed to
-          // register rather than that nothing of that kind exists.
-          if (onShelf.length === 0) {
-            return null;
-          }
-          return (
-            <Disclosure
-              key={shelf.key}
-              name={shelf.name}
-              open={isOpen(shelf.key)}
-              onToggle={() => toggle(shelf.key)}
-            >
-              {onShelf.map(entry => (
-                <button
-                  key={entry.type}
-                  type="button"
-                  draggable
-                  title={entry.description}
-                  data-test={`palette-${entry.type}`}
-                  onClick={() => onAdd(entry.type)}
-                  // The grip beside the label promised this and did not
-                  // deliver it: the rows carried the affordance of a drag
-                  // without the drag. Clicking still appends to whatever is
-                  // selected; dragging is how an author says *where*.
-                  onDragStart={event => {
-                    event.dataTransfer.setData(PALETTE_MIME, entry.type);
-                    event.dataTransfer.effectAllowed = 'copy';
-                  }}
-                  style={{
-                    display: 'flex',
-                    alignItems: 'center',
-                    justifyContent: 'space-between',
-                    gap: theme.sizeUnit,
-                    width: '100%',
-                    textAlign: 'left',
-                    padding: `${theme.sizeUnit}px ${theme.sizeUnit * 2}px`,
-                    marginBottom: theme.sizeUnit,
-                    border: `1px solid ${theme.colorBorder}`,
-                    borderRadius: theme.borderRadius,
-                    background: theme.colorBgContainer,
-                    color: theme.colorText,
-                    fontSize: theme.fontSizeSM,
-                    cursor: 'grab',
-                  }}
-                >
-                  {entry.label}
-                  {/* Decoration: the row already carries the name, so
-                      announcing the grip again would only repeat it. */}
-                  <Icons.HolderOutlined
-                    aria-hidden
-                    iconSize="s"
-                    iconColor={theme.colorTextTertiary}
-                  />
-                </button>
-              ))}
-            </Disclosure>
-          );
-        })
+        <Shelves>
+          {SHELVES.map(shelf => {
+            const onShelf = found.filter(entry => entry.shelf === shelf.key);
+            // An empty shelf is not a shelf: it would imply something failed
+            // to register rather than that nothing of that kind exists.
+            if (onShelf.length === 0) {
+              return null;
+            }
+            return (
+              <Disclosure
+                key={shelf.key}
+                name={shelf.name}
+                open={isOpen(shelf.key)}
+                onToggle={() => toggle(shelf.key)}
+              >
+                {onShelf.map(entry => (
+                  <BlockTile
+                    key={entry.type}
+                    type="button"
+                    draggable
+                    title={entry.description}
+                    data-test={`palette-${entry.type}`}
+                    onClick={() => onAdd(entry.type)}
+                    // The grip beside the label promised this and did not
+                    // deliver it: the tiles carried the affordance of a drag
+                    // without the drag. Clicking still appends to whatever is
+                    // selected; dragging is how an author says *where*.
+                    onDragStart={event => {
+                      event.dataTransfer.setData(PALETTE_MIME, entry.type);
+                      event.dataTransfer.effectAllowed = 'copy';
+                    }}
+                  >
+                    {/* Beside what it drags rather than at the far edge of
+                        the tile: the panel is resizable, and a handle pinned
+                        right drifts further from its label the wider it is
+                        pulled. Decoration — the tile already carries the name,
+                        so announcing the grip again would only repeat it. */}
+                    <span className="palette-grip" aria-hidden>
+                      <Icons.HolderOutlined iconSize="s" />
+                    </span>
+                    {entry.label}
+                  </BlockTile>
+                ))}
+              </Disclosure>
+            );
+          })}
+        </Shelves>
       )}
-    </div>
+    </Column>
   );
 }
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx
index 5cc7d7e87f3..a870b0e5df6 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx
@@ -19,13 +19,152 @@
 import { useMemo } from 'react';
 import type { ReactElement } from 'react';
 import { t } from '@apache-superset/core/translation';
-import { useTheme } from '@apache-superset/core/theme';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
 import { JsonForms } from '@jsonforms/react';
 import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
 import { renderers } from 'src/features/semanticLayers/jsonFormsHelpers';
 import { provider } from 'src/core/dashboard/store';
 import inferPropsSchema, { untypedKeys } from './inferPropsSchema';
 
+/**
+ * What the generated form is made to agree with.
+ *
+ * The controls come from a third-party renderer set, and it lays a form out
+ * for a page of its own rather than for a rail beside a canvas. Four things
+ * came out of it not matching the panel around them, and none of them can be
+ * fixed at the call site because nothing here renders the controls:
+ *
+ * - a group's name arrives as a bare `b` with no size, weight or space of its
+ *   own, so it read as the run-on end of the field above rather than as the
+ *   head of the group below. It is given `Section`'s heading, which is what a
+ *   group of fields is titled with everywhere else in this panel.
+ * - controls are sized by what they hold: some carry `width: 100%`, some sit
+ *   in an auto-width column. A column of fields that steps in and out down the
+ *   panel reads as broken before it reads as compact, so they are all told to
+ *   fill the column.
+ * - what adds a row to an array sits in a list footer, which antd centres,
+ *   while what deletes one is pushed right — so two controls doing the same
+ *   kind of job to the same array sat at opposite ends of it. Both go left,
+ *   where every other control in this rail starts.
+ * - the buttons are antd's own default, which is `tertiary` in this app's
+ *   terms and the right style for them; what they are not is the height the
+ *   rest of the rail is at, and a form of full-height buttons inside a panel
+ *   of small ones is the join showing.
+ *
+ * Scoped to this element rather than fixed in the renderers, which the
+ * semantic-layer modal also draws from and which are not this change's to move
+ * — the same reason `DashboardProperties` scopes its own input fix.
+ */
+const FormShell = styled.div`
+  ${({ theme }) => css`
+    /* A group's name, at the weight Section titles a group with. */
+    > form > b,
+    fieldset > b {
+      display: block;
+      margin: ${theme.sizeUnit * 4}px 0 ${theme.sizeUnit * 2}px;
+      font-size: ${theme.fontSize}px;
+      font-weight: ${theme.fontWeightStrong};
+      color: ${theme.colorText};
+    }
+
+    /* One column, one width.
+
+       An array's entries are handed to a grid meant for a page — two to a
+       line, so a dimension came out half the width of the field above it. In a
+       rail there is no second column to put anything in, so the grid is turned
+       down its own axis and every cell given the width. The form item's own
+       label/control row is left alone: it is already a column in this layout,
+       and it is not a grid of entries. */
+    .ant-form-item-control-input-content .ant-row:not(.ant-form-item-row) {
+      flex-direction: column;
+      align-items: stretch;
+    }
+
+    .ant-form-item-control-input-content > .ant-col,
+    .ant-form-item-control-input-content
+      .ant-row:not(.ant-form-item-row)
+      > .ant-col {
+      flex: 1 1 auto;
+      min-width: 0;
+      width: 100%;
+      max-width: 100%;
+    }
+
+    .ant-input,
+    .ant-input-number,
+    .ant-picker,
+    .ant-select {
+      width: 100%;
+    }
+
+    /* One entry of an array: its fields down the column, and what removes it
+       beneath them.
+
+       antd lays a list item as a row and pushes its actions to the far end, so
+       the fields of an entry shared the width with a Delete button and came
+       out a hundred pixels narrower than the fields around them — the only
+       reason "Column Name" sat short of "Dataset Id". Stacked, the fields get
+       the column and the button falls under them at the start, which is where
+       the other thing that acts on this array already is. */
+    .ant-list-item {
+      flex-direction: column;
+      align-items: stretch;
+      gap: ${theme.sizeUnit}px;
+      padding-inline: 0;
+    }
+
+    /* Written at antd's own depth, and doubled.
+
+       Two things have to be beaten here. antd indents the actions with
+       margin-inline-start, which a physical margin-left does not compete with;
+       and it says so through a selector wrapped in :where(), which counts for
+       nothing and leaves three classes — more than this element plus its own
+       class, until the rule is written out this long. The indent is meant for
+       a list of actions on a page-wide row; on one Delete under a field it is
+       a step with nothing to line up against. */
+    && .ant-list .ant-list-item .ant-list-item-action {
+      margin-inline-start: 0;
+      padding-inline: 0;
+      text-align: left;
+    }
+
+    && .ant-list .ant-list-item .ant-list-item-action > li {
+      padding-inline: 0;
+    }
+
+    /* Whatever acts on an array, at the start of it. What adds an entry is
+       handed to a centred flex row and what removes one to a list action, so
+       the two controls doing the same kind of job to the same array sat at
+       opposite ends of it. */
+    .ant-list-footer,
+    .ant-list-header {
+      padding-inline: 0;
+      text-align: left;
+    }
+
+    .ant-flex-justify-center,
+    .ant-form-item-control-input-content > .ant-row {
+      justify-content: flex-start;
+    }
+
+    /* At the rail's own control height, like every button beside it. */
+    .ant-btn {
+      height: ${theme.controlHeightSM}px;
+      font-size: ${theme.fontSizeSM}px;
+    }
+
+    /* One rhythm down the column: the renderers space their own items and
+       their dividers, and the two scales did not agree. */
+    .ant-form-item {
+      margin-bottom: ${theme.sizeUnit * 2}px;
+    }
+
+    .ant-divider-horizontal {
+      margin: ${theme.sizeUnit * 3}px 0;
+    }
+  `}
+`;
+
 /**
  * A block's properties as fields, generated from the values it holds.
  *
@@ -77,7 +216,7 @@ export default function PropsForm({
   );
 
   return (
-    <div
+    <FormShell
       data-test="inspector-props-form"
       // Labels above their fields, as everywhere else in this rail — beside
       // them halves the width left for the control, in the panel that most
@@ -128,6 +267,6 @@ export default function PropsForm({
             untyped.join(', '),
           ),
         )}
-    </div>
+    </FormShell>
   );
 }
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
index 25d03df4200..8c4515a38d3 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
@@ -39,7 +39,9 @@ test('a blank dashboard can still be reached, and so can the layout it arranges
   // The canvas is no longer chat-only: a palette sits beside it, so the
   // empty state names both ways in.
   expect(
-    screen.getByText('Add a building block, or ask the assistant to start'),
+    screen.getByText(
+      'Drag a building block from the panel, or ask the assistant for one.',
+    ),
   ).toBeInTheDocument();
 
   // A `/dashboard/v2/new/` load lands on nothing, and that is exactly when
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
index 6f794484e49..44dd0bbd61e 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
@@ -19,8 +19,7 @@
 import { useEffect } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { css, styled } from '@apache-superset/core/theme';
-import { Flex, Typography } from '@superset-ui/core/components';
-import { Icons } from '@superset-ui/core/components/Icons';
+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';
@@ -71,13 +70,36 @@ 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.
+ */
 const CanvasPlaceholder = styled(Flex)`
   ${({ theme }) => css`
     width: 100%;
     height: 100%;
-    border: 2px dashed ${theme.colorBorderSecondary};
+    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};
+      outline-offset: 2px;
+    }
   `}
 `;
 
@@ -169,7 +191,6 @@ export default function DashboardBuilderV2() {
                 vertical
                 align="center"
                 justify="center"
-                gap="small"
                 // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
                 role="button"
                 tabIndex={0}
@@ -183,10 +204,13 @@ export default function DashboardBuilderV2() {
                   }
                 }}
               >
-                <Icons.AppstoreOutlined iconSize="xl" />
-                <Typography.Text type="secondary">
-                  {t('Add a building block, or ask the assistant to start')}
-                </Typography.Text>
+                <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>
           ) : (