Start
Gallery
+
Buttons
Synth
Playground
Guide
@@ -36,6 +38,7 @@ export const App: React.FC = () => (
+
diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx
index 2d4f086..ac44fa2 100644
--- a/apps/docs/src/sections/ApiDocs.tsx
+++ b/apps/docs/src/sections/ApiDocs.tsx
@@ -129,13 +129,14 @@ export const ApiDocs: React.FC = () => (
|
-
|
+
|
-
|
-
|
+
|
+
|
+
|
diff --git a/apps/docs/src/sections/Buttons.tsx b/apps/docs/src/sections/Buttons.tsx
new file mode 100644
index 0000000..02676f4
--- /dev/null
+++ b/apps/docs/src/sections/Buttons.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
+/** 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 (
+
+ );
+};
+
+const GuideRow: React.FC<{ code: string; when: string; eg: string }> = ({ code, when, eg }) => (
+
+ {code}
+ {when}
+ {eg}
+
+);
+
+const Demo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
+
+);
diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css
index 1ea24c2..b17d254 100644
--- a/apps/docs/src/styles.css
+++ b/apps/docs/src/styles.css
@@ -353,6 +353,76 @@ h2.section-title {
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-rows {
display: flex;
diff --git a/packages/dreamknob/CHANGELOG.md b/packages/dreamknob/CHANGELOG.md
index eb9e2ab..07ab660 100644
--- a/packages/dreamknob/CHANGELOG.md
+++ b/packages/dreamknob/CHANGELOG.md
@@ -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
diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md
index 4687401..0a03e50 100644
--- a/packages/dreamknob/README.md
+++ b/packages/dreamknob/README.md
@@ -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)
diff --git a/packages/dreamknob/package.json b/packages/dreamknob/package.json
index be13c56..b587886 100644
--- a/packages/dreamknob/package.json
+++ b/packages/dreamknob/package.json
@@ -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.",
diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts
index 7f58d1c..f030813 100644
--- a/packages/dreamknob/src/index.ts
+++ b/packages/dreamknob/src/index.ts
@@ -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';
diff --git a/packages/dreamknob/src/skins/IndicatorLamp.tsx b/packages/dreamknob/src/skins/IndicatorLamp.tsx
index 8154410..f02f48c 100644
--- a/packages/dreamknob/src/skins/IndicatorLamp.tsx
+++ b/packages/dreamknob/src/skins/IndicatorLamp.tsx
@@ -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
{
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 {
export const LEDFader = React.forwardRef(function LEDFader({
orientation = 'vertical',
length = 160,
+ fill = false,
breadth = 34,
segments = 24,
color: colorProp,
@@ -95,6 +103,12 @@ export const LEDFader = React.forwardRef(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(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(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),
}}
>
-
+
= ({
lamps,
size = 9,
+ labelSize,
labelPosition = 'right',
onToggle,
gap = 14,
@@ -80,7 +83,7 @@ export const LampRow: React.FC = ({
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(
pressed,
defaultPressed = false,
onChange,
+ onClick,
mode = 'toggle',
color,
led = true,
@@ -59,16 +66,42 @@ export const PushButton = React.forwardRef(
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>();
+ 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(
if (e.key === ' ' || e.key === 'Enter') set(false);
},
}
- : { onClick: () => set(!on) };
+ : { onClick: () => set(!latched) };
return (
(
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(
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(
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(
{children}
)}
- {name && }
+ {name && }
);
},
);
+
+export interface ButtonProps
+ extends Omit {
+ 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(
+ function Button({ led = 'dot', ...rest }, ref) {
+ return ;
+ },
+);
From 605c38af51216a4565605c3164c4e5959f119b51 Mon Sep 17 00:00:00 2001
From: Dreamodus
Date: Tue, 14 Jul 2026 06:36:39 -0700
Subject: [PATCH 5/7] docs: point the site install command at the v1.1.2
release tarball
The READMEs were bumped for 1.1.2 but the docs Hero/GettingStarted still
showed the v1.1.1 URL.
---
apps/docs/src/sections/GettingStarted.tsx | 4 ++--
apps/docs/src/sections/Hero.tsx | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/apps/docs/src/sections/GettingStarted.tsx b/apps/docs/src/sections/GettingStarted.tsx
index f973723..895817d 100644
--- a/apps/docs/src/sections/GettingStarted.tsx
+++ b/apps/docs/src/sections/GettingStarted.tsx
@@ -30,12 +30,12 @@ export const GettingStarted: React.FC = () => (
) · peer deps: react >= 18, react-dom >= 18`}
+ code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.2/dreamknob-1.1.2.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`}
/>
Installs straight from the{' '}
- v1.1.1 release
+ v1.1.2 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 f71020d..f66c4b4 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.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
{
- navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz');
+ navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.2/dreamknob-1.1.2.tgz');
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
From d183d7d16b637d0c60c93e0dc546289937ed2795 Mon Sep 17 00:00:00 2001
From: Dreamodus
Date: Tue, 14 Jul 2026 11:16:44 -0700
Subject: [PATCH 6/7] v1.1.3: Radix-composable buttons, meter
ballistics/dBFS/loudness, PanKnob
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- PushButton/Button forward native button props (...rest) and compose their
own event handlers with consumer/Radix-injected ones, so a Button works as
a Radix `asChild` trigger (dropdowns/tooltips/popovers). Prop types now
extend React.ButtonHTMLAttributes.
- Meter gains opt-in metering: `ballistics` (PPM attack/decay), `peakDecay`
(peak-hold fall tail), `scale="db"` (feed amplitude → dBFS with reference
lines; min/max default -60/0), and `integrated` (windowed LUFS-ish loudness
readout + marker). New rAF engine in meterEngine.ts with ampToDb/dbToAmp
helpers; default behavior unchanged.
- New `PanKnob` — bipolar stereo pan: fills from center, center detent,
L/C/R readout, -50..50 default.
- Docs: ApiDocs rows + recipes (dBFS meter, pan strip, Button-as-trigger),
gallery cards (dBFS meter, Pan) + a Regenerate Button in the panel.
CHANGELOG 1.1.3; version 1.1.3; install URLs bumped.
Library + docs typecheck and build; 46 tests pass.
- Meter dBFS readout: muted-grey unit labels (LUFS matched to dB), value-first
layout in an aligned 2-column grid so peak/loudness decimals line up, and
ghost pad digits removed — the loudness row no longer looks like a stray tag.
- Action buttons (Button / PushButton mode="action") no longer show an LED
by default — an action has no state to indicate. led defaults to 'strip' for
toggle/momentary and false for action; led="dot"/"strip" opts back in.
- Meter dBFS readout: units (dB / LUFS) sit centered directly under each
value; grid alignment tidied and ghost pad digits removed. Docs: dBFS-meter
card moved next to the Meters card (same component) + a horizontal example.
---
README.md | 11 +-
apps/docs/src/sections/ApiDocs.tsx | 24 +-
apps/docs/src/sections/Buttons.tsx | 9 +-
apps/docs/src/sections/Gallery.tsx | 70 +++++-
apps/docs/src/sections/GettingStarted.tsx | 4 +-
apps/docs/src/sections/Hero.tsx | 4 +-
packages/dreamknob/CHANGELOG.md | 40 ++++
packages/dreamknob/README.md | 13 +-
packages/dreamknob/package.json | 2 +-
packages/dreamknob/src/index.ts | 2 +
packages/dreamknob/src/skins/Meter.tsx | 206 +++++++++++++++---
packages/dreamknob/src/skins/PanKnob.tsx | 60 +++++
packages/dreamknob/src/skins/PushButton.tsx | 67 ++++--
.../dreamknob/src/skins/meterEngine.test.ts | 45 ++++
packages/dreamknob/src/skins/meterEngine.ts | 167 ++++++++++++++
15 files changed, 644 insertions(+), 80 deletions(-)
create mode 100644 packages/dreamknob/src/skins/PanKnob.tsx
create mode 100644 packages/dreamknob/src/skins/meterEngine.test.ts
create mode 100644 packages/dreamknob/src/skins/meterEngine.ts
diff --git a/README.md b/README.md
index 04cf677..efeb404 100644
--- a/README.md
+++ b/README.md
@@ -35,10 +35,11 @@ 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`, `MeterBridge`,
- `Gauge`, `XYPad`, `PushButton`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`,
- `SegmentSwitch`, `TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`,
- plus `LabeledField`/`Rack` layout — every color themeable per instance.
+ `SteppedKnob`, `PanKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`
+ (PPM ballistics · dBFS · loudness), `MeterBridge`, `Gauge`, `XYPad`, `PushButton`,
+ `Button`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`, `SegmentSwitch`,
+ `TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`, plus
+ `LabeledField`/`Rack` layout — every color themeable per instance.
- **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
`useKnob`.
@@ -50,7 +51,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.1.2/dreamknob-1.1.2.tgz
+pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.3/dreamknob-1.1.3.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 ac44fa2..5df8acb 100644
--- a/apps/docs/src/sections/ApiDocs.tsx
+++ b/apps/docs/src/sections/ApiDocs.tsx
@@ -128,14 +128,15 @@ export const ApiDocs: React.FC = () => (
+
-
+
-
-
+
+
@@ -311,12 +312,25 @@ function MyKnob() {
-// A declarative channel strip
+// A declarative channel strip — now with a pan knob
+
- `}
+
+
+// A pro dBFS meter — feed amplitude, get ballistics + peak-decay + loudness
+
+
+// A Button as a Radix dropdown trigger (it forwards + composes handlers)
+
+
+ Add channel
+
+ {/* … */}
+ `}
/>
Precision & number handling
diff --git a/apps/docs/src/sections/Buttons.tsx b/apps/docs/src/sections/Buttons.tsx
index 02676f4..1cf6753 100644
--- a/apps/docs/src/sections/Buttons.tsx
+++ b/apps/docs/src/sections/Buttons.tsx
@@ -82,7 +82,7 @@ export const Buttons: React.FC = () => {
✕}
onClick={() => setFired(n => n + 1)}
aria-label="Clear"
@@ -131,9 +131,10 @@ export const Buttons: React.FC = () => {
<Button> is <PushButton mode="action"> —
- it never latches, shows a press-down and a brief LED flash, and calls{' '}
- onClick. led="dot" gives the corner-dot
- look; led={false} a plain button.
+ it never latches, shows a press-down, and calls onClick. It has{' '}
+ no LED by default — an action has no state to indicate, unlike a
+ toggle. Add led="dot" for a corner dot that flashes on
+ press, or led="strip" for the full strip.
diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx
index 050b87f..aefbe68 100644
--- a/apps/docs/src/sections/Gallery.tsx
+++ b/apps/docs/src/sections/Gallery.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import {
AlphaDisplay,
Arc,
+ Button,
DreamknobProvider,
Fader,
FlatKnob,
@@ -18,6 +19,7 @@ import {
Meter,
MeterBridge,
NeonKnob,
+ PanKnob,
Pointer,
PushButton,
Rack,
@@ -280,7 +282,10 @@ const PanelDemo: React.FC = () => {
-
+
+
+ ↻
+
{
);
};
+const DbMeterDemo: React.FC = () => {
+ // Drive with linear amplitude; the meter converts to dBFS internally.
+ const [amp, setAmp] = React.useState<[number, number]>([0.3, 0.28]);
+ React.useEffect(() => {
+ let t = 0;
+ const id = setInterval(() => {
+ t += 0.11;
+ const spike = Math.random() < 0.05 ? 0.4 : 0;
+ setAmp([
+ Math.max(0.001, Math.min(1.2, 0.34 + Math.sin(t) * 0.22 + Math.random() * 0.16 + spike)),
+ Math.max(0.001, Math.min(1.2, 0.32 + Math.sin(t * 1.25 + 1) * 0.22 + Math.random() * 0.16 + spike)),
+ ]);
+ }, 90);
+ return () => clearInterval(id);
+ }, []);
+ return (
+
+
+
+
+ );
+};
+
+const PanDemo: React.FC = () => (
+
+);
+
const ImageKnobDemo: React.FC = () => {
const src = useGeneratedStrip();
if (!src) return null;
@@ -509,6 +569,10 @@ export const Gallery: React.FC = () => (
+
+
+
+
@@ -536,6 +600,10 @@ 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.3/dreamknob-1.1.3.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`}
/>
Installs straight from the{' '}
- v1.1.2 release
+ v1.1.3 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 f66c4b4..2808d9c 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.1.2/dreamknob-1.1.2.tgz
+
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.3/dreamknob-1.1.3.tgz
{
- navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.2/dreamknob-1.1.2.tgz');
+ navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.3/dreamknob-1.1.3.tgz');
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
diff --git a/packages/dreamknob/CHANGELOG.md b/packages/dreamknob/CHANGELOG.md
index 07ab660..f336777 100644
--- a/packages/dreamknob/CHANGELOG.md
+++ b/packages/dreamknob/CHANGELOG.md
@@ -1,5 +1,45 @@
# Changelog
+## 1.1.3 — 2026-07-14
+
+Fourth integration-feedback release — a composability fix for buttons, a much
+smarter meter, and the pan control the mixer was missing.
+
+### Buttons
+- **Action buttons no longer show an LED by default.** `Button` (and
+ `PushButton mode="action"`) are plain by default — an action has no state to
+ indicate. `led` defaults to `'strip'` for `toggle`/`momentary` (which do have
+ a state) and to `false` for `action`; pass `led="dot"` / `led="strip"` to opt
+ a command button back in.
+
+### Buttons compose as Radix triggers
+- `PushButton` / `Button` now forward unknown props (`...rest`) to the
+ underlying `` and **compose** their own event handlers with any the
+ consumer (or a Radix `asChild` slot) injects. A `Button` can now be a
+ dropdown / tooltip / popover trigger: `onPointerDown`, `onKeyDown`,
+ `data-state`, `aria-expanded` etc. all reach the DOM and fire alongside the
+ button's own press behavior. The prop types now extend the native button
+ attributes.
+
+### Meter — ballistics, dBFS, loudness
+- **`ballistics`** — PPM-style attack/decay smoothing of the displayed level
+ (`true` ≈ 5 ms attack / 350 ms decay, or `{ attack, decay }` in ms).
+- **`peakDecay`** — the peak-hold marker falls with a decay tail (normalized
+ units/second) after `peakHold` instead of snapping back.
+- **`scale="db"`** — feed linear amplitude (0..1) and the meter displays dBFS;
+ `min`/`max` default to -60 / 0 dB and faint reference gridlines are drawn
+ (`showScale` / `referenceTicks` to tune). New `ampToDb` / `dbToAmp` helpers.
+- **`integrated`** — a time-windowed loudness estimate (LUFS-ish on the dB
+ scale — a windowed mean of channel power, not a full BS.1770 measurement),
+ shown as a secondary readout and a marker line (`integrationWindow`,
+ `loudnessColor`).
+- All of the above are opt-in; the default meter behaves exactly as before.
+
+### New component
+- **`PanKnob`** — a bipolar stereo-pan knob: fills from center, snaps to a
+ center detent, reads out `L` / `C` / `R`. Defaults to a −50…50 range
+ centered at 0.
+
## 1.1.2 — 2026-07-14
Third integration-feedback release — a general action button, responsive
diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md
index 0a03e50..d6c5f61 100644
--- a/packages/dreamknob/README.md
+++ b/packages/dreamknob/README.md
@@ -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.2/dreamknob-1.1.2.tgz
+pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.3/dreamknob-1.1.3.tgz
```
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
@@ -95,14 +95,16 @@ import {
LEDKnob, // segmented LED ring + true seven-segment readout
NeonKnob, // glowing arc for dark UIs
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
+ PanKnob, // bipolar stereo pan — center detent, L/C/R readout
Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
- Meter, // read-only LED level meter — mono or multi-channel (value={[l, r]})
+ Meter, // LED level meter — multi-channel, PPM ballistics, dBFS scale, loudness
MeterBridge, // labeled multi-strip meter block with per-channel peak text
Gauge, // read-only radial arc meter (round sibling of Meter) for CPU/RAM/load
XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
- PushButton, // panel button with LED strip or corner dot (toggle or momentary)
+ PushButton, // latch/hold panel button — LED strip or corner dot (toggle/momentary)
+ Button, // click-to-fire command button (no LED by default; led="dot" opt-in)
IndicatorLamp, // pressable LED lamp — the hardware checkbox (blink, momentary)
LampRow, // row of indicator lamps with console-print captions
ToggleSwitch, // rocker/slider boolean — the knob slides OFF↔ON
@@ -118,7 +120,10 @@ import {
Knob skins also take `sublabel` (a second caption line) and `labelSize` (caption
size independent of the dial); `Meter` and `Fader` take `fill` to stretch to their
-container.
+container. `Meter` adds `ballistics` (PPM attack/decay), `peakDecay`, `scale="db"`
+(feed amplitude, show dBFS) and `integrated` (windowed loudness) — all opt-in.
+`PushButton`/`Button` forward native button props and compose handlers, so a
+`Button` works as a Radix `asChild` trigger.
## Theming
diff --git a/packages/dreamknob/package.json b/packages/dreamknob/package.json
index b587886..4b08270 100644
--- a/packages/dreamknob/package.json
+++ b/packages/dreamknob/package.json
@@ -1,6 +1,6 @@
{
"name": "dreamknob",
- "version": "1.1.2",
+ "version": "1.1.3",
"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.",
diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts
index f030813..712cdc1 100644
--- a/packages/dreamknob/src/index.ts
+++ b/packages/dreamknob/src/index.ts
@@ -64,10 +64,12 @@ export { VintageKnob, type VintageKnobProps } from './skins/VintageKnob';
export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob';
export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
+export { PanKnob, type PanKnobProps } from './skins/PanKnob';
export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter';
+export { ampToDb, dbToAmp, type Ballistics } from './skins/meterEngine';
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { Gauge, type GaugeProps } from './skins/Gauge';
export { PushButton, type PushButtonProps, Button, type ButtonProps } from './skins/PushButton';
diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx
index ac9fb3b..1f51a85 100644
--- a/packages/dreamknob/src/skins/Meter.tsx
+++ b/packages/dreamknob/src/skins/Meter.tsx
@@ -4,16 +4,25 @@ import type { Taper } from '../core/math';
import { useKnobTheme } from '../core/theme';
import type { LEDFaderZone } from './LEDFader';
import { SegmentDisplay } from '../digital/SegmentDisplay';
+import { ampToDb, useMeterEngine, type Ballistics } from './meterEngine';
export interface MeterProps {
/**
* 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.
+ * meter (e.g. `[l, r]` for stereo). The meter is read-only. With
+ * `scale="db"` these are linear amplitudes (0..1); otherwise they are in the
+ * `min`..`max` domain directly.
*/
value: number | readonly number[];
min?: number;
max?: number;
taper?: Taper;
+ /**
+ * 'linear' (default) shows `value` in the `min`..`max` domain. 'db' treats
+ * `value`/`clipValue` as linear amplitude and displays dBFS; `min`/`max`
+ * then default to -60 / 0 dB.
+ */
+ scale?: 'linear' | 'db';
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
@@ -35,16 +44,42 @@ export interface MeterProps {
zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments. Default: 0.1. */
offOpacity?: number;
+ /**
+ * Attack/decay ballistics — smooth the displayed level like a PPM meter.
+ * `true` uses ~5 ms attack / ~350 ms decay; pass `{ attack, decay }` (ms) to
+ * tune. Default: off (the level follows the value directly).
+ */
+ ballistics?: boolean | Ballistics;
/**
* Peak hold time in ms — the highest recent segment stays lit and falls
* back after this long. `false` disables. Default: 1200.
*/
peakHold?: number | false;
+ /**
+ * After `peakHold`, let the peak marker fall at this many normalized units
+ * per second (a decay tail) instead of snapping back. Requires the rAF
+ * engine (also enabled by `ballistics`/`integrated`).
+ */
+ peakDecay?: number;
/** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string;
+ /**
+ * Show a time-windowed loudness estimate (LUFS-ish on the 'db' scale — a
+ * mean of channel power over `integrationWindow`, not a full BS.1770
+ * measurement). Rendered as a small secondary readout + a marker line.
+ */
+ integrated?: boolean;
+ /** Loudness integration window in ms. Default: 3000. */
+ integrationWindow?: number;
+ /** Color for the integrated-loudness readout and marker line. */
+ loudnessColor?: string;
+ /** Faint reference gridlines across the meter. Default: on for scale='db'. */
+ showScale?: boolean;
+ /** Reference marks in the value domain. Defaults to dBFS marks for 'db'. */
+ referenceTicks?: readonly number[];
/** Dedicated clip LED at the hot end (shared across channels). Default: true. */
showClip?: boolean;
- /** Value at/above which the clip LED lights. Default: `max`. */
+ /** Value (in the display domain — dB for scale='db') at/above which it lights. Default: `max`. */
clipThreshold?: number;
/**
* Signal(s) used for clip detection when different from the displayed
@@ -78,15 +113,20 @@ export interface MeterProps {
const asArray = (v: number | readonly number[]): readonly number[] =>
Array.isArray(v) ? v : [v as number];
+const DEFAULT_DB_TICKS = [0, -6, -12, -18, -24, -36, -48];
+
/**
* 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.
+ * Optional PPM ballistics, a peak-decay tail, a dBFS scale and a windowed
+ * loudness readout (all opt-in).
*/
export const Meter: React.FC = ({
value,
- min = 0,
- max = 100,
+ min: minProp,
+ max: maxProp,
taper = linearTaper,
+ scale = 'linear',
orientation = 'vertical',
length = 160,
fill = false,
@@ -96,8 +136,15 @@ export const Meter: React.FC = ({
color,
zones,
offOpacity = 0.1,
+ ballistics,
peakHold = 1200,
+ peakDecay,
peakColor,
+ integrated = false,
+ integrationWindow = 3000,
+ loudnessColor,
+ showScale,
+ referenceTicks,
showClip = true,
clipThreshold,
clipValue,
@@ -117,26 +164,49 @@ export const Meter: React.FC = ({
}) => {
const theme = useKnobTheme();
const vertical = orientation === 'vertical';
+ const dbScale = scale === 'db';
+ const min = minProp ?? (dbScale ? -60 : 0);
+ const max = maxProp ?? (dbScale ? 0 : 100);
+
const values = asArray(value);
const channels = values.length;
- const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1));
+ // Value-domain per channel (dB for scale='db', else the value itself).
+ const domain = values.map(v => (dbScale ? ampToDb(v) : v));
+ const targetNs = domain.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1));
- // Peaks: per channel, falling back together after `peakHold` ms of no rise.
- const [peaks, setPeaks] = React.useState(ns);
+ // rAF engine (ballistics / peak-decay / loudness). Null on the legacy path.
+ const animated = !!ballistics || peakDecay != null || integrated;
+ const engine = useMeterEngine(targetNs, domain, {
+ enabled: animated,
+ ballistics,
+ peakHold,
+ peakDecay: peakDecay ?? null,
+ integrated,
+ integrationWindow,
+ scale,
+ });
+ const levels = animated && engine ? engine.levels : targetNs;
+
+ // Legacy timer-based peaks (only when the engine is off).
+ const [legacyPeaks, setLegacyPeaks] = React.useState(targetNs);
React.useEffect(() => {
- if (peakHold === false) return;
- 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));
+ if (animated || peakHold === false) return;
+ setLegacyPeaks(prev => {
+ const rose = targetNs.map((n, i) => n >= (prev[i] ?? 0));
+ if (rose.every(Boolean) || prev.length !== targetNs.length)
+ return targetNs.map((n, i) => Math.max(n, prev[i] ?? 0));
+ return prev.map((p, i) => Math.max(p, targetNs[i] ?? 0));
});
- const t = setTimeout(() => setPeaks(ns), peakHold);
+ const t = setTimeout(() => setLegacyPeaks(targetNs), peakHold);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [JSON.stringify(ns), peakHold]);
+ }, [JSON.stringify(targetNs), peakHold, animated]);
+ const peaks = animated && engine ? engine.peaks : legacyPeaks;
// Clip: latch when any channel's raw (unclamped) signal reaches threshold.
- const clipSignals = clipValue !== undefined ? asArray(clipValue) : values;
+ const clipSignals = (clipValue !== undefined ? asArray(clipValue) : values).map(v =>
+ dbScale ? ampToDb(v) : v,
+ );
const hottest = Math.max(...clipSignals);
const [clipped, setClipped] = React.useState(false);
const clipTimer = React.useRef>();
@@ -182,14 +252,20 @@ export const Meter: React.FC = ({
const clipLed = 7;
const clipSpan = showClip ? clipLed + 3 : 0;
- const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments);
+ const innerLen = along - pad * 2 - clipSpan;
+ const slot = Math.max(1, innerLen / segments);
const gap = Math.min(2.5, slot * 0.35);
const segSize = Math.max(0.5, slot - gap);
+ // Position (px along the travel axis) of a normalized value, measured from
+ // the cold end.
+ const posOf = (nrm: number) =>
+ vertical ? along - pad - nrm * innerLen : pad + nrm * innerLen;
+
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 lit = Math.round((levels[c] ?? 0) * segments);
const peakIdx =
peakHold !== false && (peaks[c] ?? 0) > 0
? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1)
@@ -198,8 +274,8 @@ export const Meter: React.FC = ({
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 };
+ const barFill = isPeak ? (peakColor ?? zone) : zone;
+ const common = { rx: 1.2, fill: barFill, opacity: on ? 1 : offOpacity };
bars.push(
vertical ? (
= ({
}
}
- const hottestShown = Math.max(...values.map(v => clamp(v, min, max)));
+ // Reference gridlines (value domain -> position across the bar area).
+ const ticks = referenceTicks ?? (dbScale ? DEFAULT_DB_TICKS : []);
+ const drawScale = (showScale ?? dbScale) && ticks.length > 0;
+ const scaleLines: React.ReactNode[] = [];
+ if (drawScale) {
+ for (const tv of ticks) {
+ if (tv < min || tv > max) continue;
+ const nrm = clamp(taper.toNormalized(clamp(tv, min, max), min, max), 0, 1);
+ const p = posOf(nrm);
+ scaleLines.push(
+ vertical ? (
+
+ ) : (
+
+ ),
+ );
+ }
+ }
+
+ // Integrated-loudness marker line.
+ const loud = loudnessColor ?? theme.zoneWarn;
+ let loudnessLine: React.ReactNode = null;
+ if (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated)) {
+ const nrm = clamp(taper.toNormalized(clamp(engine.integrated, min, max), min, max), 0, 1);
+ if (nrm > 0.001) {
+ const p = posOf(nrm);
+ loudnessLine = vertical ? (
+
+ ) : (
+
+ );
+ }
+ }
+
+ const shownDomain = domain.length ? domain.map(d => clamp(d, min, max)) : [min];
+ const hottestShown = Math.max(...shownDomain);
+ const readoutColor = zoneFor(Math.max(...levels, 0));
return (
= ({
...style,
}}
>
- {showValue && (
-
-
- {unit && (
-
- {unit}
-
+ {(showValue || (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated))) && (
+ // Each readout is its own stack: the value with its unit centered
+ // directly underneath — peak above, loudness below.
+
+ {showValue && (
+
+
+ {unit && (
+
+ {unit}
+
+ )}
+
+ )}
+ {integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated) && (
+
+
+
+ {dbScale ? 'LUFS' : 'AVG'}
+
+
)}
)}
@@ -274,7 +408,9 @@ export const Meter: React.FC
= ({
aria-hidden="true"
>
+ {scaleLines}
{bars}
+ {loudnessLine}
{showClip && (
{
+ /**
+ * Readout precision for the L/R amount. Default: 0 (whole numbers).
+ * The formatter shows `C` at center, `L` left, `R` right.
+ */
+ panDecimals?: number;
+}
+
+/**
+ * A stereo pan control — a bipolar knob that fills from the center, snaps to a
+ * center detent, and reads out `L` / `C` / `R`. Defaults to a −50…50
+ * range centered at 0; pass `min`/`max` for other conventions (e.g. −1…1).
+ */
+export const PanKnob = React.forwardRef(function PanKnob(
+ {
+ min = -50,
+ max = 50,
+ defaultValue,
+ origin,
+ detents,
+ detentSize,
+ format,
+ label = 'Pan',
+ panDecimals = 0,
+ ...rest
+ },
+ ref,
+) {
+ const center = (min + max) / 2;
+ const panFormat = React.useMemo(
+ () =>
+ format ??
+ ((v: number) => {
+ const amount = Math.abs(v - center);
+ if (amount < Math.pow(10, -panDecimals) / 2) return 'C';
+ const n = amount.toFixed(panDecimals);
+ return v < center ? `L${n}` : `R${n}`;
+ }),
+ [format, center, panDecimals],
+ );
+
+ return (
+
+ );
+});
diff --git a/packages/dreamknob/src/skins/PushButton.tsx b/packages/dreamknob/src/skins/PushButton.tsx
index 51327c0..c771e2a 100644
--- a/packages/dreamknob/src/skins/PushButton.tsx
+++ b/packages/dreamknob/src/skins/PushButton.tsx
@@ -1,7 +1,16 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
-export interface PushButtonProps {
+/** Call every handler in order (skipping undefined) — for merging our own
+ * handlers with consumer/Radix-injected ones on the same event. */
+const chain =
+ (...fns: Array<((e: E) => void) | undefined>) =>
+ (e: E) => {
+ for (const fn of fns) if (typeof fn === 'function') fn(e);
+ };
+
+export interface PushButtonProps
+ extends Omit, 'onChange' | 'onClick'> {
/** Controlled pressed state. Pair with `onChange`. */
pressed?: boolean;
defaultPressed?: boolean;
@@ -17,9 +26,10 @@ export interface PushButtonProps {
/** LED / active color. */
color?: string;
/**
- * LED style: 'strip' (default, above the caption), 'dot' (small corner
- * dot — scales down better for icon-only toggles), or false for none.
- * `true` is an alias for 'strip'.
+ * LED style: 'strip' (above the caption), 'dot' (small corner dot — scales
+ * down better for icon-only toggles), or false for none. `true` is an alias
+ * for 'strip'. Defaults to 'strip' for toggle/momentary (they indicate a
+ * state) and to false for `mode="action"` (a command has no state to show).
*/
led?: boolean | 'strip' | 'dot';
/** Button height in px. Default: 36. */
@@ -35,7 +45,6 @@ export interface PushButtonProps {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
- 'aria-label'?: string;
}
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
@@ -48,7 +57,7 @@ export const PushButton = React.forwardRef(
onClick,
mode = 'toggle',
color,
- led = true,
+ led: ledProp,
size = 36,
width,
disabled = false,
@@ -57,12 +66,15 @@ export const PushButton = React.forwardRef(
children,
className,
style,
- ...aria
+ ...rest
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
+ // The LED indicates a persistent/held state, so it defaults on for
+ // toggle/momentary but off for `action` (a command has no state).
+ const led = ledProp ?? (mode === 'action' ? false : true);
const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed);
const [focusVisible, setFocusVisible] = React.useState(false);
@@ -118,25 +130,38 @@ export const PushButton = React.forwardRef(
}
: { onClick: () => set(!latched) };
+ // Merge our own handlers with any consumer/Radix-injected ones of the same
+ // name (from {...rest}), so the button works as a Radix `asChild` trigger:
+ // the injected onPointerDown/onKeyDown/data-state/aria-* all reach the DOM.
+ const restHandlers = rest as Record void) | undefined>;
+ const composedHandlers = Object.fromEntries(
+ Object.entries(momentaryProps).map(([k, fn]) => [
+ k,
+ chain(restHandlers[k], fn as (e: unknown) => void),
+ ]),
+ );
+ const handleFocus = chain(restHandlers.onFocus, (e: React.FocusEvent) => {
+ try {
+ setFocusVisible(e.currentTarget.matches(':focus-visible'));
+ } catch {
+ setFocusVisible(true);
+ }
+ });
+ const handleBlur = chain(restHandlers.onBlur, () => setFocusVisible(false));
+
return (
{
- try {
- setFocusVisible(e.currentTarget.matches(':focus-visible'));
- } catch {
- setFocusVisible(true);
- }
- }}
- onBlur={() => setFocusVisible(false)}
- {...momentaryProps}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ {...composedHandlers}
style={{
position: 'relative',
display: 'inline-flex',
@@ -234,12 +259,12 @@ export interface ButtonProps
/**
* 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.
+ * Add, Apply, Refresh). It's `PushButton` locked to `mode="action"`. Plain by
+ * default (no LED — an action has no state); pass `led="dot"` for a corner dot
+ * that flashes on press, or `led="strip"` for the full strip.
*/
export const Button = React.forwardRef(
- function Button({ led = 'dot', ...rest }, ref) {
+ function Button({ led, ...rest }, ref) {
return ;
},
);
diff --git a/packages/dreamknob/src/skins/meterEngine.test.ts b/packages/dreamknob/src/skins/meterEngine.test.ts
new file mode 100644
index 0000000..3b655fe
--- /dev/null
+++ b/packages/dreamknob/src/skins/meterEngine.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { ampToDb, dbToAmp, smoothingCoeff } from './meterEngine';
+
+describe('ampToDb', () => {
+ it('maps unity amplitude to 0 dBFS', () => {
+ expect(ampToDb(1)).toBeCloseTo(0, 6);
+ });
+
+ it('halving amplitude is about -6 dB', () => {
+ expect(ampToDb(0.5)).toBeCloseTo(-6.0206, 3);
+ });
+
+ it('a tenth is -20 dB', () => {
+ expect(ampToDb(0.1)).toBeCloseTo(-20, 6);
+ });
+
+ it('silence is -Infinity', () => {
+ expect(ampToDb(0)).toBe(-Infinity);
+ expect(ampToDb(-0.2)).toBe(-Infinity);
+ });
+
+ it('round-trips through dbToAmp', () => {
+ for (const amp of [1, 0.5, 0.25, 0.031]) {
+ expect(dbToAmp(ampToDb(amp))).toBeCloseTo(amp, 6);
+ }
+ });
+});
+
+describe('smoothingCoeff', () => {
+ it('is in (0,1) and rises with dt', () => {
+ const a = smoothingCoeff(5, 100);
+ const b = smoothingCoeff(50, 100);
+ expect(a).toBeGreaterThan(0);
+ expect(b).toBeLessThan(1);
+ expect(b).toBeGreaterThan(a);
+ });
+
+ it('reaches ~63% of the way after one time constant', () => {
+ expect(smoothingCoeff(100, 100)).toBeCloseTo(1 - Math.exp(-1), 6);
+ });
+
+ it('a shorter tau (faster attack) moves further per step', () => {
+ expect(smoothingCoeff(10, 5)).toBeGreaterThan(smoothingCoeff(10, 350));
+ });
+});
diff --git a/packages/dreamknob/src/skins/meterEngine.ts b/packages/dreamknob/src/skins/meterEngine.ts
new file mode 100644
index 0000000..909fe93
--- /dev/null
+++ b/packages/dreamknob/src/skins/meterEngine.ts
@@ -0,0 +1,167 @@
+import * as React from 'react';
+
+/** Linear amplitude (0..1+) to dBFS. 0 (or below) maps to -Infinity. */
+export const ampToDb = (amp: number): number => (amp <= 0 ? -Infinity : 20 * Math.log10(amp));
+
+/** dBFS back to linear amplitude. */
+export const dbToAmp = (db: number): number => Math.pow(10, db / 20);
+
+/** One-pole smoothing coefficient for a time constant `tau` (ms) over `dt` (ms). */
+export const smoothingCoeff = (dt: number, tau: number): number =>
+ 1 - Math.exp(-dt / Math.max(1, tau));
+
+export interface Ballistics {
+ /** Rise time constant in ms (fast). */
+ attack?: number;
+ /** Fall time constant in ms (slow). */
+ decay?: number;
+}
+
+export interface MeterEngineOptions {
+ /** Run the rAF engine at all. When false the hook returns null (legacy path). */
+ enabled: boolean;
+ /** Smooth the displayed level with attack/decay ballistics. */
+ ballistics?: boolean | Ballistics;
+ /** Peak-hold time in ms before the peak marker starts to fall. */
+ peakHold?: number | false;
+ /** Peak fall rate in normalized units per second once the hold expires. */
+ peakDecay?: number | null;
+ /** Compute a time-windowed loudness (LUFS-ish) from `rawDomain`. */
+ integrated?: boolean;
+ /** Loudness integration window in ms. */
+ integrationWindow?: number;
+ /** 'db' integrates power in the log domain; 'linear' uses RMS. */
+ scale?: 'linear' | 'db';
+}
+
+export interface MeterEngineState {
+ /** Ballistics-smoothed (or raw) normalized levels, per channel. */
+ levels: readonly number[];
+ /** Peak-hold markers, per channel, in normalized [0,1]. */
+ peaks: readonly number[];
+ /** Windowed loudness in the value domain (dB for 'db' scale), or null. */
+ integrated: number | null;
+}
+
+const DEFAULT_ATTACK = 5;
+const DEFAULT_DECAY = 350;
+
+/**
+ * A requestAnimationFrame meter engine: attack/decay ballistics, peak-hold with
+ * a decay tail, and an optional time-windowed loudness estimate. Returns null
+ * when disabled so the caller can keep its cheap legacy (timer) path.
+ *
+ * `targetNs` are the instantaneous normalized levels; `rawDomain` are the same
+ * channels in value space (dB or linear) for loudness integration.
+ */
+export const useMeterEngine = (
+ targetNs: readonly number[],
+ rawDomain: readonly number[],
+ opts: MeterEngineOptions,
+): MeterEngineState | null => {
+ const {
+ enabled,
+ ballistics,
+ peakHold = 1200,
+ peakDecay = null,
+ integrated = false,
+ integrationWindow = 3000,
+ scale = 'linear',
+ } = opts;
+
+ const useBallistics = !!ballistics;
+ const attack = (typeof ballistics === 'object' && ballistics?.attack) || DEFAULT_ATTACK;
+ const decay = (typeof ballistics === 'object' && ballistics?.decay) || DEFAULT_DECAY;
+
+ const targetRef = React.useRef(targetNs);
+ targetRef.current = targetNs;
+ const rawRef = React.useRef(rawDomain);
+ rawRef.current = rawDomain;
+
+ const [state, setState] = React.useState(() => ({
+ levels: targetNs,
+ peaks: targetNs,
+ integrated: null,
+ }));
+
+ // Per-channel timestamp of the last time the peak was refreshed.
+ const holdAt = React.useRef([]);
+ // Ring buffer of {t, power} for loudness integration.
+ const powerBuf = React.useRef<{ t: number; p: number }[]>([]);
+
+ React.useEffect(() => {
+ if (!enabled) return;
+ let raf = 0;
+ let last =
+ typeof performance !== 'undefined' && performance.now ? performance.now() : 0;
+
+ const tick = (now: number) => {
+ const dt = Math.min(100, now - last);
+ last = now;
+ const tgt = targetRef.current;
+ const raw = rawRef.current;
+
+ setState(prev => {
+ const n = tgt.length;
+ const prevLevels = prev.levels.length === n ? prev.levels : tgt;
+ const prevPeaks = prev.peaks.length === n ? prev.peaks : tgt;
+
+ const levels = tgt.map((t, i) => {
+ const l = prevLevels[i] ?? 0;
+ if (!useBallistics) return t;
+ const tau = t >= l ? attack : decay;
+ return l + (t - l) * smoothingCoeff(dt, tau);
+ });
+
+ const holdMs = peakHold === false ? Infinity : peakHold;
+ const peaks = levels.map((lvl, i) => {
+ const p = prevPeaks[i] ?? 0;
+ if (lvl >= p) {
+ holdAt.current[i] = now;
+ return lvl;
+ }
+ const heldFor = now - (holdAt.current[i] ?? now);
+ if (heldFor < holdMs) return p;
+ if (peakDecay == null) return lvl; // no decay tail — track the level
+ return Math.max(lvl, p - (peakDecay / 1000) * dt);
+ });
+
+ let integratedOut = prev.integrated;
+ if (integrated) {
+ const power =
+ raw.length > 0
+ ? raw.reduce(
+ (s, d) => s + (scale === 'db' ? Math.pow(10, d / 10) : d * d),
+ 0,
+ ) / raw.length
+ : 0;
+ const buf = powerBuf.current;
+ buf.push({ t: now, p: power });
+ while (buf.length > 1 && now - buf[0].t > integrationWindow) buf.shift();
+ const meanP = buf.reduce((s, b) => s + b.p, 0) / (buf.length || 1);
+ integratedOut =
+ scale === 'db' ? 10 * Math.log10(Math.max(meanP, 1e-12)) : Math.sqrt(meanP);
+ }
+
+ return { levels, peaks, integrated: integratedOut };
+ });
+
+ raf = requestAnimationFrame(tick);
+ };
+
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [
+ enabled,
+ useBallistics,
+ attack,
+ decay,
+ peakHold,
+ peakDecay,
+ integrated,
+ integrationWindow,
+ scale,
+ ]);
+
+ return enabled ? state : null;
+};
From 8b462dfb83ebe48968c8881822c5e7c67fc12e4f Mon Sep 17 00:00:00 2001
From: Dreamodus
Date: Tue, 14 Jul 2026 13:06:16 -0700
Subject: [PATCH 7/7] v1.1.4: light-theme support (LED wells, panel buttons,
readouts)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The controls whose chrome was hardcoded dark now adapt to a light base —
previously they rendered as dark chips/holes on light themes. (Flat knobs and
faders already adapted: their face is the `face` token.)
- `ledWell` theme token — the recessed screen behind LED segments (LEDKnob,
LEDFader, Meter) and the default well of SegmentDisplay / AlphaDisplay now
comes from the theme. Dark base keeps #0b0d0e; light base is `transparent`,
so LED controls and readouts render as clean flat rings / bars / digits on
the light panel. faceColor / background props still override.
- `scheme` theme token ('dark' | 'light', set by base) + a shared
panelButtonChrome() helper drive the raised gradient/border/shadow of
PushButton (and Button), TransportButton and SegmentSwitch — light raised
buttons on light themes instead of dark chips; unlit LED pips flip to a
faint dark so they still read.
- Docs: a "Light theme" gallery card showing the panel family under
base="light"; ledWell/scheme documented; install URLs bumped to v1.1.4.
Library + docs typecheck and build; 46 tests pass.
---
README.md | 2 +-
apps/docs/src/sections/ApiDocs.tsx | 1 +
apps/docs/src/sections/Gallery.tsx | 40 ++++++++++++++
apps/docs/src/sections/GettingStarted.tsx | 4 +-
apps/docs/src/sections/Hero.tsx | 4 +-
packages/dreamknob/CHANGELOG.md | 24 +++++++++
packages/dreamknob/README.md | 6 ++-
packages/dreamknob/package.json | 2 +-
packages/dreamknob/src/core/theme.tsx | 52 +++++++++++++++++++
.../dreamknob/src/digital/AlphaDisplay.tsx | 5 +-
.../dreamknob/src/digital/SegmentDisplay.tsx | 5 +-
packages/dreamknob/src/skins/LEDFader.tsx | 3 +-
packages/dreamknob/src/skins/LEDKnob.tsx | 3 +-
packages/dreamknob/src/skins/Meter.tsx | 3 +-
packages/dreamknob/src/skins/PushButton.tsx | 23 ++++----
.../dreamknob/src/skins/SegmentSwitch.tsx | 28 +++++++---
.../dreamknob/src/skins/TransportButton.tsx | 19 +++----
17 files changed, 180 insertions(+), 44 deletions(-)
diff --git a/README.md b/README.md
index efeb404..17c3eed 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,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.1.3/dreamknob-1.1.3.tgz
+pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.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 5df8acb..3cffec2 100644
--- a/apps/docs/src/sections/ApiDocs.tsx
+++ b/apps/docs/src/sections/ApiDocs.tsx
@@ -195,6 +195,7 @@ export const ApiDocs: React.FC = () => (
ledGreen: '#3df2ad', // SegmentDisplay default lit color
ledAmber: '#ffb84d', // AlphaDisplay default lit color
panel: 'rgba(255,255,255,0.03)', // Rack / panel chrome
+ ledWell: '#0b0d0e', // LED display well (LEDKnob/LEDFader/Meter)
}}
>
diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx
index aefbe68..2158ee2 100644
--- a/apps/docs/src/sections/Gallery.tsx
+++ b/apps/docs/src/sections/Gallery.tsx
@@ -352,6 +352,42 @@ const PanDemo: React.FC = () => (
);
+const LightThemeDemo: React.FC = () => {
+ const [play, setPlay] = React.useState(false);
+ const [mute, setMute] = React.useState(true);
+ return (
+
+
+
+
+
+
+
+
+
+ Mute
+
+
undefined} aria-label="Apply">
+ Apply
+
+
setPlay(p => !p)} size={32} />
+
+
+
+
+ );
+};
+
const ImageKnobDemo: React.FC = () => {
const src = useGeneratedStrip();
if (!src) return null;
@@ -565,6 +601,10 @@ export const Gallery: React.FC = () => (
+
+
+
+
diff --git a/apps/docs/src/sections/GettingStarted.tsx b/apps/docs/src/sections/GettingStarted.tsx
index 526962c..32dbbc9 100644
--- a/apps/docs/src/sections/GettingStarted.tsx
+++ b/apps/docs/src/sections/GettingStarted.tsx
@@ -30,12 +30,12 @@ export const GettingStarted: React.FC = () => (
) · peer deps: react >= 18, react-dom >= 18`}
+ code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`}
/>
Installs straight from the{' '}
- v1.1.3 release
+ v1.1.4 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 2808d9c..4669d40 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.1.3/dreamknob-1.1.3.tgz
+
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz
{
- navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.3/dreamknob-1.1.3.tgz');
+ navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz');
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
diff --git a/packages/dreamknob/CHANGELOG.md b/packages/dreamknob/CHANGELOG.md
index f336777..2091a6c 100644
--- a/packages/dreamknob/CHANGELOG.md
+++ b/packages/dreamknob/CHANGELOG.md
@@ -1,5 +1,29 @@
# Changelog
+## 1.1.4 — 2026-07-14
+
+Light-theme support — the controls whose chrome was hardcoded dark now adapt.
+
+### Themeable LED well
+- **`ledWell` theme token** — the recessed "screen" fill behind the LED
+ segments of `LEDKnob`, `LEDFader`, `Meter`, and the default well of
+ `SegmentDisplay` / `AlphaDisplay`, now comes from the theme instead of a
+ hardcoded near-black. Dark base keeps the classic dark well (`#0b0d0e`); the
+ **light base is `transparent`**, so LED controls and readouts render as clean
+ flat segment rings / bar meters / digits on the light panel (still clearly
+ "LED" from the lit segments) instead of dark blobs. The `faceColor` /
+ `background` props still override per-instance.
+
+### Light-theme panel chrome
+- **`scheme` theme token** (`'dark' | 'light'`, set by the `base`) drives the
+ raised gradient/border/shadow of `PushButton` (and `Button`),
+ `TransportButton` and `SegmentSwitch`. On a light base they render as light
+ raised buttons instead of dark chips; unlit LED pips flip to a faint dark so
+ they still read. New `panelButtonChrome(scheme, pressed)` helper.
+- Fixes the whole panel-button/switch family looking like dark chips on light
+ themes; the flat knobs and faders already adapted (their face is the `face`
+ token).
+
## 1.1.3 — 2026-07-14
Fourth integration-feedback release — a composability fix for buttons, a much
diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md
index d6c5f61..7b1e597 100644
--- a/packages/dreamknob/README.md
+++ b/packages/dreamknob/README.md
@@ -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.3/dreamknob-1.1.3.tgz
+pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz
```
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
@@ -137,7 +137,9 @@ import { DreamknobProvider } from 'dreamknob'
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
`text`, `label`, `ticks`, `focusRing`, `zoneGood`/`zoneWarn`/`zoneHot` (meter zones),
-`ledGreen`/`ledAmber` (display defaults), `panel` (rack chrome), `fontMono`, `fontUI`.
+`ledGreen`/`ledAmber` (display defaults), `panel` (rack chrome), `ledWell` (LED display
+well — transparent on the light base), `fontMono`, `fontUI`. `scheme` (`'dark'|'light'`)
+is set by `base` and drives the panel-button/switch chrome; you rarely set it directly.
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
`unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`,
diff --git a/packages/dreamknob/package.json b/packages/dreamknob/package.json
index 4b08270..81a8018 100644
--- a/packages/dreamknob/package.json
+++ b/packages/dreamknob/package.json
@@ -1,6 +1,6 @@
{
"name": "dreamknob",
- "version": "1.1.3",
+ "version": "1.1.4",
"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.",
diff --git a/packages/dreamknob/src/core/theme.tsx b/packages/dreamknob/src/core/theme.tsx
index 1116396..0a34f2e 100644
--- a/packages/dreamknob/src/core/theme.tsx
+++ b/packages/dreamknob/src/core/theme.tsx
@@ -40,10 +40,58 @@ export interface KnobTheme {
ledAmber: string;
/** Panel/rack chrome background (`Rack`, panel containers). */
panel: string;
+ /**
+ * The "well" behind LED displays — the recessed screen of `LEDKnob`,
+ * `LEDFader`, `Meter` and the default panel of `SegmentDisplay` /
+ * `AlphaDisplay`. Dark base keeps the classic near-black well (`#0b0d0e`) so
+ * lit segments read as glowing; the light base is `transparent`, so LED
+ * controls and readouts render as clean flat segment rings / bar meters /
+ * digits on the light panel instead of dark blobs. Per-instance
+ * `faceColor` / `background` props still override it.
+ */
+ ledWell: string;
+ /**
+ * Which base this theme derives from. Panel chrome that can't be expressed
+ * as a single token — the raised gradient/border/shadow of `PushButton`,
+ * `TransportButton`, `SegmentSwitch` — switches between dark and light
+ * treatments on this. Set automatically by the `base`; overrides rarely
+ * need to touch it.
+ */
+ scheme: 'dark' | 'light';
fontMono: string;
fontUI: string;
}
+/** Raised panel-button chrome (background gradient, border, inset/drop shadow)
+ * for the current scheme — shared by PushButton, TransportButton and the
+ * SegmentSwitch container so they stay consistent and adapt to light themes
+ * instead of being hardcoded dark chips. */
+export function panelButtonChrome(
+ scheme: 'dark' | 'light',
+ pressed: boolean,
+): { background: string; border: string; boxShadow: string } {
+ if (scheme === 'light') {
+ return {
+ border: '1px solid rgba(0,0,0,0.16)',
+ background: pressed
+ ? 'linear-gradient(180deg, #d9dbe0, #e9eaee)'
+ : 'linear-gradient(180deg, #ffffff, #e7e8ec)',
+ boxShadow: pressed
+ ? 'inset 0 2px 4px rgba(0,0,0,0.16)'
+ : 'inset 0 1px 0 rgba(255,255,255,0.9), 0 1px 2px rgba(0,0,0,0.13)',
+ };
+ }
+ return {
+ border: '1px solid rgba(0,0,0,0.7)',
+ background: pressed
+ ? 'linear-gradient(180deg, #17181d, #232429)'
+ : 'linear-gradient(180deg, #35363d, #1d1e24)',
+ boxShadow: pressed
+ ? 'inset 0 2px 5px rgba(0,0,0,0.65)'
+ : 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
+ };
+}
+
export const darkTheme: KnobTheme = {
track: 'rgba(255,255,255,0.12)',
face: 'rgba(255,255,255,0.05)',
@@ -57,6 +105,8 @@ export const darkTheme: KnobTheme = {
ledGreen: '#3df2ad',
ledAmber: '#ffb84d',
panel: 'rgba(255,255,255,0.03)',
+ ledWell: '#0b0d0e',
+ scheme: 'dark',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};
@@ -74,6 +124,8 @@ export const lightTheme: KnobTheme = {
ledGreen: '#0fa571',
ledAmber: '#b26a00',
panel: 'rgba(0,0,0,0.04)',
+ ledWell: 'transparent',
+ scheme: 'light',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};
diff --git a/packages/dreamknob/src/digital/AlphaDisplay.tsx b/packages/dreamknob/src/digital/AlphaDisplay.tsx
index 142affc..872dc77 100644
--- a/packages/dreamknob/src/digital/AlphaDisplay.tsx
+++ b/packages/dreamknob/src/digital/AlphaDisplay.tsx
@@ -145,7 +145,7 @@ export const AlphaDisplay: React.FC = ({
color,
weight = 1,
gap = DEFAULT_GAP,
- background = '#0a0d0c',
+ background: backgroundProp,
ghostOpacity = 0.08,
skew = 6,
glow = 1.4,
@@ -156,6 +156,9 @@ export const AlphaDisplay: React.FC = ({
}) => {
const theme = useKnobTheme();
const lit = color ?? theme.ledAmber;
+ // Default the display well to the theme's ledWell (transparent on the light
+ // base) so readouts adapt to light themes.
+ const background = backgroundProp ?? theme.ledWell ?? '#0a0d0c';
const filterId = React.useId();
const half = BASE_HALF * weight;
const POLY = buildPoly(half);
diff --git a/packages/dreamknob/src/digital/SegmentDisplay.tsx b/packages/dreamknob/src/digital/SegmentDisplay.tsx
index 95725ba..c9b623d 100644
--- a/packages/dreamknob/src/digital/SegmentDisplay.tsx
+++ b/packages/dreamknob/src/digital/SegmentDisplay.tsx
@@ -57,7 +57,7 @@ export const SegmentDisplay: React.FC = ({
zones,
weight = 1,
gap,
- background = '#0a0d0c',
+ background: backgroundProp,
ghostOpacity = 0.09,
skew = 6,
glow = 1.6,
@@ -67,6 +67,9 @@ export const SegmentDisplay: React.FC = ({
style,
}) => {
const theme = useKnobTheme();
+ // Default the display well to the theme's ledWell (dark base = dark well,
+ // light base = transparent) so readouts adapt to light themes.
+ const background = backgroundProp ?? theme.ledWell ?? '#0a0d0c';
const filterId = React.useId();
const numeric = typeof value === 'number' ? value : NaN;
const resolved =
diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx
index d8fb95c..6b68c4e 100644
--- a/packages/dreamknob/src/skins/LEDFader.tsx
+++ b/packages/dreamknob/src/skins/LEDFader.tsx
@@ -71,7 +71,7 @@ export const LEDFader = React.forwardRef(function
zones,
offOpacity = 0.13,
glow = true,
- faceColor = '#0b0d0e',
+ faceColor: faceColorProp,
focusRing,
label,
showValue = true,
@@ -89,6 +89,7 @@ export const LEDFader = React.forwardRef(function
const [editing, setEditing] = React.useState(false);
const theme = useKnobTheme();
const color = colorProp ?? theme.accent;
+ const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const ring = focusRing ?? theme.focusRing;
const vertical = orientation === 'vertical';
const pad = 5;
diff --git a/packages/dreamknob/src/skins/LEDKnob.tsx b/packages/dreamknob/src/skins/LEDKnob.tsx
index 43968ae..baf4224 100644
--- a/packages/dreamknob/src/skins/LEDKnob.tsx
+++ b/packages/dreamknob/src/skins/LEDKnob.tsx
@@ -90,7 +90,7 @@ export const LEDKnob = React.forwardRef(function L
digits = 3,
displayDecimals,
labelColor,
- faceColor = '#0b0d0e',
+ faceColor: faceColorProp,
label,
sublabel,
labelSize,
@@ -102,6 +102,7 @@ export const LEDKnob = React.forwardRef(function L
}, ref) {
const theme = useKnobTheme();
const color = colorProp ?? theme.accent ?? '#3df2ad';
+ const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const id = React.useId();
const glowId = `${id}-glow`;
return (
diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx
index 1f51a85..ba348be 100644
--- a/packages/dreamknob/src/skins/Meter.tsx
+++ b/packages/dreamknob/src/skins/Meter.tsx
@@ -151,7 +151,7 @@ export const Meter: React.FC = ({
clipHold = 1500,
clipColor = '#ff2b39',
onClip,
- faceColor = '#0b0d0e',
+ faceColor: faceColorProp,
label,
showValue = false,
digits = 4,
@@ -163,6 +163,7 @@ export const Meter: React.FC = ({
...aria
}) => {
const theme = useKnobTheme();
+ const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const vertical = orientation === 'vertical';
const dbScale = scale === 'db';
const min = minProp ?? (dbScale ? -60 : 0);
diff --git a/packages/dreamknob/src/skins/PushButton.tsx b/packages/dreamknob/src/skins/PushButton.tsx
index c771e2a..a3cf560 100644
--- a/packages/dreamknob/src/skins/PushButton.tsx
+++ b/packages/dreamknob/src/skins/PushButton.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { useKnobTheme } from '../core/theme';
+import { panelButtonChrome, useKnobTheme } from '../core/theme';
/** Call every handler in order (skipping undefined) — for merging our own
* handlers with consumer/Radix-injected ones on the same event. */
@@ -87,6 +87,9 @@ export const PushButton = React.forwardRef(
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 chrome = panelButtonChrome(theme.scheme, on);
+ // Unlit LED tint: faint white on dark chrome, faint black on light.
+ const ledOff = theme.scheme === 'light' ? 'rgba(0,0,0,0.13)' : 'rgba(255,255,255,0.09)';
const set = (next: boolean) => {
if (next === latched) return;
@@ -174,16 +177,12 @@ export const PushButton = React.forwardRef(
minWidth: size * 1.4,
padding: '0 12px',
borderRadius: 7,
- border: '1px solid rgba(0,0,0,0.7)',
- background: on
- ? 'linear-gradient(180deg, #17181d, #232429)'
- : 'linear-gradient(180deg, #35363d, #1d1e24)',
+ border: chrome.border,
+ background: chrome.background,
outline: 'none',
- boxShadow: `${
- on
- ? 'inset 0 2px 5px rgba(0,0,0,0.65)'
- : 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
- }${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
+ boxShadow: `${chrome.boxShadow}${
+ focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''
+ }`,
color: on ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.28),
@@ -206,7 +205,7 @@ export const PushButton = React.forwardRef(
width: '55%',
height: Math.max(3, size * 0.09),
borderRadius: 3,
- background: ledOn ? accent : 'rgba(255,255,255,0.09)',
+ background: ledOn ? accent : ledOff,
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',
}}
@@ -222,7 +221,7 @@ export const PushButton = React.forwardRef(
width: Math.max(4, size * 0.14),
height: Math.max(4, size * 0.14),
borderRadius: '50%',
- background: ledOn ? accent : 'rgba(255,255,255,0.09)',
+ background: ledOn ? accent : ledOff,
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',
}}
diff --git a/packages/dreamknob/src/skins/SegmentSwitch.tsx b/packages/dreamknob/src/skins/SegmentSwitch.tsx
index ae89925..153bc0a 100644
--- a/packages/dreamknob/src/skins/SegmentSwitch.tsx
+++ b/packages/dreamknob/src/skins/SegmentSwitch.tsx
@@ -46,6 +46,7 @@ export const SegmentSwitch = React.forwardRef(null);
+ const chrome = panelButtonChrome(theme.scheme, active);
const accent =
color ??
@@ -145,17 +146,11 @@ export const TransportButton = React.forwardRef