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
+6
View File
@@ -180,6 +180,12 @@ class DashboardDef(BaseModel):
#: How many columns the grid is cut into, so a dashboard can be matched to
#: the panel it will hang on.
columns: int = Field(default=12, ge=1, le=48)
#: The panel this dashboard is drawn for, in CSS pixels. Both the editor
#: and the wall panel scale that surface to fit whatever room they have, so
#: an arrangement does not depend on the window it was made in. Zero means
#: "unset" and the client falls back to its default.
canvas_width: int = Field(default=1920, ge=0, le=7680)
canvas_height: int = Field(default=1080, ge=0, le=4320)
pages: list[PageDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
+28
View File
@@ -218,6 +218,20 @@ export const DashboardDef_InputSchema = {
title: 'Columns',
default: 12
},
canvas_width: {
type: 'integer',
maximum: 7680,
minimum: 0,
title: 'Canvas Width',
default: 1920
},
canvas_height: {
type: 'integer',
maximum: 4320,
minimum: 0,
title: 'Canvas Height',
default: 1080
},
pages: {
items: {
'$ref': '#/components/schemas/PageDef-Input'
@@ -255,6 +269,20 @@ export const DashboardDef_OutputSchema = {
title: 'Columns',
default: 12
},
canvas_width: {
type: 'integer',
maximum: 7680,
minimum: 0,
title: 'Canvas Width',
default: 1920
},
canvas_height: {
type: 'integer',
maximum: 4320,
minimum: 0,
title: 'Canvas Height',
default: 1080
},
pages: {
items: {
'$ref': '#/components/schemas/PageDef-Output'
+4
View File
@@ -83,6 +83,8 @@ export type DashboardDef_Input = {
name: string;
title?: string;
columns?: number;
canvas_width?: number;
canvas_height?: number;
pages?: Array<PageDef_Input>;
version?: number;
};
@@ -94,6 +96,8 @@ export type DashboardDef_Output = {
name: string;
title?: string;
columns?: number;
canvas_width?: number;
canvas_height?: number;
pages?: Array<PageDef_Output>;
version?: number;
};
@@ -9,8 +9,13 @@ import {
Settings2,
} from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout"
import { type CSSProperties, useEffect, useRef, useState } from "react"
import {
GridLayout,
type Layout,
type Position,
setTransform,
} from "react-grid-layout"
import "react-grid-layout/css/styles.css"
import {
@@ -39,6 +44,8 @@ import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
CanvasSurface,
canvasOf,
columnsOf,
type Dashboard,
DashboardView,
@@ -47,6 +54,7 @@ import {
pagesOf,
placement,
ROW_HEIGHT,
rowsOf,
sectionsOf,
widgetsOf,
} from "./DashboardView"
@@ -127,6 +135,20 @@ function layoutOf(widgets: WidgetDef[], columns: number): Layout {
})
}
/**
* How the grid positions and drags items on a CSS-scaled surface.
*
* The library's own `createScaledStrategy` divides the drag delta by the scale
* but drops the container offset while doing it, so a widget jumps on pick-up.
* Leaving `calcDragPosition` unset keeps its parent-relative maths, which
* already divides by `scale` — the only thing that needed saying.
*/
const scaledStrategy = (scale: number) => ({
type: "transform" as const,
scale,
calcStyle: setTransform as (pos: Position) => CSSProperties,
})
const same = (a: Layout, b: Layout) =>
a.length === b.length &&
a.every((item, index) => {
@@ -204,7 +226,7 @@ export function DashboardEditor({
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
const section = page ? sectionsOf(page)[0] : undefined
const widgets = section ? widgetsOf(section) : []
const { width, containerRef, mounted } = useContainerWidth()
const canvas = canvasOf(draft)
const updateWidgets = (next: WidgetDef[]) => {
if (!page || !section) return
@@ -293,26 +315,29 @@ export function DashboardEditor({
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
</p>
) : !edit ? (
<DashboardView dashboard={draft} pageId={page.id} />
) : widgets.length === 0 ? (
) : edit && widgets.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below.
</p>
) : (
<div ref={containerRef}>
{/* Measured first: laying out against a guessed width would place every
widget once and then move it. */}
{mounted ? (
<CanvasSurface dashboard={draft} dots={edit}>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} pageId={page.id} />
) : (
<GridLayout
width={width}
width={canvas.width}
layout={layout}
onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)}
gridConfig={{
cols: columns,
rowHeight: ROW_HEIGHT,
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),
}}
// 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.
@@ -341,15 +366,16 @@ export function DashboardEditor({
</div>
))}
</GridLayout>
) : null}
</div>
)
}
</CanvasSurface>
)
return (
<>
<div
className={cn(
"dot-canvas absolute inset-0 overflow-y-auto px-4 pb-24 pt-20 transition-[padding] duration-200",
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
data-testid="dashboard-canvas"
@@ -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 ?? {}
+12 -19
View File
@@ -6,8 +6,9 @@
* so nothing here touches the byte-identical token blocks.
*/
/* The same dot grid the flow viewport paints, as plain CSS: a dashboard has no
zoom, so it needs none of React Flow's rescaling. */
/* The placement grid, as plain CSS. One dot per cell corner: the surface hands
in the pitch its widgets actually snap to (`--dot-x` / `--dot-y`), and the
negative offset puts a dot centre on the grid's own origin. */
.dot-canvas {
background-color: var(--background);
background-image: radial-gradient(
@@ -15,42 +16,34 @@
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
transparent 0
);
background-size: 24px 24px;
background-size: var(--dot-x, 24px) var(--dot-y, 24px);
background-position: -1px -1px;
}
/*
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
* panel can be matched to its own width. Below the large breakpoint the stored
* placement is meaningless — three columns cannot hold a twelve-column
* arrangement — so widgets stack full width and keep only their height.
* panel can be matched to its own width. There is no responsive fallback: the
* grid lives on a canvas of the panel's own pixel size, which is scaled to the
* viewport rather than reflowed into it.
*/
.widget-grid {
display: grid;
gap: 0.75rem;
grid-auto-rows: 5rem;
grid-template-columns: minmax(0, 1fr);
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
}
.widget-cell {
grid-column: span var(--w);
grid-row: span var(--h);
min-width: 0;
}
@media (min-width: 64rem) {
.widget-grid {
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
}
.widget-cell {
grid-column: span var(--w);
}
/* Only once something has actually been placed; an untouched dashboard has
/* Only once something has actually been placed; an untouched dashboard has
every widget at 0,0 and would pile up on one cell. */
.widget-grid[data-placed] .widget-cell {
.widget-grid[data-placed] .widget-cell {
grid-column: var(--x) / span var(--w);
grid-row: var(--y) / span var(--h);
}
}
/*
+74 -4
View File
@@ -27,7 +27,13 @@ import {
SelectValue,
} from "@/components/ui/select"
import { MAX_SERIES } from "./ChartWidget"
import { COLUMN_CHOICES, columnsOf, type Dashboard } from "./DashboardView"
import {
CANVAS_PRESETS,
COLUMN_CHOICES,
canvasOf,
columnsOf,
type Dashboard,
} from "./DashboardView"
import { messageCatalogQueryOptions } from "./queries"
import {
acceptsDtype,
@@ -314,12 +320,16 @@ export function WidgetPanel({
)
}
/** Presets are matched on their size, so a custom one simply matches none. */
const sizeKey = (size: { width: number; height: number }) =>
`${size.width}x${size.height}`
/**
* The dashboard's own settings, in the panel its widgets use.
*
* The grid size is the one that matters: a wall panel is a fixed width, and
* twelve columns on a seven-inch screen is a different dashboard than twelve
* on a television.
* The canvas is the one that matters: a wall panel is a fixed size, and twelve
* columns on a seven-inch screen is a different dashboard than twelve on a
* television.
*/
export function DashboardPanel({
open,
@@ -337,6 +347,7 @@ export function DashboardPanel({
onClose: () => void
}) {
const [confirmOpen, setConfirmOpen] = useState(false)
const canvas = canvasOf(dashboard)
return (
<>
@@ -390,6 +401,65 @@ export function DashboardPanel({
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Canvas</span>
<Select
value={
CANVAS_PRESETS.some(
(preset) => sizeKey(preset) === sizeKey(canvas),
)
? sizeKey(canvas)
: ""
}
onValueChange={(value) => {
const preset = CANVAS_PRESETS.find(
(candidate) => sizeKey(candidate) === value,
)
if (preset)
onChange({
canvas_width: preset.width,
canvas_height: preset.height,
})
}}
>
<SelectTrigger data-testid="dashboard-canvas-size">
<SelectValue placeholder="Custom" />
</SelectTrigger>
<SelectContent>
{CANVAS_PRESETS.map((preset) => (
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
{preset.label} {preset.width}×{preset.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
type="number"
aria-label="Canvas width"
data-testid="canvas-width"
value={canvas.width}
onChange={(event) =>
onChange({ canvas_width: Number(event.target.value) || 0 })
}
/>
<Input
type="number"
aria-label="Canvas height"
data-testid="canvas-height"
value={canvas.height}
onChange={(event) =>
onChange({ canvas_height: Number(event.target.value) || 0 })
}
/>
</div>
<p className="text-sm text-muted-foreground">
The panel this is drawn for, in pixels. Editing and viewing both
scale that surface to fit, so the arrangement is the same
everywhere.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
+12 -3
View File
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { DashboardView } from "@/components/Dashboard/DashboardView"
import {
CanvasSurface,
DashboardView,
} from "@/components/Dashboard/DashboardView"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth"
@@ -30,8 +33,14 @@ function PanelView() {
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
return (
<main className="dot-canvas min-h-svh w-full overflow-y-auto p-4">
{dashboard ? <DashboardView dashboard={dashboard as Dashboard} /> : null}
<main className="h-svh w-full overflow-hidden p-4">
{dashboard ? (
// The panel's own surface, scaled to whatever screen it landed on. No
// dots: nothing is being arranged here.
<CanvasSurface dashboard={dashboard as Dashboard}>
{() => <DashboardView dashboard={dashboard as Dashboard} />}
</CanvasSurface>
) : null}
</main>
)
}