v1.1.4: light-theme support (LED wells, panel buttons, readouts)

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.
This commit is contained in:
Dreamodus 2026-07-14 13:06:16 -07:00
parent d183d7d16b
commit 8b462dfb83
17 changed files with 180 additions and 44 deletions

View file

@ -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

View file

@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
Zero runtime dependencies.
```
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.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`,

View file

@ -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.",

View file

@ -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,
};

View file

@ -145,7 +145,7 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
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<AlphaDisplayProps> = ({
}) => {
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);

View file

@ -57,7 +57,7 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
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<SegmentDisplayProps> = ({
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 =

View file

@ -71,7 +71,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
zones,
offOpacity = 0.13,
glow = true,
faceColor = '#0b0d0e',
faceColor: faceColorProp,
focusRing,
label,
showValue = true,
@ -89,6 +89,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(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;

View file

@ -90,7 +90,7 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
digits = 3,
displayDecimals,
labelColor,
faceColor = '#0b0d0e',
faceColor: faceColorProp,
label,
sublabel,
labelSize,
@ -102,6 +102,7 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(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 (

View file

@ -151,7 +151,7 @@ export const Meter: React.FC<MeterProps> = ({
clipHold = 1500,
clipColor = '#ff2b39',
onClip,
faceColor = '#0b0d0e',
faceColor: faceColorProp,
label,
showValue = false,
digits = 4,
@ -163,6 +163,7 @@ export const Meter: React.FC<MeterProps> = ({
...aria
}) => {
const theme = useKnobTheme();
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const vertical = orientation === 'vertical';
const dbScale = scale === 'db';
const min = minProp ?? (dbScale ? -60 : 0);

View file

@ -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<HTMLButtonElement, PushButtonProps>(
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<HTMLButtonElement, PushButtonProps>(
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<HTMLButtonElement, PushButtonProps>(
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<HTMLButtonElement, PushButtonProps>(
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',
}}

View file

@ -46,6 +46,7 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const light = theme.scheme === 'light';
const isControlled = value !== undefined;
const [internal, setInternal] = React.useState(defaultValue);
const active = Math.max(0, Math.min(options.length - 1, isControlled ? (value as number) : internal));
@ -88,9 +89,13 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
display: 'inline-flex',
height: size,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.7)',
background: 'linear-gradient(180deg, #16171c, #101116)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06), 0 2px 4px rgba(0,0,0,0.4)',
border: light ? '1px solid rgba(0,0,0,0.14)' : '1px solid rgba(0,0,0,0.7)',
background: light
? 'linear-gradient(180deg, #e3e4e8, #edeef1)'
: 'linear-gradient(180deg, #16171c, #101116)',
boxShadow: light
? 'inset 0 1px 2px rgba(0,0,0,0.1)'
: 'inset 0 1px 0 rgba(255,255,255,0.06), 0 2px 4px rgba(0,0,0,0.4)',
overflow: 'hidden',
opacity: disabled ? 0.45 : 1,
userSelect: 'none',
@ -122,11 +127,20 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
padding: '0 12px',
minWidth: size * 1.6,
border: 'none',
borderRight: i < options.length - 1 ? '1px solid rgba(0,0,0,0.55)' : 'none',
borderRight:
i < options.length - 1
? `1px solid ${light ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.55)'}`
: 'none',
background: isActive
? 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
? light
? 'linear-gradient(180deg, #ffffff, #f4f5f7)'
: 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
: 'transparent',
boxShadow: isActive ? 'inset 0 2px 4px rgba(0,0,0,0.55)' : undefined,
boxShadow: isActive
? light
? '0 1px 2px rgba(0,0,0,0.16)'
: 'inset 0 2px 4px rgba(0,0,0,0.55)'
: undefined,
color: isActive ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.34),
@ -145,7 +159,7 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
width: '55%',
height: Math.max(2, size * 0.08),
borderRadius: 2,
background: isActive ? accent : 'rgba(255,255,255,0.08)',
background: isActive ? accent : light ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.08)',
boxShadow: isActive ? `0 0 ${size * 0.2}px ${accent}` : undefined,
transition: 'background 80ms, box-shadow 80ms',
}}

View file

@ -1,5 +1,5 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
import { panelButtonChrome, useKnobTheme } from '../core/theme';
export type TransportKind =
| 'play'
@ -90,6 +90,7 @@ export const TransportButton = React.forwardRef<HTMLButtonElement, TransportButt
const theme = useKnobTheme();
const [focusVisible, setFocusVisible] = React.useState(false);
const glyphRef = React.useRef<SVGSVGElement>(null);
const chrome = panelButtonChrome(theme.scheme, active);
const accent =
color ??
@ -145,17 +146,11 @@ export const TransportButton = React.forwardRef<HTMLButtonElement, TransportButt
height: size,
padding: 0,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.6)',
background: active
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
boxShadow: `${
active
? 'inset 0 2px 5px rgba(0,0,0,0.6)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
}${active ? `, 0 0 ${size * 0.28}px ${accent}55` : ''}${
focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''
}`,
border: chrome.border,
background: chrome.background,
boxShadow: `${chrome.boxShadow}${
active ? `, 0 0 ${size * 0.28}px ${accent}55` : ''
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
outline: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.45 : 1,