feat: knob magnetic snap near default + reset brightness pulse + detent marker#1322
feat: knob magnetic snap near default + reset brightness pulse + detent marker#1322
Conversation
…nt marker Three micro-interactions for the Knob component: 1. **Magnetic snap near default**: When dragging within 3% of default value, sensitivity reduces by 50%. Within 1% it snaps to exact default. Makes it easy to return to factory setting without precision input. 2. **Default detent marker**: Tiny dot on the arc track at the default value position. Provides a visual target for where "neutral" is. 3. **Reset brightness pulse**: On double-click reset, a colored ring fades out over 200ms around the knob, confirming the action. Closes #1320 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR enhances the Knob UI micro-interactions by adding a magnetic snap region around the default value, rendering a default-position detent marker, and showing a brief brightness pulse on double-click reset.
Changes:
- Add magnetic snap behavior near
defaultValue(reduced drag sensitivity + snap-to-default threshold). - Render a default detent marker on the knob track and a reset pulse ring during reset.
- Add
@keyframes knob-pulseto support the reset pulse animation.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/index.css | Adds knob-pulse keyframes used by the reset pulse ring animation. |
| src/components/ui/Knob.tsx | Implements magnetic snap logic, detent marker rendering, and reset pulse visuals/transitioning. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let sensitivity = fine ? range / 2000 : range / 200; | ||
|
|
||
| // Magnetic snap: reduce sensitivity near default value | ||
| const snapZone = range * 0.03; // 3% of range | ||
| if (Math.abs(dragStart.current.value - defaultValue) < snapZone) { | ||
| sensitivity *= 0.5; | ||
| } |
There was a problem hiding this comment.
defaultValue is now referenced inside the onMouseDown callback (via the onMove handler) but it is not included in the useCallback dependency list. If defaultValue ever changes between renders, the magnetic snap logic can use a stale value. Add defaultValue to the dependency array (or refactor so the callback doesn’t capture it).
|
|
||
| // Snap to exact default if within snap zone | ||
| if (Math.abs(newVal - defaultValue) < snapZone * 0.3) { | ||
| newVal = defaultValue; |
There was a problem hiding this comment.
The snap-to-default branch assigns newVal = defaultValue without applying roundToStep/clamping. This can emit a value outside [min,max] or off-step if defaultValue isn’t perfectly aligned. Consider using applyStep(defaultValue) (or otherwise ensuring defaultValue is validated) before calling onChange.
| newVal = defaultValue; | |
| newVal = applyStep(defaultValue); |
| @@ -251,6 +264,36 @@ export function Knob({ | |||
| style={isResetting ? { transition: 'd 200ms ease-out' } : undefined} | |||
There was a problem hiding this comment.
transition: 'd 200ms ease-out' relies on animating the SVG path d attribute, which is not consistently supported across browsers via CSS transitions. If this is meant to guarantee the 200ms reset animation, consider animating a reliably animatable property (e.g., stroke-dashoffset/length) or driving a separate visual-only animated value over 200ms while keeping onChange(defaultValue) immediate.
| style={isResetting ? { transition: 'd 200ms ease-out' } : undefined} |
| // Magnetic snap: reduce sensitivity near default value | ||
| const snapZone = range * 0.03; // 3% of range | ||
| if (Math.abs(dragStart.current.value - defaultValue) < snapZone) { | ||
| sensitivity *= 0.5; | ||
| } | ||
|
|
||
| const delta = mv.movementY * sensitivity; | ||
| const newVal = applyStep(dragStart.current.value + delta); | ||
| let newVal = applyStep(dragStart.current.value + delta); | ||
|
|
||
| // Snap to exact default if within snap zone | ||
| if (Math.abs(newVal - defaultValue) < snapZone * 0.3) { | ||
| newVal = defaultValue; | ||
| } |
There was a problem hiding this comment.
New behaviors (magnetic snap thresholding and snapping-to-default) aren’t covered by unit tests. Since this component already has interaction tests (e.g., src/components/ui/__tests__/Knob.test.tsx), add assertions for: reduced sensitivity within ±3% of default, snapping to exact default within ~1%, and that snapping respects step/clamping.
| {/* Default value detent marker — tiny tick on the track */} | ||
| {(() => { | ||
| const defAngle = valueToAngle(defaultValue, min, max, arc); | ||
| const defPos = polarToXY(defAngle - 90, trackR); | ||
| return ( | ||
| <circle | ||
| cx={defPos.x} | ||
| cy={defPos.y} | ||
| r={1} | ||
| fill="rgba(255,255,255,0.15)" | ||
| /> | ||
| ); | ||
| })()} | ||
|
|
||
| {/* Reset brightness pulse */} | ||
| {isResetting && ( | ||
| <circle | ||
| cx={radius} | ||
| cy={radius} | ||
| r={trackR + strokeWidth} | ||
| fill="none" | ||
| stroke={color} | ||
| strokeWidth={1} | ||
| opacity={0.6} | ||
| style={{ | ||
| animation: 'knob-pulse 200ms ease-out forwards', | ||
| }} | ||
| /> | ||
| )} |
There was a problem hiding this comment.
The new detent marker and reset pulse rendering isn’t currently exercised by tests. Consider adding a small DOM-level assertion in Knob.test.tsx that the detent marker circle is present (and that the pulse circle appears only while data-resetting is true) to prevent regressions.
| {(() => { | ||
| const defAngle = valueToAngle(defaultValue, min, max, arc); | ||
| const defPos = polarToXY(defAngle - 90, trackR); | ||
| return ( | ||
| <circle | ||
| cx={defPos.x} | ||
| cy={defPos.y} | ||
| r={1} | ||
| fill="rgba(255,255,255,0.15)" |
There was a problem hiding this comment.
valueToAngle(defaultValue, min, max, arc) is used directly for the detent marker position. If defaultValue is ever out of range, the marker will be rendered off-arc. Consider clamping defaultValue to [min,max] for marker geometry (and ideally for the snap target too) to keep visuals consistent.
|
Closing — the same features (magnetic snap, detent marker, reset pulse) were already merged to main via another parallel worktree. No action needed. |
Summary
Closes #1320
Test plan
npx tsc --noEmit— 0 errorsnpm test— 3744 passed🤖 Generated with Claude Code