Conversation
Single source of truth for DMX-to-percent and percent-to-DMX conversions, channel type classification, and step size helpers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
localStorage-backed hook following the same pattern as useScrollDirectionPreference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Continuum channels show 0.0%-100.0% in percent mode. Discrete channels (GOBO, COLOR_WHEEL, etc.) always show raw DMX. All callbacks still emit DMX values — conversion happens at the display boundary only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Callers can now specify a base step size. ChannelSlider passes percentage- appropriate step sizes when in percent mode. Default step=1 preserves existing behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New 'Display Preferences' section with a toggle to switch between DMX values (0-255) and percentage display (0.0%-100.0%). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: DMX Percentage Display ModeThis is a well-structured feature with clean architectural decisions. The conversion boundary at IssuesState Synchronization — Potential Bug
For the current routing setup (Settings is a separate route), this works fine. But it's a fragile assumption. Consider either:
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && (e.newValue === 'dmx' || e.newValue === 'percent')) {
setDisplayModeState(e.newValue);
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, []);Accessibility — Toggle Button Missing LabelThe settings toggle has // Add aria-labelledby pointing to the h3
<h3 id="display-pref-label" className="...">
Show values as percentages
</h3>
...
<button
role="switch"
aria-checked={displayMode === 'percent'}
aria-labelledby="display-pref-label"
...
>Shift Key Behavior — Asymmetric UXIn DMX mode, Shift = coarser (×10 steps). In percent mode, Shift = finer (×0.1 steps). This inversion will likely surprise users. The comment in Float Precision Drift in
|
There was a problem hiding this comment.
Pull request overview
Adds an optional “percentage display” mode for DMX channel values, including a persisted user preference and UI/interaction updates so sliders can display/edit in percent while the underlying state remains raw DMX.
Changes:
- Introduces
dmxPercentageutility functions (DMX↔percent conversion, formatting, step helpers) with unit tests. - Adds
useDisplayModehook (localStorage-backed) with tests, and a Settings toggle UI to control it. - Updates
ChannelSliderto render/edit values in percent for continuous channels and extendsuseValueScrubwith a configurablestep.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/utils/dmxPercentage.ts |
New conversion + formatting utilities and channel-type classification for percent display. |
src/utils/__tests__/dmxPercentage.test.ts |
Unit tests for DMX↔percent conversions, formatting, channel classification, and step helpers. |
src/hooks/useValueScrub.ts |
Adds step support for fractional scrubbing/quantization in wheel and touch interactions. |
src/hooks/useDisplayMode.ts |
New localStorage-backed preference hook for DMX vs percent display mode. |
src/hooks/__tests__/useDisplayMode.test.ts |
Tests for default mode, persistence, and invalid stored values. |
src/components/ChannelSlider.tsx |
Applies display-boundary conversion logic and percent-mode UI behavior. |
src/components/__tests__/ChannelSlider.test.tsx |
Adds coverage for percent mode behavior (continuous vs discrete, conversion, slider ranges). |
src/app/(main)/settings/page.tsx |
Adds “Display Preferences” section with a toggle switch for percent display mode. |
.gitignore |
Ignores .worktrees/. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <button | ||
| role="switch" | ||
| aria-checked={displayMode === 'percent'} | ||
| onClick={() => setDisplayMode(displayMode === 'percent' ? 'dmx' : 'percent')} | ||
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ | ||
| displayMode === 'percent' | ||
| ? 'bg-blue-600' | ||
| : 'bg-gray-200 dark:bg-gray-600' | ||
| }`} | ||
| > | ||
| <span | ||
| className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ | ||
| displayMode === 'percent' ? 'translate-x-6' : 'translate-x-1' | ||
| }`} | ||
| /> | ||
| </button> |
There was a problem hiding this comment.
The switch button has role="switch" but no accessible name (it contains no text and isn’t associated to the nearby heading/description). Add aria-label or aria-labelledby so screen readers can announce what the switch controls.
| onChange: (newDisplayValue) => { | ||
| const dmxValue = usePercent ? percentToDmx(newDisplayValue, min, max) : newDisplayValue; | ||
| setLocalValue(dmxValue); | ||
| onChange(dmxValue); | ||
| }, | ||
| onChangeComplete, | ||
| onChangeComplete: onChangeComplete ? (newDisplayValue) => { | ||
| const dmxValue = usePercent ? percentToDmx(newDisplayValue, min, max) : newDisplayValue; | ||
| onChangeComplete(dmxValue); | ||
| } : undefined, |
There was a problem hiding this comment.
In percent mode, useValueScrub emits changes at 0.1% granularity, but percentToDmx rounds to an integer DMX value. This means many scrub updates can map to the same DMX value, yet onChange(dmxValue) is still called repeatedly with an unchanged value (potentially causing redundant state updates/DMX sends). Consider short-circuiting when dmxValue === localValue before calling setLocalValue/onChange (and similarly for onChangeComplete).
src/components/ChannelSlider.tsx
Outdated
| // Determine if this channel should show percentages | ||
| const min = channel.minValue || DEFAULT_MIN_VALUE; | ||
| const max = channel.maxValue || DEFAULT_MAX_VALUE; | ||
| const usePercent = isPercentMode && isPercentageChannel(channel.type); |
There was a problem hiding this comment.
usePercent is based only on channel.type, but the channel object already carries isDiscrete (used elsewhere to indicate channels with multiple DMX ranges). As-is, channels like STROBE or other types that are flagged isDiscrete: true will still render as percentages in percent mode. Consider incorporating channel.isDiscrete into the decision (e.g., never use percent when isDiscrete is true) and add a test case covering a non-GOBO discrete channel.
| const usePercent = isPercentMode && isPercentageChannel(channel.type); | |
| const usePercent = isPercentMode && !channel.isDiscrete && isPercentageChannel(channel.type); |
src/components/ChannelSlider.tsx
Outdated
| const rawValue = parseFloat(e.target.value); | ||
| if (isNaN(rawValue)) return; | ||
| const dmxValue = usePercent ? percentToDmx(rawValue, min, max) : Math.max(min, Math.min(max, Math.round(rawValue))); |
There was a problem hiding this comment.
handleInputChange returns early when the input parses to NaN. For a controlled <input type="number">, this makes it impossible to clear the field (empty string) and can interfere with intermediate edits, because the value snaps back immediately. Consider handling '' explicitly (e.g., treat as min/default) or keeping a transient string state for the input while editing.
| const rawValue = parseFloat(e.target.value); | |
| if (isNaN(rawValue)) return; | |
| const dmxValue = usePercent ? percentToDmx(rawValue, min, max) : Math.max(min, Math.min(max, Math.round(rawValue))); | |
| const { value } = e.target; | |
| // Treat an empty string as a request to reset to the minimum value | |
| if (value === '') { | |
| const dmxValue = min; | |
| setLocalValue(dmxValue); | |
| onChange(dmxValue); | |
| if (onChangeComplete) { | |
| onChangeComplete(dmxValue); | |
| } | |
| return; | |
| } | |
| const rawValue = parseFloat(value); | |
| if (isNaN(rawValue)) return; | |
| const dmxValue = usePercent | |
| ? percentToDmx(rawValue, min, max) | |
| : Math.max(min, Math.min(max, Math.round(rawValue))); |
src/hooks/useValueScrub.ts
Outdated
| const clamp = useCallback( | ||
| (val: number): number => Math.max(min, Math.min(max, Math.round(val))), | ||
| [min, max] | ||
| (val: number): number => { | ||
| const clamped = Math.max(min, Math.min(max, val)); | ||
| if (step < 1) { | ||
| // Round to step precision (e.g., step=0.1 → 1 decimal place) | ||
| const precision = Math.round(-Math.log10(step)); | ||
| const factor = Math.pow(10, precision); | ||
| return Math.round(clamped * factor) / factor; | ||
| } | ||
| return Math.round(clamped); |
There was a problem hiding this comment.
The new step support has two correctness issues: (1) step < 1 rounding uses -Math.log10(step) which only works for steps that are exact powers of 10 (e.g. 0.1) and will mis-round for steps like 0.25; and (2) step <= 0 will cause log10/division-by-zero problems in both clamp and touch quantization. Consider validating step > 0 and rounding/quantizing via a generic Math.round(val / step) * step (with appropriate floating-point handling) rather than decimal-place math.
| /** | ||
| * Base step size for value changes (default: 1). | ||
| * When using percentage mode, the caller passes the percentage step size | ||
| * and handles conversion externally. | ||
| */ | ||
| step?: number; |
There was a problem hiding this comment.
step introduces new behavior (fractional rounding, wheel accumulation thresholds, touch quantization), but there are no added/updated tests covering non-default step values. Since useValueScrub already has an extensive test suite, please add cases for step=0.1 (fractional output) and at least one non-power-of-10 step (e.g. 0.25) plus validation behavior for invalid steps (<= 0).
- Add aria-label to display mode toggle for accessibility - Short-circuit redundant onChange when scrub maps to same DMX value - Incorporate channel.isDiscrete flag in percentage mode decision - Handle empty number input (reset to min instead of NaN early-return) - Use generic step quantization in useValueScrub clamp (works for any step size, not just powers of 10) - Add useValueScrub tests for fractional step (0.1, 0.25) and default - Add ChannelSlider test for isDiscrete override on continuum type Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewOverall this is a well-structured feature with a clean separation of concerns: a dedicated conversion utility, a purpose-built hook, and display-boundary conversions that keep parent state in raw DMX. The test coverage is solid. A few issues worth addressing before merging: Bug:
Potential issue: isolated useState won't propagate across already-mounted components
This works fine today because Settings and the slider views are on separate routes. But if a quick-settings toggle is ever added to the same page as the sliders, the preference will appear to update in the toggle but not in the sliders. A shared React Context or a Minor: floating-point accumulation in
Minor: Previously clearing the input did not trigger Minor: Both helpers are well-tested but never imported outside the test file. The number input uses Minor: Shift-key step semantics are inverted between modes
This is intentional and documented in the JSDoc, but a user who switches modes frequently may find it surprising. Worth considering whether percent mode should use Shift for ±10% instead, to keep muscle memory consistent. Nit: hydration flash in Next.js SSR
Positive notes
Generated with Claude Code |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 11 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Value should be quantized to step=0.1 | ||
| expect(newValue).toBeGreaterThan(50); | ||
| // Check it's a multiple of 0.1 (within floating point tolerance) | ||
| expect(Math.round(newValue * 10) % 1).toBe(0); |
There was a problem hiding this comment.
This quantization assertion is ineffective: Math.round(newValue * 10) is always an integer, so % 1 will always be 0 regardless of newValue. Use an epsilon check like “newValue / step is close to an integer” so the test fails if quantization breaks.
| expect(Math.round(newValue * 10) % 1).toBe(0); | |
| const step = 0.1; | |
| const multiple = newValue / step; | |
| const epsilon = 1e-6; | |
| expect(Math.abs(multiple - Math.round(multiple))).toBeLessThan(epsilon); |
| // Value should be quantized to 0.25 increments | ||
| expect(Math.round(newValue * 4) % 1).toBe(0); |
There was a problem hiding this comment.
Same issue here: Math.round(newValue * 4) % 1 will always be 0. Adjust the test to verify newValue is a multiple of 0.25 within floating-point tolerance (e.g. compare against Math.round(newValue / 0.25) * 0.25).
| // Value should be quantized to 0.25 increments | |
| expect(Math.round(newValue * 4) % 1).toBe(0); | |
| // Value should be quantized to 0.25 increments (within floating-point tolerance) | |
| const quantized = Math.round(newValue / 0.25) * 0.25; | |
| expect(newValue).toBeCloseTo(quantized, 10); |
| onChange: (newDisplayValue) => { | ||
| const dmxValue = usePercent ? percentToDmx(newDisplayValue, min, max) : newDisplayValue; | ||
| if (dmxValue === localValue) return; | ||
| setLocalValue(dmxValue); | ||
| onChange(dmxValue); | ||
| }, |
There was a problem hiding this comment.
In percent mode, multiple adjacent display values (e.g. 50.2% vs 50.3%) can convert to the same DMX value due to rounding. The current dmxValue === localValue guard can be bypassed if events fire before React commits the state update (stale localValue in the closure), leading to repeated onChange calls with the same DMX. Consider tracking the last-emitted DMX value in a ref and comparing against that instead of localValue here.
| setLocalValue(newValue); | ||
| onChange(newValue); | ||
| const rawValue = parseFloat(e.target.value); | ||
| const dmxValue = usePercent ? percentToDmx(rawValue, min, max) : Math.round(rawValue); |
There was a problem hiding this comment.
handleSliderChange always calls onChange, even if the computed dmxValue is unchanged (common in percent mode due to rounding collisions). Adding an early-return when dmxValue === localValue will avoid redundant state updates and prevent spamming parent updates/DMX output while dragging.
| const dmxValue = usePercent ? percentToDmx(rawValue, min, max) : Math.round(rawValue); | |
| const dmxValue = usePercent ? percentToDmx(rawValue, min, max) : Math.round(rawValue); | |
| if (dmxValue === localValue) return; |
| const rawValue = parseFloat(inputValue); | ||
| if (isNaN(rawValue)) return; | ||
| const dmxValue = usePercent ? percentToDmx(rawValue, min, max) : Math.max(min, Math.min(max, Math.round(rawValue))); | ||
| setLocalValue(dmxValue); | ||
| onChange(dmxValue); | ||
| if (onChangeComplete) { | ||
| onChangeComplete(clampedValue); | ||
| onChangeComplete(dmxValue); | ||
| } |
There was a problem hiding this comment.
Similarly, handleInputChange calls onChange/onChangeComplete even when rawValue converts to the same dmxValue as the current state (likely with 0.1% steps). Consider skipping updates when dmxValue === localValue to avoid redundant parent updates.
src/hooks/useValueScrub.ts
Outdated
| if (step >= 1) return Math.round(clamped); | ||
| // Quantize to step size (works for any step, not just powers of 10) | ||
| return Math.round(clamped / step) * step; |
There was a problem hiding this comment.
clamp() currently ignores step values >= 1 and always Math.rounds, so callers using larger integer steps (e.g. step: 5 or 10) will not get quantization to that step size. Consider quantizing for all step values (e.g. round to nearest multiple of step) and only special-case step === 1 if needed.
| if (step >= 1) return Math.round(clamped); | |
| // Quantize to step size (works for any step, not just powers of 10) | |
| return Math.round(clamped / step) * step; | |
| // Determine an effective, positive step size with a safe default | |
| const effectiveStep = | |
| typeof step === 'number' && step !== 0 ? Math.abs(step) : 1; | |
| // Fast path for the common case: integer stepping of 1 | |
| if (effectiveStep === 1) { | |
| return Math.round(clamped); | |
| } | |
| // Quantize to the nearest multiple of the effective step size | |
| return Math.round(clamped / effectiveStep) * effectiveStep; |
Review Iteration CompleteAll 6 Copilot review comments have been addressed in commit 4ca5e59:
Threads to resolve manually:
Status:
|
- Fix tautological test assertions for step quantization (0.1 and 0.25) - Add redundant-onChange short-circuit to handleSliderChange and handleInputChange (was only in scrub callback) - Make useValueScrub clamp quantize for all step sizes, not just < 1 - Validate step > 0 with safe fallback to 1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
PR Review: DMX Percentage Display Mode Overall this is a clean, well-structured feature. The architecture is solid - keeping all state in raw DMX internally and converting only at the display boundary is the right approach. Test coverage is thorough. Potential Bug: useDisplayMode State Not Shared Across Components Each call to useDisplayMode() creates its own independent React state instance. SettingsPage and ChannelSlider both call the hook separately, so changing the setting in Settings will NOT propagate to already-mounted ChannelSlider components - they only read localStorage on mount. In the current page-router setup this works in practice (navigating between Settings and the lighting controls causes a remount), but it is a fragile assumption. If the toggle ever appears on the same page as the sliders, the sliders will not react to the change. Consider adding a window storage event listener in the hook to keep all instances in sync, or lifting this into a React Context. Minor: Redundant Inline Step Value displayStep is computed on line 113 of ChannelSlider.tsx but the useValueScrub call on line 126 duplicates the same expression inline instead of reusing displayStep. Just pass step: displayStep. Minor: Toggle Button Missing type=button The toggle in settings/page.tsx does not specify type=button. While it is not inside a form today, adding it is defensive best practice. Minor: Unintentional package-lock.json Change The diff removes dev:true from the fsevents entry. This looks like an artifact of running npm install in a macOS vs Linux environment switch. fsevents is a macOS-only optional dependency and should retain its dev classification. Worth reverting this hunk if unintentional. Positive Observations
The useDisplayMode state isolation issue is the main one worth addressing before merging; the rest are minor polish items. Great feature overall. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 11 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/utils/dmxPercentage.ts
Outdated
| export function dmxToPercent(dmxValue: number, min = 0, max = 255): number { | ||
| const range = max - min; | ||
| if (range === 0) return 0; | ||
| return ((dmxValue - min) / range) * 100; |
There was a problem hiding this comment.
dmxToPercent is documented as returning a percentage in the 0.0–100.0 range, but it currently does not clamp the result. If dmxValue is outside [min,max] (or min/max are misconfigured), callers can get negative/over-100 values and formatPercent will render those. Either clamp the computed percent to [0,100] (recommended) or update the docstring to reflect that out-of-range values are allowed.
| return ((dmxValue - min) / range) * 100; | |
| const percent = ((dmxValue - min) / range) * 100; | |
| return Math.max(0, Math.min(100, percent)); |
| // Use step size for accumulation threshold | ||
| if (Math.abs(accumulatedDelta.current) >= step) { | ||
| const steps = Math.trunc(accumulatedDelta.current / step); | ||
| accumulatedDelta.current -= steps * step; | ||
|
|
||
| const newValue = clamp(lastValue.current + change); | ||
| const newValue = clamp(lastValue.current + steps * step); | ||
| if (newValue !== lastValue.current) { |
There was a problem hiding this comment.
step is sanitized inside clamp (falls back to 1 when step <= 0 or non-number), but the wheel accumulation logic uses step directly as the threshold/divisor. If a caller passes step: 0 (or a negative/NaN), this becomes >= 0 and divides by 0, producing incorrect behavior. Use the same effectiveStep for the threshold/division and when applying steps * step, or normalize step once up front.
| // Quantize to step size | ||
| const steppedChange = Math.round(valueChange / step) * step; |
There was a problem hiding this comment.
Touch scrubbing quantization does Math.round(valueChange / step) * step without guarding against step <= 0 / non-finite values. Since clamp already normalizes invalid steps to 1, this path should also use the same normalized effectiveStep (or early-normalize step once) to avoid division-by-zero or NaN propagation.
| // Quantize to step size | |
| const steppedChange = Math.round(valueChange / step) * step; | |
| // Quantize to step size, normalizing invalid steps to 1 | |
| const effectiveStep = | |
| Number.isFinite(step) && step > 0 ? step : 1; | |
| const steppedChange = Math.round(valueChange / effectiveStep) * effectiveStep; |
src/components/ChannelSlider.tsx
Outdated
| const step = percentStep(e.shiftKey); | ||
| const currentPercent = dmxToPercent(localValue, min, max); | ||
| const newPercent = Math.max(0, Math.min(100, currentPercent + direction * step)); | ||
| newDmxValue = percentToDmx(newPercent, min, max); |
There was a problem hiding this comment.
In percent mode with Shift held, percentStep returns 0.1, but handleKeyDown recomputes currentPercent from the current DMX value on every keypress and immediately converts back via percentToDmx. For most DMX values, adding 0.1% does not change the rounded DMX value, so newDmxValue equals localValue and the keypress becomes a permanent no-op (repeated presses never accumulate). Consider accumulating in display/percent space across key presses, or ensure a minimum 1-DMX change when the percent step would round back to the same DMX value.
| newDmxValue = percentToDmx(newPercent, min, max); | |
| let percentBasedDmx = percentToDmx(newPercent, min, max); | |
| // If the percent-based step would result in no DMX change (due to rounding), | |
| // fall back to a minimum 1-DMX step in the same direction. | |
| if (percentBasedDmx === localValue) { | |
| const fallbackDmx = Math.max(min, Math.min(max, localValue + direction * 1)); | |
| newDmxValue = fallbackDmx; | |
| } else { | |
| newDmxValue = percentBasedDmx; | |
| } |
- Clamp dmxToPercent output to 0-100 range for safety - Normalize step once upfront in useValueScrub (guards against 0, negative, NaN in wheel and touch handlers, not just clamp) - Fix Shift+arrow no-op in percent mode: when 0.1% step rounds to same DMX value, fall back to minimum 1-DMX change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: DMX Percentage Display ModeOverall this is a well-structured feature with good test coverage and a clean architecture (conversion at the display boundary, raw DMX everywhere else). A few things worth addressing: Bug:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 11 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/hooks/useValueScrub.ts
Outdated
| (val: number): number => { | ||
| const clamped = Math.max(min, Math.min(max, val)); | ||
| if (step === 1) return Math.round(clamped); | ||
| return Math.round(clamped / step) * step; |
There was a problem hiding this comment.
clamp() quantizes via Math.round(clamped / step) * step, which rounds relative to 0 rather than relative to min. If min is not an exact multiple of step, this can produce values outside [min,max] (e.g. min=5,max=6,step=2 -> returns 6 or 8). Consider quantizing relative to min (e.g. min + round((clamped - min)/step)*step) and then re-clamping to [min,max] to guarantee bounds.
| return Math.round(clamped / step) * step; | |
| const quantized = min + Math.round((clamped - min) / step) * step; | |
| return Math.max(min, Math.min(max, quantized)); |
Prevents values from drifting outside [min,max] when min is not a multiple of step. Re-clamps after quantization for safety. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code Review - feat: DMX percentage display modeOverall this is a well-structured feature. The core design decision -- keeping raw DMX everywhere and converting only at the display boundary inside Critical: useDisplayMode state is not shared between components
If a user toggles the setting in Settings while The fix is a shared React Context. Wrap the app layout with a Minor: displayStep computed but not reused in useValueScrub call In Minor: Empty input immediately resets to min -- breaks type-while-clearing UX In Minor: Shift-key step semantics are inverted between modes DMX mode: Shift+Arrow = coarser (+/-10). Percent mode: Shift+Arrow = finer (+/-0.1%). The tooltip is accurate per mode, but users switching between modes may find the reversal surprising. A brief comment explaining the deliberate inversion would help future maintainers. Nit: Unintentional package-lock.json change The diff removes What is working well
The shared-state issue is the main architectural concern; the others are minor polish items. Great work on the feature overall! |
All review comments have been addressedAddressed across 3 fix commits (4ca5e59, 31c3fcb, 0c04823, ee2a444): Round 1 (6 comments):
Round 2 (6 comments — 4 were duplicates of round 1): Round 3 (5 comments — 4 were duplicates of round 2): Threads to resolve manually:All 17 review threads — most are outdated (code changed), all have been addressed. Status:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 11 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Compute display-space values | ||
| const displayValue = usePercent ? dmxToPercent(localValue, min, max) : localValue; | ||
| const displayMin = usePercent ? 0 : min; | ||
| const displayMax = usePercent ? 100 : max; | ||
| const displayStep = usePercent ? 0.1 : 1; | ||
|
|
||
| // Set up value scrub gestures (wheel + touch) | ||
| const { wheelProps, touchScrubProps, containerRef } = useValueScrub({ | ||
| value: localValue, | ||
| min: channel.minValue || DEFAULT_MIN_VALUE, | ||
| max: channel.maxValue || DEFAULT_MAX_VALUE, | ||
| onChange: (newValue) => { | ||
| setLocalValue(newValue); | ||
| onChange(newValue); | ||
| value: displayValue, | ||
| min: displayMin, | ||
| max: displayMax, | ||
| step: usePercent ? 0.1 : 1, | ||
| onChange: (newDisplayValue) => { | ||
| const dmxValue = usePercent ? percentToDmx(newDisplayValue, min, max) : newDisplayValue; | ||
| if (dmxValue === localValue) return; | ||
| setLocalValue(dmxValue); | ||
| onChange(dmxValue); | ||
| }, | ||
| onChangeComplete, | ||
| onChangeComplete: onChangeComplete ? (newDisplayValue) => { | ||
| const dmxValue = usePercent ? percentToDmx(newDisplayValue, min, max) : newDisplayValue; | ||
| onChangeComplete(dmxValue); | ||
| } : undefined, |
There was a problem hiding this comment.
In percent mode, displayValue is derived from integer DMX (dmxToPercent(localValue)), but scrubbing/input operates in 0.1% increments. Many 0.1% changes round back to the same DMX (e.g., 75.0% -> 191 -> displays ~74.9%), which can cause the UI to “snap back” and can desync useValueScrub’s internal lastValue (it will advance in percent space even when localValue doesn’t change). Consider either (a) keeping scrubbing in DMX space (value=min/max=DMX, step=1) and only formatting as percent, or (b) storing a local percent editing state so display space can advance smoothly, or (c) choosing a percent step aligned to DMX resolution (e.g., 100/(max-min)).
There was a problem hiding this comment.
This is an inherent tradeoff of displaying a 256-value integer as a percentage. The display will snap to DMX-aligned percentages because the underlying state is an integer — showing a smooth 50.1% when the real value is DMX 128 (50.196%) would be misleading.
The short-circuit guard (dmxValue === localValue) in the scrub onChange already prevents redundant parent updates. The useValueScrub internal lastValue tracks display-space values but the conversion boundary ensures only real DMX changes propagate.
Keeping scrub in DMX space (option a) would lose the percentage-scaled gesture feel. A local percent editing state (option b) would decouple display from reality. Both are more complex for marginal benefit. Skipping this change.
src/components/ChannelSlider.tsx
Outdated
| title="Scroll to adjust, arrow keys for ±1, Shift+arrow for ±10. Touch and drag vertically to scrub." | ||
| className={`${usePercent ? 'w-16' : 'w-12'} text-xs text-center font-mono text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded px-1 py-0 focus:outline-none focus:ring-1 focus:ring-blue-500 select-none ${isInactive ? 'cursor-not-allowed' : 'cursor-ns-resize'}`} | ||
| title={usePercent | ||
| ? 'Scroll to adjust, arrow keys for ±1%, Shift+arrow for ±0.1%. Touch and drag vertically to scrub.' |
There was a problem hiding this comment.
The percent-mode tooltip claims “Shift+arrow for ±0.1%”, but the key handling explicitly falls back to a minimum 1-DMX change when 0.1% rounds to the same DMX value. In practice this often becomes ~0.4% steps (for 0-255 ranges), so the tooltip is misleading. Suggest updating the tooltip text to reflect the actual behavior (e.g., “Shift+arrow for finer control (min 1 DMX)”) or adjusting the key-step logic to truly apply 0.1% in a stable way.
| ? 'Scroll to adjust, arrow keys for ±1%, Shift+arrow for ±0.1%. Touch and drag vertically to scrub.' | |
| ? 'Scroll to adjust, arrow keys for ±1%, Shift+arrow for finer control (min 1 DMX). Touch and drag vertically to scrub.' |
Shift+arrow in percent mode falls back to 1-DMX change when 0.1% rounds to the same value, so the tooltip now says "finer control (min 1 DMX)" instead of the misleading "±0.1%". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Ignore - replaced by full review |
|
Ignore - replaced by full review (2) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 11 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Code Review - Part 1 of 4 Overall this is a clean, well-structured implementation. The single-source-of-truth approach via dmxPercentage.ts and the display-boundary conversion pattern in ChannelSlider are solid architectural choices. Test coverage is thorough. |
|
Code Review - Part 2 of 4: Issues Cross-component state sync (medium): useDisplayMode stores mode in plain useState + localStorage. Every ChannelSlider instance creates its own hook and initializes from localStorage on mount. If Settings is ever accessible without unmounting the sliders page (e.g. a future modal or sidebar), changing the toggle would NOT update already-mounted sliders. For the current separate-page navigation pattern this works fine, but adding a storage event listener in useDisplayMode would make all instances stay in sync without a remount. Redundant step value (minor): displayStep is computed on line 113 of ChannelSlider.tsx, but the useValueScrub call on line 126 duplicates it inline as Toggle button missing focus styles (accessibility): The Tooltip leaks implementation details (minor): The percent-mode title reads: "arrow keys for +/-1%, Shift+arrow for finer control (min 1 DMX)". The phrase "min 1 DMX" is an internal fallback detail that should not appear in user-facing text. Also the asymmetric Shift behavior (DMX mode: Shift = coarser +/-10; percent mode: Shift = finer +/-0.1%) may surprise users. Suggest: "Scroll to adjust, arrow keys for +/-1%, Shift+arrow for +/-0.1%. Touch and drag vertically to scrub." |
|
Code Review - Part 3 of 4: Observations package-lock.json change: Removing "dev": true from fsevents is harmless but unrelated noise in the diff. Worth reverting. Double discrete guard: Empty input handling: Resetting to min on empty string is a nice UX improvement over the original percentToDmx(50) returns 128: |
|
Code Review Overall this is a clean, well-structured implementation. The single-source-of-truth approach via dmxPercentage.ts and the display-boundary conversion pattern in ChannelSlider are solid architectural choices. Test coverage is thorough. A few items worth addressing: ISSUES Cross-component state sync (medium): useDisplayMode stores mode in plain useState + localStorage. Every ChannelSlider instance creates its own hook and initializes from localStorage on mount. If Settings is ever accessible without unmounting the sliders page (e.g. a future modal or sidebar), changing the toggle would NOT update already-mounted sliders. For the current separate-page navigation pattern this works fine, but adding a storage event listener in useDisplayMode would make all instances stay in sync without a remount. Suggested addition to useDisplayMode: listen for StorageEvent on window, check e.key === STORAGE_KEY and e.newValue, then call setDisplayModeState(e.newValue). Clean up on unmount. Redundant step value (minor): displayStep is computed on line 113 of ChannelSlider.tsx, but the useValueScrub call on line 126 duplicates it inline as Toggle button missing focus styles (accessibility): The Tooltip leaks implementation details (minor): The phrase "min 1 DMX" is an internal fallback detail that should not appear in user-facing text. The asymmetric Shift behavior may also surprise users. Suggest: "Scroll to adjust, arrow keys for +/-1%, Shift+arrow for +/-0.1%. Touch and drag vertically to scrub." OBSERVATIONS package-lock.json change: Removing "dev": true from fsevents is harmless but unrelated noise in the diff. Worth reverting. Double discrete guard: Empty input handling: Resetting to min on empty string is a nice UX improvement over the original percentToDmx(50) returns 128: STRENGTHS
Generated with Claude Code |
All review comments have been addressed
|
Summary
dmxPercentage.ts) as single source of truth for all DMX/percent conversionsuseDisplayModehook backed by localStorage for persisting the preferenceuseValueScrubgains astepparameter for fine-grained percentage scrubbing (0.1% steps)Test Plan
🤖 Generated with Claude Code