Hold the tile title still in edit mode, and thin crowded slider ticks

The grip's `-m-1 p-1` pair cancelled on three sides only: `.dui-frame-head`
sets `margin-top: -0.5rem` unlayered in core.css, which beats a Tailwind
utility, so the padding above went uncancelled and the title dropped 4px the
moment the editor opened. The grip's geometry moves into core.css beside the
head's own half step, where one declaration cancels all four sides.

Slider tick labels were placed in percent with nothing measured, so five
four-character labels crowded on a tile narrower than the default four
columns. A ResizeObserver on the slider reports its width and every Nth label
is kept, N a divisor of the interval count so the first and last stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
This commit is contained in:
2026-08-28 14:20:02 +02:00
co-authored by Claude Opus 5
parent 841209a630
commit 42048881f7
8 changed files with 78 additions and 22 deletions
@@ -5,7 +5,7 @@
* either look: the state, the keyboard and every `aria-` live here, and a
* renderer only decides what it looks like while doing it.
*/
import { useCallback, useId, useRef, useState } from "react"
import { useCallback, useEffect, useId, useRef, useState } from "react"
import { fractionOf } from "./config"
@@ -141,6 +141,32 @@ export function tickIntervals(steps: number): number {
return [4, 3, 2].find((count) => Number.isInteger(steps / count)) ?? 4
}
/**
* The labels that fit the width the scale was measured in.
*
* The marks are placed in percent, which says nothing about how wide a label
* is: five four-character labels crowd on a tile narrower than the four
* columns a slider is given by default. Keep every Nth instead, with N a
* divisor of the interval count so the first and last — the two that anchor
* the range — are always among those kept.
*/
export function fitMarks<T extends { label: string }>(
marks: T[],
width: number,
): T[] {
const intervals = marks.length - 1
// Not measured yet, or nothing to thin.
if (width === 0 || intervals < 2) return marks
const widest = Math.max(...marks.map((mark) => mark.label.length))
// A 0.75rem tabular digit runs about 7px, and neighbours need a gap.
const fits = Math.floor(width / (widest * 7 + 12))
const stride =
[1, 2, 3, 4, 5].find(
(n) => intervals % n === 0 && intervals / n + 1 <= fits,
) ?? intervals
return marks.filter((_, index) => index % stride === 0)
}
/**
* A value set by dragging, published when the handle is let go.
*
@@ -175,6 +201,20 @@ export function useSliderDrag({
const [draft, setDraft] = useState<number | null>(null)
const current = draft ?? value
// How much room the scale under the track actually has, which is what says
// how many of its labels can be drawn without them running into each other.
const trackRef = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(0)
useEffect(() => {
const el = trackRef.current
if (!el) return
const observer = new ResizeObserver(([entry]) =>
setWidth(entry.contentRect.width),
)
observer.observe(el)
return () => observer.disconnect()
}, [])
const release = () => {
if (draft === null) return
onCommit(draft)
@@ -187,8 +227,23 @@ export function useSliderDrag({
// without a precision setting of its own.
const digits = (String(step).split(".")[1] ?? "").length
/** The scale under the track, drawn rather than declared: no browser
* renders `<option label>` for a range, and a 2022 °C setpoint is
* unusable without numbers to aim at. */
const marks =
ticks && orientation === "horizontal" && span > 0
? Array.from({ length: intervals + 1 }, (_, index) => ({
percent: (index / intervals) * 100,
label: String(
Number((min + (span * index) / intervals).toFixed(digits)),
),
}))
: []
return {
current,
/** Goes on the slider, whose width is the scale's width. */
trackRef,
fraction: fractionOf(current, min, max),
inputProps: {
type: "range" as const,
@@ -213,17 +268,6 @@ export function useSliderDrag({
onKeyUp: release,
onBlur: release,
},
/** The scale under the track, drawn rather than declared: no browser
* renders `<option label>` for a range, and a 2022 °C setpoint is
* unusable without numbers to aim at. */
marks:
ticks && orientation === "horizontal" && span > 0
? Array.from({ length: intervals + 1 }, (_, index) => ({
percent: (index / intervals) * 100,
label: String(
Number((min + (span * index) / intervals).toFixed(digits)),
),
}))
: [],
marks: fitMarks(marks, width),
}
}
@@ -65,6 +65,18 @@
gap: 0.5rem;
}
/*
* Arranging: the grip grows the drag target by its own padding and takes it
* straight back, so the title does not move when the editor opens. All four
* sides are set here rather than as utilities on the head, because the half
* step above is set here too — a `-m-1` sits in Tailwind's utilities layer and
* loses to this unlayered `margin-top`, leaving the padding above uncancelled.
*/
.dui-frame-head.widget-grip {
margin: -0.75rem -0.25rem -0.25rem;
padding: 0.25rem;
}
.dui-frame-title {
overflow: hidden;
text-overflow: ellipsis;
@@ -65,7 +65,7 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks } = useSliderDrag(props)
const { fraction, inputProps, marks, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
@@ -74,6 +74,7 @@ export function Slider(props: SliderProps) {
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
>
<div className="dui-slider-body">
@@ -79,8 +79,7 @@ export function Frame({
<div
className={cn(
"dui-frame-head fx-frame-title",
grip &&
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
grip && `${TESTID.grip} cursor-grab active:cursor-grabbing`,
)}
>
<span className="dui-frame-title">{title}</span>
@@ -76,7 +76,7 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks } = useSliderDrag(props)
const { fraction, inputProps, marks, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
@@ -85,6 +85,7 @@ export function Slider(props: SliderProps) {
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
>
<div className="dui-slider-body">
@@ -114,8 +114,7 @@ export function Frame({
<div
className={cn(
"dui-frame-head gl-frame-title",
grip &&
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
grip && `${TESTID.grip} cursor-grab active:cursor-grabbing`,
)}
>
<span className="dui-frame-title">{title}</span>
@@ -105,7 +105,7 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks } = useSliderDrag(props)
const { fraction, inputProps, marks, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
@@ -114,6 +114,7 @@ export function Slider(props: SliderProps) {
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
>
<div className="dui-slider-body">
@@ -78,8 +78,7 @@ export function Frame({
<div
className={cn(
"dui-frame-head m3-frame-title",
grip &&
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
grip && `${TESTID.grip} cursor-grab active:cursor-grabbing`,
)}
>
<span className="dui-frame-title">{title}</span>