From b6017d60c8cee895cdc4bc84f1670742b39ed5a2 Mon Sep 17 00:00:00 2001 From: ollyhearn Date: Tue, 2 Jun 2026 18:10:19 +0300 Subject: [PATCH] chore: knob upd & memories --- CLAUDE.md | 2 + src/App.tsx | 9 ++ src/components/knob/index.tsx | 253 ++++++++++++++++++++++++++++++++++ src/components/ui.tsx | 1 + src/stories/Knob.stories.tsx | 81 +++++++++++ src/styles/components.css | 198 ++++++++++++++++++++++++++ 6 files changed, 544 insertions(+) create mode 100644 src/components/knob/index.tsx create mode 100644 src/stories/Knob.stories.tsx diff --git a/CLAUDE.md b/CLAUDE.md index ae787d3..ebf318a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,8 @@ Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/ Exception — text inputs (`text-field/`): `TextField`/`TextArea`/`SearchField` animate letters in/out (osu!-lazer style). Native fields can't animate per-glyph, so the real element renders with transparent text (`modern-sk-field--animated`, caret stays visible) over a mirrored per-character `` overlay that plays the `modern-sk-char-in/out` keyframes; an LCS diff preserves letter identity so only inserted/removed glyphs animate. The overlay still derives all appearance from `modern-sk-*` classes/tokens — the only JS-set styles are the per-letter pin offset and scroll-sync transform. Pass `animated={false}` to opt out and render the plain native field. +Exception — knob (`knob/`): a rotary circular slider (`Knob`), ported from the design handoff. Two visuals move at different speeds — the **dial** is bound 1:1 to the pointer while dragging (instant, continuous angle via inline `transform: rotate`), while the **gauge fill + value** snap to detents and *glide* between them (like the stepped Slider). It tracks two values: a continuous `visual` (drives the dial mid-drag) and the snapped `committed` (drives fill/ticks/aria); `dialValue = dragging ? visual : committed`, so on drag-release the dial settles to the detent. Three non-obvious constraints, all already tried-and-rejected — don't regress them: (1) the gauge **fill must be the full arc revealed via `strokeDashoffset`** set as a *plain number* — `pathLength=1` + CSS `var()`/`calc` silently collapsed to a full ring, and `transition: d` on a swept arc interpolates the endpoint as a straight chord and distorts the arc mid-glide; dashoffset eases along the true circle. (2) the dial transition is disabled via `.is-dragging` so the mouse-bound dial never lags. (3) wheel-to-change uses a **native non-passive `wheel` listener** (via `useEffect` + a ref-held handler) because React's synthetic `onWheel` is passive and `preventDefault` can't block page scroll. `animated` defaults `true` when `step > 0`. + ### Styling system (`src/styles/`) - `tokens.css` — single source of truth: color/type CSS custom properties (no font loading — see Fonts above). Every component reads from here. diff --git a/src/App.tsx b/src/App.tsx index 5e36c7a..1df7c9a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { Dialog, DialogClose, IconButton, + Knob, List, MenuRow, MenuSeparator, @@ -248,6 +249,14 @@ const App = () => { +
+
Knobs — circular sliders
+
+ + + +
+
diff --git a/src/components/knob/index.tsx b/src/components/knob/index.tsx new file mode 100644 index 0000000..48f86d3 --- /dev/null +++ b/src/components/knob/index.tsx @@ -0,0 +1,253 @@ +import { + useEffect, + useId, + useRef, + useState, + type CSSProperties, + type KeyboardEvent, + type PointerEvent, +} from 'react'; +import { cx } from '../utils'; + +type KnobAccent = 'lime' | 'ember'; + +type KnobProps = { + /** Controlled value. */ + value?: number; + /** Uncontrolled starting value. Defaults to `min`. */ + defaultValue?: number; + /** Fires with the snapped value on every change. */ + onValueChange?: (value: number) => void; + min?: number; + max?: number; + /** Snap increment. `0`/omitted = continuous (no detents, no ticks). */ + step?: number; + /** Accent colour for the gauge fill + pointer. `'lime'` (default) or `'ember'`. */ + accent?: KnobAccent; + /** Diameter in px. Drives every internal measurement via `--knob-size`. */ + size?: number; + /** + * Glide the gauge fill + pointer between detents. The dial always tracks the + * pointer 1:1 while dragging; this only eases the *value* settle (keyboard, + * wheel, drag-release) — like the stepped Slider. Defaults to `true` when + * `step > 0`, `false` otherwise. + */ + animated?: boolean; + disabled?: boolean; + className?: string; + id?: string; + 'aria-label'?: string; + 'aria-labelledby'?: string; +}; + +// Gauge geometry: 270° sweep starting at the 7-o'clock position. R is the arc +// radius in the 100×100 SVG viewBox. +const START = -135; +const SWEEP = 270; +const R = 43; + +const polar = (cx: number, cy: number, r: number, aDeg: number): [number, number] => { + const t = (aDeg * Math.PI) / 180; + return [cx + r * Math.sin(t), cy - r * Math.cos(t)]; +}; + +const arcPath = (a0: number, a1: number) => { + if (a1 - a0 < 0.01) a1 = a0 + 0.01; + const [x0, y0] = polar(50, 50, R, a0); + const [x1, y1] = polar(50, 50, R, a1); + return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; +}; + +const TRACK_PATH = arcPath(START, START + SWEEP); +// Arc length of the gauge. The fill is the full arc revealed via dashoffset, so +// the glide eases ALONG the circle (transitioning `d` interpolates endpoints in +// a straight chord and distorts the arc mid-animation). +const ARC_LEN = (R * SWEEP * Math.PI) / 180; + +export const Knob = ({ + value: valueProp, + defaultValue, + onValueChange, + min = 0, + max = 100, + step = 0, + accent = 'lime', + size, + animated, + disabled = false, + className, + id, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, +}: KnobProps) => { + const span = max - min || 1; + const stepped = step > 0; + const isAnimated = animated !== undefined ? animated : stepped; + + const clamp = (v: number) => Math.min(max, Math.max(min, v)); + const snap = (v: number) => + stepped ? clamp(Math.round((v - min) / step) * step + min) : clamp(v); + + const isControlled = valueProp !== undefined; + const [internal, setInternal] = useState(() => snap(defaultValue ?? min)); + const committed = isControlled ? snap(valueProp) : internal; + + // Continuous, pointer-bound position for the dial; only meaningful mid-drag. + const [visual, setVisual] = useState(committed); + const [dragging, setDragging] = useState(false); + + const elRef = useRef(null); + const a0 = useRef(0); + const v0 = useRef(0); + + const reactId = useId(); + const knobId = id ?? reactId; + + // `continuous` keeps the dial on the raw pointer angle; otherwise the dial + // settles (and glides) to the snapped detent. + const commit = (raw: number, continuous: boolean) => { + const c = clamp(raw); + const snapped = snap(c); + setVisual(continuous ? c : snapped); + if (!isControlled) setInternal(snapped); + if (snapped !== committed) onValueChange?.(snapped); + }; + + const angleAt = (clientX: number, clientY: number) => { + const el = elRef.current; + if (!el) return 0; + const r = el.getBoundingClientRect(); + return ( + (Math.atan2(clientX - (r.left + r.width / 2), -(clientY - (r.top + r.height / 2))) * 180) / + Math.PI + ); + }; + + const onPointerDown = (e: PointerEvent) => { + if (disabled) return; + e.preventDefault(); + elRef.current?.focus(); + elRef.current?.setPointerCapture(e.pointerId); + setDragging(true); + a0.current = angleAt(e.clientX, e.clientY); + v0.current = committed; + }; + + const onPointerMove = (e: PointerEvent) => { + if (!dragging) return; + let d = angleAt(e.clientX, e.clientY) - a0.current; + if (d > 180) d -= 360; + if (d < -180) d += 360; + commit(v0.current + (d / SWEEP) * span, true); + }; + + const endDrag = (e: PointerEvent) => { + if (!dragging) return; + elRef.current?.releasePointerCapture(e.pointerId); + setDragging(false); + }; + + // React's synthetic onWheel is passive, so preventDefault can't block page + // scroll — attach a native non-passive listener. A ref keeps it current + // without re-binding every render. + const wheelRef = useRef<(e: WheelEvent) => void>(() => {}); + wheelRef.current = (e) => { + if (disabled) return; + e.preventDefault(); + commit(committed - (step || span / 100) * Math.sign(e.deltaY), false); + }; + useEffect(() => { + const el = elRef.current; + if (!el) return; + const handler = (e: WheelEvent) => wheelRef.current(e); + el.addEventListener('wheel', handler, { passive: false }); + return () => el.removeEventListener('wheel', handler); + }, []); + + const onKeyDown = (e: KeyboardEvent) => { + if (disabled) return; + const big = step || span / 10; + const sm = step || span / 100; + const k = e.key; + if (k === 'ArrowUp' || k === 'ArrowRight') commit(committed + sm, false); + else if (k === 'ArrowDown' || k === 'ArrowLeft') commit(committed - sm, false); + else if (k === 'PageUp') commit(committed + big, false); + else if (k === 'PageDown') commit(committed - big, false); + else if (k === 'Home') commit(min, false); + else if (k === 'End') commit(max, false); + else return; + e.preventDefault(); + }; + + const dialValue = dragging ? visual : committed; + const dialAngle = START + ((clamp(dialValue) - min) / span) * SWEEP; + const fillOffset = ARC_LEN * (1 - (committed - min) / span); + const valueNow = stepped ? committed : Math.round(committed); + + const ticks = stepped + ? Array.from({ length: Math.round(span / step) + 1 }, (_, i) => { + const a = START + (i / Math.round(span / step)) * SWEEP; + const [x1, y1] = polar(50, 50, R + 6, a); + const [x2, y2] = polar(50, 50, R + 10.5, a); + const val = min + (i / Math.round(span / step)) * span; + return { x1, y1, x2, y2, on: val <= committed + 1e-6, key: i }; + }) + : []; + + return ( +
+ + + + + {ticks.map((t) => ( + + ))} + + +
+
+ +
+
+
+
+ ); +}; diff --git a/src/components/ui.tsx b/src/components/ui.tsx index e9bbc53..44194ff 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -5,6 +5,7 @@ export * from './select'; export * from './selection'; export * from './segmented-control'; export * from './slider'; +export * from './knob'; export * from './tabs'; export * from './progress'; export * from './badge'; diff --git a/src/stories/Knob.stories.tsx b/src/stories/Knob.stories.tsx new file mode 100644 index 0000000..26ee0db --- /dev/null +++ b/src/stories/Knob.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from 'storybook-react-rsbuild'; +import { useState } from 'react'; +import { Knob } from '../components/ui'; + +const meta = { + title: 'Inputs/Knob', + component: Knob, + parameters: { + docs: { + description: { + component: + 'Skeuomorphic rotary control — a circular slider. Drag anywhere on the cap (relative angular drag, no jump-to-pointer), scroll to nudge, or focus and use the arrow keys. The dial tracks the pointer 1:1 while dragging; the gauge fill + value snap to detents and *glide* between them when `animated` (on by default for stepped knobs), exactly like the stepped Slider.', + }, + }, + }, + args: { defaultValue: 62, min: 0, max: 100, step: 0, accent: 'lime' }, + argTypes: { + defaultValue: { control: 'number', description: 'Uncontrolled starting value.' }, + min: { control: 'number' }, + max: { control: 'number' }, + step: { control: 'number', description: '`0` = continuous; `> 0` adds detents + ticks.' }, + accent: { control: 'inline-radio', options: ['lime', 'ember'] }, + size: { control: 'number', description: 'Diameter in px (default 108).' }, + animated: { + control: 'boolean', + description: 'Glide the fill/value between detents. Defaults to `true` when `step > 0`.', + }, + disabled: { control: 'boolean' }, + className: { control: 'text' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +/** Continuous — the dial and gauge follow the pointer with no snapping. */ +export const Continuous: Story = { + args: { defaultValue: 62, step: 0 }, +}; + +/** Stepped — the dial stays bound to the mouse while the fill/value snap and glide into detents. */ +export const Stepped: Story = { + args: { defaultValue: 3, min: 1, max: 5, step: 1 }, +}; + +/** Ember accent variant. */ +export const Ember: Story = { + args: { defaultValue: 40, accent: 'ember' }, +}; + +export const Disabled: Story = { + args: { defaultValue: 50, disabled: true }, +}; + +const Readout = (args: React.ComponentProps) => { + const [v, setV] = useState(62); + return ( +
+ + + {v} + % + +
+ ); +}; + +/** Controlled, with a live readout below the knob. */ +export const WithReadout: Story = { + render: (args) => , + args: { defaultValue: 62 }, +}; diff --git a/src/styles/components.css b/src/styles/components.css index d7bfc23..b66b854 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -738,6 +738,204 @@ textarea.modern-sk-field { white-space: nowrap; } +/* ---------- KNOB (circular slider) ---------- + Skeuomorphic rotary control. The dial follows the pointer 1:1 while dragging + (bound to the mouse); the gauge fill + value snap to detents and *glide* + between them (like the stepped Slider) when `--animated`. Anatomy: + recessed SVG gauge ring → glossy cap → knurled dial (rotates, carries the + accent pointer) → turned-metal hub. Sizing flows from --knob-size. */ +.modern-sk-knob { + --knob-size: 108px; + --knob-accent: var(--lime); + --knob-accent-deep: var(--lime-deep); + --knob-glow: rgba(190, 242, 100, 0.5); + position: relative; + flex-shrink: 0; + width: var(--knob-size); + height: var(--knob-size); + cursor: grab; + outline: none; + touch-action: none; + -webkit-tap-highlight-color: transparent; +} +.modern-sk-knob.is-dragging { + cursor: grabbing; +} +.modern-sk-knob--disabled { + cursor: default; + opacity: 0.55; +} + +/* recessed gauge ring */ +.modern-sk-knob__gauge { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} +.modern-sk-knob__track { + fill: none; + stroke: var(--steel-800); + stroke-width: 5; + stroke-linecap: round; +} +/* The fill is the full arc, revealed up to the value via stroke-dashoffset (set + inline as a plain number). When `--animated`, transition the offset so the + accent eases along the circle between detents. */ +.modern-sk-knob__fill { + fill: none; + stroke: var(--knob-accent); + stroke-width: 5; + stroke-linecap: round; + filter: drop-shadow(0 0 5px var(--knob-glow)); + transition: stroke var(--dur-quick) var(--ease-out); +} +.modern-sk-knob--animated .modern-sk-knob__fill { + transition: + stroke-dashoffset 0.12s ease-out, + stroke var(--dur-quick) var(--ease-out); +} +.modern-sk-knob__tick { + stroke: var(--fg-3); + stroke-width: 1.6; + stroke-linecap: round; + opacity: 0.45; + transition: + stroke var(--dur-quick), + opacity var(--dur-quick); +} +.modern-sk-knob__tick.is-on { + stroke: var(--knob-accent); + opacity: 0.95; +} + +/* glossy cap — static top-down gloss (light source stays put) */ +.modern-sk-knob__cap { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(var(--knob-size) - 34px); + height: calc(var(--knob-size) - 34px); + border-radius: 50%; + border: 1px solid var(--hair-strong); + background: + radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.16), transparent 58%), + var(--grad-key); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.08) inset, + 0 4px 9px rgba(0, 0, 0, 0.5), + 0 11px 22px rgba(0, 0, 0, 0.42); + overflow: hidden; + transition: box-shadow var(--dur-quick) var(--ease-out); +} + +/* knurled dial — rotates with the pointer, carries the glowing pointer dot. + The ribbed grip lives on ::before (masked to a ring) so the pointer dot can + sit unmasked on top and keep its full halo. While dragging it tracks the + mouse with no transition; otherwise it glides to the settled value. */ +.modern-sk-knob__dial { + position: absolute; + inset: 0; + border-radius: 50%; + will-change: transform; +} +.modern-sk-knob--animated .modern-sk-knob__dial { + transition: transform 0.12s ease-out; +} +.modern-sk-knob.is-dragging .modern-sk-knob__dial { + transition: none; +} +.modern-sk-knob__dial::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: repeating-conic-gradient( + from 0deg, + rgba(255, 255, 255, 0.09) 0deg 3deg, + rgba(0, 0, 0, 0.28) 3deg 6deg + ); + -webkit-mask: radial-gradient(circle, transparent 0 58%, #000 61% 100%); + mask: radial-gradient(circle, transparent 0 58%, #000 61% 100%); +} +.modern-sk-knob__pointer { + position: absolute; + top: 5%; + left: 50%; + transform: translateX(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--knob-accent); + box-shadow: 0 0 6px var(--knob-glow); +} +/* turned-metal hub covers the inner ends; pointer reads as an outer notch */ +.modern-sk-knob__hub { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48%; + height: 48%; + border-radius: 50%; + background: radial-gradient(circle at 50% 34%, #36372c, #1d1e17); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.07) inset, + 0 1px 3px rgba(0, 0, 0, 0.55); +} +.modern-sk-knob:focus-visible .modern-sk-knob__cap { + box-shadow: + var(--focus-ring), + 0 1px 0 rgba(255, 255, 255, 0.08) inset, + 0 4px 9px rgba(0, 0, 0, 0.5), + 0 11px 22px rgba(0, 0, 0, 0.42); +} + +/* ember accent variant */ +.modern-sk-knob--ember { + --knob-accent: var(--ember); + --knob-accent-deep: var(--ember-deep); + --knob-glow: rgba(233, 87, 43, 0.5); +} + +@media (prefers-reduced-motion: reduce) { + .modern-sk-knob--animated .modern-sk-knob__fill, + .modern-sk-knob--animated .modern-sk-knob__dial { + transition: none; + } +} + +/* light theme: brighter cap gloss, darker knurl ridges so the grip reads, a + pale turned hub, and a lighter recessed gauge channel. */ +[data-theme='light'] .modern-sk-knob__cap { + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.9) inset, + 0 2px 5px rgba(0, 0, 0, 0.16), + 0 7px 16px rgba(0, 0, 0, 0.12); +} +[data-theme='light'] .modern-sk-knob__dial::before { + background: repeating-conic-gradient( + from 0deg, + rgba(0, 0, 0, 0.09) 0deg 3deg, + rgba(255, 255, 255, 0.6) 3deg 6deg + ); +} +[data-theme='light'] .modern-sk-knob__hub { + background: radial-gradient(circle at 50% 34%, #ffffff, #e2e2d6); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.9) inset, + 0 1px 3px rgba(0, 0, 0, 0.16); +} +[data-theme='light'] .modern-sk-knob__track { + stroke: var(--steel-500); +} +[data-theme='light'] .modern-sk-knob__tick { + stroke: var(--steel-400); +} + /* ---------- STEPPER ---------- */ .modern-sk-stepper { display: inline-flex;