Round paging follows the finger: faces and dashboards on a track, swiped one to one with the next beside it, turned or sent back on release, rubber-banded at the ends; keys and dots slide the same track; the reading dot a smaller companion of the handle
Docs / docs (push) Successful in 24s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 3m23s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Successful in 1m33s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-10-14 17:08:51 +02:00
co-authored by Claude Opus 5.5
parent 541dbce584
commit 603635f947
7 changed files with 474 additions and 183 deletions
+37 -21
View File
@@ -101,10 +101,12 @@ the track, the value's fill, or the handle.
| Dot | Size, tile / ring | On the track | On the fill | On the handle |
|---|---|---|---|---|
| Stop: a tick, an option | half the track's thickness (4 on the 8 track) / 6 | `--muted-foreground` | `--primary-foreground` at 70 % | hidden under it |
| Reading | three quarters of the track's thickness (6) / 10 | `--foreground` | `--primary-foreground` | Fluksio `--foreground`, Material `--primary-foreground`, Glass black at 75 % |
| Reading | the track's thickness (8) / 12 | `--foreground` | `--primary-foreground` | Fluksio `--foreground`, Material `--primary-foreground`, Glass black at 75 % |
The reading is the larger dot in the stronger colour, and it is the one dot
that shows on the handle, so a reading at the setpoint is still visible.
The reading is a filled circle like the handle, smaller and in the text colour
rather than the handle's paint, so it reads as what the room is beside what it
is set to. It is larger than a stop and stronger in colour, and it is the one
dot that shows on the handle, so a reading at the setpoint is still visible.
| Part | Value |
|---|---|
@@ -260,11 +262,11 @@ face, and the editor marks it **Poor fit for round**.
| Shift+PageDown / Shift+PageUp | next / previous dashboard of the panel |
| Arrow keys | turn the ring, not the page |
A swipe is a drag of at least 40 canvas pixels, decided by its longer axis; a
drag that starts on the ring turns the ring instead. A dashboard reopens on the
face it was last left on in this session, otherwise on its first. Landing on
another dashboard shows its title and icon in the middle of the face for one
second; a round panel has no rail.
A swipe carries the page with the finger ([Swiping](#swiping)); a drag that
starts on the ring turns the ring instead. A dashboard reopens on the face it
was last left on in this session, otherwise on its first. Landing on another
dashboard shows its title and icon in the middle of the face for one second; a
round panel has no rail.
### Geometry
@@ -282,7 +284,7 @@ look's muted text colour.
| Ring band | 40 along the edge |
| Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius c − 20, stroke 16 with round caps |
| Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across, its ticks as [dots](#dots-on-the-track); the value as the centre reading |
| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading as a dot 10 across on the centre line at its fraction of the sweep ([Dots on the track](#dots-on-the-track)) |
| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading as a dot 12 across on the centre line at its fraction of the sweep ([Dots on the track](#dots-on-the-track)) |
| Selector | a value arc from the start to the selected option (none at the first), a dot 6 across per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; its label as the centre reading, at most 36 |
| Colour | hue h at h/360 of the sweep in 150 strokes of 2°, red at both ends; the handle filled with the colour; a swatch 96 across in the middle, with a hairline border |
| Gauge | track and value arc, no handle; the value as the centre reading |
@@ -326,10 +328,10 @@ dashboard's look's.
| What | Moves | From → to | Timing | Input during it |
|---|---|---|---|---|
| Face paging (round) | the new face and the old | new: x +48 (next) or −48 (previous), opacity 0 → x 0, opacity 1; old: x 0, opacity 1 → x −48 (next) or +48, opacity 0. Both at once | spring | another page retargets: each face heads for its new end from where it is |
| Dashboard paging (round panel) | the new dashboard's face and the old | the same on y: the next rises from +48, the previous comes down from −48. One opened by its address only fades | spring | as faces |
| 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 |
| 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 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
| 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) | — |
| 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 | — |
@@ -341,17 +343,31 @@ dashboard's look's.
| Dashboard change (rectangular panel) | every tile | the old dashboard goes at once; each new tile opacity 0 → 1, y +8 → 0 | spring | — |
| Rail | the current entry's marker | slides to the new entry | spring | retargets |
With reduced motion asked for, nothing travels: pages and dashboards only
cross-fade (opacity at the spring), and every value is drawn at its new place
at once. The dot and the name pill, which only change colour or opacity, keep
their timing.
With reduced motion asked for, nothing travels on its own: a page under the
finger still follows it, but every turn, return and value lands at once. The
dot and the name pill, which only change colour or opacity, keep their timing.
A device may simplify where its hardware is slow, keeping the direction and
the end state: a page may slide without the fade, fade without the slide, or
cut; a retarget may restart the curve from where it is without carrying the
speed; a counting number may switch; and the value arc may be redrawn each
frame as an arc to the eased angle, which is what the browser's reveal
amounts to.
the end state: a turn a key or a dot asked for may cut; a retarget may restart
the curve from where it is without carrying the speed; a counting number may
switch; and the value arc may be redrawn each frame as an arc to the eased
angle, which is what the browser's reveal amounts to.
### Swiping
Faces are swiped sideways and a panel's dashboards up and down, with the page
under the finger, as a tile view moves.
| Part | Value |
|---|---|
| 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 |
| 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 |
| Press during a turn | lands the turn at once; the new drag starts from a page at rest |
| A swipe that began on a control | is not a press of it |
## Showing one
@@ -347,6 +347,7 @@ export function DashboardView({
rail,
editing,
onDashboard,
around,
}: {
dashboard: Dashboard
renderWidget?: (widget: WidgetDef) => React.ReactNode
@@ -358,8 +359,9 @@ export function DashboardView({
* stacked editor draws through here, where the canvas draws its own grid. */
editing?: boolean
/** A round one's way to the panel's other dashboards: next (1) or
* previous (-1). */
* previous (-1), and their names. */
onDashboard?: (by: number) => void
around?: [string | undefined, string | undefined]
}) {
const all = widgetsOf(dashboard)
const round = isRound(dashboard)
@@ -397,6 +399,7 @@ export function DashboardView({
dashboard={dashboard}
size={canvasOf(dashboard).width}
onDashboard={onDashboard}
around={around}
/>
) : (
<WidgetGrid
@@ -28,6 +28,7 @@ export function PanelSurface({
rail,
touch,
onDashboard,
around,
}: {
dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
@@ -40,8 +41,9 @@ export function PanelSurface({
/** 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. */
* others instead, whose names are `around` it. */
onDashboard?: (by: number) => void
around?: [string | undefined, string | undefined]
}) {
const hasRail = Boolean(rail)
const stack = stacked && !isRound(dashboard)
@@ -53,9 +55,10 @@ export function PanelSurface({
dashboard={dashboard}
rail={hasRail}
onDashboard={onDashboard}
around={around}
/>
),
[dashboard, hasRail, onDashboard],
[dashboard, hasRail, onDashboard, around],
)
return (
<div className="relative size-full">
+385 -151
View File
@@ -1,12 +1,15 @@
import { useQuery } from "@tanstack/react-query"
import { Lock, Power } from "lucide-react"
import {
AnimatePresence,
animate,
motion,
useIsPresent,
useMotionValue,
useReducedMotion,
useTransform,
type Variants,
} from "motion/react"
import { useEffect, useRef, useState } from "react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
import { slotsFor } from "@/components/Common/UplotChart"
@@ -17,6 +20,7 @@ import type { Dashboard } from "./DashboardView"
import { useBoundValue, useBoundValues } from "./dataContext"
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 { useUi } from "./ui"
@@ -48,13 +52,15 @@ import {
fitSize,
GAP_RISE,
GAP_SIZE,
PAGE_SLOP,
PRESS,
pageOffset,
RING_START,
RING_SWEEP,
SWIPE,
TITLE_RISE,
TITLE_SIZE,
TITLE_WIDTH,
turnsPage,
} from "./ui/core/ring"
import {
barReadings,
@@ -103,85 +109,253 @@ const lastFace = new Map<string, number>()
/** The dashboard a panel showed last, so landing on another can say which. */
let lastShown: string | null = null
/** How far a face or a dashboard slides in as it is paged to, in canvas
* pixels, fading in as it comes. */
const PAGE_SLIDE = 48
/** A face arrives from the side it was paged towards. */
const TURN: Variants = {
enter: (way: number) => ({ opacity: 0, x: way * PAGE_SLIDE }),
shown: { opacity: 1, x: 0 },
leave: (way: number) => ({ opacity: 0, x: way * -PAGE_SLIDE }),
/**
* One way a round face is swiped: sideways through its faces, or up and down
* through the panel's dashboards. Offsets and speeds are canvas pixels.
*/
type Axis = {
/** Whether there is a page onwards (1) or back (-1). */
open: (by: 1 | -1) => boolean
/** The page dragged to `offset`, the one `by` that way beside it. */
drag: (offset: number, by: 1 | -1) => void
/** Let go: turn to the page that way, or go back, at `speed` per second. */
release: (by: 1 | -1, turn: boolean, speed: number) => void
}
/** And a dashboard from below or above, as the faces do from the side. */
const RISE: Variants = {
enter: (way: number) => ({ opacity: 0, y: way * PAGE_SLIDE }),
shown: { opacity: 1, y: 0 },
leave: (way: number) => ({ opacity: 0, y: way * -PAGE_SLIDE }),
/**
* Swiping, with the page under the finger.
*
* Nothing moves until the drag has gone `PAGE_SLOP` along one axis, which it
* then keeps; from there the page follows the finger exactly and the next one
* comes in beside it, or, past the last, the page gives a little and no more.
* On release it turns if it went far enough or was flung (`turnsPage`), and
* goes back otherwise. A drag that starts on the ring turns the ring instead.
*/
function usePageDrag(
size: number,
box: React.RefObject<HTMLElement | null>,
axes: { x: Axis; y?: Axis },
swiped: React.RefObject<boolean>,
/** Settles whatever is still moving before a new drag takes hold. */
settle: () => void,
) {
const latest = useRef(axes)
latest.current = axes
return (event: React.PointerEvent) => {
swiped.current = false
if (!event.isPrimary) return
if ((event.target as Element).closest(".dui-ring-hit")) return
settle()
const scale = (box.current?.getBoundingClientRect().width || size) / size
const from = { x: event.clientX, y: event.clientY }
let axis: "x" | "y" | null = null
let along = 0
// The last moments of the drag, for the speed it was let go at.
const trail: [number, number][] = []
const target = () => (axis === "y" ? latest.current.y : latest.current.x)
const move = (moved: PointerEvent) => {
if (moved.pointerId !== event.pointerId) return
const dx = (moved.clientX - from.x) / scale
const dy = (moved.clientY - from.y) / scale
if (!axis) {
if (Math.max(Math.abs(dx), Math.abs(dy)) < PAGE_SLOP) return
axis = Math.abs(dx) >= Math.abs(dy) ? "x" : "y"
swiped.current = true
}
along = axis === "x" ? dx : dy
trail.push([moved.timeStamp, along])
while (trail.length > 2 && moved.timeStamp - trail[0][0] > 80) {
trail.shift()
}
// Towards the left, or up, is onwards, as a page is turned.
const by = along < 0 ? 1 : -1
const page = target()
page?.drag(pageOffset(along, page.open(by)), by)
}
const end = (ended: PointerEvent) => {
if (ended.pointerId !== event.pointerId) return
window.removeEventListener("pointermove", move)
window.removeEventListener("pointerup", end)
window.removeEventListener("pointercancel", end)
const page = target()
if (!axis || !page) return
// Over the last 80 ms before letting go: a drag held still and then
// released was not flung.
const recent = trail.filter(([at]) => ended.timeStamp - at <= 80)
const [then, was] = recent[0] ?? [ended.timeStamp, along]
const speed =
ended.timeStamp > then
? ((along - was) / (ended.timeStamp - then)) * 1000
: 0
const by = along < 0 ? 1 : -1
page.release(
by,
ended.type === "pointerup" &&
page.open(by) &&
turnsPage(along, speed, size),
speed,
)
}
window.addEventListener("pointermove", move)
window.addEventListener("pointerup", end)
window.addEventListener("pointercancel", end)
}
}
/**
* A panel's round dashboards, one at a time, paged up and down.
*
* The next one rises in from below as the last leaves upwards — the way a
* swipe up moved them — and the previous one comes down from above. One that
* arrives without a turn, by its address, only fades in.
* 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.
*/
export function RoundPanel({
dashboard,
size,
onDashboard,
around,
}: {
dashboard: Dashboard
size: number
onDashboard?: (by: number) => void
/** The panel's dashboards before and after this one. */
around?: [string | undefined, string | undefined]
}) {
const { look } = useLook()
// Read between renders: the turn asked for, and the side the dashboard it
// brings arrives from once it has loaded.
const turned = useRef(0)
const shown = useRef(dashboard.name)
const way = useRef(0)
if (shown.current !== dashboard.name) {
shown.current = dashboard.name
way.current = turned.current
turned.current = 0
}
return (
<AnimatePresence initial={false} custom={way.current}>
<motion.div
key={dashboard.name}
custom={way.current}
variants={RISE}
initial="enter"
animate="shown"
exit="leave"
transition={LOOK[look].spring}
className="absolute inset-0"
>
<RoundView
dashboard={dashboard}
size={size}
onDashboard={
onDashboard &&
((by) => {
turned.current = by
onDashboard(by)
})
}
/>
</motion.div>
</AnimatePresence>
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.
const [held, setHeld] = useState<Dashboard | null>(null)
const [beside, setBeside] = 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 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 })
setLanded(false)
onDashboard(by)
if (still) {
y.jump(-by * size)
setLanded(true)
return
}
// Landed once it is within a pixel, rather than when the spring has
// quite come to rest: the dashboard can take over from there.
animate(y, -by * size, {
...spring,
velocity: speed,
onUpdate: (at) => {
if (Math.abs(at + by * size) < 1) setLanded(true)
},
}).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.
useLayoutEffect(() => {
if (!held || !landed || dashboard.name === held.name) return
setHeld(null)
setBeside(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)
},
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))
},
}
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}
</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 ?? "")
return (
<output
data-testid="dashboard-announce"
className="flex h-9 max-w-[220px] items-center gap-2 rounded-full border border-border bg-card/80 px-4 font-medium shadow-e2 backdrop-blur-md"
style={{ fontSize: 15 }}
>
{Glyph ? <Glyph className="size-4 shrink-0" aria-hidden /> : null}
<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.
*
* Its widgets are the faces, in their order. Left and right page through
* them — a swipe, a page dot, PageUp/PageDown — stopping at either end; up and
* down are the panel's other dashboards (`onDashboard`, a swipe or
* Its widgets are the faces, in their order, side by side: a swipe drags the
* one shown and brings the next in beside it, and a page dot or
* PageUp/PageDown slides it across; either stops at the ends. Up and down are
* 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.
@@ -197,6 +371,8 @@ export function RoundView({
editing,
onPick,
onDashboard,
rise,
named,
}: {
dashboard: Dashboard
/** The face's diameter: the canvas width. */
@@ -207,25 +383,31 @@ export function RoundView({
onPick?: (id: string) => void
/** To the next (1) or the previous (-1) dashboard on this panel. */
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
}) {
const widgets = dashboard.widgets ?? []
const { look } = useLook()
const still = useReducedMotion()
const [own, setOwn] = useState(() => lastFace.get(dashboard.name) ?? 0)
const index = Math.max(0, Math.min(widgets.length - 1, face ?? own))
const widget = widgets[index]
const box = useRef<HTMLDivElement>(null)
// Read between renders: the face a page turn left, for the side the next
// one arrives from.
const was = useRef(index)
const way = useRef(0)
if (was.current !== index) {
way.current = index > was.current ? 1 : -1
was.current = index
}
const swiped = useRef(false)
// A dashboard on its way out keeps its keys to itself.
const present = useIsPresent()
const locked = useLocked()
// The faces on the track: the one at its origin and, while one is peeked
// at or on its way in, the one beside it, on the side it lies.
const [strip, setStrip] = useState<{ at: number; beside: number | null }>(
() => ({ at: index, beside: null }),
)
const x = useMotionValue(0)
// The speed a swipe was let go at, which the turn it starts carries on, and
// whether a drag has the track.
const fling = useRef(0)
const dragging = useRef(false)
const [announce, setAnnounce] = useState(
() =>
Boolean(onDashboard) &&
@@ -243,6 +425,51 @@ export function RoundView({
return () => clearTimeout(timer)
}, [announce])
// A new face slides in from beside the one shown: brought there by a
// swipe already, or put there now for a dot, a key or the editor.
useLayoutEffect(() => {
if (index === strip.at) {
// Turned back to before a turn had landed: it slides back.
if (strip.beside === null || dragging.current || x.isAnimating()) return
let live = true
const back = animate(x, 0, LOOK[look].spring)
back.then(() => live && setStrip({ at: index, beside: null }))
return () => {
live = false
back.stop()
}
}
if (strip.beside !== index) {
// A turn still on its way finishes at once first.
x.jump(0)
setStrip({ at: strip.beside ?? strip.at, beside: index })
return
}
const side = Math.sign(index - strip.at)
const land = () => setStrip({ at: index, beside: null })
if (still) {
land()
return
}
let live = true
const turn = animate(x, -side * size, {
...LOOK[look].spring,
velocity: fling.current,
})
fling.current = 0
turn.then(() => live && land())
return () => {
live = false
turn.stop()
}
}, [index, strip, still, look, size, x])
// One face on the track again: it is moved to the origin and the track
// with it, in the same frame, so nothing is seen to move.
useLayoutEffect(() => {
if (strip.beside === null) x.jump(0)
}, [strip.beside, x])
const page = (next: number) => {
if (next < 0 || next >= widgets.length || next === index) return
lastFace.set(dashboard.name, next)
@@ -250,42 +477,96 @@ export function RoundView({
onFace?.(next)
}
const keys = !editing && present
useShortcuts({
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),
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),
})
/** A drag that ends far enough away is a swipe, measured in canvas pixels
* whatever the canvas is scaled to. A drag along the ring turns it. */
const track = (event: React.PointerEvent) => {
swiped.current = false
if ((event.target as Element).closest(".dui-ring-hit")) return
const from = { x: event.clientX, y: event.clientY }
window.addEventListener(
"pointerup",
(end) => {
const scale =
(box.current?.getBoundingClientRect().width || size) / size
const dx = (end.clientX - from.x) / scale
const dy = (end.clientY - from.y) / scale
if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE) return
swiped.current = true
// Towards the left is onwards, as a page is turned.
if (Math.abs(dx) >= Math.abs(dy)) page(index + (dx < 0 ? 1 : -1))
else onDashboard?.(dy < 0 ? 1 : -1)
},
{ once: true },
)
const sideways: Axis = {
open: (by) => index + by >= 0 && index + by < widgets.length,
drag: (offset, by) => {
dragging.current = true
const beside = sideways.open(by) ? index + by : null
if (strip.at !== index || strip.beside !== beside) {
setStrip({ at: index, beside })
}
x.set(offset)
},
release: (by, turn, speed) => {
dragging.current = false
if (turn) {
fling.current = speed
page(index + by)
return
}
const back = () => setStrip({ at: index, beside: null })
if (still) {
back()
return
}
animate(x, 0, { ...LOOK[look].spring, velocity: speed }).then(back)
},
}
const track = usePageDrag(size, box, { x: sideways, y: rise }, swiped, () => {
// A turn still running lands at once, so the new drag starts from a
// page at rest.
if (!x.isAnimating() && strip.beside === null) return
x.stop()
flushSync(() => setStrip({ at: index, beside: null }))
x.jump(0)
})
if (!widget) return null
const Glyph = resolveIcon(dashboard.icon ?? "")
// Closer together where there are more than fit the gap.
const pitch = Math.min(DOT_PITCH, DOTS_WIDTH / widgets.length)
const drawn = (at: number, offset: number) => {
const one = widgets[at]
if (!one) return null
return (
<div
// The same element wherever it sits on the track, so a face that
// slides in is not drawn afresh once it has.
key={one.id}
className="absolute inset-0"
style={{ translate: `${offset}px 0` }}
>
<ErrorBoundary
fallback={<Middle size={size}>This face could not be drawn.</Middle>}
resetKeys={[one.id]}
>
<Face
widget={one}
dashboard={dashboard.name}
size={size}
editing={editing}
onPick={onPick && (() => onPick(one.id))}
/>
</ErrorBoundary>
{/* In the ring's gap, where a knob's bezel does not reach; a slider's
gap line holds it already, led by the lock. */}
{locked && !editing && !hasGapLine(one) ? (
<div
aria-live="polite"
data-testid={TESTID.locked}
className="pointer-events-none absolute inset-x-0 flex items-center justify-center gap-1.5 text-muted-foreground"
style={{
top: size - GAP_RISE - GAP_SIZE,
height: GAP_SIZE * 2,
fontSize: GAP_SIZE,
}}
>
<Lock style={{ width: GAP_SIZE, height: GAP_SIZE }} aria-hidden />
Read-only
</div>
) : null}
</div>
)
}
return (
// A face is pressed, swiped and paged rather than focused: its controls
// and the dots below are what the keyboard reaches.
@@ -315,50 +596,12 @@ export function RoundView({
}
}}
>
<AnimatePresence initial={false} custom={way.current}>
<motion.div
key={widget.id}
custom={way.current}
variants={TURN}
initial="enter"
animate="shown"
exit="leave"
transition={LOOK[look].spring}
className="absolute inset-0"
>
<ErrorBoundary
fallback={
<Middle size={size}>This face could not be drawn.</Middle>
}
resetKeys={[widget.id]}
>
<Face
widget={widget}
dashboard={dashboard.name}
size={size}
editing={editing}
onPick={onPick && (() => onPick(widget.id))}
/>
</ErrorBoundary>
{/* In the ring's gap, where a knob's bezel does not reach; a slider's
gap line holds it already, led by the lock. */}
{locked && !editing && !hasGapLine(widget) ? (
<div
aria-live="polite"
data-testid={TESTID.locked}
className="pointer-events-none absolute inset-x-0 flex items-center justify-center gap-1.5 text-muted-foreground"
style={{
top: size - GAP_RISE - GAP_SIZE,
height: GAP_SIZE * 2,
fontSize: GAP_SIZE,
}}
>
<Lock style={{ width: GAP_SIZE, height: GAP_SIZE }} aria-hidden />
Read-only
</div>
) : null}
</motion.div>
</AnimatePresence>
<motion.div className="absolute inset-0" style={{ x }}>
{drawn(strip.at, 0)}
{strip.beside === null
? null
: drawn(strip.beside, Math.sign(strip.beside - strip.at) * size)}
</motion.div>
{widgets.length > 1 ? (
<nav
@@ -386,7 +629,7 @@ export function RoundView({
</nav>
) : null}
<AnimatePresence>
<AnimatePresence initial={!named}>
{announce ? (
<motion.div
key="announce"
@@ -396,16 +639,7 @@ export function RoundView({
exit="exit"
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
>
<output
data-testid="dashboard-announce"
className="flex h-9 max-w-[220px] items-center gap-2 rounded-full border border-border bg-card/80 px-4 font-medium shadow-e2 backdrop-blur-md"
style={{ fontSize: 15 }}
>
{Glyph ? <Glyph className="size-4 shrink-0" aria-hidden /> : null}
<span className="truncate">
{dashboard.title || dashboard.name}
</span>
</output>
<NamePill dashboard={dashboard} />
</motion.div>
) : null}
</AnimatePresence>
@@ -525,8 +525,8 @@
}
.dui-slider-reading {
width: calc(var(--dui-track-size, 0.5rem) * 0.75);
height: calc(var(--dui-track-size, 0.5rem) * 0.75);
width: var(--dui-track-size, 0.5rem);
height: var(--dui-track-size, 0.5rem);
background: var(--dui-reading);
}
@@ -25,9 +25,10 @@ export const RING_HANDLE = 28
/** A stop on the ring — a selector's option, a slider's tick — as a dot this
* wide on the ring's centre line. */
export const RING_MARK = 6
/** What a slider reads beside its value, as a larger dot on the centre line:
* told from a stop by its size and its stronger colour. */
export const RING_READING = 10
/** What a slider reads beside its value, as a smaller companion of the
* handle on the centre line: told from a stop by its size and its stronger
* colour, and from the handle by its size and its fill. */
export const RING_READING = 12
/** How long the ring waits after the last notch before it publishes. */
export const RING_SETTLE_MS = 300
/** The gap's line — a slider's title and value, when what it reads is in
@@ -105,8 +106,32 @@ export const barLegend = (rows: number) =>
export const chordWidth = (inner: number, rise: number, margin: number) =>
Math.floor(2 * Math.sqrt(Math.max(0, inner ** 2 - rise ** 2)) - margin)
/** A drag this long, mostly along one axis, is a swipe rather than a press. */
export const SWIPE = 40
/**
* Swiping between pages, faces sideways and dashboards up and down. A drag
* picks its axis once it has gone `PAGE_SLOP` along one; the page follows it
* one to one, and past the last page gives `PAGE_RUBBER` of the drag, at most
* `PAGE_OVERSCROLL`. Let go, it turns if it went `PAGE_COMMIT` of the face or
* was flung at `PAGE_FLING` a second towards the next page.
*/
export const PAGE_SLOP = 8
export const PAGE_RUBBER = 0.3
export const PAGE_OVERSCROLL = 48
export const PAGE_COMMIT = 0.25
export const PAGE_FLING = 500
/** Where a page dragged `by` is drawn: under the finger while there is a
* page that way (`open`), held back past the last. */
export const pageOffset = (by: number, open: boolean) =>
open
? by
: Math.sign(by) * Math.min(PAGE_OVERSCROLL, Math.abs(by) * PAGE_RUBBER)
/** Whether a drag let go `by` along, at `speed` a second, turns the page. */
export const turnsPage = (by: number, speed: number, size: number) =>
Math.abs(by) >= PAGE_COMMIT * size ||
(Math.abs(by) >= PAGE_SLOP &&
Math.sign(speed) === Math.sign(by) &&
Math.abs(speed) >= PAGE_FLING)
/** How long a change of dashboard shows which one it landed on. */
export const ANNOUNCE_MS = 1000
+11 -1
View File
@@ -1,6 +1,6 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { useCallback } from "react"
import { useCallback, useMemo } from "react"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelRail } from "@/components/Dashboard/PanelRail"
@@ -66,6 +66,15 @@ function PanelRoute() {
const stacked = useIsMobile()
const rail = dashboards.length > 1
const round = dashboard?.shape === "round"
// The ones either side, which a round one is swiped to and shows the
// names of on the way. Stable, so the canvas is not rebuilt for it.
const at = dashboards.indexOf(current ?? "")
const before = dashboards[at - 1]
const after = dashboards[at + 1]
const around = useMemo(
(): [string | undefined, string | undefined] => [before, after],
[before, after],
)
// Stops at either end, as the faces of one do.
const step = useCallback(
(by: number) => {
@@ -123,6 +132,7 @@ function PanelRoute() {
stacked={stacked}
touch={touch}
onDashboard={rail && round ? step : undefined}
around={rail && round ? around : undefined}
// Drawn on the panel rather than beside it: a screen showing four
// dashboards shows the way between them too.
rail={