From b500af84ecfaa564b6e8663db701c0eed29400ee Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 16:34:10 +0200 Subject: [PATCH] Dashboard motion: round dashboards page up and down, the ring eases to a notch or a reading, the slider tile to a value set elsewhere; reduced motion honoured by every tween; motion spec in the docs Co-Authored-By: Claude Opus 5.5 --- docs/interface/dashboards.md | 48 +++++++++- .../components/Dashboard/DashboardView.tsx | 5 +- .../src/components/Dashboard/RingWidget.tsx | 66 ++++++------- .../src/components/Dashboard/RoundFace.tsx | 96 +++++++++++++++++-- .../components/Dashboard/ui/core/controls.ts | 11 ++- .../src/components/Dashboard/ui/core/core.css | 6 +- .../src/components/Dashboard/ui/core/ring.ts | 6 +- .../components/Dashboard/ui/core/values.ts | 62 +++++++++--- .../Dashboard/ui/fluksio/Controls.tsx | 12 +-- .../Dashboard/ui/glass/Controls.tsx | 12 +-- .../Dashboard/ui/material/Controls.tsx | 12 +-- 11 files changed, 245 insertions(+), 91 deletions(-) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index b43906e..7f13909 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -277,7 +277,7 @@ look's muted text colour. | Any other type | its rectangular body, without a card, from y = c − 64 to c + S/(2√2), x = c ± S/(2√2) (Knob: 53–307 × 116–307) | | Page dots | one per face, from two faces: dots 6 across on targets 16 apart (closer when more than 128 wide would be needed), centred at y = S − 12; the current one in the text colour, the others muted at half opacity | | Dashboard name | a pill centred at (c, c), 36 high: the icon 16 and the title at 15, medium, at most 220 wide, on the frosted surface; for 1000 ms | -| Paging | a new face slides in 48 from the side it was paged towards and fades in; the old one leaves the other way | +| Paging | faces slide sideways, dashboards up and down; see [Motion](#motion) | | Locked | controls do nothing and read as disabled; the ring at 0.45 opacity | Paint follows the look: Fluksio draws a muted track, a primary value and a @@ -285,6 +285,52 @@ handle of the ground colour ringed in primary; Material a tonal track and a primary handle; Glass a translucent track, a glowing value and a white handle. A Switch or Button face wears the look's own button. +## Motion + +How a dashboard moves, for devices that draw it natively. Distances are canvas +pixels. Each look moves at its own spring; a device without springs runs the +spring's closed form for the duration given and then sets the end value, or +uses the cubic-bezier that stands in for it. + +| Look | Spring (stiffness, damping, mass) | ζ, ω₀ | Duration (within 1 %) | Overshoot | Cubic-bezier stand-in | +|---|---|---|---|---|---| +| Fluksio | 420, 34, 1 | 0.830, 20.49 s⁻¹ | 210 ms | 0.9 % | (0.26, 0.12, 0.25, 1.01) over 250 ms, at most 1.5 % off | +| Material | 550, 38, 1 | 0.810, 23.45 s⁻¹ | 270 ms | 1.3 % | (0.20, 0.11, 0.27, 1.19) over 260 ms, at most 1.7 % off | +| Glass | 320, 22, 0.8 | 0.688, 20.00 s⁻¹ | 330 ms | 5.1 % | (0.26, 0.20, 0.20, 1.33) over 320 ms, at most 1.7 % off | + +From rest, a spring's progress is p(t) = 1 − e^(−ζω₀t)·(cos ω_d t + +(ζω₀/ω_d)·sin ω_d t), with ω_d = ω₀·√(1 − ζ²). "Spring" below is the +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 | +| 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) | — | +| 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 | — | +| Slider's reading on the ring, gauge on the ring | the reading's mark; the value arc | from where it is drawn to the new fraction | spring | retargets | +| Numbers on the ring | centre reading, gap line | switch at once, with the notch | none | — | +| Value face, Value and Gauge tiles | the number | counts to the new value, written to its precision | spring | retargets | +| Slider tile set elsewhere | the fill's end, the handle, the reading's mark | from where it is drawn to the new fraction | spring | a drag or a key moves it at once | +| Gauge and Bar tiles | the value arc, each bar's fill | from where it is drawn to the new fraction | spring | retargets | +| 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. + +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. + ## Showing one Open `/view/{name}` for a dashboard-only view with your normal session. diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 92bcff6..6b76dbb 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -4,7 +4,7 @@ import type { DashboardDef_Output, Placement, WidgetDef } from "@/client" import { cn } from "@/lib/utils" import "./dashboard.css" -import { RoundView } from "./RoundFace" +import { RoundPanel } from "./RoundFace" import { LockedProvider, PaletteProvider, @@ -393,8 +393,7 @@ export function DashboardView({ {round ? ( // Its own face memory per dashboard, so a panel switching to another // starts that one where it was left. - - send(dial.wire(next)), + const { current, held, latest, move, commit } = useRingDraft( + dial.value, + (next) => send(dial.wire(next)), ) const svg = useRef(null) const face = useRef(null) const spin = useRef(0) const turns = Boolean(dial.notch) && !locked const at = dial.at(current) - const animated = useAnimatedFraction(at ?? 0) - const shown = useTransform(animated, (fraction) => (fraction > 0 ? 1 : 0)) + const along = useEased(at ?? 0, held, Boolean(dial.hue)) + // At the minimum the value arc is a zero-length dash whose round caps would + // still paint a dot, so it is not drawn there. + const shown = useTransform(along, (fraction) => (fraction > 0 ? 1 : 0)) + const handleX = useTransform(along, (fraction) => ringPoint(size, fraction).x) + const handleY = useTransform(along, (fraction) => ringPoint(size, fraction).y) const turn = (by: number) => { if (turns && dial.notch) move(dial.notch(latest(), by), true) @@ -327,17 +333,14 @@ export function Ring({ // Written to the slider's own decimals and in its unit: the two are read // against each other. const other = beside.message ? dial.reading(read?.value ?? null) : null - const handle = dial.handle && at !== null ? ringPoint(size, at) : null // The same reading, placed: a bar across the band where it sits on the // slider's scale, held to the sweep at either end. const actual = typeof read?.value === "number" ? dial.at(read.value) : null - const across = - actual === null - ? null - : [ - ringPoint(size, actual, -RING_READING / 2), - ringPoint(size, actual, RING_READING / 2), - ] + const across = useTransform(useEased(actual ?? 0), (fraction) => { + const from = ringPoint(size, fraction, -RING_READING / 2) + const to = ringPoint(size, fraction, RING_READING / 2) + return `M${from.x} ${from.y}L${to.x} ${to.y}` + }) const whole = ringArc(size, 0, 1) const name = widget.title || text(cfg.target, widget.id) @@ -394,26 +397,16 @@ export function Ring({ className="dui-ring-track" /> )} - {/* A slider's reading follows the hand, so it is drawn where it - is. A gauge eases to its reading, revealed rather than re-pathed - as the gauge tile's arc is, and transparent at the minimum where - its round caps would still paint a dot. */} - {dial.fill && dial.notch && at ? ( - - ) : dial.fill && !dial.notch ? ( + {/* Revealed rather than re-pathed, as the gauge tile's arc is: `d` + does not ease, and `pathLength` makes the reveal the fraction. */} + {dial.fill ? ( ) : null} {dial.marks?.map((mark, index) => { @@ -430,20 +423,17 @@ export function Ring({ ) })} {/* Under the handle, so where the two meet the value being set wins. */} - {across ? ( - ) : null} - {handle ? ( - () /** 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 * 48 }), + enter: (way: number) => ({ opacity: 0, x: way * PAGE_SLIDE }), shown: { opacity: 1, x: 0 }, - leave: (way: number) => ({ opacity: 0, x: way * -48 }), + leave: (way: number) => ({ opacity: 0, x: way * -PAGE_SLIDE }), +} + +/** 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 }), +} + +/** + * 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. + */ +export function RoundPanel({ + dashboard, + size, + onDashboard, +}: { + dashboard: Dashboard + size: number + onDashboard?: (by: number) => void +}) { + 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 ( + + + { + turned.current = by + onDashboard(by) + }) + } + /> + + + ) } /** @@ -124,6 +196,8 @@ export function RoundView({ was.current = index } const swiped = useRef(false) + // A dashboard on its way out keeps its keys to itself. + const present = useIsPresent() const [announce, setAnnounce] = useState( () => Boolean(onDashboard) && @@ -148,11 +222,12 @@ export function RoundView({ onFace?.(next) } + const keys = !editing && present 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), }) /** A drag that ends far enough away is a swipe, measured in canvas pixels @@ -270,9 +345,10 @@ export function RoundView({ {announce ? ( ( * A drag would otherwise send a value per pixel and flood whatever is * listening, so the draft follows the finger and only the release publishes. * While there is no draft the handle follows the engine, which is what makes a - * value set elsewhere show up here. + * value set elsewhere show up here — easing there, as a reading does, where a + * drag or a key moves it at once. */ export function useSliderDrag({ value, @@ -242,13 +244,16 @@ export function useSliderDrag({ })) : [] + const fraction = useEased(fractionOf(current, min, max), draft !== null) + const markAt = useEased(mark === null ? 0 : fractionOf(mark, min, max)) + return { current, /** Goes on the slider, whose width is the scale's width. */ trackRef, - fraction: fractionOf(current, min, max), + fraction, /** Where the mark sits along the track, 0..1 and held to it; null for none. */ - markAt: mark === null ? null : fractionOf(mark, min, max), + markAt: mark === null ? null : markAt, inputProps: { type: "range" as const, min, diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index 7e0650a..a7f4018 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -694,11 +694,15 @@ stroke: var(--foreground); } -/* Page dots, one per face, in the ring's gap. */ +/* Page dots, one per face, in the ring's gap. The current one changes over + as the face slides in: a colour, so reduced motion keeps it. */ .dui-dot-mark { border-radius: 50%; background: var(--muted-foreground); opacity: 0.5; + transition: + background-color var(--duration-base) var(--ease-standard), + opacity var(--duration-base) var(--ease-standard); } .dui-dot[aria-current="page"] .dui-dot-mark { diff --git a/frontend/src/components/Dashboard/ui/core/ring.ts b/frontend/src/components/Dashboard/ui/core/ring.ts index 9fed5ea..cd4ca09 100644 --- a/frontend/src/components/Dashboard/ui/core/ring.ts +++ b/frontend/src/components/Dashboard/ui/core/ring.ts @@ -132,7 +132,7 @@ export function fractionAt(dx: number, dy: number): number { * a draft the ring follows the engine, through the caller's hold-until-echo. */ export function useRingDraft(value: T, send: (value: T) => void) { - const [draft, setDraft] = useState<{ value: T } | null>(null) + const [draft, setDraft] = useState<{ value: T; settle: boolean } | null>(null) // Read between renders: notches arrive faster than React draws, and each // one steps from the last rather than from what was last drawn. const pending = useRef<{ value: T } | null>(null) @@ -155,12 +155,14 @@ export function useRingDraft(value: T, send: (value: T) => void) { return { current: draft ? draft.value : value, + /** A drag is moving it, which the drawing follows without easing. */ + held: draft !== null && !draft.settle, /** The value a further notch steps from. */ latest: () => (pending.current ? pending.current.value : value), /** Move the draft; `settle` publishes once nothing has moved it for a while. */ move: (next: T, settle: boolean) => { pending.current = { value: next } - setDraft({ value: next }) + setDraft({ value: next, settle }) if (timer.current) clearTimeout(timer.current) timer.current = settle ? setTimeout(commit, RING_SETTLE_MS) : null }, diff --git a/frontend/src/components/Dashboard/ui/core/values.ts b/frontend/src/components/Dashboard/ui/core/values.ts index eee04e5..ca05850 100644 --- a/frontend/src/components/Dashboard/ui/core/values.ts +++ b/frontend/src/components/Dashboard/ui/core/values.ts @@ -3,16 +3,20 @@ * * A number that jumps is hard to read and a number that crawls is a distraction * — both sets tween theirs, at their own look's stiffness, through the motion - * values here. `` at the app root switches - * every one of them off when the device asks. + * values here. These are motion values rather than animated props, which + * `` never reaches, so `useEased` asks the + * device itself and moves at once when it wants less motion. */ import { + animate, + cancelFrame, + frame, type MotionValue, useMotionValue, - useSpring, + useReducedMotion, useTransform, } from "motion/react" -import { useEffect } from "react" +import { useLayoutEffect, useRef } from "react" import type { WidgetDef } from "@/client" import { CHART_SLOTS } from "@/components/Common/UplotChart" @@ -43,14 +47,12 @@ export function useAnimatedNumber( value: unknown, precision: number | null, ): { label: MotionValue; numeric: boolean } { - const { look } = useLook() const numeric = typeof value === "number" - const raw = useMotionValue(numeric ? value : 0) - const settled = useSpring(raw, LOOK[look].spring) + // A reading that goes away and comes back tweens from where it last was. + const last = useRef(numeric ? value : 0) + if (numeric) last.current = value + const settled = useEased(last.current) const written = precision ?? (numeric ? decimalsOf(value as number) : null) - useEffect(() => { - if (numeric) raw.set(value as number) - }, [numeric, value, raw]) return { label: useTransform(settled, (at) => format(at, written)), numeric, @@ -59,12 +61,42 @@ export function useAnimatedNumber( /** Where a reading sits on its scale, tweened, for a fill or an arc. */ export function useAnimatedFraction(fraction: number): MotionValue { + return useEased(fraction) +} + +/** + * A number on its way to `value`, at the look's spring. + * + * A new value retargets a move still running, from where it is and as fast as + * it was going. It moves at once instead while a hand is on it (`held`: a drag + * stays under the finger), when the device asks for reduced motion, and for a + * value that `wraps`, a hue, when it crosses the seam — more than half the + * range, which would otherwise sweep back along the whole of it. + */ +export function useEased( + value: number, + held = false, + wraps = false, +): MotionValue { const { look } = useLook() - const raw = useMotionValue(fraction) - useEffect(() => { - raw.set(fraction) - }, [fraction, raw]) - return useSpring(raw, LOOK[look].spring) + const still = useReducedMotion() + const eased = useMotionValue(value) + // Before paint, so a drag is drawn in the frame it moved in. + useLayoutEffect(() => { + if (held || still || (wraps && Math.abs(value - eased.get()) > 0.5)) { + // In the frame about to be drawn rather than now: what the reader + // derives from this value re-subscribes after this effect, and would + // miss a change made in it. + const jump = () => eased.jump(value) + frame.preRender(jump) + return () => cancelFrame(jump) + } + const move = animate(eased, value, LOOK[look].spring) + // Stopped rather than left running when the reader goes away; a value + // that changes again retargets instead, from inside `animate`. + return () => move.stop() + }, [value, held, wraps, still, look, eased]) + return eased } /** diff --git a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx index 844dfa2..74c217e 100644 --- a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx @@ -12,7 +12,7 @@ import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown } from "lucide-react" -import { motion } from "motion/react" +import { type MotionStyle, motion } from "motion/react" import { cn } from "@/lib/utils" import { asOriginal, text } from "../core/config" @@ -68,14 +68,14 @@ export function Slider(props: SliderProps) { const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return ( -
@@ -88,10 +88,10 @@ export function Slider(props: SliderProps) { {/* Under the handle, so where the two meet the value being set wins; after the input only for its `:disabled`, as it takes no pointer. */} {markAt === null ? null : ( - )} @@ -115,7 +115,7 @@ export function Slider(props: SliderProps) { ))}
) : null} -
+
) } diff --git a/frontend/src/components/Dashboard/ui/glass/Controls.tsx b/frontend/src/components/Dashboard/ui/glass/Controls.tsx index cc6ab99..d7cbfa3 100644 --- a/frontend/src/components/Dashboard/ui/glass/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Controls.tsx @@ -7,7 +7,7 @@ */ import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown } from "lucide-react" -import { motion } from "motion/react" +import { type MotionStyle, motion } from "motion/react" import { cn } from "@/lib/utils" import { asOriginal, text } from "../core/config" @@ -79,14 +79,14 @@ export function Slider(props: SliderProps) { const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return ( -
@@ -99,10 +99,10 @@ export function Slider(props: SliderProps) { {/* Under the handle, so where the two meet the value being set wins; after the input only for its `:disabled`, as it takes no pointer. */} {markAt === null ? null : ( - )} @@ -126,7 +126,7 @@ export function Slider(props: SliderProps) { ))}
) : null} -
+ ) } diff --git a/frontend/src/components/Dashboard/ui/material/Controls.tsx b/frontend/src/components/Dashboard/ui/material/Controls.tsx index 5b7ba46..2b63c2b 100644 --- a/frontend/src/components/Dashboard/ui/material/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/material/Controls.tsx @@ -8,7 +8,7 @@ import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown } from "lucide-react" -import { AnimatePresence, motion } from "motion/react" +import { AnimatePresence, type MotionStyle, motion } from "motion/react" import { cn } from "@/lib/utils" import { asOriginal, text } from "../core/config" @@ -108,14 +108,14 @@ export function Slider(props: SliderProps) { const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return ( -
@@ -128,10 +128,10 @@ export function Slider(props: SliderProps) { {/* Under the handle, so where the two meet the value being set wins; after the input only for its `:disabled`, as it takes no pointer. */} {markAt === null ? null : ( - )} @@ -155,7 +155,7 @@ export function Slider(props: SliderProps) { ))}
) : null} -
+ ) }