DreamKnob/packages/dreamknob/src/skins/MeterBridge.tsx
Dreamodus d8b7b55505 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.
2026-07-13 12:07:52 -07:00

124 lines
3.5 KiB
TypeScript

import * as React from 'react';
import { useKnobTheme } from '../core/theme';
import { Meter, type MeterProps } from './Meter';
export interface MeterBridgeChannel {
label: string;
value: number;
/** Peak signal for clip detection, when different from `value`. */
clipValue?: number;
}
export interface MeterBridgeProps
extends Omit<MeterProps, 'value' | 'clipValue' | 'label' | 'showValue' | 'aria-label'> {
channels: readonly MeterBridgeChannel[];
/** Numeric peak-hold text above each bar. Default: true. */
showPeakText?: boolean;
/** Decimals for the peak text. Default: 1. */
peakTextDecimals?: number;
/** Block caption under the bridge. */
label?: string;
'aria-label'?: string;
}
/**
* A labeled multi-strip meter block — n channels, per-channel peak text, one
* shared clip LED. The composition every mixer rebuilds, prebuilt.
*/
export const MeterBridge: React.FC<MeterBridgeProps> = ({
channels,
showPeakText = true,
peakTextDecimals = 1,
label,
min = 0,
max = 100,
breadth = 16,
peakHold = 1200,
className,
style,
...meterProps
}) => {
const theme = useKnobTheme();
const values = channels.map(c => c.value);
const clipValues = channels.map(c => c.clipValue ?? c.value);
const chGap = channels.length > 1 ? 3 : 0;
// Numeric peak-hold per channel for the text row.
const [peaks, setPeaks] = React.useState<readonly number[]>(values);
React.useEffect(() => {
if (peakHold === false) return;
setPeaks(prev =>
prev.length === values.length ? values.map((v, i) => Math.max(v, prev[i] ?? -Infinity)) : values,
);
const t = setTimeout(() => setPeaks(values), peakHold);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(values), peakHold]);
const cell: React.CSSProperties = {
width: breadth,
textAlign: 'center',
fontFamily: theme.fontMono,
whiteSpace: 'nowrap',
overflow: 'visible',
};
return (
<div
className={className}
role="group"
aria-label={meterProps['aria-label' as never] ?? label}
style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4, ...style }}
>
{showPeakText && (
<div style={{ display: 'flex', gap: chGap }}>
{peaks.map((p, i) => (
<span key={i} style={{ ...cell, fontSize: 9, color: theme.label }}>
{Math.max(p, min) <= min ? '—' : Math.min(p, max).toFixed(peakTextDecimals)}
</span>
))}
</div>
)}
<Meter
{...meterProps}
min={min}
max={max}
breadth={breadth}
peakHold={peakHold}
value={values}
clipValue={clipValues}
aria-label={undefined}
/>
<div style={{ display: 'flex', gap: chGap }}>
{channels.map(c => (
<span
key={c.label}
style={{
...cell,
fontSize: 9.5,
fontFamily: theme.fontUI,
letterSpacing: '0.05em',
textTransform: 'uppercase',
color: theme.label,
}}
>
{c.label}
</span>
))}
</div>
{label && (
<span
style={{
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: theme.label,
}}
>
{label}
</span>
)}
</div>
);
};