diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx
index 3124816..8775fc3 100644
--- a/frontend/src/components/Dashboard/DashboardEditor.tsx
+++ b/frontend/src/components/Dashboard/DashboardEditor.tsx
@@ -95,9 +95,17 @@ const AUTOSAVE_MS = 800
/**
* Controls a widget owns. A press on one of these is the widget's own — a
* slider still slides in edit mode — so it never counts as picking the widget.
+ *
+ * The drag handle counts too: a press on the grip is a move, not a pick, and
+ * the mouseup ending a drag still fires a click on the frame. Selecting there
+ * would open the settings panel — and the canvas lurches while it animates —
+ * every time a widget is dropped. The body is what picks it instead, which is
+ * how the resize handle has always behaved. On a phone the grip class is never
+ * applied, so the header keeps selecting: there is no dragging to confuse it
+ * with.
*/
const INTERACTIVE =
- "button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle"
+ "button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip"
function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(
@@ -328,6 +336,11 @@ export function DashboardEditor({
)
const layout = layoutOf(widgets, columns)
+ const rows = rowsOf(draft)
+ // maxRows below only constrains a *drag*. A stored placement is corrected
+ // against `cols` and nothing else, so a canvas that shrank leaves whatever
+ // now falls past its bottom edge drawn under the clip.
+ const clipped = layout.filter((item) => item.y + item.h > rows)
/** Store what the grid ended up doing, unless it did nothing. */
const applyLayout = (next: Layout) => {
@@ -351,6 +364,21 @@ export function DashboardEditor({
)
}
+ /** Shelf-pack in reading order, which is the only reflow that can help:
+ * the grid already compacts vertically, so nothing below the canvas has
+ * room above it — it has to move sideways. */
+ const reflow = () => {
+ const at = new Map(layout.map((item) => [item.i, item]))
+ const ordered = [...widgets].sort((a, b) => {
+ const left = at.get(a.id)
+ const right = at.get(b.id)
+ return (
+ (left?.y ?? 0) - (right?.y ?? 0) || (left?.x ?? 0) - (right?.x ?? 0)
+ )
+ })
+ applyLayout(packed(ordered, columns))
+ }
+
const active = widgets.find((widget) => widget.id === selected) ?? null
const panelOpen = Boolean(active) || settingsOpen
@@ -438,8 +466,9 @@ export function DashboardEditor({
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the
- // panel it is being drawn for.
- maxRows: rowsOf(draft),
+ // panel it is being drawn for. Shared with the warning above, so
+ // the constraint and what it is warned about cannot disagree.
+ maxRows: rows,
}}
// Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited.
@@ -520,6 +549,44 @@ export function DashboardEditor({
>
{edit ? (
<>
+ {/* A canvas can be shrunk to less than what is arranged on it,
+ and the grid says nothing: the placement it corrects is
+ bounded by the columns alone. Say so, and offer the one
+ remedy — re-packing across the columns. Not an error, so no
+ destructive colour and no brand fill either; Done already
+ owns the one brand affordance here. A phone has no canvas to
+ fall off, so it never sees this. */}
+ {clipped.length > 0 && !stacked ? (
+
+
+ {clipped.length === 1
+ ? "1 widget sits below the canvas — a panel this size will not show it."
+ : `${clipped.length} widgets sit below the canvas — a panel this size will not show them.`}
+
+
+
+ ) : null}
+
diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx
index f751ee1..c24f322 100644
--- a/frontend/src/components/Dashboard/panels.tsx
+++ b/frontend/src/components/Dashboard/panels.tsx
@@ -730,7 +730,7 @@ export function WidgetPanel({