Round panel: the dashboards either side drawn in advance and live, on the face they open on, and dragged in up and down as faces are sideways; the name pill only on landing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-10-14 17:20:22 +02:00
co-authored by Claude Opus 5.5
parent 603635f947
commit 0bf2b12268
3 changed files with 108 additions and 86 deletions
+4 -3
View File
@@ -329,9 +329,9 @@ dashboard's look's.
| What | Moves | From → to | Timing | Input during it |
|---|---|---|---|---|
| Face paging (round) | the faces, side by side on a track | the track slides one face width S, the new face coming in from the side it lies on (the next from the right). A dot that skips faces brings its face in beside the one shown, so a turn is always one width | spring, from where the track is (a swipe's release speed carried on) | a new turn while one runs: the running one lands at once and the next starts |
| Dashboard paging (round panel) | the dashboard, and beside it the next one's name | slides S up (the next, from below) or down (the previous, from above), the name pill centred on an otherwise empty face; once it has landed and the dashboard has loaded, the dashboard replaces its name in place. One opened by its address replaces the last at once | spring | further paging waits for the turn to land |
| Dashboard paging (round panel) | the dashboard, and beside it the next one | slides S up (the next, from below) or down (the previous, from above), the next one drawn on the face it opens on; once landed, it is the dashboard shown, and its name pill shows for a moment. One opened by its address replaces the last at once | spring | further paging waits for the turn to land |
| Page dot | the current dot and the last | muted at 0.5 opacity ↔ text colour at 1 | 200 ms, cubic-bezier(0.4, 0, 0.2, 1) | retargets |
| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0; on a dashboard a turn brought, where the name came in already, it starts at 1 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
| Dashboard name | the pill, on landing on another dashboard | opacity 0 → 1, held 1000 ms, 1 → 0 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
| Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has |
| Ring value set by a drag | the same | follows the pointer exactly | none | — |
| Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — |
@@ -362,7 +362,8 @@ under the finger, as a tile view moves.
|---|---|
| Start | a press anywhere on the face but the ring band, which turns the ring instead |
| Axis | none until the drag has gone 8 from where it began along either axis; then the longer of the two, kept until release |
| Follow | the page moves with the drag one to one, and the page that way is drawn beside it, edge to edge, S away: the next face, or the next dashboard's name pill on an otherwise empty face |
| Follow | the page moves with the drag one to one, and the page that way is drawn beside it, edge to edge, S away: the next face, or the next dashboard on the face it would open on (the one last shown there, else its first), with its live values |
| Neighbours | the dashboards before and after the one shown are built in advance — loaded, drawn and fed live values — so the one a swipe reaches is complete from the first move. They take no input until they are the one shown |
| Past the last | nothing beside it; the page moves 0.3 of the drag, at most 48 |
| Release | turns if the drag went at least S/4 (90 on the Knob), or went at least 8 and was moving at least 500 a second towards the next page over its last 80 ms; otherwise goes back |
| Turn, go back | the look's spring from where the page is, carrying the release speed; a device may use the look's cubic-bezier over its duration from where it is |
@@ -400,6 +400,7 @@ export function DashboardView({
size={canvasOf(dashboard).width}
onDashboard={onDashboard}
around={around}
editing={editing}
/>
) : (
<WidgetGrid
+103 -83
View File
@@ -22,7 +22,12 @@ import { resolveIcon } from "./icons"
import { usePublish } from "./publish"
import { dashboardQueryOptions } from "./queries"
import { CentreReading, FaceTitle, hasGapLine, Ring } from "./RingWidget"
import { useLocked, usePalette } from "./settings"
import {
LockedProvider,
PaletteProvider,
useLocked,
usePalette,
} from "./settings"
import { useUi } from "./ui"
import { arcPath } from "./ui/core/arc"
import {
@@ -206,47 +211,54 @@ function usePageDrag(
/**
* A panel's round dashboards, one at a time, paged up and down.
*
* Swiped, the dashboard follows the finger and the next one's name comes up
* from below it (the previous one's down from above); let go far enough and
* the turn completes, and the dashboard itself takes its name's place once it
* has loaded. Shift+PageUp/PageDown turn the same way. One opened by its
* address replaces the last at once.
* The dashboards either side are drawn a screen above and below, ahead of
* time and live, each on the face it would open on, so a swipe up or down
* drags the next one in exactly as a sideways swipe drags the next face. Let
* go far enough and the turn completes; Shift+PageUp/PageDown turn the same
* way. One opened by its address replaces the last at once.
*/
export function RoundPanel({
dashboard,
size,
onDashboard,
around,
editing,
}: {
dashboard: Dashboard
size: number
onDashboard?: (by: number) => void
/** The panel's dashboards before and after this one. */
around?: [string | undefined, string | undefined]
/** Being arranged, so nothing is locked. */
editing?: boolean
}) {
const { look } = useLook()
const still = useReducedMotion()
const spring = LOOK[look].spring
const y = useMotionValue(0)
// The dashboard held at the origin while a turn is on its way to the next,
// the side the next one's name is on, whether the turn has landed, and the
// last dashboard a turn brought, which has been named already.
// where that one is and what it is called, and whether the turn has landed.
const [held, setHeld] = useState<Dashboard | null>(null)
const [beside, setBeside] = useState<{ by: 1 | -1; name: string } | null>(
const [turning, setTurning] = useState<{ by: 1 | -1; name: string } | null>(
null,
)
const [landed, setLanded] = useState(false)
const [reached, setReached] = useState<string | null>(null)
const shown = held ?? dashboard
const name = (by: number) => around?.[by > 0 ? 1 : 0]
const read = (of: string | undefined) => ({
...dashboardQueryOptions(of ?? ""),
enabled: Boolean(of),
})
const before = useQuery(read(around?.[0])).data
const after = useQuery(read(around?.[1])).data
// By the name it had when the turn began: the panel's neighbours are
// counted from the new dashboard as soon as it is asked for.
const target = useQuery(read(turning?.name)).data
const turn = (by: 1 | -1, speed = 0) => {
const next = name(by)
if (!next || !onDashboard || held) return
setHeld(dashboard)
// Its name as it was when the turn began: the panel's neighbours are
// counted from the new dashboard as soon as it is asked for.
setBeside({ by, name: next })
setTurning({ by, name: next })
setLanded(false)
onDashboard(by)
if (still) {
@@ -265,68 +277,87 @@ export function RoundPanel({
}).then(() => setLanded(true))
}
// Landed, and the dashboard it went to has loaded: that one takes its
// name's place, which it was drawn as, so nothing moves.
// Landed, and the panel has moved on to it: it becomes the one at the
// origin, which it is already drawn as, so nothing moves.
useLayoutEffect(() => {
if (!held || !landed || dashboard.name === held.name) return
setHeld(null)
setBeside(null)
setTurning(null)
setLanded(false)
setReached(dashboard.name)
y.jump(0)
}, [held, landed, dashboard.name, y])
const rise: Axis = {
open: (by) => Boolean(name(by) && onDashboard) && !held,
drag: (offset, by) => {
if (held) return
const next = onDashboard ? name(by) : undefined
if (next !== beside?.name) setBeside(next ? { by, name: next } : null)
y.set(offset)
drag: (offset) => {
if (!held) y.set(offset)
},
release: (by, turnIt, speed) => {
if (held) return
if (turnIt) {
turn(by, speed)
return
}
if (still) {
y.jump(0)
setBeside(null)
return
}
animate(y, 0, { ...spring, velocity: speed }).then(() => setBeside(null))
if (turnIt) turn(by, speed)
else if (still) y.jump(0)
else animate(y, 0, { ...spring, velocity: speed })
},
}
// On the track by place: the one shown and its neighbours a screen either
// side; during a turn, the one it left and the one it is going to.
const pages: [Dashboard | undefined, number][] =
held && turning
? [
[held, 0],
[target, turning.by],
]
: [
[before, -1],
[dashboard, 0],
[after, 1],
]
return (
<motion.div className="absolute inset-0" style={{ y }}>
<RoundView
// Its own face memory per dashboard, so a panel switching to another
// starts that one where it was left.
key={shown.name}
dashboard={shown}
size={size}
onDashboard={onDashboard && ((by) => turn(by > 0 ? 1 : -1))}
rise={rise}
named={reached === shown.name}
/>
{beside ? (
<div
className="pointer-events-none absolute inset-x-0 flex items-center justify-center"
style={{ top: beside.by * size, height: size }}
>
<DashboardName name={beside.name} />
</div>
) : null}
{pages.map(([page, at]) => {
if (!page) return null
const view = (
<RoundView
dashboard={page}
size={size}
peek={at !== 0}
onDashboard={
at === 0 && onDashboard
? (by) => turn(by > 0 ? 1 : -1)
: undefined
}
rise={at === 0 ? rise : undefined}
/>
)
return (
// Keyed by name, so a neighbour becomes the one shown, and back,
// without being drawn afresh: its own face memory and all.
<div
key={page.name}
className="absolute inset-0"
style={{ translate: `0 ${at * size}px` }}
inert={at !== 0}
>
<PaletteProvider dashboard={page}>
{editing ? (
view
) : (
<LockedProvider dashboard={page}>{view}</LockedProvider>
)}
</PaletteProvider>
</div>
)
})}
</motion.div>
)
}
/** A dashboard's icon and title, on the frosted surface: what a panel shows
* of a dashboard it is turning to, and of one it has just landed on. */
function NamePill({ dashboard }: { dashboard: Dashboard | undefined }) {
const Glyph = resolveIcon(dashboard?.icon ?? "")
* for a moment of a dashboard it has just landed on. */
function NamePill({ dashboard }: { dashboard: Dashboard }) {
const Glyph = resolveIcon(dashboard.icon ?? "")
return (
<output
data-testid="dashboard-announce"
@@ -334,21 +365,11 @@ function NamePill({ dashboard }: { dashboard: Dashboard | undefined }) {
style={{ fontSize: 15 }}
>
{Glyph ? <Glyph className="size-4 shrink-0" aria-hidden /> : null}
<span className="truncate">{dashboard?.title || dashboard?.name}</span>
<span className="truncate">{dashboard.title || dashboard.name}</span>
</output>
)
}
/** The name of a dashboard not drawn yet, read for it — which is also what
* has it loaded by the time a turn lands on it. */
function DashboardName({ name }: { name: string }) {
const { data } = useQuery({
...dashboardQueryOptions(name),
enabled: Boolean(name),
})
return <NamePill dashboard={data ?? ({ name } as Dashboard)} />
}
/**
* A round dashboard: one widget at a time, each drawn as the whole face.
*
@@ -358,7 +379,8 @@ function DashboardName({ name }: { name: string }) {
* the panel's other dashboards (`rise`, `onDashboard`: a swipe or
* Shift+PageUp/PageDown), and landing on one names it for a moment. Each
* dashboard comes back to the face it was left on. The arrow keys stay the
* ring's.
* ring's. A `peek` is a neighbour drawn beside the one shown: it answers to
* nothing until it becomes the one shown.
*
* Arranging, the editor says which face is shown (`face`, `onFace`) and a
* press on a face that is not one of its controls picks it (`onPick`).
@@ -372,7 +394,7 @@ export function RoundView({
onPick,
onDashboard,
rise,
named,
peek,
}: {
dashboard: Dashboard
/** The face's diameter: the canvas width. */
@@ -385,9 +407,7 @@ export function RoundView({
onDashboard?: (by: number) => void
/** Swiping up and down, to the panel's other dashboards. */
rise?: Axis
/** Turned to, its name already shown on the way: it stays without
* fading in again. */
named?: boolean
peek?: boolean
}) {
const widgets = dashboard.widgets ?? []
const { look } = useLook()
@@ -408,16 +428,15 @@ export function RoundView({
// whether a drag has the track.
const fling = useRef(0)
const dragging = useRef(false)
const [announce, setAnnounce] = useState(
() =>
Boolean(onDashboard) &&
lastShown !== null &&
lastShown !== dashboard.name,
)
const [announce, setAnnounce] = useState(false)
// Named for a moment when a panel lands on it from another — once it is
// the one shown, not while it waits beside it.
useEffect(() => {
if (onDashboard) lastShown = dashboard.name
}, [onDashboard, dashboard.name])
if (!onDashboard || peek) return
if (lastShown !== null && lastShown !== dashboard.name) setAnnounce(true)
lastShown = dashboard.name
}, [onDashboard, peek, dashboard.name])
useEffect(() => {
if (!announce) return
@@ -477,11 +496,12 @@ export function RoundView({
onFace?.(next)
}
const keys = !editing && !peek
useShortcuts({
pagedown: () => (editing ? false : page(index + 1)),
pageup: () => (editing ? false : page(index - 1)),
"shift+pagedown": () => (onDashboard && !editing ? onDashboard(1) : false),
"shift+pageup": () => (onDashboard && !editing ? onDashboard(-1) : false),
pagedown: () => (keys ? page(index + 1) : false),
pageup: () => (keys ? page(index - 1) : false),
"shift+pagedown": () => (onDashboard && keys ? onDashboard(1) : false),
"shift+pageup": () => (onDashboard && keys ? onDashboard(-1) : false),
})
const sideways: Axis = {
@@ -629,7 +649,7 @@ export function RoundView({
</nav>
) : null}
<AnimatePresence initial={!named}>
<AnimatePresence>
{announce ? (
<motion.div
key="announce"