From d8b7b55505f856e9876e61a9606365e644adc97e Mon Sep 17 00:00:00 2001 From: Dreamodus Date: Mon, 13 Jul 2026 12:07:52 -0700 Subject: [PATCH] v1.1.0: integration-feedback release Bugs from real-product integration: - Meter/LEDFader clamp geometry below breadth 14 (no negative SVG rects) - mixColors falls back to CSS color-mix() so theme tokens can be var()/oklch() - type-in editing fires a real edit gesture (Start/onChange/End, source 'edit') - ScrubField Enter/Escape no longer double-commit or commit-on-revert via blur - disabled dims the whole control uniformly, data-disabled on the wrapper New capabilities: - commitMode 'release': drags preview, emit once on pointer-up, Esc discards - Meter accepts value=[l, r, ...]; theme zoneGood/zoneWarn/zoneHot defaults - XYPad per-axis invert + detents; Fader valuePosition top|end|none - LED skins: unit labels, uncapped displayDecimals, aria-valuetext defaults New components: IndicatorLamp, SegmentSwitch, ScrubField, MeterBridge, PushButton led='dot'. Docs: change-contract table, recipes, gallery cards. --- README.md | 7 +- apps/docs/src/sections/ApiDocs.tsx | 99 +++++- apps/docs/src/sections/Gallery.tsx | 100 +++++- apps/docs/src/sections/GettingStarted.tsx | 4 +- apps/docs/src/sections/Hero.tsx | 4 +- packages/dreamknob/CHANGELOG.md | 42 +++ packages/dreamknob/README.md | 10 +- packages/dreamknob/package.json | 2 +- packages/dreamknob/src/components/Knob.tsx | 2 +- packages/dreamknob/src/core/colors.test.ts | 10 +- packages/dreamknob/src/core/colors.ts | 14 +- packages/dreamknob/src/core/theme.tsx | 12 + packages/dreamknob/src/core/types.ts | 23 +- packages/dreamknob/src/hooks/useKnob.ts | 72 ++++- packages/dreamknob/src/index.ts | 4 + packages/dreamknob/src/skins/Fader.tsx | 155 +++++---- packages/dreamknob/src/skins/FlatKnob.tsx | 1 + packages/dreamknob/src/skins/ImageKnob.tsx | 12 +- .../dreamknob/src/skins/IndicatorLamp.tsx | 184 +++++++++++ packages/dreamknob/src/skins/LEDFader.tsx | 51 ++- packages/dreamknob/src/skins/LEDKnob.tsx | 28 +- packages/dreamknob/src/skins/MetalKnob.tsx | 2 +- packages/dreamknob/src/skins/Meter.tsx | 187 ++++++----- packages/dreamknob/src/skins/MeterBridge.tsx | 124 ++++++++ packages/dreamknob/src/skins/NeonKnob.tsx | 2 +- packages/dreamknob/src/skins/PushButton.tsx | 26 +- packages/dreamknob/src/skins/RubberKnob.tsx | 2 +- packages/dreamknob/src/skins/ScrubField.tsx | 294 ++++++++++++++++++ .../dreamknob/src/skins/SegmentSwitch.tsx | 162 ++++++++++ packages/dreamknob/src/skins/SteppedKnob.tsx | 1 + packages/dreamknob/src/skins/XYPad.tsx | 58 +++- 31 files changed, 1471 insertions(+), 223 deletions(-) create mode 100644 packages/dreamknob/src/skins/IndicatorLamp.tsx create mode 100644 packages/dreamknob/src/skins/MeterBridge.tsx create mode 100644 packages/dreamknob/src/skins/ScrubField.tsx create mode 100644 packages/dreamknob/src/skins/SegmentSwitch.tsx diff --git a/README.md b/README.md index 6f3f7a5..d40beb9 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob' rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log / power / custom tapers. - **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`, - `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`, - `PushButton`, `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance. + `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `MeterBridge`, + `XYPad`, `PushButton`, `IndicatorLamp`, `SegmentSwitch`, `ScrubField`, + `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance. - **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`, `KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with `useKnob`. @@ -48,7 +49,7 @@ See `packages/dreamknob/README.md` and the docs app for the full API. The easiest way is the published release on our Forgejo: ```bash -pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz +pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz ``` Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types): diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx index a7664eb..20ff93c 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -67,8 +67,9 @@ export const ApiDocs: React.FC = () => ( - - + + + @@ -87,21 +88,56 @@ export const ApiDocs: React.FC = () => ( via pointer capture · every control shows a keyboard-only focus ring.

+

The change contract

+

+ Every way a value can move maps to a ChangeMeta.source, and every + user gesture is bracketed by exactly one onChangeStart /{' '} + onChangeEnd pair — wire undo snapshots to Start and history commits + to End and the ledger always balances. +

+ + + + + + + + + + + + + + + + + +
sourceEmitted byStart/End?onChange cadence
'drag'pointer drags (rotary, linear, track, XY, scrub)yes — one pair per drageach snapped value ('live') or once on release ('release')
'wheel'scroll wheel / trackpadyes — one pair per burst (150–250 ms settle)each notch
'keyboard'arrows, PageUp/Down, Home/Endyes — one pair per presseach step
'reset'double-click resetyesonce
'edit'type-in editors (editable, ScrubField)yesonce, on commit
'api'setValue() from your codeno — programmatic moves are not gestureseach call that lands on a new value
+

+ A cancelled gesture (Esc, or disabled flipping mid-drag) restores the + start value and still fires onChangeEnd, so Start/End counts always + match. onChange never repeats a value within a gesture. +

+

Prebuilt skins

- + - - + + - - - + + + + + + +
@@ -118,13 +154,19 @@ export const ApiDocs: React.FC = () => ( label) and every interactive root exposes{' '} data-dragging/data-disabled/data-focus-visible{' '} — style any state or part with plain CSS. <Arc> additionally - accepts gradient (position-anchored color stops). + accepts gradient (position-anchored color stops). Geometry is + self-defending: Meter/LEDFader clamp their internal + padding below breadth 14 and never emit negative dimensions, so + 4-px status slivers render fine.

Theming

Every skin resolves its color and font defaults from the active theme; per-instance - props always win. Use base="light" for light backgrounds. + props always win. Use base="light" for light backgrounds. Any CSS + color works — hex, rgb(), oklch(), even{' '} + var(--accent): gradient and glow math uses hex fast-paths where it + can and falls back to CSS color-mix() everywhere else.

( text: '#fff', label: 'rgba(255,255,255,0.5)', fontMono: '"IBM Plex Mono", monospace', + zoneGood: '#3df2ad', // meter zones: green / amber / red + zoneWarn: '#ffd23e', // used by Meter, MeterBridge and + zoneHot: '#ff4d6b', // LEDFader when no zones are given }} > @@ -189,6 +234,40 @@ function MyKnob() { }`} /> +

Recipes

+

+ The prop combinations we reach for in real consoles and inspectors. +

+ + +// Angle dial — endless, wraps 0↔360, full-circle travel + + +// Expensive parameter — drag previews, engine hears one change on release + rebuildReverbTail(v)} aria-label="Room size" /> + +// Wide-range filter cutoff — log taper puts the musical range under your thumb + v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : v + ' Hz'} + aria-label="Cutoff" /> + +// Inspector knob — relative drag (no grab-jump), flick fast / drag slow + + +// One-line inspector row — fader with the readout beside it + + +// Stereo meter — one component, two channels, shared clip LED +`} + /> +

Precision & number handling

Values are snapped to step (anchored at min) and rounded diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx index 774e08e..eebd097 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -8,14 +8,18 @@ import { ImageKnob, Knob, KnobValue, + IndicatorLamp, LEDFader, LEDKnob, MetalKnob, Meter, + MeterBridge, NeonKnob, Pointer, PushButton, RubberKnob, + ScrubField, + SegmentSwitch, SegmentDisplay, SteppedKnob, Ticks, @@ -117,15 +121,78 @@ const MeterDemo: React.FC = () => { }, 90); return () => clearInterval(id); }, []); - const zones = [ - { upTo: 0.72, color: '#3df2ad' }, - { upTo: 0.9, color: '#ffd23e' }, - { upTo: 1, color: '#ff4d6b' }, - ]; return ( -

- - +
+ + +
+ ); +}; + +const LampSwitchDemo: React.FC = () => { + const [mode, setMode] = React.useState(1); + const [armed, setArmed] = React.useState(true); + return ( +
+ +
+ + + + ⌗ + +
+
+ ); +}; + +const CommitModeDemo: React.FC = () => { + const [commits, setCommits] = React.useState(0); + return ( +
+ setCommits(n => n + 1)} + aria-label="Release commit demo" + /> +
+ + + onChange calls + +
); }; @@ -343,10 +410,25 @@ export const Gallery: React.FC = () => (
- + + + + + + +
+ + +
+
+ + + + + ( ) · peer deps: react >= 18, react-dom >= 18`} + code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`} />

Installs straight from the{' '} - v1.0.0 release + v1.1.0 release {' '} on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No CSS file to import — everything is SVG and inline styles. diff --git a/apps/docs/src/sections/Hero.tsx b/apps/docs/src/sections/Hero.tsx index f1a37a0..bac156d 100644 --- a/apps/docs/src/sections/Hero.tsx +++ b/apps/docs/src/sections/Hero.tsx @@ -25,10 +25,10 @@ export const Hero: React.FC = () => { range, any decimal precision, and colors you fully control.

- pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz + pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz + ); + }, +); diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx index a57f78a..0fec015 100644 --- a/packages/dreamknob/src/skins/LEDFader.tsx +++ b/packages/dreamknob/src/skins/LEDFader.tsx @@ -38,8 +38,10 @@ export interface LEDFaderProps extends Omit { showValue?: boolean; /** Digit cells in the readout. Default: 4. */ digits?: number; - /** Decimals in the readout (defaults to the control's decimals, capped at 1). */ + /** Decimals in the readout. Defaults to the control's decimals — size `digits` to fit. */ displayDecimals?: number; + /** Unit suffix beside the readout, e.g. "dB". */ + unit?: string; labelColor?: string; /** Click the readout to type an exact value. */ editable?: boolean; @@ -67,6 +69,7 @@ export const LEDFader = React.forwardRef(function showValue = true, digits = 4, displayDecimals, + unit, labelColor, editable = false, parseValue, @@ -77,7 +80,7 @@ export const LEDFader = React.forwardRef(function }, ref) { const [editing, setEditing] = React.useState(false); const theme = useKnobTheme(); - const color = colorProp ?? theme.accent ?? '#3df2ad'; + const color = colorProp ?? theme.accent; const ring = focusRing ?? theme.focusRing; const vertical = orientation === 'vertical'; const pad = 5; @@ -85,16 +88,22 @@ export const LEDFader = React.forwardRef(function ...core, interaction: vertical ? 'track-vertical' : 'track-horizontal', trackInset: pad, + getAriaValueText: core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined), }); const id = React.useId(); const glowId = `${id}-glow`; const w = vertical ? breadth : length; const h = vertical ? length : breadth; - const zoneList: readonly LEDFaderZone[] = React.useMemo( - () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), - [zones, color], - ); + const zoneList: readonly LEDFaderZone[] = React.useMemo(() => { + if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo); + if (color) return [{ upTo: 1, color }]; + return [ + { upTo: 0.72, color: theme.zoneGood }, + { upTo: 0.9, color: theme.zoneWarn }, + { upTo: 1, color: theme.zoneHot }, + ]; + }, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]); const zoneFor = (t: number): string => (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; @@ -142,12 +151,14 @@ export const LEDFader = React.forwardRef(function return (
@@ -171,22 +182,29 @@ export const LEDFader = React.forwardRef(function initial={String(knob.value)} onCommit={raw => { const parsed = (parseValue ?? defaultParseValue)(raw); - if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed); + if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed); setEditing(false); }} onCancel={() => setEditing(false)} style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }} /> ) : ( - + + + {unit && ( + + {unit} + + )} + )}
)} @@ -199,6 +217,7 @@ export const LEDFader = React.forwardRef(function }} style={{ ...knob.bind.style, + opacity: undefined, // the outer wrapper owns disabled dimming width: w, height: h, display: 'inline-flex', diff --git a/packages/dreamknob/src/skins/LEDKnob.tsx b/packages/dreamknob/src/skins/LEDKnob.tsx index 850d7c1..cee45a3 100644 --- a/packages/dreamknob/src/skins/LEDKnob.tsx +++ b/packages/dreamknob/src/skins/LEDKnob.tsx @@ -17,7 +17,7 @@ export interface LEDKnobProps extends SkinProps { segments?: number; /** Digit cells in the center readout. */ digits?: number; - /** Decimals shown in the readout (defaults to the knob's decimals, capped at 2). */ + /** Decimals shown in the readout. Defaults to the knob's decimals — make sure `digits` has room. */ displayDecimals?: number; labelColor?: string; faceColor?: string; @@ -92,6 +92,7 @@ export const LEDKnob = React.forwardRef(function L labelColor, faceColor = '#0b0d0e', label, + unit, focusRing, className, style, @@ -102,7 +103,15 @@ export const LEDKnob = React.forwardRef(function L const id = React.useId(); const glowId = `${id}-glow`; return ( - + `${v} ${unit}` : undefined)} + > {ctx => ( <> @@ -120,9 +129,22 @@ export const LEDKnob = React.forwardRef(function L + {unit && ( + + {unit} + + )} {label && ( (functi const indicator = indicatorColor ?? (silver ? '#1b1c21' : color); return ( - + format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}> diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx index 2ccf9a8..d088b48 100644 --- a/packages/dreamknob/src/skins/Meter.tsx +++ b/packages/dreamknob/src/skins/Meter.tsx @@ -6,21 +6,24 @@ import type { LEDFaderZone } from './LEDFader'; import { SegmentDisplay } from '../digital/SegmentDisplay'; export interface MeterProps { - /** Level to display. The meter is read-only — always controlled. */ - value: number; + /** + * Level to display — a single number, or an array for a multi-channel + * meter (e.g. `[l, r]` for stereo). The meter is read-only. + */ + value: number | readonly number[]; min?: number; max?: number; taper?: Taper; orientation?: 'vertical' | 'horizontal'; /** Travel length in px. Default: 160. */ length?: number; - /** Cross-axis size in px. Default: 16. */ + /** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */ breadth?: number; /** Number of LED segments. Default: 28. */ segments?: number; - /** LED color when no zones are given. */ + /** LED color when no zones are given. Defaults to the theme's zone colors. */ color?: string; - /** Meter zones, e.g. green / yellow / red by normalized position. */ + /** Meter zones by normalized position. Defaults to theme zoneGood/Warn/Hot. */ zones?: readonly LEDFaderZone[]; /** Opacity of unlit segments. Default: 0.1. */ offOpacity?: number; @@ -31,40 +34,45 @@ export interface MeterProps { peakHold?: number | false; /** Peak indicator color. Defaults to the peak's zone color. */ peakColor?: string; - /** Dedicated clip LED at the hot end of the meter. Default: true. */ + /** Dedicated clip LED at the hot end (shared across channels). Default: true. */ showClip?: boolean; /** Value at/above which the clip LED lights. Default: `max`. */ clipThreshold?: number; /** - * Signal used for clip detection, when it differs from the displayed - * level — e.g. show RMS on the bar but clip on sample peaks. Defaults - * to `value`. + * Signal(s) used for clip detection when different from the displayed + * level — e.g. show RMS on the bars but clip on sample peaks. Same shape + * as `value`. Defaults to `value`. */ - clipValue?: number; + clipValue?: number | readonly number[]; /** * How long the clip LED stays lit in ms, or 'latch' to stay lit until * clicked. Default: 1500. */ clipHold?: number | 'latch'; clipColor?: string; - /** Fired when the signal first crosses the clip threshold. */ + /** Fired when any channel first crosses the clip threshold. */ onClip?: (value: number) => void; /** Panel color behind the LEDs. */ faceColor?: string; label?: string; - /** Show a seven-segment readout. Default: false. */ + /** Show a seven-segment readout (of the hottest channel). Default: false. */ showValue?: boolean; digits?: number; displayDecimals?: number; + /** Unit suffix beside the readout, e.g. "dB". */ + unit?: string; labelColor?: string; className?: string; style?: React.CSSProperties; 'aria-label'?: string; } +const asArray = (v: number | readonly number[]): readonly number[] => + Array.isArray(v) ? v : [v as number]; + /** - * Read-only LED level meter with peak hold — the display-side sibling of - * LEDFader for VU/output metering. + * Read-only LED level meter with peak hold and a latching clip LED — pass an + * array of values for stereo/multi-channel bars sharing one clip indicator. */ export const Meter: React.FC = ({ value, @@ -75,7 +83,7 @@ export const Meter: React.FC = ({ length = 160, breadth = 16, segments = 28, - color = '#3df2ad', + color, zones, offOpacity = 0.1, peakHold = 1200, @@ -91,6 +99,7 @@ export const Meter: React.FC = ({ showValue = false, digits = 4, displayDecimals = 1, + unit, labelColor, className, style, @@ -98,22 +107,27 @@ export const Meter: React.FC = ({ }) => { const theme = useKnobTheme(); const vertical = orientation === 'vertical'; - const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1); + const values = asArray(value); + const channels = values.length; + const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1)); - // Peak: track the highest recent level, fall back after `peakHold` ms. - const [peak, setPeak] = React.useState(n); + // Peaks: per channel, falling back together after `peakHold` ms of no rise. + const [peaks, setPeaks] = React.useState(ns); React.useEffect(() => { if (peakHold === false) return; - if (n >= peak) { - setPeak(n); - return; - } - const t = setTimeout(() => setPeak(n), peakHold); + setPeaks(prev => { + const rose = ns.map((n, i) => n >= (prev[i] ?? 0)); + if (rose.every(Boolean) || prev.length !== ns.length) return ns.map((n, i) => Math.max(n, prev[i] ?? 0)); + return prev.map((p, i) => Math.max(p, ns[i] ?? 0)); + }); + const t = setTimeout(() => setPeaks(ns), peakHold); return () => clearTimeout(t); - }, [n, peak, peakHold]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(ns), peakHold]); - // Clip: latch a dedicated LED when the raw (unclamped) signal reaches the - // threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode. + // Clip: latch when any channel's raw (unclamped) signal reaches threshold. + const clipSignals = clipValue !== undefined ? asArray(clipValue) : values; + const hottest = Math.max(...clipSignals); const [clipped, setClipped] = React.useState(false); const clipTimer = React.useRef>(); const wasOver = React.useRef(false); @@ -121,10 +135,9 @@ export const Meter: React.FC = ({ onClipRef.current = onClip; React.useEffect(() => { if (!showClip) return; - const signal = clipValue ?? value; - const over = signal >= (clipThreshold ?? max) - 1e-9; + const over = hottest >= (clipThreshold ?? max) - 1e-9; if (over) { - if (!wasOver.current) onClipRef.current?.(signal); + if (!wasOver.current) onClipRef.current?.(hottest); setClipped(true); if (clipHold !== 'latch') { clearTimeout(clipTimer.current); @@ -132,59 +145,84 @@ export const Meter: React.FC = ({ } } wasOver.current = over; - }, [value, clipValue, showClip, clipThreshold, max, clipHold]); + }, [hottest, showClip, clipThreshold, max, clipHold]); React.useEffect(() => () => clearTimeout(clipTimer.current), []); - const pad = 4; - const w = vertical ? breadth : length; - const h = vertical ? length : breadth; - const zoneList: readonly LEDFaderZone[] = React.useMemo( - () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), - [zones, color], - ); + const zoneList: readonly LEDFaderZone[] = React.useMemo(() => { + if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo); + if (color) return [{ upTo: 1, color }]; + return [ + { upTo: 0.72, color: theme.zoneGood }, + { upTo: 0.9, color: theme.zoneWarn }, + { upTo: 1, color: theme.zoneHot }, + ]; + }, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]); const zoneFor = (t: number): string => (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; - const lit = Math.round(n * segments); - const peakIdx = - peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1; - // Reserve room at the hot end for the clip LED (7px LED + 3px gap). + // Geometry — clamped so thin meters degrade gracefully instead of + // producing negative SVG rects. pad shrinks below breadth 14. + const pad = breadth < 14 ? 2 : 4; + const cross = Math.max(1, breadth - pad * 2); + const chGap = channels > 1 ? 3 : 0; + const along = length; // travel axis + const across = channels * breadth + (channels - 1) * chGap; + const w = vertical ? across : along; + const h = vertical ? along : across; + const clipLed = 7; const clipSpan = showClip ? clipLed + 3 : 0; - const slot = (length - pad * 2 - clipSpan) / segments; + const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments); const gap = Math.min(2.5, slot * 0.35); - const cross = breadth - pad * 2; + const segSize = Math.max(0.5, slot - gap); - const leds: React.ReactNode[] = []; - for (let i = 0; i < segments; i++) { - const zone = zoneFor((i + 1) / segments); - const isPeak = i === peakIdx && i >= lit; - const on = i < lit || isPeak; - const fill = isPeak ? (peakColor ?? zone) : zone; - const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity }; - leds.push( - vertical ? ( - - ) : ( - - ), - ); + const bars: React.ReactNode[] = []; + for (let c = 0; c < channels; c++) { + const offset = c * (breadth + chGap) + pad; + const lit = Math.round((ns[c] ?? 0) * segments); + const peakIdx = + peakHold !== false && (peaks[c] ?? 0) > 0 + ? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1) + : -1; + for (let i = 0; i < segments; i++) { + const zone = zoneFor((i + 1) / segments); + const isPeak = i === peakIdx && i >= lit; + const on = i < lit || isPeak; + const fill = isPeak ? (peakColor ?? zone) : zone; + const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity }; + bars.push( + vertical ? ( + + ) : ( + + ), + ); + } } + const hottestShown = Math.max(...values.map(v => clamp(v, min, max))); + return (
= ({ }} > {showValue && ( -
+
+ {unit && ( + + {unit} + + )}
)}
+ {showPeakText && ( +
+ {peaks.map((p, i) => ( + + {Math.max(p, min) <= min ? '—' : Math.min(p, max).toFixed(peakTextDecimals)} + + ))} +
+ )} + +
+ {channels.map(c => ( + + {c.label} + + ))} +
+ {label && ( + + {label} + + )} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/NeonKnob.tsx b/packages/dreamknob/src/skins/NeonKnob.tsx index fb522cb..0b9b0ec 100644 --- a/packages/dreamknob/src/skins/NeonKnob.tsx +++ b/packages/dreamknob/src/skins/NeonKnob.tsx @@ -36,7 +36,7 @@ export const NeonKnob = React.forwardRef(function const id = React.useId(); const glowId = `${id}-glow`; return ( - + format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}> ( ...style, }} > - {led && ( + {(led === true || led === 'strip') && (