v1.1.2: action button, responsive faders, label scaling

Third integration-feedback release from building the streaming studio.

- New `Button` — a click-to-fire command button in the panel style, the
  non-latching sibling of PushButton. Implemented as PushButton's new
  `mode="action"` (never latches, press-down + brief LED flash, `onClick`);
  led="dot" (default) matches the corner-dot look, led={false} is plain.
- `fill` on LEDFader (parity with Fader) — faders stretch to fill their
  container in resizable panels instead of a fixed pixel length.
- `labelSize` on IndicatorLamp (and LampRow) — shrink the caption below its
  10px floor to match dense header rows.
- Docs: a dedicated "Buttons & switches" section with a decision guide
  (Button/PushButton/TransportButton/ToggleSwitch/SegmentSwitch) + live demos.

Library + docs typecheck and build; 38 tests pass.
This commit is contained in:
Dreamodus 2026-07-14 06:11:26 -07:00
parent 89c7a7f603
commit a6348d5c46
13 changed files with 372 additions and 27 deletions

View file

@ -1,5 +1,37 @@
# Changelog
## 1.1.2 — 2026-07-14
Third integration-feedback release — a general action button, responsive
faders everywhere, and the label-scaling gap, all surfaced building a
resizable streaming studio.
### New components
- `Button` — a normal **click-to-fire** command button in the panel style
(Regenerate, Add, Apply, Refresh), the non-latching sibling of `PushButton`.
It's `PushButton` locked to the new `mode="action"`: never latches, shows a
press-down + a brief LED flash, calls `onClick`. `led="dot"` (default) gives
the corner-dot look; `led={false}` a plain button.
### Buttons
- `PushButton` gains **`mode="action"`** (alongside `toggle`/`momentary`) and
an `onClick` prop — the stateless command-button behavior `Button` wraps.
### Responsive faders
- **`fill`** on `LEDFader` (matching `Fader`) — the fader stretches along its
travel axis to fill the container instead of a fixed `length`, so faders in
resizable dock panels size themselves with no wrapper measurement.
### Label scaling
- **`labelSize`** on `IndicatorLamp` — the caption otherwise tracks `size` but
is floored at 10px; set this to shrink it with a small lamp (e.g. matching a
dense header row). Also on `LampRow` for parity.
### Docs
- A dedicated **Buttons & switches** section with a "which do I use?" guide
(Button = action · PushButton = latch · TransportButton = transport ·
ToggleSwitch = boolean · SegmentSwitch = mode) and a live demo of each.
## 1.1.1 — 2026-07-13
Second integration-feedback release — the friction that showed up once the

View file

@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
Zero runtime dependencies.
```
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.2/dreamknob-1.1.2.tgz
```
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)

View file

@ -1,6 +1,6 @@
{
"name": "dreamknob",
"version": "1.1.1",
"version": "1.1.2",
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
"license": "MIT",
"author": "Dreamodus Software Inc.",

View file

@ -70,7 +70,7 @@ export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter';
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { Gauge, type GaugeProps } from './skins/Gauge';
export { PushButton, type PushButtonProps } from './skins/PushButton';
export { PushButton, type PushButtonProps, Button, type ButtonProps } from './skins/PushButton';
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';

View file

@ -19,6 +19,12 @@ export interface IndicatorLampProps {
readOnly?: boolean;
/** Caption beside the lamp. */
label?: string;
/**
* Caption font size in px, independent of the lamp diameter. Without it the
* label tracks `size` but is floored at 10px; set this to shrink the caption
* with a small lamp (e.g. matching a dense header row's height).
*/
labelSize?: number;
labelPosition?: 'right' | 'left';
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
@ -45,6 +51,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
blink = false,
readOnly = false,
label,
labelSize,
labelPosition = 'right',
disabled = false,
name,
@ -166,7 +173,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
<span
style={{
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.9),
fontSize: labelSize ?? Math.max(10, size * 0.9),
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: lit ? theme.text : theme.label,

View file

@ -15,6 +15,13 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/**
* Stretch along the travel axis to fill the container (horizontal fills
* width, vertical fills height) instead of a fixed `length` the same
* responsive behavior as `Fader`, so faders in resizable dock panels size
* themselves instead of needing wrapper measurement.
*/
fill?: boolean;
/** Cross-axis size in px. Default: 34. */
breadth?: number;
/** Number of LED segments. Default: 24. */
@ -57,6 +64,7 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
orientation = 'vertical',
length = 160,
fill = false,
breadth = 34,
segments = 24,
color: colorProp,
@ -95,6 +103,12 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
// Fill stretches the travel axis (via preserveAspectRatio='none'); the cross
// axis stays at `breadth`. The LED geometry is computed in the fixed viewBox
// and scaled to the element — pointer mapping measures the real element, so
// interaction stays correct at any rendered width.
const ctrlW = fill && !vertical ? '100%' : w;
const ctrlH = fill && vertical ? '100%' : h;
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
if (color) return [{ upTo: 1, color }];
@ -157,7 +171,8 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
flexDirection: 'column',
alignItems: 'center',
gap: 6,
width: w,
width: fill && !vertical ? '100%' : w,
height: fill && vertical ? '100%' : undefined,
opacity: core.disabled ? 0.45 : undefined,
...style,
}}
@ -218,14 +233,21 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
style={{
...knob.bind.style,
opacity: undefined, // the outer wrapper owns disabled dimming
width: w,
height: h,
display: 'inline-flex',
width: ctrlW,
height: ctrlH,
display: fill ? 'flex' : 'inline-flex',
borderRadius: 6,
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
}}
>
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<svg
width={ctrlW}
height={ctrlH}
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio={fill ? 'none' : undefined}
style={{ display: 'block' }}
aria-hidden="true"
>
<defs>
<filter
id={glowId}

View file

@ -11,6 +11,8 @@ export interface LampRowProps {
lamps: readonly LampRowItem[];
/** Lamp diameter in px. Default: 9. */
size?: number;
/** Caption font size in px. Default: 9.5. */
labelSize?: number;
/** Caption placement relative to each lamp. Default: 'right'. */
labelPosition?: 'right' | 'below';
/** Make lamps clickable (a compact indicator toggle bank). */
@ -31,6 +33,7 @@ export interface LampRowProps {
export const LampRow: React.FC<LampRowProps> = ({
lamps,
size = 9,
labelSize,
labelPosition = 'right',
onToggle,
gap = 14,
@ -80,7 +83,7 @@ export const LampRow: React.FC<LampRowProps> = ({
<span
style={{
fontFamily: theme.fontUI,
fontSize: 9.5,
fontSize: labelSize ?? 9.5,
fontWeight: 600,
letterSpacing: '0.12em',
textTransform: 'uppercase',

View file

@ -6,8 +6,14 @@ export interface PushButtonProps {
pressed?: boolean;
defaultPressed?: boolean;
onChange?: (pressed: boolean) => void;
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
mode?: 'toggle' | 'momentary';
/**
* 'toggle' latches; 'momentary' is on only while held; 'action' is a normal
* click-to-fire command button it never latches, shows a press-down + a
* brief LED flash, and calls `onClick`. Default: 'toggle'.
*/
mode?: 'toggle' | 'momentary' | 'action';
/** Fired on activation in `action` mode (click, Enter, or Space). */
onClick?: () => void;
/** LED / active color. */
color?: string;
/**
@ -39,6 +45,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
pressed,
defaultPressed = false,
onChange,
onClick,
mode = 'toggle',
color,
led = true,
@ -59,16 +66,42 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed);
const [focusVisible, setFocusVisible] = React.useState(false);
const on = isControlled ? (pressed as boolean) : internal;
// Action mode is stateless: a transient `held` (pointer down) drives the
// press-down look and a short `flash` lights the LED on activation.
const [held, setHeld] = React.useState(false);
const [flash, setFlash] = React.useState(false);
const flashTimer = React.useRef<ReturnType<typeof setTimeout>>();
React.useEffect(() => () => clearTimeout(flashTimer.current), []);
const latched = isControlled ? (pressed as boolean) : internal;
const on = mode === 'action' ? held : latched; // press-down / background inset
const ledOn = mode === 'action' ? held || flash : latched;
const set = (next: boolean) => {
if (next === on) return;
if (next === latched) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
const fire = () => {
onClick?.();
setFlash(true);
clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setFlash(false), 160);
};
const momentaryProps =
mode === 'momentary'
mode === 'action'
? {
onClick: fire,
onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
setHeld(true);
},
onPointerUp: () => setHeld(false),
onPointerCancel: () => setHeld(false),
onPointerLeave: () => setHeld(false),
}
: mode === 'momentary'
? {
onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
@ -83,14 +116,14 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
if (e.key === ' ' || e.key === 'Enter') set(false);
},
}
: { onClick: () => set(!on) };
: { onClick: () => set(!latched) };
return (
<button
ref={ref}
type="button"
disabled={disabled}
aria-pressed={on}
aria-pressed={mode === 'action' ? undefined : latched}
aria-label={aria['aria-label']}
data-pressed={on ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined}
@ -148,8 +181,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: '55%',
height: Math.max(3, size * 0.09),
borderRadius: 3,
background: on ? accent : 'rgba(255,255,255,0.09)',
boxShadow: on ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
background: ledOn ? accent : 'rgba(255,255,255,0.09)',
boxShadow: ledOn ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms',
}}
/>
@ -164,8 +197,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: Math.max(4, size * 0.14),
height: Math.max(4, size * 0.14),
borderRadius: '50%',
background: on ? accent : 'rgba(255,255,255,0.09)',
boxShadow: on ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
background: ledOn ? accent : 'rgba(255,255,255,0.09)',
boxShadow: ledOn ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms',
}}
/>
@ -177,7 +210,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
aria-hidden="true"
style={{
display: 'inline-flex',
color: on ? accent : theme.label,
color: ledOn ? accent : theme.label,
transition: 'color 80ms',
}}
>
@ -187,8 +220,26 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
{children}
</span>
)}
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
{name && <input type="hidden" name={name} value={latched ? 'on' : 'off'} readOnly />}
</button>
);
},
);
export interface ButtonProps
extends Omit<PushButtonProps, 'mode' | 'pressed' | 'defaultPressed' | 'onChange'> {
onClick?: () => void;
}
/**
* A normal click-to-fire command button in the studio-panel style the
* non-latching sibling of `PushButton`. Use it for actions (Regenerate,
* Add, Apply, Refresh). It's `PushButton` locked to `mode="action"`; pass
* `led="dot"` for the corner-dot look (like a Fresh Seed button) or
* `led={false}` for a plain button.
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button({ led = 'dot', ...rest }, ref) {
return <PushButton ref={ref} mode="action" led={led} {...rest} />;
},
);