From f5412d2f7d0a2f5bb98fa0081869b2f7756c628c Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 16:42:56 +0200 Subject: [PATCH] Marks as dots inside the track: ticks, a selector's options and a slider's reading on the track's midline and the ring's centre line, coloured by the fill or the handle over them; the selector ring fills to its option Co-Authored-By: Claude Opus 5.5 --- docs/interface/dashboards.md | 48 +++-- .../src/components/Dashboard/RingWidget.tsx | 153 +++++++++++----- .../components/Dashboard/ui/core/controls.ts | 5 + .../src/components/Dashboard/ui/core/core.css | 169 ++++++++++++++++-- .../components/Dashboard/ui/core/marks.tsx | 55 ++++++ .../src/components/Dashboard/ui/core/ring.ts | 11 +- .../Dashboard/ui/fluksio/Controls.tsx | 14 +- .../Dashboard/ui/glass/Controls.tsx | 14 +- .../components/Dashboard/ui/glass/glass.css | 5 + .../Dashboard/ui/material/Controls.tsx | 14 +- .../Dashboard/ui/material/material.css | 5 + frontend/tests/widgets.spec.ts | 6 +- 12 files changed, 387 insertions(+), 112 deletions(-) create mode 100644 frontend/src/components/Dashboard/ui/core/marks.tsx diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 7f13909..8e6e784 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -75,12 +75,10 @@ stands in for the value until one arrives. On a round face it takes the middle slider's value. It is also marked where it sits on the slider's scale, as a thermostat marks -the room's temperature: the handle is what is set, the mark what is. The mark -is a bar across the track or the ring, in the text colour, at the reading's -fraction of the scale, (reading − min) / (max − min) held to 0–1, so a reading -outside the range sits at that end. It is drawn over the track and its fill -and under the handle, and is longer than the handle, so it still shows where -the two meet. It takes no pointer, and there is none until a value arrives. +the room's temperature: the handle is what is set, the dot what is. The dot is +at the reading's fraction of the scale, (reading − min) / (max − min) held to +0–1, so a reading outside the range sits at that end. It takes no pointer, and +there is none until a value arrives. See [Dots on the track](#dots-on-the-track). Sizes are CSS pixels of the canvas, as in the round geometry below. The slider's own value is at the tile's text size (14; 16 on a touch-friendly @@ -92,9 +90,31 @@ panel), centred on a line as tall as the control (32; 48 touch-friendly). | Row | directly under the slider's value, right-aligned with it, in the column right of the track (12 apart). With ticks the line shares the ticks' row, so the tile grows no taller; without them it adds 16 | | Row width | the value column is as wide as its wider line, but at most half the row; the track takes the rest. What does not fit is cut from the label; the value and unit never are | | Fader | directly under the slider's value, both centred under the track, 8 below it; the line no wider than the tile inside its padding, cut from the label first | -| Mark (row) | a bar 2 wide and the handle's diameter + 8 long (16 + 8 = 24; 24 + 8 = 32 touch-friendly), centred on the track's midline (the track is 8 thick). Its centre sits on the handle's travel: handle ÷ 2 + fraction × (track length − handle) from the track's left end, so mark and handle line up at the same value | -| Mark (fader) | the same turned: 2 high, handle + 8 wide, centred on the track, measured up from its bottom end | -| Locked | the mark at 0.45 opacity, as the handle | + +### Dots on the track + +A slider's ticks, a selector's options on a round face and a slider's reading +are dots inside the track, on its midline — on a round face the ring's centre +line, radius c − 20. Each is drawn in the colour that reads on what covers it: +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 % | + +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. + +| Part | Value | +|---|---| +| Place (tile) | the dot's centre on the handle's travel: handle ÷ 2 + fraction × (track length − handle) from the track's left end (from its bottom end on a fader), so a dot and the handle line up at the same value | +| Place (ring) | the dot's centre at the fraction of the sweep, as the handle | +| Ticks | with `ticks` on (the default), n intervals of the scale — the steps when there are 5 or fewer, else the first of 4, 3 or 2 that divides the steps evenly, else 4 — and a dot at each i/n, both ends included. A row keeps its labels under the track; a fader has none | +| Options | a dot per option at i/(n − 1) of the sweep (one option: the middle). The selector's ring has a value arc from the start to the selected option, so the options up to it are on the fill | +| Colour change | where the fill or the handle covers a dot, that part of it takes the colour for the fill or the handle, so a dot changes colour as the fill's end or the handle passes over it, and follows them while they move. A device may instead switch a whole dot when the fill's end or the handle's centre passes the dot's centre | +| Order | track, dots, fill, the dots again over the fill, handle, the reading again over the handle | +| Locked | in a tile, the dots at 0.45 opacity, as the handle; on the ring, the whole ring at 0.45 | ## Charts @@ -260,9 +280,9 @@ look's muted text colour. | 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 | | 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; 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's mark: a bar 36 long and 3 wide, square-ended, in the text colour, along the radius at the reading's fraction of the sweep (the same angle the handle has at that value), centred on the ring's centre line, so from radius c − 38 to c − 2; over the track and value arc, under the handle | -| Selector | a 6 mark 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 | +| 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)) | +| 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 | @@ -311,10 +331,10 @@ dashboard's look's. | 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 | +| Slider's reading on the ring, gauge on the ring | the reading's dot; the value arc | from where it is drawn to the new fraction; a dot's colour follows the fill and the handle as they pass | 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 | +| Slider tile set elsewhere | the fill's end, the handle, the reading's dot | 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 | diff --git a/frontend/src/components/Dashboard/RingWidget.tsx b/frontend/src/components/Dashboard/RingWidget.tsx index c6654b4..404071f 100644 --- a/frontend/src/components/Dashboard/RingWidget.tsx +++ b/frontend/src/components/Dashboard/RingWidget.tsx @@ -1,5 +1,5 @@ -import { motion, useTransform } from "motion/react" -import { useEffect, useRef } from "react" +import { type MotionValue, motion, useTransform } from "motion/react" +import { useEffect, useId, useRef } from "react" import type { WidgetDef } from "@/client" import { cn } from "@/lib/utils" @@ -21,6 +21,7 @@ import { showTitle, text, } from "./ui/core/config" +import { tickIntervals } from "./ui/core/controls" import { CENTRE_LABEL, CENTRE_SWATCH, @@ -34,7 +35,6 @@ import { RING_INSET, RING_MARK, RING_READING, - RING_READING_STROKE, RING_STROKE, ringArc, ringPoint, @@ -78,10 +78,13 @@ type Dial = { /** Written out; empty for a colour, whose handle is the reading. */ reading: (value: unknown) => string unit?: string - /** A value arc from the start to the reading: a slider and a gauge. */ + /** A value arc from the start to the reading: a slider, a selector and a + * gauge. */ fill?: boolean handle?: boolean - marks?: number[] + /** Dots along the sweep, as fractions of it: a selector's options, a + * slider's ticks. */ + stops?: number[] hue?: boolean } @@ -109,8 +112,9 @@ function dialOf(widget: WidgetDef, live: unknown): Dial { const option = options[index as number] return option ? (option.label ?? text(option.value)) : "—" }, + fill: true, handle: true, - marks: options.map((_, index) => at(index) ?? 0.5), + stops: options.map((_, index) => at(index) ?? 0.5), } } @@ -159,6 +163,8 @@ function dialOf(widget: WidgetDef, live: unknown): Dial { const digits = (String(step).split(".")[1] ?? "").length const fit = (value: number) => Number(Math.min(max, Math.max(min, value)).toFixed(digits)) + // The tile's scale, as dots: the same intervals its labels are cut into. + const intervals = tickIntervals((max - min) / step) return { value: typeof live === "number" ? live : min, at: (value) => fractionOf(value as number, min, max), @@ -170,6 +176,10 @@ function dialOf(widget: WidgetDef, live: unknown): Dial { unit, fill: true, handle: true, + stops: + cfg.ticks !== false && max > min + ? Array.from({ length: intervals + 1 }, (_, index) => index / intervals) + : [], } } @@ -333,14 +343,16 @@ 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 - // The same reading, placed: a bar across the band where it sits on the - // slider's scale, held to the sweep at either end. + // The same reading, placed: a dot 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 = 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 readAt = useEased(actual ?? 0) + const readX = useTransform(readAt, (fraction) => ringPoint(size, fraction).x) + const readY = useTransform(readAt, (fraction) => ringPoint(size, fraction).y) + const readDot = actual === null ? null : { x: readX, y: readY } + // What the dots are drawn under: the value arc and the handle, which + // repaint whatever part of a dot they cover (`RingDots`). + const id = useId().replace(/[^\w-]/g, "") const whole = ringArc(size, 0, 1) const name = widget.title || text(cfg.target, widget.id) @@ -397,38 +409,23 @@ export function Ring({ className="dui-ring-track" /> )} + {/* 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) => { - const point = ringPoint(size, mark) - return ( - + - ) - })} - {/* Under the handle, so where the two meet the value being set wins. */} - {actual !== null ? ( - + + + + ) : null} {dial.handle && at !== null ? ( ) : null} + {readDot && dial.handle && at !== null ? ( + + + + ) : null} + + {/* The value arc again, as a stencil: a dot it has reached is + drawn in the colour that reads on it, to the pixel it covers. */} + + + + + + + {turns || editing ? ( ; y: MotionValue } | null +}) { + return ( + <> + {stops.map((stop, index) => { + const point = ringPoint(size, stop) + return ( + + ) + })} + {reading ? ( + + ) : null} + + ) +} + /** * The colour ring's track: every hue in order along the sweep, red at both * ends. Drawn as short strokes — an SVG stroke takes no conic gradient — each diff --git a/frontend/src/components/Dashboard/ui/core/controls.ts b/frontend/src/components/Dashboard/ui/core/controls.ts index fc390ea..17bc3db 100644 --- a/frontend/src/components/Dashboard/ui/core/controls.ts +++ b/frontend/src/components/Dashboard/ui/core/controls.ts @@ -254,6 +254,11 @@ export function useSliderDrag({ fraction, /** Where the mark sits along the track, 0..1 and held to it; null for none. */ markAt: mark === null ? null : markAt, + /** The scale's ticks as dots in the track, 0..1, in either orientation. */ + stops: + ticks && span > 0 + ? Array.from({ length: intervals + 1 }, (_, index) => index / intervals) + : [], 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 a7f4018..e0e5378 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -412,43 +412,162 @@ translate: -50% 50%; } -/* A second value on the track, such as what a setpoint is set against: a bar - across the track, on the handle's own travel so the two line up at the same - value, and longer than the handle so it still shows when they meet. Under - the input, so the hit area is the input's alone. */ -.dui-slider-mark { +/* The colours of the dots inside a slider's track and along the ring, by what + each copy sits on: the track, the fill, the handle. A look whose handle is + not the page colour restates the last. */ +.dui-slider, +.dui-ring { + --dui-stop: var(--muted-foreground); + --dui-stop-reached: color-mix( + in srgb, + var(--primary-foreground) 70%, + transparent + ); + --dui-reading: var(--foreground); + --dui-reading-reached: var(--primary-foreground); + --dui-reading-on-handle: var(--foreground); +} + +/* + * Dots inside the track, on its midline: a tick's stop, and what a slider reads + * beside its value — such as the room's temperature beside a setpoint — as a + * larger one in a stronger colour. Both sit on the handle's own travel, so a + * dot lines up with the handle at the same value. + * + * Drawn up to three times, each copy in the colour that reads on what it sits + * on: on the track; again over the fill, cut to the fill's extent; and the + * reading once more over the handle, cut to the handle. So a dot changes + * colour exactly where the fill or the handle passes it, as the ring's do. + * After the input only for its `:disabled`, as none of them takes a pointer. + */ +.dui-slider-dots, +.dui-slider-reached, +.dui-slider-on-thumb { position: absolute; - background: var(--foreground); pointer-events: none; } -.dui-slider[data-orientation="horizontal"] .dui-slider-mark { +.dui-slider-dots { + overflow: hidden; + border-radius: 9999px; +} + +.dui-slider[data-orientation="horizontal"] .dui-slider-dots { + left: 0; + right: 0; + top: 50%; + height: var(--dui-track-size, 0.5rem); + translate: 0 -50%; +} + +.dui-slider[data-orientation="vertical"] .dui-slider-dots { + top: 0; + bottom: 0; + left: 50%; + width: var(--dui-track-size, 0.5rem); + translate: -50% 0; +} + +.dui-slider-reached { + inset: 0; +} + +.dui-slider[data-orientation="horizontal"] .dui-slider-reached { + clip-path: inset(0 calc((1 - var(--dui-fraction, 0)) * 100%) 0 0); +} + +.dui-slider[data-orientation="vertical"] .dui-slider-reached { + clip-path: inset(calc((1 - var(--dui-fraction, 0)) * 100%) 0 0 0); +} + +/* Over the handle, which comes before it. */ +.dui-slider-on-thumb { + inset: 0; + z-index: 1; +} + +.dui-slider[data-orientation="horizontal"] .dui-slider-on-thumb { + clip-path: circle( + calc(var(--dui-thumb) / 2) at + calc( + var(--dui-thumb) / + 2 + + var(--dui-fraction, 0) * + (100% - var(--dui-thumb)) + ) + 50% + ); +} + +.dui-slider[data-orientation="vertical"] .dui-slider-on-thumb { + clip-path: circle( + calc(var(--dui-thumb) / 2) at 50% + calc( + 100% - + var(--dui-thumb) / + 2 - + var(--dui-fraction, 0) * + (100% - var(--dui-thumb)) + ) + ); +} + +.dui-slider-stop, +.dui-slider-reading { + position: absolute; + border-radius: 50%; +} + +.dui-slider-stop { + width: calc(var(--dui-track-size, 0.5rem) / 2); + height: calc(var(--dui-track-size, 0.5rem) / 2); + background: var(--dui-stop); +} + +.dui-slider-reading { + width: calc(var(--dui-track-size, 0.5rem) * 0.75); + height: calc(var(--dui-track-size, 0.5rem) * 0.75); + background: var(--dui-reading); +} + +.dui-slider-reached .dui-slider-stop { + background: var(--dui-stop-reached); +} + +.dui-slider-reached .dui-slider-reading { + background: var(--dui-reading-reached); +} + +.dui-slider-on-thumb .dui-slider-reading { + background: var(--dui-reading-on-handle); +} + +.dui-slider[data-orientation="horizontal"] + :is(.dui-slider-stop, .dui-slider-reading) { top: 50%; left: calc( var(--dui-thumb) / 2 + - var(--dui-mark, 0) * + var(--dui-at, 0) * (100% - var(--dui-thumb)) ); - width: 2px; - height: calc(var(--dui-thumb) + 0.5rem); translate: -50% -50%; } -.dui-slider[data-orientation="vertical"] .dui-slider-mark { +.dui-slider[data-orientation="vertical"] + :is(.dui-slider-stop, .dui-slider-reading) { left: 50%; bottom: calc( var(--dui-thumb) / 2 + - var(--dui-mark, 0) * + var(--dui-at, 0) * (100% - var(--dui-thumb)) ); - width: calc(var(--dui-thumb) + 0.5rem); - height: 2px; translate: -50% 50%; } -.dui-slider-input:disabled ~ .dui-slider-mark { +.dui-slider-input:disabled ~ .dui-slider-dots, +.dui-slider-input:disabled ~ .dui-slider-on-thumb { opacity: 0.45; } @@ -685,13 +804,25 @@ opacity: 1; } -.dui-ring-mark { - fill: var(--muted-foreground); +/* The ring's dots, in the colours the slider's track uses for its own. */ +.dui-ring-stop { + fill: var(--dui-stop); } -/* What a slider reads beside its value, as the tile's track marks it. */ .dui-ring-reading { - stroke: var(--foreground); + fill: var(--dui-reading); +} + +.dui-ring-reached .dui-ring-stop { + fill: var(--dui-stop-reached); +} + +.dui-ring-reached .dui-ring-reading { + fill: var(--dui-reading-reached); +} + +.dui-ring-on-handle .dui-ring-reading { + fill: var(--dui-reading-on-handle); } /* Page dots, one per face, in the ring's gap. The current one changes over diff --git a/frontend/src/components/Dashboard/ui/core/marks.tsx b/frontend/src/components/Dashboard/ui/core/marks.tsx new file mode 100644 index 0000000..c339e54 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/marks.tsx @@ -0,0 +1,55 @@ +/** + * What a slider marks inside its track: a dot per tick, and what it reads + * beside its value as a larger one. + * + * The same markup in every look — the dots are geometry, and `core.css` draws + * them in the look's tokens — so it lives here rather than three times over. + * The copies over the fill and over the handle are what let a dot change + * colour exactly where either passes it. + */ +import { type MotionStyle, type MotionValue, motion } from "motion/react" + +export function SliderMarks({ + stops, + mark, +}: { + /** Where each tick sits along the track, 0..1. */ + stops: number[] + /** Where the reading sits, 0..1; null for none. */ + mark: MotionValue | null +}) { + if (stops.length === 0 && mark === null) return null + const dots = ( + <> + {stops.map((at) => ( + + ))} + {mark ? ( + + ) : null} + + ) + return ( + <> + + {dots} + {dots} + + {mark ? ( + + + + ) : null} + + ) +} diff --git a/frontend/src/components/Dashboard/ui/core/ring.ts b/frontend/src/components/Dashboard/ui/core/ring.ts index cd4ca09..7870897 100644 --- a/frontend/src/components/Dashboard/ui/core/ring.ts +++ b/frontend/src/components/Dashboard/ui/core/ring.ts @@ -22,13 +22,12 @@ export const RING_SWEEP = 300 export const RING_STROKE = 16 /** The handle's diameter. */ export const RING_HANDLE = 28 -/** A dropdown's option marks, as a diameter. */ +/** 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, marked on the ring: a bar across the - * band this long and this wide, longer than the handle so it still shows - * where the two meet. */ -export const RING_READING = RING_HANDLE + 8 -export const RING_READING_STROKE = 3 +/** 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 /** 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 diff --git a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx index 74c217e..efad0ef 100644 --- a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx @@ -26,6 +26,7 @@ import type { } from "../core/contract" import { useSegmented, useSliderDrag, useSwitch } from "../core/controls" import { useLook } from "../core/look" +import { SliderMarks } from "../core/marks" import { LOOK } from "../core/motion" export function Button({ @@ -65,7 +66,8 @@ export function Switch(props: SwitchProps) { } export function Slider(props: SliderProps) { - const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) + const { fraction, inputProps, marks, markAt, stops, trackRef } = + useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return ( - {/* 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 : ( - - )} + {marks.length > 0 ? ( diff --git a/frontend/src/components/Dashboard/ui/glass/Controls.tsx b/frontend/src/components/Dashboard/ui/glass/Controls.tsx index d7cbfa3..0103ed1 100644 --- a/frontend/src/components/Dashboard/ui/glass/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Controls.tsx @@ -26,6 +26,7 @@ import { useSwitch, } from "../core/controls" import { useLook } from "../core/look" +import { SliderMarks } from "../core/marks" import { LOOK } from "../core/motion" export function Button({ @@ -76,7 +77,8 @@ export function Switch(props: SwitchProps) { } export function Slider(props: SliderProps) { - const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) + const { fraction, inputProps, marks, markAt, stops, trackRef } = + useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return ( - {/* 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 : ( - - )} + {marks.length > 0 ? ( diff --git a/frontend/src/components/Dashboard/ui/glass/glass.css b/frontend/src/components/Dashboard/ui/glass/glass.css index ad8016a..8c23b93 100644 --- a/frontend/src/components/Dashboard/ui/glass/glass.css +++ b/frontend/src/components/Dashboard/ui/glass/glass.css @@ -435,6 +435,11 @@ filter: drop-shadow(0 2px 8px rgb(0 0 0 / 0.35)); } +/* A reading's dot on the handle, which is white here in either theme. */ +[data-look="glass"] :is(.dui-ring, .dui-slider) { + --dui-reading-on-handle: rgb(0 0 0 / 0.75); +} + [data-look="glass"] .dui-ring-swatch { stroke: rgb(255 255 255 / 0.85); stroke-width: 2px; diff --git a/frontend/src/components/Dashboard/ui/material/Controls.tsx b/frontend/src/components/Dashboard/ui/material/Controls.tsx index 2b63c2b..f5fdc58 100644 --- a/frontend/src/components/Dashboard/ui/material/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/material/Controls.tsx @@ -28,6 +28,7 @@ import { useSwitch, } from "../core/controls" import { useLook } from "../core/look" +import { SliderMarks } from "../core/marks" import { LOOK } from "../core/motion" /** The state layer plus whatever presses are still fading. */ @@ -105,7 +106,8 @@ export function Switch(props: SwitchProps) { } export function Slider(props: SliderProps) { - const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) + const { fraction, inputProps, marks, markAt, stops, trackRef } = + useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return ( - {/* 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 : ( - - )} + {marks.length > 0 ? ( diff --git a/frontend/src/components/Dashboard/ui/material/material.css b/frontend/src/components/Dashboard/ui/material/material.css index 7fb1918..22475bd 100644 --- a/frontend/src/components/Dashboard/ui/material/material.css +++ b/frontend/src/components/Dashboard/ui/material/material.css @@ -334,6 +334,11 @@ fill: var(--primary); } +/* A reading's dot on the handle, which is primary here. */ +[data-look="material"] :is(.dui-ring, .dui-slider) { + --dui-reading-on-handle: var(--primary-foreground); +} + [data-look="material"] .dui-ring-swatch { stroke: var(--card); stroke-width: 3px; diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 5dc381a..c89f10f 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -432,8 +432,10 @@ test("a slider writes what it sets against under its value", async ({ await expect(page.getByTestId("slider-reading")).toHaveText( /^Level\s*80\.0\s*kW$/, ) - // And placed on the track, as a mark beside the handle. - await expect(page.locator(".dui-slider-mark")).toBeVisible() + // And placed on the track, as a dot inside it. + await expect( + page.locator(".dui-slider-dots > .dui-slider-reading"), + ).toBeVisible() }) test("a reading is written to its own precision while it is moving", async ({