Give a dashboard the panel's own size, and put the dots on its grid

A dashboard now carries the canvas it is drawn for (canvas_width /
canvas_height, presets plus two numbers in the settings panel). Editor and
wall panel render that surface at its true pixel size and scale it to fit,
so a side panel opening changes only the scale — never the arrangement
being made. With the width and column count known the dot pitch is exact,
(width + gap) / columns by row height + gap, so a dot sits where every
widget corner snaps. The wall panel shows no dots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 19:08:09 +02:00
co-authored by Claude Fable 5
parent fcd43e9ad9
commit c2321fe942
8 changed files with 308 additions and 79 deletions
@@ -1,3 +1,5 @@
import { useEffect, useRef, useState } from "react"
import type {
DashboardDef_Output,
PageDef_Output,
@@ -17,12 +19,27 @@ export const DEFAULT_COLUMNS = 12
/** What a grid size setting may be set to; a panel is matched to one of these. */
export const COLUMN_CHOICES = [6, 8, 12, 16, 24]
/** The panel a dashboard is drawn for, if the document does not say. */
export const DEFAULT_CANVAS = { width: 1920, height: 1080 }
/** Panels worth a name; anything else is typed in as two numbers. */
export const CANVAS_PRESETS = [
{ label: '7" panel', width: 1024, height: 600 },
{ label: '10" tablet', width: 1280, height: 800 },
{ label: "Full HD", width: 1920, height: 1080 },
{ label: '10" tablet, portrait', width: 800, height: 1280 },
{ label: "Full HD, portrait", width: 1080, height: 1920 },
]
/** One grid row, in pixels — the unit widget heights are multiples of. */
export const ROW_HEIGHT = 80
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12
/** Distance between two row origins — what a widget's `y` is counted in. */
export const ROW_PITCH = ROW_HEIGHT + GRID_GAP
/**
* The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components.
@@ -34,6 +51,82 @@ export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS
export const canvasOf = (dashboard: Dashboard) => ({
width: dashboard.canvas_width || DEFAULT_CANVAS.width,
height: dashboard.canvas_height || DEFAULT_CANVAS.height,
})
/** How many rows of the grid fit in the canvas; the rest is off the panel. */
export const rowsOf = (dashboard: Dashboard) =>
Math.max(1, Math.floor((canvasOf(dashboard).height + GRID_GAP) / ROW_PITCH))
/**
* The dashboard's own surface: exactly the panel's pixel size, scaled to fit
* whatever room is left around it.
*
* Scaling rather than reflowing is the point. A side panel opening, or a
* narrower screen, changes only the scale — the arrangement being designed,
* and the dot grid under it, stay the layout the panel will actually show.
*/
export function CanvasSurface({
dashboard,
dots,
children,
}: {
dashboard: Dashboard
/** Draw the placement grid. A wall panel is not being arranged, so: no. */
dots?: boolean
children: (scale: number) => React.ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
const [box, setBox] = useState({ width: 0, height: 0 })
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new ResizeObserver(([entry]) =>
setBox({
width: entry.contentRect.width,
height: entry.contentRect.height,
}),
)
observer.observe(element)
return () => observer.disconnect()
}, [])
const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height)
return (
<div ref={ref} className="relative size-full overflow-hidden">
{/* Measured first: a guessed scale would place the whole panel once and
then move it. */}
{scale > 0 ? (
<div
className={cn("absolute overflow-hidden", dots && "dot-canvas")}
data-testid="canvas-surface"
style={
{
width,
height,
left: (box.width - width * scale) / 2,
top: (box.height - height * scale) / 2,
transform: `scale(${scale})`,
transformOrigin: "top left",
// One dot per cell corner. The column pitch the grid snaps to is
// (width + gap) / columns; a row is one row plus the gap.
"--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`,
"--dot-y": `${ROW_PITCH}px`,
} as React.CSSProperties
}
>
{children(scale)}
</div>
) : null}
</div>
)
}
export function placement(widget: WidgetDef): Placement {
const layout = (widget.layout ?? {}) as Record<string, Placement>
return layout.lg ?? layout.md ?? layout.sm ?? {}