Round faces: a bar as concentric arcs with a legend in the middle; larger title, gap line and face text; Read-only in the ring's gap; a round dashboard unpadded on a phone; reduced-motion ring assertion

This commit is contained in:
Melvin Strobl
2026-10-14 16:52:46 +02:00
parent bae4b15505
commit c294789d85
9 changed files with 308 additions and 33 deletions
+15 -13
View File
@@ -226,7 +226,8 @@ restrict API permissions and does not disable controls in edit mode.
For a round screen such as a rotary knob, set the canvas to **Round** in the
dashboard settings, or pick the **Knob** preset (360×360). A round canvas is
square (`shape: "round"` forces `canvas_height` to `canvas_width`), and nothing
outside its circle is drawn.
outside its circle is drawn. It fills the screen at any width: a phone neither
stacks nor pads it.
A round dashboard has no grid. It shows one widget at a time, each drawn as the
whole face, in the order of `widgets`; `layout` is not read. In the editor, the
@@ -244,8 +245,8 @@ the screen.
A Slider's reading (**Beside it**) is written large in the middle, under its
label; the slider's own title and value move to the line in the ring's gap.
Value, Switch, Button, Clock, Icon, Notification and Text have faces of their
own. Every other type is drawn as its rectangular body in the middle of the
Value, Bar, Switch, Button, Clock, Icon, Notification and Text have faces of
their own. Every other type is drawn as its rectangular body in the middle of the
face, and the editor marks it **Poor fit for round**.
### Moving between faces
@@ -275,7 +276,7 @@ look's muted text colour.
| Part | Value |
|---|---|
| Face | circle of diameter S at (c, c) |
| Title | one line centred at y = c − 80, 15, muted, at most 200 wide; the widget's title, or the label of a slider's reading |
| Title | one line centred at y = c − 80, 18, muted, at most 200 wide; the widget's title, or the label of a slider's reading |
| Middle | a 240 × 128 box centred at (c, c); what a face draws in its middle is centred in it and clipped to it |
| Centre reading | centred at (c, c), line height 1, its unit after it at 0.4 em, muted. Size: 72, or 240 ÷ (0.6 × the characters written) rounded down where that is smaller, never below 24 |
| Ring band | 40 along the edge |
@@ -285,20 +286,21 @@ look's muted text colour.
| 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 |
| Gap line | centred at y = S − 28, 13, at most 160 wide |
| Gap line | centred at y = S − 28, 15, at most 140 wide, clear of the ring's end caps |
| Value | the reading as the centre reading, to its precision; a word shrinks like any other |
| Switch | a disc 128 across centred at (c, c): the look's button, filled when on and tonal when off, with a power glyph 40 over the word On or Off at 15; a press toggles |
| Button | a disc 128 across centred at (c, c), tonal, its label at 15 over at most three lines; a press sends its value |
| Clock | the time (hours and minutes, the device's locale) sized as a centre reading, over the date (weekday, day, month) at 15, muted, 4 apart; the pair centred at (c, c) |
| Icon | the glyph 96 across over its label at 15, muted, 4 apart; the pair centred at (c, c). Nothing matched: a dash at 72, muted |
| Notification | the title at 15, medium (an error in the destructive colour, led by ⚠), over the body at 15, muted; centred lines in the middle |
| Text | centred lines at 15: `#` at 1.35 em and `##` medium, `###` muted, list items led by a bullet; in the middle |
| Not wired | the problem, at 15, muted, in the middle; a ring type draws no ring |
| Bar | an arc per row, the first outermost, each over the ring's sweep (start 120°, 300°) with round caps: a track in the look's track paint and the row's value arc in its data colour, on its own scale (none at the minimum). n rows share a band 64 thick inward from c − 12, 3 apart: stroke s = min(16, (64 − 3(n − 1)) / n), centre-line radius rᵢ = c − 12 − s/2 − i(s + 3), so up to three rows are 16 thick and the first is on the ring (c − 20). The middle is the legend, a line per row in order: a dot 8 across in the row's colour, 8, the label (muted, cut with an ellipsis), 8, the value and unit to the row's precision (— for none). Lines of 22 at 17 for up to 5 rows, 18 at 15 for 6–7, 16 at 13 for 8; the block centred at (c, c), as wide as its widest line but at most 240 and at most the width of the disc inside the innermost arc (radius c − 12 − n·s − 3(n − 1)) at the block's top and bottom, less 16. The title is at most that disc's width at the title's line (y = c − 89), less 8 |
| Switch | a disc 128 across centred at (c, c): the look's button, filled when on and tonal when off, with a power glyph 40 over the word On or Off at 17; a press toggles |
| Button | a disc 128 across centred at (c, c), tonal, its label at 17 over at most three lines; a press sends its value |
| Clock | the time (hours and minutes, the device's locale) sized as a centre reading, over the date (weekday, day, month) at 17, muted, 4 apart; the pair centred at (c, c) |
| Icon | the glyph 96 across over its label at 17, muted, 4 apart; the pair centred at (c, c). Nothing matched: a dash at 72, muted |
| Notification | the title at 17, medium (an error in the destructive colour, led by ⚠), over the body at 17, muted; centred lines in the middle |
| Text | centred lines at 17: `#` at 1.35 em and `##` medium, `###` muted, list items led by a bullet; in the middle |
| Not wired | the problem, at 17, muted, in the middle; a ring type draws no ring |
| 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 | faces slide sideways, dashboards up and down; see [Motion](#motion) |
| Locked | controls do nothing and read as disabled; the ring at 0.45 opacity |
| Locked | controls do nothing and read as disabled; the ring at 0.45 opacity. **Read-only** in the gap line's place (centred at y = S − 28): a lock glyph 15 and the word at 15, 6 apart, muted. A face whose gap line is in use leads that line with the lock glyph instead. Nothing is drawn outside the circle |
Paint follows the look: Fluksio draws a muted track, a primary value and a
handle of the ground colour ringed in primary; Material a tonal track and a
@@ -118,6 +118,8 @@ export function LockNotice({ dashboard }: { dashboard: Dashboard }) {
// 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)
if (!locked) return null
// 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>
}
@@ -1,3 +1,4 @@
import { Lock } from "lucide-react"
import { type MotionValue, motion, useTransform } from "motion/react"
import { useEffect, useId, useRef } from "react"
@@ -5,6 +6,7 @@ import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { usePublish } from "./publish"
import { useLocked } from "./settings"
import {
colorFormatOf,
cssOf,
@@ -21,6 +23,7 @@ import {
showTitle,
text,
} from "./ui/core/config"
import { TESTID } from "./ui/core/contract"
import { tickIntervals } from "./ui/core/controls"
import {
CENTRE_LABEL,
@@ -183,6 +186,12 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
}
}
/** A slider that reads something beside its value, which then takes the
* middle and moves the slider's own title and value to the gap's line. */
export const hasGapLine = (widget: WidgetDef) =>
widget.type === "slider" &&
Boolean(((config(widget).reading ?? {}) as Series).message)
/**
* A face's title: one slot, above the middle, whichever widget it is.
*
@@ -191,17 +200,20 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
*/
export function FaceTitle({
size,
width = TITLE_WIDTH,
children,
}: {
size: number
/** Narrower where something is drawn inside the ring beside it. */
width?: number
children: React.ReactNode
}) {
return (
<p
className="pointer-events-none absolute truncate text-center text-muted-foreground"
style={{
left: size / 2 - TITLE_WIDTH / 2,
width: TITLE_WIDTH,
left: size / 2 - width / 2,
width,
top: size / 2 - TITLE_RISE - TITLE_SIZE,
lineHeight: `${TITLE_SIZE * 2}px`,
fontSize: TITLE_SIZE,
@@ -273,6 +285,9 @@ export function Ring({
onPick?: () => void
}) {
const { value, send, locked } = usePublish(widget, dashboard)
// The dashboard's own lock, which the gap's line leads with when it holds
// the gap (`RoundView` writes it there otherwise).
const readOnly = useLocked()
const cfg = config(widget)
// A gauge reads its message rather than publishing to a target.
const bound = useBoundValue(
@@ -517,6 +532,15 @@ export function Ring({
fontSize: GAP_SIZE,
}}
>
{readOnly ? (
<Lock
role="img"
aria-label="Read-only"
data-testid={TESTID.locked}
className="shrink-0 text-muted-foreground"
style={{ width: GAP_SIZE, height: GAP_SIZE }}
/>
) : null}
{title ? (
<span className="min-w-0 truncate text-muted-foreground">
{title}
+179 -6
View File
@@ -1,40 +1,66 @@
import { Power } from "lucide-react"
import { Lock, Power } from "lucide-react"
import {
AnimatePresence,
motion,
useIsPresent,
useTransform,
type Variants,
} from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
import { slotsFor } from "@/components/Common/UplotChart"
import { fadeIn } from "@/lib/motion"
import { useShortcuts } from "@/lib/shortcuts"
import { useClock } from "./ClockWidget"
import type { Dashboard } from "./DashboardView"
import { useBoundValue } from "./dataContext"
import { useBoundValue, useBoundValues } from "./dataContext"
import { resolveIcon } from "./icons"
import { usePublish } from "./publish"
import { CentreReading, FaceTitle, Ring } from "./RingWidget"
import { CentreReading, FaceTitle, hasGapLine, Ring } from "./RingWidget"
import { useLocked, usePalette } from "./settings"
import { useUi } from "./ui"
import { config, format, num, scrolls, showTitle, text } from "./ui/core/config"
import { arcPath } from "./ui/core/arc"
import {
config,
format,
num,
rowsOf,
scrolls,
showTitle,
text,
} from "./ui/core/config"
import { TESTID } from "./ui/core/contract"
import { useLook } from "./ui/core/look"
import { LOOK } from "./ui/core/motion"
import {
ANNOUNCE_MS,
BODY_HEIGHT,
BODY_WIDTH,
barArcs,
barLegend,
CENTRE_VALUE,
chordWidth,
DOT,
DOT_PITCH,
DOTS_RISE,
DOTS_WIDTH,
fitSize,
GAP_RISE,
GAP_SIZE,
PRESS,
RING_START,
RING_SWEEP,
SWIPE,
TITLE_RISE,
TITLE_SIZE,
TITLE_WIDTH,
} from "./ui/core/ring"
import { useAnimatedNumber } from "./ui/core/values"
import {
barReadings,
useAnimatedFraction,
useAnimatedNumber,
} from "./ui/core/values"
import {
WIDGET_LABELS,
WidgetBody,
@@ -58,6 +84,7 @@ export const RING_TYPES = new Set<WidgetKind>([
*/
export const ROUND_TYPES = new Set<WidgetKind>([
...RING_TYPES,
"bar",
"stat",
"switch",
"button",
@@ -198,6 +225,7 @@ export function RoundView({
const swiped = useRef(false)
// A dashboard on its way out keeps its keys to itself.
const present = useIsPresent()
const locked = useLocked()
const [announce, setAnnounce] = useState(
() =>
Boolean(onDashboard) &&
@@ -312,6 +340,23 @@ export function RoundView({
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>
@@ -386,6 +431,9 @@ function Face({
onPick?: () => void
}) {
const issue = widgetIssue(widget)
if (widget.type === "bar" && !issue) {
return <BarFace widget={widget} size={size} />
}
if (RING_TYPES.has(widget.type) && !issue) {
return (
<Ring
@@ -499,6 +547,131 @@ function StatFace({ widget }: { widget: WidgetDef }) {
)
}
/**
* A bar as rings: an arc per row, the first outermost, each on its own scale
* and in the dashboard's data colour for its row, as the tile's bars are. The
* middle is their legend — a dot of the row's colour, its label and its value
* — which is what says which arc is which.
*/
function BarFace({ widget, size }: { widget: WidgetDef; size: number }) {
const rows = rowsOf(widget).filter((row) => row.message)
const live = useBoundValues(rows.map((row) => row.message as string))
const readings = barReadings(widget, live, slotsFor(usePalette()))
const { stroke, radii, inner } = barArcs(size, readings.length)
const legend = barLegend(readings.length)
const height = readings.length * legend.line
// As wide as its widest line, and no wider than the inner disc is at the
// legend's top and bottom lines.
const width = Math.min(BODY_WIDTH, chordWidth(inner, height / 2, 16))
const title = showTitle(widget) ? widget.title : ""
return (
<>
<svg
width={size}
height={size}
className="pointer-events-none absolute inset-0"
aria-hidden="true"
>
{readings.map((row, index) => (
<BarArc
// Two rows may read the same message; position is the identity.
key={`row-${index}`}
fraction={row.fraction}
color={row.color}
radius={radii[index]}
stroke={stroke}
size={size}
/>
))}
</svg>
{title ? (
<FaceTitle
size={size}
// Clear of the inner arcs, which come in this far with many rows.
width={Math.min(
TITLE_WIDTH,
chordWidth(inner, TITLE_RISE + TITLE_SIZE / 2, 8),
)}
>
{title}
</FaceTitle>
) : null}
<div
className="pointer-events-none absolute grid items-center gap-x-2 tabular-nums"
style={{
left: size / 2,
translate: "-50%",
width: "max-content",
maxWidth: width,
top: (size - height) / 2,
gridTemplateColumns: "auto minmax(0, 1fr) auto",
gridAutoRows: legend.line,
fontSize: legend.size,
}}
>
{readings.map((row, index) => (
<div
key={`row-${index}`}
role="img"
aria-label={`${row.label} ${format(row.value, row.precision)}${row.unit ?? ""}`}
data-testid="bar-face-row"
className="contents"
>
<span
className="size-2 rounded-full"
style={{ background: row.color }}
/>
<span className="truncate text-muted-foreground">{row.label}</span>
<span className="whitespace-nowrap text-right">
{format(row.value, row.precision)}
{row.unit ?? ""}
</span>
</div>
))}
</div>
</>
)
}
/** One row's arc: its track, and its reading eased along it. */
function BarArc({
fraction,
color,
radius,
stroke,
size,
}: {
fraction: number
color: string
radius: number
stroke: number
size: number
}) {
const along = useAnimatedFraction(fraction)
// None at the minimum, where the round caps would still paint a dot.
const shown = useTransform(along, (at) => (at > 0 ? 1 : 0))
const d = arcPath(RING_START, RING_START + RING_SWEEP, radius, size / 2)
return (
<>
<path
d={d}
fill="none"
strokeWidth={stroke}
strokeLinecap="round"
className="dui-ring-track"
/>
<motion.path
d={d}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
style={{ pathLength: along, opacity: shown }}
/>
</>
)
}
/** The time as large as a reading, shrunk to fit as one is, over the date. */
function ClockFace() {
const { time, date } = useClock()
@@ -706,13 +706,13 @@
* A round dashboard (`data-shape="round"` on the canvas): one widget at a
* time, each the whole face (`RoundFace.tsx`). Its geometry is in `ring.ts`.
*
* The middle of a face is written at 15px and centred, and the bodies a face
* The middle of a face is written at 17px and centred, and the bodies a face
* borrows from its tile are sized for it here: a glyph — or the dash for none —
* at the size a reading has, a note or a notification as centred lines.
*/
.dui-face-body {
overflow: hidden;
font-size: 15px;
font-size: 17px;
line-height: 1.35;
}
@@ -741,7 +741,7 @@
height: 100%;
border-radius: 50%;
padding: 0 12px;
font-size: 15px;
font-size: 17px;
}
.dui-face-press > button > :last-child {
@@ -32,16 +32,16 @@ export const RING_READING = 10
export const RING_SETTLE_MS = 300
/** The gap's line — a slider's title and value, when what it reads is in
* the middle — centred this far above the bottom of the face, at this size,
* and at most this wide: inside the face at any size. */
* and at most this wide: clear of the ring's end caps at any size. */
export const GAP_RISE = 28
export const GAP_SIZE = 13
export const GAP_WIDTH = 160
export const GAP_SIZE = 15
export const GAP_WIDTH = 140
/** Every face's title: centred this far above the middle, at this size, and
* at most this wide. One place, so a face is read the same way whichever
* widget it is. */
export const TITLE_RISE = 80
export const TITLE_SIZE = 15
export const TITLE_SIZE = 18
export const TITLE_WIDTH = 200
/** What a face draws in its middle — a reading, a press, a few lines — fits
@@ -67,6 +67,44 @@ export const DOT = 6
export const DOT_PITCH = 16
export const DOTS_WIDTH = 128
/**
* A bar's face: an arc per row, concentric, the first outermost.
*
* The arcs share a band this thick from the ring's own outer edge inward, a
* constant gap apart, each as thick as the ring while that fits and thinner
* with more rows. Its legend's lines are as large as the middle allows.
*/
export const BAR_BAND = 64
export const BAR_GAP = 3
export function barArcs(size: number, rows: number) {
const stroke = Math.min(RING_STROKE, (BAR_BAND - BAR_GAP * (rows - 1)) / rows)
// The ring's track's outer edge, so up to three rows draw on the ring.
const outer = size / 2 - RING_INSET / 2 + RING_STROKE / 2
return {
stroke,
radii: Array.from(
{ length: rows },
(_, index) => outer - stroke / 2 - index * (stroke + BAR_GAP),
),
/** What is left inside the innermost arc. */
inner: outer - rows * stroke - (rows - 1) * BAR_GAP,
}
}
/** A legend line's text size and height, by how many rows share the middle. */
export const barLegend = (rows: number) =>
rows <= 5
? { size: 17, line: 22 }
: rows <= 7
? { size: 15, line: 18 }
: { size: 13, line: 16 }
/** How wide a line `rise` above or below the middle can be inside a disc of
* radius `inner`, less `margin`. */
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
+2 -1
View File
@@ -108,7 +108,8 @@ function PanelRoute() {
// edges by the grid inset, and a second margin outside it costs the
// scale: a dashboard drawn for this screen would come out at 95% of
// the screen it was drawn for.
stacked ? "overflow-y-auto p-4" : "overflow-hidden",
// A round one fills the screen as it does a knob's, stacked or not.
stacked && !round ? "overflow-y-auto p-4" : "overflow-hidden",
)}
style={root.style}
>
+5 -3
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
import { ContextBar } from "@/components/Dashboard/ContextBar"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { type Dashboard, isRound } from "@/components/Dashboard/DashboardView"
import { useDataContext } from "@/components/Dashboard/dataContext"
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
@@ -66,8 +66,10 @@ function PanelView() {
"h-svh w-full bg-background text-foreground",
root.className,
// See `panel.$id`: the canvas keeps its own margin, so a second one
// here only shrinks it.
stacked ? "overflow-y-auto p-4" : "overflow-hidden",
// here only shrinks it — and a round one is never stacked.
stacked && !isRound(dashboard as Dashboard)
? "overflow-y-auto p-4"
: "overflow-hidden",
)}
style={root.style}
>
+33
View File
@@ -111,3 +111,36 @@ test("two wheel notches publish once, two steps on", async ({ page }) => {
await page.keyboard.press("PageDown")
await expect(page.getByText("The second face")).toBeVisible()
})
test("with reduced motion a notch moves the handle at once", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.goto(`/view/${dashboard}`)
const ring = page.getByRole("slider", { name: "Level" })
await ring.waitFor({ timeout: 20000 })
const box = (await ring.boundingBox())!
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
const before = await ring.getAttribute("aria-valuenow")
await page.mouse.wheel(0, -100)
await expect(ring).not.toHaveAttribute("aria-valuenow", before ?? "")
// Two frames on, where a spring would still be on its way.
const cx = () =>
page.evaluate(
() =>
new Promise<string | null>((done) =>
requestAnimationFrame(() =>
requestAnimationFrame(() =>
done(
document
.querySelector(".dui-ring-handle")
?.getAttribute("cx") ?? null,
),
),
),
),
)
const first = await cx()
await page.waitForTimeout(600)
expect(await cx()).toBe(first)
})