diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx
index 3e294b7..06abb5d 100644
--- a/frontend/src/components/Dashboard/DashboardView.tsx
+++ b/frontend/src/components/Dashboard/DashboardView.tsx
@@ -1,5 +1,5 @@
import { motion } from "motion/react"
-import { useEffect, useRef, useState } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import type { DashboardDef_Output, Placement, WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
@@ -188,6 +188,15 @@ export function CanvasSurface({
const scale = Math.min(box.width / width, box.height / height)
const root = useCanvasRoot(dashboard, touch)
const area = areaOf(dashboard, Boolean(rail))
+ // The arrangement, held across the renders that are not about it. This
+ // component reads the dashboard's own settings, so a bound one wakes it on
+ // every message the flow sends — and calling a render prop again rebuilds
+ // every tile. With the callback and the scale both unmoved React skips the
+ // whole subtree instead.
+ const content = useMemo(
+ () => (scale > 0 ? children(scale) : null),
+ [children, scale],
+ )
return (
@@ -238,7 +247,7 @@ export function CanvasSurface({
{dots ? (
) : null}
- {children(scale)}
+ {content}
diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx
index ab21a07..b4648d9 100644
--- a/frontend/src/components/Dashboard/PanelSurface.tsx
+++ b/frontend/src/components/Dashboard/PanelSurface.tsx
@@ -1,3 +1,5 @@
+import { useCallback } from "react"
+
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import {
CanvasSurface,
@@ -33,6 +35,13 @@ export function PanelSurface({
/** Whether the screen this hangs on is touched. Stated by the panel route. */
touch?: boolean
}) {
+ const hasRail = Boolean(rail)
+ // Stable, so the canvas can keep the tiles it already built when one of the
+ // dashboard's own settings re-renders the route above this.
+ const canvas = useCallback(
+ () => ,
+ [dashboard, hasRail],
+ )
return (
{stacked ? (
@@ -41,7 +50,7 @@ export function PanelSurface({
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
- {() => }
+ {canvas}
)}
{/* Outside the canvas, so it needs the look stated for it. */}
diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx
index abd5d57..3d00362 100644
--- a/frontend/src/components/Dashboard/settings.tsx
+++ b/frontend/src/components/Dashboard/settings.tsx
@@ -1,9 +1,9 @@
-import { createContext, useContext } from "react"
+import { createContext, useContext, useMemo } from "react"
import type { DashboardDef_Output, SettingDef } from "@/client"
import { NO_PALETTE } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore"
-import { isLight, parsePalette, rolesOf } from "./ui/core/theme"
+import { isLight, parsePalette, type Roles, rolesOf } from "./ui/core/theme"
/**
* The dashboard's own settings channel.
@@ -109,6 +109,24 @@ export const useDashboardPalette = (
dashboard: DashboardDef_Output | undefined,
): string[] => parsePalette(useSetting(dashboard, "palette"))
+/**
+ * The palette as roles, keeping its identity while the colours keep theirs.
+ *
+ * `parsePalette` and `rolesOf` build a fresh array and a fresh object on every
+ * render, and a bound setting re-renders whatever reads it on every message the
+ * flow sends — so without this the two contexts below hand every widget a new
+ * value several times a second. The colours joined are the whole key: roles are
+ * derived from the hexes and from nothing else (`ui/core/theme.check.ts`).
+ */
+export function useDashboardRoles(
+ dashboard: DashboardDef_Output | undefined,
+): Roles | null {
+ const hexes = useDashboardPalette(dashboard)
+ const key = hexes.join(",")
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the colours are the dependency; `hexes` is a fresh array every render.
+ return useMemo(() => rolesOf(hexes), [key])
+}
+
/** An image drawn under the widgets, or `""` for the look's own ground. */
export function useDashboardBackground(
dashboard: DashboardDef_Output | undefined,
@@ -173,7 +191,7 @@ export function PaletteProvider({
dashboard: DashboardDef_Output | undefined
children: React.ReactNode
}) {
- const palette = rolesOf(useDashboardPalette(dashboard))?.series ?? NO_PALETTE
+ const palette = useDashboardRoles(dashboard)?.series ?? NO_PALETTE
return (
{children}
diff --git a/frontend/src/components/Dashboard/ui/core/look.tsx b/frontend/src/components/Dashboard/ui/core/look.tsx
index be3290f..8449fa5 100644
--- a/frontend/src/components/Dashboard/ui/core/look.tsx
+++ b/frontend/src/components/Dashboard/ui/core/look.tsx
@@ -10,17 +10,17 @@
* menu is portalled to `body` and lands outside the canvas. A surface that
* floats out of the tree re-states the tokens rather than borrowing the app's.
*/
-import { createContext, useContext } from "react"
+import { createContext, useContext, useMemo } from "react"
import type { DashboardDef_Output } from "@/client"
import { useTheme } from "@/components/theme-provider"
import {
type Look,
useDashboardLook,
- useDashboardPalette,
+ useDashboardRoles,
useDashboardTheme,
} from "../../settings"
-import { rolesOf, tokenStyle } from "./theme"
+import { tokenStyle } from "./theme"
type Canvas = {
look: Look
@@ -30,6 +30,9 @@ type Canvas = {
style: React.CSSProperties
}
+/** A dashboard that names no palette, as one object rather than one per render. */
+const NO_TOKENS: React.CSSProperties = {}
+
const LookContext = createContext