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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-10-14 16:52:46 +02:00
co-authored by Claude Opus 5.5
parent f5412d2f7d
commit 541dbce584
9 changed files with 308 additions and 33 deletions
@@ -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}
>