Files
app/frontend/src/components/Dashboard/PanelSurface.tsx
T
stroblmeandClaude Opus 5.5 17d58697c4 Stagger a rectangular panel's new dashboard by rail order
Changing dashboard kept the grid mounted, so every new tile rose at once.
The grid is drawn afresh per dashboard and its tiles come in row by row:
from below, top row first, for one further down the rail, and from above,
bottom row first, for one further up, as a round panel's dashboards slide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
2026-10-15 16:12:38 +02:00

140 lines
5.3 KiB
TypeScript

import { useCallback } from "react"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import {
CanvasGround,
CanvasSurface,
DashboardView,
isRound,
} from "@/components/Dashboard/DashboardView"
import { useDashboardLocked } from "@/components/Dashboard/settings"
import { useUi } from "@/components/Dashboard/ui"
import { LookProvider } from "@/components/Dashboard/ui/core/look"
import { cn } from "@/lib/utils"
/**
* One dashboard filling whatever screen it landed on.
*
* The whole of what a wall panel draws, shared by the single-dashboard route
* (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows
* the same thing either way — the second merely has a rail beside it.
*
* The dashboard's theme is stated by the route rather than here: on these two
* the screen *is* the dashboard, so it has to cover the letterbox and the rail
* as well as the canvas.
*/
export function PanelSurface({
dashboard,
stacked,
rail,
touch,
onDashboard,
around,
turned,
}: {
dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
* its size, which reads as nothing at all. Stack it instead. Never a round
* one: a knob's face is about a phone's width already. */
stacked?: boolean
/** The way between this panel's dashboards, drawn on the canvas itself, or
* along the bottom of the screen when stacked. */
rail?: React.ReactNode
/** Whether the screen this hangs on is touched. Stated by the panel route. */
touch?: boolean
/** A round dashboard has no rail: it is swiped up and down to the panel's
* others instead, whose names are `around` it. */
onDashboard?: (by: number) => void
around?: [string | undefined, string | undefined]
/** Which way along the rail this dashboard was reached: down (1) or up
* (-1). */
turned?: number
}) {
const hasRail = Boolean(rail)
const stack = stacked && !isRound(dashboard)
// 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(
() => (
<DashboardView
dashboard={dashboard}
rail={hasRail}
onDashboard={onDashboard}
around={around}
turned={turned}
/>
),
[dashboard, hasRail, onDashboard, around, turned],
)
return (
<div className="relative size-full">
{stack ? (
// Nothing scales a stack, so there is no canvas element here to state
// the look on: this branch mounts what `CanvasSurface` mounts for the
// other one, touch included.
<LookProvider dashboard={dashboard} touch={touch}>
{/* The look's ground, held to the screen while the stack scrolls
over it, as it stays put under a canvas. */}
<div className="pointer-events-none fixed inset-0">
<CanvasGround dashboard={dashboard} />
</div>
{/* Padded here rather than by the route: the stack overflows this
box, so only padding inside the overflow clears the rail. */}
<div className={cn("relative", rail && "pb-24")}>
<DashboardView dashboard={dashboard} stacked turned={turned} />
</div>
{rail ? (
// No column to spare on a phone, so the rail is a row floating
// over the bottom edge.
<div className="pointer-events-none fixed inset-x-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-10 flex justify-center">
{rail}
</div>
) : null}
</LookProvider>
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
<CanvasSurface dashboard={dashboard} rail={rail} touch={touch}>
{canvas}
</CanvasSurface>
)}
{/* Outside the canvas, so it needs the look stated for it. */}
<LookProvider dashboard={dashboard} touch={touch}>
{stack && rail ? (
// Lifted clear of the rail floating along the bottom.
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-20">
<LockNotice dashboard={dashboard} />
</div>
) : (
<LockNotice dashboard={dashboard} />
)}
</LookProvider>
</div>
)
}
/**
* What a locked dashboard looks like, beyond controls that read as disabled.
*
* Shared with the editor's view mode, which shows the same locked dashboard
* from inside the app.
*
* Without it a read-only panel is a panel whose buttons do nothing, which
* reads as broken rather than as locked. Frosted chrome over content, like
* every other floating surface, and it says the state in words — a glyph on
* its own is not a label.
*
* Live, because `locked` may be driven by a flow: the notice appears and goes
* with the lock rather than with the page load.
*/
export function LockNotice({ dashboard }: { dashboard: Dashboard }) {
const { Notice } = useUi()
// Read straight from the document rather than through `useLocked`: that
// provider sits inside the grid, and this notice is beside it.
const locked = useDashboardLocked(dashboard)
// A round face says it in its own gap (`RoundView`): a corner outside the
// circle is under a knob's bezel.
if (!locked || isRound(dashboard)) return null
return <Notice>Read-only</Notice>
}