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

@ -50,7 +50,7 @@ See `packages/dreamknob/README.md` and the docs app for the full API.
The easiest way is the published release on our Forgejo: The easiest way is the published release on our Forgejo:
```bash ```bash
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
``` ```
Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types): Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types):

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { AdvancedGuide } from './sections/AdvancedGuide'; import { AdvancedGuide } from './sections/AdvancedGuide';
import { ApiDocs } from './sections/ApiDocs'; import { ApiDocs } from './sections/ApiDocs';
import { Buttons } from './sections/Buttons';
import { Console } from './sections/Console'; import { Console } from './sections/Console';
import { Gallery } from './sections/Gallery'; import { Gallery } from './sections/Gallery';
import { GettingStarted } from './sections/GettingStarted'; import { GettingStarted } from './sections/GettingStarted';
@ -26,6 +27,7 @@ export const App: React.FC = () => (
<div className="nav-links"> <div className="nav-links">
<a href="#start">Start</a> <a href="#start">Start</a>
<a href="#gallery">Gallery</a> <a href="#gallery">Gallery</a>
<a href="#buttons">Buttons</a>
<a href="#synth">Synth</a> <a href="#synth">Synth</a>
<a href="#playground">Playground</a> <a href="#playground">Playground</a>
<a href="#guide">Guide</a> <a href="#guide">Guide</a>
@ -36,6 +38,7 @@ export const App: React.FC = () => (
<Hero /> <Hero />
<GettingStarted /> <GettingStarted />
<Gallery /> <Gallery />
<Buttons />
<Console /> <Console />
<Synth /> <Synth />
<Playground /> <Playground />

View file

@ -129,13 +129,14 @@ export const ApiDocs: React.FC = () => (
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." /> <Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." /> <Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format, valuePosition ('top' | 'end' | 'none'), fill (stretch to container)." /> <Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format, valuePosition ('top' | 'end' | 'none'), fill (stretch to container)." />
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}] — defaults to the theme's green/amber/red), glow, digits, unit." /> <Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}] — defaults to the theme's green/amber/red), glow, digits, unit, fill (stretch to container)." />
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." /> <Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
<Row name="<Meter />" type="display" desc="Read-only LED level meter: value (number or [l, r, ...]), zones (theme green/amber/red), peakHold, peakColor, orientation, unit, fill (stretch to container), disabled, seven-segment readout. Clip LED: showClip, clipThreshold, clipHold (ms or 'latch'), clipColor, onClip." /> <Row name="<Meter />" type="display" desc="Read-only LED level meter: value (number or [l, r, ...]), zones (theme green/amber/red), peakHold, peakColor, orientation, unit, fill (stretch to container), disabled, seven-segment readout. Clip LED: showClip, clipThreshold, clipHold (ms or 'latch'), clipColor, onClip." />
<Row name="<MeterBridge />" type="display" desc="Labeled multi-strip meter block: channels ([{label, value, clipValue?}]), per-channel peak-hold text, shared clip LED — the mixer meter row, prebuilt." /> <Row name="<MeterBridge />" type="display" desc="Labeled multi-strip meter block: channels ([{label, value, clipValue?}]), per-channel peak-hold text, shared clip LED — the mixer meter row, prebuilt." />
<Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs (each with min/max/step/invert/detents), drag + arrow keys (arrows follow the visual direction), grid, crosshair, Escape cancel, double-click reset." /> <Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs (each with min/max/step/invert/detents), drag + arrow keys (arrows follow the visual direction), grid, crosshair, Escape cancel, double-click reset." />
<Row name="<PushButton />" type="switch" desc="Panel button: toggle or momentary, controlled/uncontrolled, aria-pressed. led: 'strip' | 'dot'. leadingIcon slot tinted to the LED." /> <Row name="<Button />" type="action" desc="Click-to-fire command button in the panel style (Regenerate, Add, Apply). PushButton locked to mode='action' — never latches, press-down + LED flash, onClick. led: 'dot' (default) | 'strip' | false." />
<Row name="<IndicatorLamp />" type="switch · display" desc="Panel LED lamp: pressable toggle (role='switch') or read-only status light, blink, momentary, label either side — the checkbox, in hardware form." /> <Row name="<PushButton />" type="switch" desc="Panel button: mode 'toggle' | 'momentary' | 'action', controlled/uncontrolled, aria-pressed. led: 'strip' | 'dot'. leadingIcon slot tinted to the LED." />
<Row name="<IndicatorLamp />" type="switch · display" desc="Panel LED lamp: pressable toggle (role='switch') or read-only status light, blink, momentary, label either side (labelSize to shrink the caption below its 10px floor) — the checkbox, in hardware form." />
<Row name="<SegmentSwitch />" type="selector" desc="Segmented option switch (radiogroup): options list, LED tick on the active cell, arrow/Home/End keys, controlled/uncontrolled index." /> <Row name="<SegmentSwitch />" type="selector" desc="Segmented option switch (radiogroup): options list, LED tick on the active cell, arrow/Home/End keys, controlled/uncontrolled index." />
<Row name="<ScrubField />" type="numeric field" desc="After-Effects-style number field: drag the label to scrub (Shift = fine), click to type (Enter commits, Esc reverts), arrows step, wheel nudges. role='spinbutton'." /> <Row name="<ScrubField />" type="numeric field" desc="After-Effects-style number field: drag the label to scrub (Shift = fine), click to type (Enter commits, Esc reverts), arrows step, wheel nudges. role='spinbutton'." />
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: AZ 09 and symbols (now incl. : $ %), chars padding, align, weight, gap, glow, skew. Defaults to theme.ledAmber." /> <Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: AZ 09 and symbols (now incl. : $ %), chars padding, align, weight, gap, glow, skew. Defaults to theme.ledAmber." />

View file

@ -0,0 +1,156 @@
import React from 'react';
import {
Button,
PushButton,
TransportButton,
ToggleSwitch,
SegmentSwitch,
} from 'dreamknob';
/** Small glyph for a button's leadingIcon (the docs app has no icon dep). */
const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span aria-hidden="true" style={{ fontSize: 14, lineHeight: 1 }}>
{children}
</span>
);
/** The button/switch family, side by side, with a "which do I use?" guide. */
export const Buttons: React.FC = () => {
const [playing, setPlaying] = React.useState(false);
const [armed, setArmed] = React.useState(false);
const [bypass, setBypass] = React.useState(false);
const [fresh, setFresh] = React.useState(true);
const [mode, setMode] = React.useState(0);
const [fired, setFired] = React.useState(0);
return (
<section className="block" id="buttons">
<div className="container">
<div className="section-kicker">Buttons &amp; switches</div>
<h2 className="section-title">Press, latch, or pick</h2>
<p className="section-sub">
Five components cover every panel control that isn&apos;t a dial. The
only question is what the press <em>means</em>:
</p>
<div className="guide-grid">
<GuideRow
code="<Button>"
when="Fire a one-off action"
eg="Regenerate · Add · Apply · Refresh"
/>
<GuideRow
code="<PushButton>"
when="Latch a boolean state (or hold it)"
eg="Mute · Solo · Bypass · push-to-talk"
/>
<GuideRow
code="<TransportButton>"
when="Transport / deck control"
eg="Play · Stop · Record · Panic"
/>
<GuideRow
code="<ToggleSwitch>"
when="An on/off setting that reads as a switch"
eg="Fresh seed · Monitor · Loop"
/>
<GuideRow
code="<SegmentSwitch>"
when="Pick one of a few modes"
eg="Linear · Radial · Conic"
/>
</div>
<div className="btn-demo">
<Demo label={`<Button> — action, fired ${fired}×`}>
<Button
color="#4cc2ff"
leadingIcon={<Glyph></Glyph>}
onClick={() => setFired(n => n + 1)}
aria-label="Regenerate"
>
Regenerate
</Button>
<Button
color="#3df2ad"
led="dot"
leadingIcon={<Glyph></Glyph>}
onClick={() => setFired(n => n + 1)}
aria-label="Fresh"
>
Fresh
</Button>
<Button
color="#ff4d6b"
led={false}
leadingIcon={<Glyph></Glyph>}
onClick={() => setFired(n => n + 1)}
aria-label="Clear"
>
Clear
</Button>
</Demo>
<Demo label="<PushButton> — latch / hold">
<PushButton
pressed={bypass}
onChange={setBypass}
color="#ffd23e"
aria-label="Bypass"
>
Bypass
</PushButton>
<PushButton
mode="momentary"
color="#4cc2ff"
led="dot"
size={30}
aria-label="Talk"
>
Talk
</PushButton>
</Demo>
<Demo label="<TransportButton>">
<TransportButton kind="play" active={playing} onClick={() => setPlaying(p => !p)} size={34} />
<TransportButton kind="stop" onClick={() => setPlaying(false)} size={34} />
<TransportButton kind="record" active={armed} onClick={() => setArmed(a => !a)} size={34} />
</Demo>
<Demo label="<ToggleSwitch> · <SegmentSwitch>">
<ToggleSwitch on={fresh} onChange={setFresh} color="#c9a6ff" showStateLabel aria-label="Fresh seed" />
<SegmentSwitch
options={['LINEAR', 'RADIAL', 'CONIC']}
value={mode}
onChange={setMode}
color="#4cc2ff"
aria-label="Gradient kind"
/>
</Demo>
</div>
<p className="section-sub" style={{ marginTop: 20 }}>
<code>&lt;Button&gt;</code> is <code>&lt;PushButton mode=&quot;action&quot;&gt;</code>
it never latches, shows a press-down and a brief LED flash, and calls{' '}
<code>onClick</code>. <code>led=&quot;dot&quot;</code> gives the corner-dot
look; <code>led=&#123;false&#125;</code> a plain button.
</p>
</div>
</section>
);
};
const GuideRow: React.FC<{ code: string; when: string; eg: string }> = ({ code, when, eg }) => (
<div className="guide-row">
<code className="guide-code">{code}</code>
<span className="guide-when">{when}</span>
<span className="guide-eg">{eg}</span>
</div>
);
const Demo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div className="btn-demo-cell">
<div className="btn-demo-label">{label}</div>
<div className="btn-demo-row">{children}</div>
</div>
);

View file

@ -353,6 +353,76 @@ h2.section-title {
text-transform: uppercase; text-transform: uppercase;
} }
/* Buttons section: decision guide + live demo grid */
.guide-grid {
display: flex;
flex-direction: column;
gap: 1px;
margin: 8px 0 28px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--line);
}
.guide-row {
display: grid;
grid-template-columns: 190px 1fr;
gap: 4px 18px;
align-items: baseline;
padding: 12px 16px;
background: #101219;
}
.guide-code {
font-family: var(--font-mono);
font-size: 13px;
color: #4cc2ff;
}
.guide-when {
font-size: 14px;
color: var(--text);
}
.guide-eg {
grid-column: 2;
font-size: 12.5px;
color: var(--text-dim);
}
.btn-demo {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.btn-demo-cell {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 12px;
background: linear-gradient(180deg, #16181f, #0e0f14);
border: 1px solid var(--line-strong);
}
.btn-demo-label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--text-dim);
text-transform: uppercase;
}
.btn-demo-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-height: 40px;
}
@media (max-width: 560px) {
.guide-row {
grid-template-columns: 1fr;
}
.guide-eg {
grid-column: 1;
}
}
/* ---- synth panel ---- */ /* ---- synth panel ---- */
.synth-rows { .synth-rows {
display: flex; display: flex;

View file

@ -1,5 +1,37 @@
# Changelog # 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 ## 1.1.1 — 2026-07-13
Second integration-feedback release — the friction that showed up once the 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. 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) (Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)

View file

@ -1,6 +1,6 @@
{ {
"name": "dreamknob", "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.", "description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
"license": "MIT", "license": "MIT",
"author": "Dreamodus Software Inc.", "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 { Meter, type MeterProps } from './skins/Meter';
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge'; export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { Gauge, type GaugeProps } from './skins/Gauge'; 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 { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow'; export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch'; export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';

View file

@ -19,6 +19,12 @@ export interface IndicatorLampProps {
readOnly?: boolean; readOnly?: boolean;
/** Caption beside the lamp. */ /** Caption beside the lamp. */
label?: string; 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'; labelPosition?: 'right' | 'left';
disabled?: boolean; disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */ /** Render a hidden form input carrying "on"/"off". */
@ -45,6 +51,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
blink = false, blink = false,
readOnly = false, readOnly = false,
label, label,
labelSize,
labelPosition = 'right', labelPosition = 'right',
disabled = false, disabled = false,
name, name,
@ -166,7 +173,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
<span <span
style={{ style={{
fontFamily: theme.fontUI, fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.9), fontSize: labelSize ?? Math.max(10, size * 0.9),
letterSpacing: '0.07em', letterSpacing: '0.07em',
textTransform: 'uppercase', textTransform: 'uppercase',
color: lit ? theme.text : theme.label, color: lit ? theme.text : theme.label,

View file

@ -15,6 +15,13 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
orientation?: 'vertical' | 'horizontal'; orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */ /** Travel length in px. Default: 160. */
length?: number; 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. */ /** Cross-axis size in px. Default: 34. */
breadth?: number; breadth?: number;
/** Number of LED segments. Default: 24. */ /** 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({ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
orientation = 'vertical', orientation = 'vertical',
length = 160, length = 160,
fill = false,
breadth = 34, breadth = 34,
segments = 24, segments = 24,
color: colorProp, color: colorProp,
@ -95,6 +103,12 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
const w = vertical ? breadth : length; const w = vertical ? breadth : length;
const h = vertical ? length : breadth; 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(() => { const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo); if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
if (color) return [{ upTo: 1, color }]; if (color) return [{ upTo: 1, color }];
@ -157,7 +171,8 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
width: w, width: fill && !vertical ? '100%' : w,
height: fill && vertical ? '100%' : undefined,
opacity: core.disabled ? 0.45 : undefined, opacity: core.disabled ? 0.45 : undefined,
...style, ...style,
}} }}
@ -218,14 +233,21 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
style={{ style={{
...knob.bind.style, ...knob.bind.style,
opacity: undefined, // the outer wrapper owns disabled dimming opacity: undefined, // the outer wrapper owns disabled dimming
width: w, width: ctrlW,
height: h, height: ctrlH,
display: 'inline-flex', display: fill ? 'flex' : 'inline-flex',
borderRadius: 6, borderRadius: 6,
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined), ...(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> <defs>
<filter <filter
id={glowId} id={glowId}

View file

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

View file

@ -6,8 +6,14 @@ export interface PushButtonProps {
pressed?: boolean; pressed?: boolean;
defaultPressed?: boolean; defaultPressed?: boolean;
onChange?: (pressed: boolean) => void; 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. */ /** LED / active color. */
color?: string; color?: string;
/** /**
@ -39,6 +45,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
pressed, pressed,
defaultPressed = false, defaultPressed = false,
onChange, onChange,
onClick,
mode = 'toggle', mode = 'toggle',
color, color,
led = true, led = true,
@ -59,16 +66,42 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
const isControlled = pressed !== undefined; const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed); const [internal, setInternal] = React.useState(defaultPressed);
const [focusVisible, setFocusVisible] = React.useState(false); 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) => { const set = (next: boolean) => {
if (next === on) return; if (next === latched) return;
if (!isControlled) setInternal(next); if (!isControlled) setInternal(next);
onChange?.(next); onChange?.(next);
}; };
const fire = () => {
onClick?.();
setFlash(true);
clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setFlash(false), 160);
};
const momentaryProps = 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) => { onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
@ -83,14 +116,14 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
if (e.key === ' ' || e.key === 'Enter') set(false); if (e.key === ' ' || e.key === 'Enter') set(false);
}, },
} }
: { onClick: () => set(!on) }; : { onClick: () => set(!latched) };
return ( return (
<button <button
ref={ref} ref={ref}
type="button" type="button"
disabled={disabled} disabled={disabled}
aria-pressed={on} aria-pressed={mode === 'action' ? undefined : latched}
aria-label={aria['aria-label']} aria-label={aria['aria-label']}
data-pressed={on ? '' : undefined} data-pressed={on ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined} data-focus-visible={focusVisible ? '' : undefined}
@ -148,8 +181,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: '55%', width: '55%',
height: Math.max(3, size * 0.09), height: Math.max(3, size * 0.09),
borderRadius: 3, borderRadius: 3,
background: on ? accent : 'rgba(255,255,255,0.09)', background: ledOn ? 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)', 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', transition: 'background 80ms, box-shadow 80ms',
}} }}
/> />
@ -164,8 +197,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: Math.max(4, size * 0.14), width: Math.max(4, size * 0.14),
height: Math.max(4, size * 0.14), height: Math.max(4, size * 0.14),
borderRadius: '50%', borderRadius: '50%',
background: on ? accent : 'rgba(255,255,255,0.09)', background: ledOn ? 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)', 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', transition: 'background 80ms, box-shadow 80ms',
}} }}
/> />
@ -177,7 +210,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
aria-hidden="true" aria-hidden="true"
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
color: on ? accent : theme.label, color: ledOn ? accent : theme.label,
transition: 'color 80ms', transition: 'color 80ms',
}} }}
> >
@ -187,8 +220,26 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
{children} {children}
</span> </span>
)} )}
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />} {name && <input type="hidden" name={name} value={latched ? 'on' : 'off'} readOnly />}
</button> </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} />;
},
);