v1.1.1: second integration-feedback release
Readout legibility (the #1 ask): - bold own-cell decimal point + colon in seven/fourteen-seg (not hairlines) - weight prop thickens strokes; gap controls inter-cell kerning Display colors: - SegmentDisplay defaults to theme.ledGreen, AlphaDisplay to theme.ledAmber - valueColor fn + value-space zones on SegmentDisplay (self-coloring readouts) - charset: colon in both, $ and % in 14-seg; disabled + data-disabled Knob captions: sublabel (second line) + labelSize (independent of dial size). New components: - ToggleSwitch (rocker/slider boolean, role=switch) - Gauge (read-only radial arc meter, round sibling of Meter) - TransportButton (play/stop/record/pause/panic, record blinks) - LampRow (console-print lamp bank), LabeledField, Rack (panel chrome) Responsiveness: fill on Meter + Fader (stretch to container). Niceties: PushButton leadingIcon; theme ledGreen/ledAmber/panel tokens. 54 -> 63 exports. 38 unit tests. Browser-verified.
This commit is contained in:
parent
d8b7b55505
commit
5b97091902
32 changed files with 1535 additions and 133 deletions
|
|
@ -36,8 +36,9 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
|
|||
power / custom tapers.
|
||||
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
|
||||
`SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `MeterBridge`,
|
||||
`XYPad`, `PushButton`, `IndicatorLamp`, `SegmentSwitch`, `ScrubField`,
|
||||
`SegmentDisplay`, `AlphaDisplay` — every color themeable per instance.
|
||||
`Gauge`, `XYPad`, `PushButton`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`,
|
||||
`SegmentSwitch`, `TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`,
|
||||
plus `LabeledField`/`Rack` layout — every color themeable per instance.
|
||||
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
|
||||
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
|
||||
`useKnob`.
|
||||
|
|
@ -49,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:
|
||||
|
||||
```bash
|
||||
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz
|
||||
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz
|
||||
```
|
||||
|
||||
Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types):
|
||||
|
|
|
|||
|
|
@ -128,24 +128,32 @@ export const ApiDocs: React.FC = () => (
|
|||
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals (any precision), unit label under the digits." />
|
||||
<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="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format, valuePosition ('top' | 'end' | 'none' — 'end' gives one-line inspector rows)." />
|
||||
<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="<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, ...] for multi-channel), zones (defaults to theme green/amber/red), peakHold (ms), peakColor, orientation, unit, seven-segment readout. Clip LED: showClip (default on), clipThreshold (default max), clipHold (ms or 'latch', click to clear), 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="<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' (default) or 'dot' — the corner dot scales down for small icon-only toggles." />
|
||||
<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="<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="<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="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: A–Z 0–9 and symbols, chars padding, align, glow, skew." />
|
||||
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
|
||||
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: A–Z 0–9 and symbols (now incl. : $ %), chars padding, align, weight, gap, glow, skew. Defaults to theme.ledAmber." />
|
||||
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, weight (thicker strokes), gap (kerning), valueColor / zones (color by value), bold own-cell decimal + colon, disabled. Defaults to theme.ledGreen." />
|
||||
<Row name="<Gauge />" type="display" desc="Read-only radial arc meter — the round sibling of Meter. value/min/max/taper, zones or valueColor, needle, center readout, unit. For CPU/RAM/load." />
|
||||
<Row name="<ToggleSwitch />" type="switch" desc="Rocker/slider boolean (role='switch'): the knob slides OFF↔ON. label, showStateLabel, ←/→ keys, controlled/uncontrolled, name." />
|
||||
<Row name="<TransportButton />" type="switch" desc="play / pause / stop / record / rewind / forward / panic, styled to match the controls beside it. active lights it; record blinks (reduced-motion aware)." />
|
||||
<Row name="<LampRow />" type="display" desc="Row of small indicator lamps with console-print captions (● CUE ● MUTE). Read-only status, or pass onToggle to make them pressable." />
|
||||
<Row name="<LabeledField />" type="layout" desc="One aligned inspector row: label · control · unit. Share labelWidth across a stack and the controls line up." />
|
||||
<Row name="<Rack />" type="layout" desc="Channel-strip / rack container owning panel chrome + spacing: orientation row|column, title, nestable. Declarative console sections." />
|
||||
</Table>
|
||||
<p className="api-note">
|
||||
Every knob skin and fader also takes <code>label</code>, <code>showValue</code>,{' '}
|
||||
<code>unit</code>, <code>format</code>, <code>className</code>,{' '}
|
||||
<code>style</code>, <code>name</code> plus every core prop above, and forwards a
|
||||
ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
|
||||
Every knob skin and fader also takes <code>label</code>, <code>sublabel</code>{' '}
|
||||
(a second caption line — <code>SEED</code> over <code>world #7</code>),{' '}
|
||||
<code>labelSize</code> (caption size independent of the dial diameter),{' '}
|
||||
<code>showValue</code>, <code>unit</code>, <code>format</code>,{' '}
|
||||
<code>className</code>, <code>style</code>, <code>name</code> plus every core prop
|
||||
above, and forwards a ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
|
||||
<code>showValue</code>/<code>unit</code>/<code>format</code>; the display
|
||||
components <code>Meter</code>/<code>SegmentDisplay</code>/<code>AlphaDisplay</code>{' '}
|
||||
have their own smaller prop sets). Primitive-drawn parts carry{' '}
|
||||
|
|
@ -180,8 +188,11 @@ export const ApiDocs: React.FC = () => (
|
|||
label: 'rgba(255,255,255,0.5)',
|
||||
fontMono: '"IBM Plex Mono", monospace',
|
||||
zoneGood: '#3df2ad', // meter zones: green / amber / red
|
||||
zoneWarn: '#ffd23e', // used by Meter, MeterBridge and
|
||||
zoneHot: '#ff4d6b', // LEDFader when no zones are given
|
||||
zoneWarn: '#ffd23e', // used by Meter, MeterBridge, Gauge
|
||||
zoneHot: '#ff4d6b', // and LEDFader when no zones given
|
||||
ledGreen: '#3df2ad', // SegmentDisplay default lit color
|
||||
ledAmber: '#ffb84d', // AlphaDisplay default lit color
|
||||
panel: 'rgba(255,255,255,0.03)', // Rack / panel chrome
|
||||
}}
|
||||
>
|
||||
<FlatKnob ... /> <Fader ... /> <LEDKnob ... />
|
||||
|
|
@ -234,6 +245,18 @@ function MyKnob() {
|
|||
}`}
|
||||
/>
|
||||
|
||||
<h3 className="api-h">Small readouts</h3>
|
||||
<p className="api-note">
|
||||
Segment displays keep their proportions down to about{' '}
|
||||
<strong>11 px</strong> tall; below that, raise <code>weight</code> to thicken
|
||||
the strokes rather than shrinking further. The decimal point and colon render as
|
||||
bold cells with their own width (not hairlines between digits), so{' '}
|
||||
<code>17.5</code> and <code>1:23:45</code> stay readable in a status bar. Widen{' '}
|
||||
<code>gap</code> when packed tokens like <code>18.1/24</code> crowd. Colons and{' '}
|
||||
<code>$</code>/<code>%</code> are in the charset, so a whole token can live in one
|
||||
display instead of text beside it.
|
||||
</p>
|
||||
|
||||
<h3 className="api-h">Recipes</h3>
|
||||
<p className="api-note">
|
||||
The prop combinations we reach for in real consoles and inspectors.
|
||||
|
|
@ -265,7 +288,34 @@ function MyKnob() {
|
|||
editable unit="px" aria-label="Radius" />
|
||||
|
||||
// Stereo meter — one component, two channels, shared clip LED
|
||||
<Meter value={[left, right]} min={-60} max={6} clipThreshold={0} />`}
|
||||
<Meter value={[left, right]} min={-60} max={6} clipThreshold={0} />
|
||||
|
||||
// Resource gauge — colors itself green → amber → red as load climbs
|
||||
<Gauge value={cpu} min={0} max={100} unit="%" label="CPU" size={96}
|
||||
zones={[{upTo: 0.75, color: '#3df2ad'},
|
||||
{upTo: 0.9, color: '#ffd23e'},
|
||||
{upTo: 1, color: '#ff4d6b'}]} />
|
||||
|
||||
// Self-coloring resource readout — no app-side color math
|
||||
<SegmentDisplay value={cpu} digits={3} unit="%" weight={1.4}
|
||||
zones={[{upTo: 74, color: '#3df2ad'},
|
||||
{upTo: 89, color: '#ffd23e'},
|
||||
{upTo: Infinity, color: '#ff4d6b'}]} />
|
||||
|
||||
// Aligned inspector rows — share labelWidth so controls line up
|
||||
<LabeledField label="Radius" unit="px" labelWidth={64}>
|
||||
<ScrubField defaultValue={12} min={0} max={200} />
|
||||
</LabeledField>
|
||||
<LabeledField label="Fresh seed" labelWidth={64}>
|
||||
<ToggleSwitch defaultOn showStateLabel />
|
||||
</LabeledField>
|
||||
|
||||
// A declarative channel strip
|
||||
<Rack orientation="column" title="Channel 1">
|
||||
<FlatKnob label="Gain" sublabel="dB" />
|
||||
<Fader orientation="horizontal" fill valuePosition="end" />
|
||||
<Meter value={level} min={-60} max={6} fill orientation="horizontal" />
|
||||
</Rack>`}
|
||||
/>
|
||||
|
||||
<h3 className="api-h">Precision & number handling</h3>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ import {
|
|||
DreamknobProvider,
|
||||
Fader,
|
||||
FlatKnob,
|
||||
Gauge,
|
||||
ImageKnob,
|
||||
Knob,
|
||||
KnobValue,
|
||||
IndicatorLamp,
|
||||
LabeledField,
|
||||
LampRow,
|
||||
LEDFader,
|
||||
LEDKnob,
|
||||
MetalKnob,
|
||||
|
|
@ -17,12 +20,15 @@ import {
|
|||
NeonKnob,
|
||||
Pointer,
|
||||
PushButton,
|
||||
Rack,
|
||||
RubberKnob,
|
||||
ScrubField,
|
||||
SegmentSwitch,
|
||||
SegmentDisplay,
|
||||
SteppedKnob,
|
||||
Ticks,
|
||||
ToggleSwitch,
|
||||
TransportButton,
|
||||
VintageKnob,
|
||||
XYPad,
|
||||
logTaper,
|
||||
|
|
@ -197,6 +203,95 @@ const CommitModeDemo: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const RESOURCE_ZONES = [
|
||||
{ upTo: 0.75, color: '#3df2ad' },
|
||||
{ upTo: 0.9, color: '#ffd23e' },
|
||||
{ upTo: 1, color: '#ff4d6b' },
|
||||
];
|
||||
|
||||
const GaugeDemo: React.FC = () => {
|
||||
const [load, setLoad] = React.useState<[number, number]>([38, 61]);
|
||||
React.useEffect(() => {
|
||||
let t = 0;
|
||||
const id = setInterval(() => {
|
||||
t += 0.12;
|
||||
setLoad([
|
||||
Math.max(2, Math.min(100, 45 + Math.sin(t) * 30 + Math.random() * 12)),
|
||||
Math.max(2, Math.min(100, 62 + Math.sin(t * 0.7 + 2) * 26 + Math.random() * 10)),
|
||||
]);
|
||||
}, 140);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
|
||||
<Gauge value={load[0]} min={0} max={100} size={104} unit="%" label="CPU" zones={RESOURCE_ZONES} />
|
||||
<Gauge value={load[1]} min={0} max={100} size={104} unit="%" label="GPU" zones={RESOURCE_ZONES} needle={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SmallReadoutDemo: React.FC = () => {
|
||||
const [t, setT] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(() => setT(v => v + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const hh = String(Math.floor(t / 3600) % 100).padStart(2, '0');
|
||||
const mm = String(Math.floor(t / 60) % 60).padStart(2, '0');
|
||||
const ss = String(t % 60).padStart(2, '0');
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<SegmentDisplay value={17.5} digits={3} decimals={1} height={13} weight={1.5} />
|
||||
<SegmentDisplay value={`${hh}:${mm}:${ss}`} height={13} weight={1.4} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<SegmentDisplay value={82} digits={3} height={16} weight={1.4} zones={[
|
||||
{ upTo: 74, color: '#3df2ad' },
|
||||
{ upTo: 89, color: '#ffd23e' },
|
||||
{ upTo: Infinity, color: '#ff4d6b' },
|
||||
]} />
|
||||
<AlphaDisplay value="$4.20" height={15} weight={1.3} />
|
||||
<AlphaDisplay value="47%" height={15} weight={1.3} color="#3df2ad" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PanelDemo: React.FC = () => {
|
||||
const [fresh, setFresh] = React.useState(true);
|
||||
const [mode, setMode] = React.useState(0);
|
||||
const [playing, setPlaying] = React.useState(false);
|
||||
const [armed, setArmed] = React.useState(false);
|
||||
return (
|
||||
<Rack orientation="column" title="Performer" accentColor="#c9a6ff">
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FlatKnob size={62} defaultValue={7} min={0} max={64} step={1} color="#c9a6ff"
|
||||
label="Seed" sublabel="world #7" labelSize={9} aria-label="Seed" />
|
||||
<FlatKnob size={62} defaultValue={40} min={0} max={100} color="#c9a6ff"
|
||||
label="Refresh" sublabel="every 40" labelSize={9} aria-label="Refresh" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<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} />
|
||||
<TransportButton kind="panic" onClick={() => { setPlaying(false); setArmed(false); }} size={34} />
|
||||
</div>
|
||||
<LabeledField label="Fresh seed" labelWidth={78}>
|
||||
<ToggleSwitch on={fresh} onChange={setFresh} color="#c9a6ff" showStateLabel aria-label="Fresh seed" />
|
||||
</LabeledField>
|
||||
<SegmentSwitch options={['SOLID', 'GRAD', 'NOISE']} value={mode} onChange={setMode} color="#c9a6ff" aria-label="Fill mode" />
|
||||
<LampRow
|
||||
lamps={[
|
||||
{ label: 'Cue', on: playing, color: '#3df2ad' },
|
||||
{ label: 'Rec', on: armed, color: '#ff4d6b' },
|
||||
{ label: 'Seed', on: fresh, color: '#c9a6ff' },
|
||||
]}
|
||||
/>
|
||||
</Rack>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageKnobDemo: React.FC = () => {
|
||||
const src = useGeneratedStrip();
|
||||
if (!src) return null;
|
||||
|
|
@ -429,6 +524,18 @@ export const Gallery: React.FC = () => (
|
|||
<CommitModeDemo />
|
||||
</Card>
|
||||
|
||||
<Card title="Gauge" desc="Read-only radial arc meter — the round sibling of Meter. Colors itself green→amber→red as load climbs." tag="<Gauge />">
|
||||
<GaugeDemo />
|
||||
</Card>
|
||||
|
||||
<Card title="Small readouts" desc="Bold own-cell decimal + colon, weight to thicken strokes, and value-colored zones — legible at status-bar sizes. Colons and $/% are in the charset." tag="weight · zones · : $ %">
|
||||
<SmallReadoutDemo />
|
||||
</Card>
|
||||
|
||||
<Card title="Panel: strip + switches" desc="Rack chrome, ToggleSwitch, TransportButton, SegmentSwitch and a LampRow — a console section, declarative." tag="<Rack /> · <ToggleSwitch /> · <TransportButton />">
|
||||
<PanelDemo />
|
||||
</Card>
|
||||
|
||||
<Card title="Gradient arc" desc="Position-anchored gradient — the red zone stays at the top of travel." tag="<Arc gradient={...} />">
|
||||
<Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo">
|
||||
<Arc
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ export const GettingStarted: React.FC = () => (
|
|||
|
||||
<Step n={1} title="Install">
|
||||
<CodeBlock
|
||||
code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz\n# (or npm install <same url>) · peer deps: react >= 18, react-dom >= 18`}
|
||||
code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz\n# (or npm install <same url>) · peer deps: react >= 18, react-dom >= 18`}
|
||||
/>
|
||||
<p className="api-note" style={{ marginTop: 10 }}>
|
||||
Installs straight from the{' '}
|
||||
<a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases">
|
||||
v1.1.0 release
|
||||
v1.1.1 release
|
||||
</a>{' '}
|
||||
on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No
|
||||
CSS file to import — everything is SVG and inline styles.
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ export const Hero: React.FC = () => {
|
|||
range, any decimal precision, and colors you fully control.
|
||||
</p>
|
||||
<div className="install">
|
||||
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz</span>
|
||||
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz');
|
||||
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,45 @@
|
|||
# Changelog
|
||||
|
||||
## 1.1.1 — 2026-07-13
|
||||
|
||||
Second integration-feedback release — the friction that showed up once the
|
||||
displays and switches went into dense, resizable, themed product panels.
|
||||
|
||||
### Readout legibility
|
||||
- Seven-segment decimal point and colon now render as **bold cells with their
|
||||
own width** (not hairlines between digits), so `17.5` and `1:23:45` stay
|
||||
readable at status-bar sizes
|
||||
- `weight` prop on `SegmentDisplay`/`AlphaDisplay` thickens the segments (a font
|
||||
weight); `gap` controls inter-cell spacing for crowded readouts like `18.1/24`
|
||||
|
||||
### Displays: theme + value colors
|
||||
- `SegmentDisplay` defaults to `theme.ledGreen`, `AlphaDisplay` to
|
||||
`theme.ledAmber` — theme your readouts once instead of prop-drilling `color`
|
||||
- `valueColor: (v) => string` and value-space `zones` on `SegmentDisplay` — a
|
||||
resource readout colors itself green/amber/red without app-side math
|
||||
- Charset: `:` colon (both displays), `$` and `%` (14-seg)
|
||||
- `disabled` + `data-disabled` on displays (uniform dimming like the controls)
|
||||
|
||||
### Knob captions
|
||||
- `sublabel` — a second caption line (`SEED` over `world #7`)
|
||||
- `labelSize` — caption size independent of the dial diameter
|
||||
|
||||
### New components
|
||||
- `ToggleSwitch` — rocker/slider boolean (`role="switch"`), the knob slides
|
||||
OFF↔ON; `showStateLabel`, ←/→ keys
|
||||
- `Gauge` — read-only radial arc meter (the round sibling of `Meter`) for
|
||||
CPU/RAM/load; zones or `valueColor`, needle, center readout
|
||||
- `TransportButton` — play/pause/stop/record/rewind/forward/panic family styled
|
||||
to match the controls beside it; `record` blinks (reduced-motion aware)
|
||||
- `LampRow` — a row of indicator lamps with console-print captions
|
||||
- `LabeledField` — label · control · unit in one aligned row (share `labelWidth`)
|
||||
- `Rack` — channel-strip / rack container owning panel chrome + spacing
|
||||
|
||||
### Responsiveness & niceties
|
||||
- `fill` on `Meter` and `Fader` — stretch to the container instead of fixed px
|
||||
- `led="dot"` corner behavior plus a `leadingIcon` slot on `PushButton`
|
||||
- Theme gains `ledGreen`, `ledAmber`, `panel` tokens
|
||||
|
||||
## 1.1.0 — 2026-07-13
|
||||
|
||||
Integration-feedback release — everything in here came from wiring dreamknob into a
|
||||
|
|
|
|||
|
|
@ -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.0/dreamknob-1.1.0.tgz
|
||||
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz
|
||||
```
|
||||
|
||||
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
|
||||
|
|
@ -73,7 +73,7 @@ when you provide `getAriaValueText`).
|
|||
the cursor mid-gesture.
|
||||
- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free.
|
||||
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with
|
||||
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'`).
|
||||
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api'`).
|
||||
- `wrap` — endless encoder mode: values roll around min↔max (pair with
|
||||
`angleOffset={0} angleRange={360}`).
|
||||
- `animateChanges` — tween the pointer on programmatic changes (preset loads);
|
||||
|
|
@ -100,16 +100,26 @@ import {
|
|||
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
||||
Meter, // read-only LED level meter — mono or multi-channel (value={[l, r]})
|
||||
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)
|
||||
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
|
||||
SegmentSwitch, // segmented option selector (radiogroup, LED tick)
|
||||
TransportButton,// play/stop/record/pause/panic family (record blinks)
|
||||
ScrubField, // AE-style numeric field: drag the label to scrub, click to type
|
||||
SegmentDisplay, // standalone seven-segment numeric display
|
||||
AlphaDisplay, // fourteen-segment alphanumeric display
|
||||
SegmentDisplay, // seven-segment numeric display (weight, gap, value-color zones)
|
||||
AlphaDisplay, // fourteen-segment alphanumeric display (now incl. : $ %)
|
||||
LabeledField, // label · control · unit in one aligned inspector row
|
||||
Rack, // channel-strip / rack container (panel chrome + spacing)
|
||||
} from 'dreamknob'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Theming
|
||||
|
||||
```tsx
|
||||
|
|
@ -121,7 +131,8 @@ import { DreamknobProvider } from 'dreamknob'
|
|||
```
|
||||
|
||||
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
|
||||
`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
|
||||
`text`, `label`, `ticks`, `focusRing`, `zoneGood`/`zoneWarn`/`zoneHot` (meter zones),
|
||||
`ledGreen`/`ledAmber` (display defaults), `panel` (rack chrome), `fontMono`, `fontUI`.
|
||||
|
||||
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
|
||||
`unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dreamknob",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ export interface KnobTheme {
|
|||
zoneWarn: string;
|
||||
/** Meter/LED zone semantics: too hot / clipping. */
|
||||
zoneHot: string;
|
||||
/** Default lit color for green seven-segment readouts (`SegmentDisplay`). */
|
||||
ledGreen: string;
|
||||
/** Default lit color for amber alphanumeric readouts (`AlphaDisplay`). */
|
||||
ledAmber: string;
|
||||
/** Panel/rack chrome background (`Rack`, panel containers). */
|
||||
panel: string;
|
||||
fontMono: string;
|
||||
fontUI: string;
|
||||
}
|
||||
|
|
@ -48,6 +54,9 @@ export const darkTheme: KnobTheme = {
|
|||
zoneGood: '#3df2ad',
|
||||
zoneWarn: '#ffd23e',
|
||||
zoneHot: '#ff4d6b',
|
||||
ledGreen: '#3df2ad',
|
||||
ledAmber: '#ffb84d',
|
||||
panel: 'rgba(255,255,255,0.03)',
|
||||
fontMono: MONO_FONT,
|
||||
fontUI: UI_FONT,
|
||||
};
|
||||
|
|
@ -62,6 +71,9 @@ export const lightTheme: KnobTheme = {
|
|||
zoneGood: '#0fa571',
|
||||
zoneWarn: '#c99000',
|
||||
zoneHot: '#d92546',
|
||||
ledGreen: '#0fa571',
|
||||
ledAmber: '#b26a00',
|
||||
panel: 'rgba(0,0,0,0.04)',
|
||||
fontMono: MONO_FONT,
|
||||
fontUI: UI_FONT,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
|
||||
// Fourteen-segment geometry in a local 10 x 18 digit cell.
|
||||
// Outer ring: a top, b top-right, c bottom-right, d bottom, e bottom-left,
|
||||
|
|
@ -7,38 +8,38 @@ import * as React from 'react';
|
|||
|
||||
const W = 10;
|
||||
const H = 18;
|
||||
const GAP = 3.4;
|
||||
const HALF = 1.05;
|
||||
const DEFAULT_GAP = 3.4;
|
||||
const BASE_HALF = 1.05;
|
||||
const INSET = 0.45;
|
||||
|
||||
const hSeg = (y: number, x1: number, x2: number): string => {
|
||||
const hSeg = (y: number, x1: number, x2: number, half: number): string => {
|
||||
const a = x1 + INSET;
|
||||
const b = x2 - INSET;
|
||||
return `${a},${y} ${a + HALF},${y - HALF} ${b - HALF},${y - HALF} ${b},${y} ${b - HALF},${y + HALF} ${a + HALF},${y + HALF}`;
|
||||
return `${a},${y} ${a + half},${y - half} ${b - half},${y - half} ${b},${y} ${b - half},${y + half} ${a + half},${y + half}`;
|
||||
};
|
||||
|
||||
const vSeg = (x: number, y1: number, y2: number): string => {
|
||||
const vSeg = (x: number, y1: number, y2: number, half: number): string => {
|
||||
const a = y1 + INSET;
|
||||
const b = y2 - INSET;
|
||||
return `${x},${a} ${x + HALF},${a + HALF} ${x + HALF},${b - HALF} ${x},${b} ${x - HALF},${b - HALF} ${x - HALF},${a + HALF}`;
|
||||
return `${x},${a} ${x + half},${a + half} ${x + half},${b - half} ${x},${b} ${x - half},${b - half} ${x - half},${a + half}`;
|
||||
};
|
||||
|
||||
type Seg14 =
|
||||
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
|
||||
| 'g1' | 'g2' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm';
|
||||
|
||||
const POLY: Partial<Record<Seg14, string>> = {
|
||||
a: hSeg(1, 1, 9),
|
||||
d: hSeg(17, 1, 9),
|
||||
g1: hSeg(9, 1, 5),
|
||||
g2: hSeg(9, 5, 9),
|
||||
f: vSeg(1, 1, 9),
|
||||
b: vSeg(9, 1, 9),
|
||||
e: vSeg(1, 9, 17),
|
||||
c: vSeg(9, 9, 17),
|
||||
i: vSeg(5, 1, 9),
|
||||
l: vSeg(5, 9, 17),
|
||||
};
|
||||
const buildPoly = (half: number): Partial<Record<Seg14, string>> => ({
|
||||
a: hSeg(1, 1, 9, half),
|
||||
d: hSeg(17, 1, 9, half),
|
||||
g1: hSeg(9, 1, 5, half),
|
||||
g2: hSeg(9, 5, 9, half),
|
||||
f: vSeg(1, 1, 9, half),
|
||||
b: vSeg(9, 1, 9, half),
|
||||
e: vSeg(1, 9, 17, half),
|
||||
c: vSeg(9, 9, 17, half),
|
||||
i: vSeg(5, 1, 9, half),
|
||||
l: vSeg(5, 9, 17, half),
|
||||
});
|
||||
|
||||
/** Diagonals render as thick lines: [x1, y1, x2, y2]. */
|
||||
const DIAG: Partial<Record<Seg14, [number, number, number, number]>> = {
|
||||
|
|
@ -85,6 +86,8 @@ const CHARS: Record<string, Seg14[]> = {
|
|||
X: ['h', 'j', 'k', 'm'],
|
||||
Y: ['h', 'j', 'l'],
|
||||
Z: ['a', 'd', 'j', 'k'],
|
||||
// '$' is an S with the center verticals lit through it.
|
||||
$: ['a', 'c', 'd', 'f', 'g1', 'g2', 'i', 'l'],
|
||||
'-': ['g1', 'g2'],
|
||||
'_': ['d'],
|
||||
'=': ['g1', 'g2', 'd'],
|
||||
|
|
@ -96,17 +99,25 @@ const CHARS: Record<string, Seg14[]> = {
|
|||
' ': [],
|
||||
};
|
||||
|
||||
const ALL_SEGMENTS = Object.keys({ ...POLY, ...DIAG }) as Seg14[];
|
||||
const ALL_SEGMENTS = Object.keys({ ...buildPoly(BASE_HALF), ...DIAG }) as Seg14[];
|
||||
|
||||
export interface AlphaDisplayProps {
|
||||
/** Text: A–Z, 0–9, space, - _ = + * / \ ? and '.' (attaches to the previous cell). */
|
||||
/** Text: A–Z, 0–9, space, - _ = + * / \ ? $ : % and '.' (attaches to the previous cell). */
|
||||
value: string;
|
||||
/** Fixed number of character cells; the text is padded/cropped to fit. */
|
||||
chars?: number;
|
||||
align?: 'left' | 'right';
|
||||
/** Character height in px. Default: 22. */
|
||||
height?: number;
|
||||
/** Lit color. Defaults to the theme's `ledAmber`. */
|
||||
color?: string;
|
||||
/**
|
||||
* Segment thickness multiplier — thickens strokes so the display holds up at
|
||||
* small heights. Default: 1.
|
||||
*/
|
||||
weight?: number;
|
||||
/** Inter-cell spacing. Default: 3.4. */
|
||||
gap?: number;
|
||||
/** Panel background. Set to 'none' to disable. */
|
||||
background?: string;
|
||||
/** Opacity of unlit segments. Default: 0.08. */
|
||||
|
|
@ -116,6 +127,8 @@ export interface AlphaDisplayProps {
|
|||
/** LED glow strength (0 disables). Default: 1.4. */
|
||||
glow?: number;
|
||||
padding?: number;
|
||||
/** Dim the display uniformly (matches disabled controls). */
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
|
@ -129,20 +142,29 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
chars,
|
||||
align = 'left',
|
||||
height = 22,
|
||||
color = '#ffb84d',
|
||||
color,
|
||||
weight = 1,
|
||||
gap = DEFAULT_GAP,
|
||||
background = '#0a0d0c',
|
||||
ghostOpacity = 0.08,
|
||||
skew = 6,
|
||||
glow = 1.4,
|
||||
padding = 6,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const lit = color ?? theme.ledAmber;
|
||||
const filterId = React.useId();
|
||||
const half = BASE_HALF * weight;
|
||||
const POLY = buildPoly(half);
|
||||
const dot = half * 1.35;
|
||||
const diagWidth = 1.5 * weight;
|
||||
|
||||
let text = value.toUpperCase();
|
||||
if (chars !== undefined) {
|
||||
const cellCount = text.replace(/\./g, '').length;
|
||||
const cellCount = text.replace(/[.]/g, '').length;
|
||||
if (cellCount < chars) {
|
||||
const pad = ' '.repeat(chars - cellCount);
|
||||
text = align === 'right' ? pad + text : text + pad;
|
||||
|
|
@ -164,19 +186,43 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
for (const ch of text) {
|
||||
if (ch === '.') {
|
||||
cells.push(
|
||||
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={H - 1.2} r={1.15} fill={color} />,
|
||||
<rect key={key++} x={x - dot} y={H - dot * 2} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />,
|
||||
);
|
||||
x += dot * 2 + Math.min(gap * 0.6, 2.6);
|
||||
continue;
|
||||
}
|
||||
const lit = new Set(CHARS[ch] ?? []);
|
||||
if (ch === ':') {
|
||||
const cx = x + dot;
|
||||
cells.push(
|
||||
<g key={key++}>
|
||||
<rect x={cx - dot} y={H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
|
||||
<rect x={cx - dot} y={H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
|
||||
</g>,
|
||||
);
|
||||
x += dot * 2 + gap;
|
||||
continue;
|
||||
}
|
||||
if (ch === '%') {
|
||||
// Slash with a dot at each end — the percent idiom.
|
||||
cells.push(
|
||||
<g key={key++} transform={`translate(${x} 0)`}>
|
||||
<line x1={2} y1={16} x2={8} y2={2} stroke={lit} strokeWidth={diagWidth} strokeLinecap="butt" />
|
||||
<circle cx={2.8} cy={4} r={dot * 1.05} fill={lit} />
|
||||
<circle cx={7.2} cy={14} r={dot * 1.05} fill={lit} />
|
||||
</g>,
|
||||
);
|
||||
x += W + gap;
|
||||
continue;
|
||||
}
|
||||
const on = new Set(CHARS[ch] ?? []);
|
||||
const parts: React.ReactNode[] = [];
|
||||
for (const seg of ALL_SEGMENTS) {
|
||||
const on = lit.has(seg);
|
||||
if (!on && ghostOpacity <= 0) continue;
|
||||
const opacity = on ? 1 : ghostOpacity;
|
||||
const isOn = on.has(seg);
|
||||
if (!isOn && ghostOpacity <= 0) continue;
|
||||
const opacity = isOn ? 1 : ghostOpacity;
|
||||
const poly = POLY[seg];
|
||||
if (poly) {
|
||||
parts.push(<polygon key={seg} points={poly} fill={color} opacity={opacity} />);
|
||||
parts.push(<polygon key={seg} points={poly} fill={lit} opacity={opacity} />);
|
||||
} else {
|
||||
const [x1, y1, x2, y2] = DIAG[seg]!;
|
||||
parts.push(
|
||||
|
|
@ -186,8 +232,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
stroke={lit}
|
||||
strokeWidth={diagWidth}
|
||||
strokeLinecap="butt"
|
||||
opacity={opacity}
|
||||
/>,
|
||||
|
|
@ -199,10 +245,10 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
{parts}
|
||||
</g>,
|
||||
);
|
||||
x += W + GAP;
|
||||
x += W + gap;
|
||||
}
|
||||
|
||||
const contentW = Math.max(x - GAP, 0);
|
||||
const contentW = Math.max(x - gap, 0);
|
||||
const scale = height / H;
|
||||
const w = contentW * scale + padding * 2 + height * 0.15;
|
||||
const h = height + padding * 2;
|
||||
|
|
@ -213,7 +259,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
height={h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={className}
|
||||
style={style}
|
||||
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
role="img"
|
||||
aria-label={value}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,33 @@
|
|||
import * as React from 'react';
|
||||
import { formatForDisplay, renderSegmentText } from './segments';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
import { DisplayZone, formatForDisplay, renderSegmentText, zoneColorForValue } from './segments';
|
||||
|
||||
export interface SegmentDisplayProps {
|
||||
value: number | string;
|
||||
/** Number of digit cells (decimal points don't count). Default: 4. */
|
||||
/** Number of digit cells (points/colons don't count). Default: 4. */
|
||||
digits?: number;
|
||||
decimals?: number;
|
||||
/** Digit height in px. Default: 28. */
|
||||
/** Digit height in px. Default: 28. Below ~11px, bump `weight` to stay legible. */
|
||||
height?: number;
|
||||
/** Lit color. Defaults to the theme's `ledGreen`. `valueColor`/`zones` win over it. */
|
||||
color?: string;
|
||||
/**
|
||||
* Color the readout from its numeric value — e.g. green/amber/red by load.
|
||||
* Takes precedence over `color`/`zones`. Ignored for string values.
|
||||
*/
|
||||
valueColor?: (value: number) => string;
|
||||
/**
|
||||
* Value-space color zones (thresholds are values, not normalized), e.g.
|
||||
* `[{upTo: 75, color: green}, {upTo: 90, color: amber}, {upTo: Infinity, color: red}]`.
|
||||
*/
|
||||
zones?: readonly DisplayZone[];
|
||||
/**
|
||||
* Segment thickness multiplier — thickens strokes so the display holds up at
|
||||
* small heights. Default: 1.
|
||||
*/
|
||||
weight?: number;
|
||||
/** Inter-cell spacing. Default: 3.2 — raise it to space crowded readouts. */
|
||||
gap?: number;
|
||||
/** Panel background. Set to 'none' to disable. */
|
||||
background?: string;
|
||||
/** Opacity of unlit segments. Default: 0.09. */
|
||||
|
|
@ -18,6 +37,8 @@ export interface SegmentDisplayProps {
|
|||
/** LED glow strength (0 disables). Default: 1.6. */
|
||||
glow?: number;
|
||||
padding?: number;
|
||||
/** Dim the display uniformly (matches disabled controls). */
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
|
@ -31,22 +52,37 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
|
|||
digits = 4,
|
||||
decimals = 0,
|
||||
height = 28,
|
||||
color = '#3df2ad',
|
||||
color,
|
||||
valueColor,
|
||||
zones,
|
||||
weight = 1,
|
||||
gap,
|
||||
background = '#0a0d0c',
|
||||
ghostOpacity = 0.09,
|
||||
skew = 6,
|
||||
glow = 1.6,
|
||||
padding = 6,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const filterId = React.useId();
|
||||
const numeric = typeof value === 'number' ? value : NaN;
|
||||
const resolved =
|
||||
(valueColor && !Number.isNaN(numeric) ? valueColor(numeric) : undefined) ??
|
||||
(zones && !Number.isNaN(numeric) ? zoneColorForValue(zones, numeric) : undefined) ??
|
||||
color ??
|
||||
theme.ledGreen;
|
||||
|
||||
const text =
|
||||
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
|
||||
const { nodes, width, height: cellH } = renderSegmentText(text, {
|
||||
color,
|
||||
color: resolved,
|
||||
ghostOpacity,
|
||||
skew,
|
||||
weight,
|
||||
gap,
|
||||
});
|
||||
|
||||
const scale = height / cellH;
|
||||
|
|
@ -59,7 +95,8 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
|
|||
height={h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={className}
|
||||
style={style}
|
||||
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
role="img"
|
||||
aria-label={typeof value === 'number' ? String(value) : value}
|
||||
>
|
||||
|
|
|
|||
46
packages/dreamknob/src/digital/segments.test.ts
Normal file
46
packages/dreamknob/src/digital/segments.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { formatForDisplay, zoneColorForValue } from './segments';
|
||||
|
||||
describe('formatForDisplay', () => {
|
||||
it('right-aligns within the digit width', () => {
|
||||
// 2 digits (3,5) fill a 2-cell width — the point is extra, no leading pad.
|
||||
expect(formatForDisplay(3.5, 2, 1)).toBe('3.5');
|
||||
expect(formatForDisplay(42, 3, 0)).toBe(' 42');
|
||||
});
|
||||
|
||||
it('overflows to dashes when it does not fit', () => {
|
||||
expect(formatForDisplay(12345, 3, 0)).toBe('---');
|
||||
});
|
||||
|
||||
it('does not count the decimal point toward the digit budget', () => {
|
||||
// 3 digits (1,2,3) + a point still fits digits=3.
|
||||
expect(formatForDisplay(12.3, 3, 1)).toBe('12.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoneColorForValue', () => {
|
||||
const zones = [
|
||||
{ upTo: 74, color: 'green' },
|
||||
{ upTo: 89, color: 'amber' },
|
||||
{ upTo: Infinity, color: 'red' },
|
||||
];
|
||||
|
||||
it('picks the zone whose upper bound the value falls under', () => {
|
||||
expect(zoneColorForValue(zones, 40)).toBe('green');
|
||||
expect(zoneColorForValue(zones, 80)).toBe('amber');
|
||||
expect(zoneColorForValue(zones, 99)).toBe('red');
|
||||
});
|
||||
|
||||
it('is inclusive at the boundary', () => {
|
||||
expect(zoneColorForValue(zones, 74)).toBe('green');
|
||||
expect(zoneColorForValue(zones, 89)).toBe('amber');
|
||||
});
|
||||
|
||||
it('sorts unordered zones before resolving', () => {
|
||||
const unordered = [
|
||||
{ upTo: Infinity, color: 'red' },
|
||||
{ upTo: 74, color: 'green' },
|
||||
];
|
||||
expect(zoneColorForValue(unordered, 10)).toBe('green');
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,9 @@ import * as React from 'react';
|
|||
|
||||
const DIGIT_W = 10;
|
||||
const DIGIT_H = 18;
|
||||
const GAP = 3.2; // spacing between digit cells (leaves room for decimal points)
|
||||
const DEFAULT_GAP = 3.2; // spacing between digit cells (leaves room for points)
|
||||
const BASE_HALF = 1.1; // half segment thickness at weight 1
|
||||
const INSET = 0.45; // gap between adjacent segments
|
||||
|
||||
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
|
||||
|
||||
|
|
@ -57,30 +59,30 @@ const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
|
|||
const segmentsFor = (ch: string): SegmentKey[] =>
|
||||
CHAR_SEGMENTS[ch] ?? CHAR_SEGMENTS[ch.toUpperCase()] ?? CHAR_SEGMENTS[ch.toLowerCase()] ?? [];
|
||||
|
||||
const H_HALF = 1.1; // half thickness
|
||||
const INSET = 0.45; // gap between adjacent segments
|
||||
|
||||
const hSegment = (y: number): string => {
|
||||
const hSegment = (y: number, half: number): string => {
|
||||
const x1 = 1 + INSET;
|
||||
const x2 = DIGIT_W - 1 - INSET;
|
||||
return `${x1},${y} ${x1 + H_HALF},${y - H_HALF} ${x2 - H_HALF},${y - H_HALF} ${x2},${y} ${x2 - H_HALF},${y + H_HALF} ${x1 + H_HALF},${y + H_HALF}`;
|
||||
return `${x1},${y} ${x1 + half},${y - half} ${x2 - half},${y - half} ${x2},${y} ${x2 - half},${y + half} ${x1 + half},${y + half}`;
|
||||
};
|
||||
|
||||
const vSegment = (x: number, y1: number, y2: number): string => {
|
||||
const vSegment = (x: number, y1: number, y2: number, half: number): string => {
|
||||
const a = y1 + INSET;
|
||||
const b = y2 - INSET;
|
||||
return `${x},${a} ${x + H_HALF},${a + H_HALF} ${x + H_HALF},${b - H_HALF} ${x},${b} ${x - H_HALF},${b - H_HALF} ${x - H_HALF},${a + H_HALF}`;
|
||||
return `${x},${a} ${x + half},${a + half} ${x + half},${b - half} ${x},${b} ${x - half},${b - half} ${x - half},${a + half}`;
|
||||
};
|
||||
|
||||
const SEGMENT_POINTS: Record<SegmentKey, string> = {
|
||||
a: hSegment(1),
|
||||
g: hSegment(DIGIT_H / 2),
|
||||
d: hSegment(DIGIT_H - 1),
|
||||
f: vSegment(1, 1, DIGIT_H / 2),
|
||||
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2),
|
||||
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1),
|
||||
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1),
|
||||
};
|
||||
/** Build the seven segment polygons for a given half-thickness (weight). */
|
||||
const buildSegments = (half: number): Record<SegmentKey, string> => ({
|
||||
a: hSegment(1, half),
|
||||
g: hSegment(DIGIT_H / 2, half),
|
||||
d: hSegment(DIGIT_H - 1, half),
|
||||
f: vSegment(1, 1, DIGIT_H / 2, half),
|
||||
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2, half),
|
||||
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1, half),
|
||||
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1, half),
|
||||
});
|
||||
|
||||
const SEGMENT_ORDER: SegmentKey[] = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
|
||||
|
||||
export interface SegmentRenderOptions {
|
||||
color: string;
|
||||
|
|
@ -88,40 +90,69 @@ export interface SegmentRenderOptions {
|
|||
ghostOpacity: number;
|
||||
/** Negative skew for the classic italic LCD look, in degrees. */
|
||||
skew: number;
|
||||
/**
|
||||
* Segment thickness multiplier — thickens the strokes so a display holds
|
||||
* up at small heights (like a font weight). Default: 1.
|
||||
*/
|
||||
weight?: number;
|
||||
/** Inter-cell spacing override. Default: 3.2. */
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `text` (digits, '-', '.', ' ') as seven-segment polygons in local
|
||||
* coordinates (digit height 18). Returns the nodes plus the total width.
|
||||
* Render `text` (digits, '-', '.', ':', ' ') as seven-segment polygons in
|
||||
* local coordinates (digit height 18). The decimal point and colon are drawn
|
||||
* as bold cells with their own width so they hold up at small sizes rather
|
||||
* than vanishing between digits. Returns the nodes plus the total width.
|
||||
*/
|
||||
export const renderSegmentText = (
|
||||
text: string,
|
||||
{ color, ghostOpacity, skew }: SegmentRenderOptions,
|
||||
{ color, ghostOpacity, skew, weight = 1, gap = DEFAULT_GAP }: SegmentRenderOptions,
|
||||
): { nodes: React.ReactNode; width: number; height: number } => {
|
||||
const half = BASE_HALF * weight;
|
||||
const segPoints = buildSegments(half);
|
||||
const cells: React.ReactNode[] = [];
|
||||
let x = 0;
|
||||
let key = 0;
|
||||
|
||||
// Bold point: a rounded cell, not a hairline. Sized to the segment weight.
|
||||
const dot = half * 1.35;
|
||||
|
||||
for (const ch of text) {
|
||||
if (ch === '.') {
|
||||
// Decimal point sits in the gap after the previous digit.
|
||||
cells.push(
|
||||
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={DIGIT_H - 1.2} r={1.2} fill={color} />,
|
||||
<rect
|
||||
key={key++}
|
||||
x={x - dot}
|
||||
y={DIGIT_H - dot * 2}
|
||||
width={dot * 2}
|
||||
height={dot * 2}
|
||||
rx={dot * 0.5}
|
||||
fill={color}
|
||||
/>,
|
||||
);
|
||||
// A little breathing room so the following digit doesn't crowd the point.
|
||||
x += dot * 2 + Math.min(gap * 0.6, 2.4);
|
||||
continue;
|
||||
}
|
||||
if (ch === ':') {
|
||||
const cx = x + dot;
|
||||
cells.push(
|
||||
<g key={key++}>
|
||||
<rect x={cx - dot} y={DIGIT_H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
|
||||
<rect x={cx - dot} y={DIGIT_H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
|
||||
</g>,
|
||||
);
|
||||
x += dot * 2 + gap;
|
||||
continue;
|
||||
}
|
||||
const lit = new Set(segmentsFor(ch));
|
||||
const digit: React.ReactNode[] = [];
|
||||
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
|
||||
SEGMENT_ORDER.forEach(seg => {
|
||||
const on = lit.has(seg);
|
||||
if (!on && ghostOpacity <= 0) return;
|
||||
digit.push(
|
||||
<polygon
|
||||
key={seg}
|
||||
points={SEGMENT_POINTS[seg]}
|
||||
fill={color}
|
||||
opacity={on ? 1 : ghostOpacity}
|
||||
/>,
|
||||
<polygon key={seg} points={segPoints[seg]} fill={color} opacity={on ? 1 : ghostOpacity} />,
|
||||
);
|
||||
});
|
||||
cells.push(
|
||||
|
|
@ -129,18 +160,26 @@ export const renderSegmentText = (
|
|||
{digit}
|
||||
</g>,
|
||||
);
|
||||
x += DIGIT_W + GAP;
|
||||
x += DIGIT_W + gap;
|
||||
}
|
||||
|
||||
const width = Math.max(x - GAP, 0);
|
||||
const nodes = skew ? (
|
||||
<g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g>
|
||||
) : (
|
||||
<>{cells}</>
|
||||
);
|
||||
const width = Math.max(x - gap, 0);
|
||||
const nodes = skew ? <g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g> : <>{cells}</>;
|
||||
return { nodes, width, height: DIGIT_H };
|
||||
};
|
||||
|
||||
export interface DisplayZone {
|
||||
/** Upper bound (inclusive) of this zone in value space. */
|
||||
upTo: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Resolve a value to its zone color (value-space thresholds, not normalized). */
|
||||
export const zoneColorForValue = (zones: readonly DisplayZone[], value: number): string => {
|
||||
const sorted = [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||
return (sorted.find(z => value <= z.upTo + 1e-9) ?? sorted[sorted.length - 1]).color;
|
||||
};
|
||||
|
||||
/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */
|
||||
export const formatForDisplay = (
|
||||
value: number,
|
||||
|
|
@ -148,7 +187,7 @@ export const formatForDisplay = (
|
|||
decimals: number,
|
||||
): string => {
|
||||
let text = value.toFixed(decimals);
|
||||
const cellCount = (s: string) => s.replace(/\./g, '').length;
|
||||
const cellCount = (s: string) => s.replace(/[.:]/g, '').length;
|
||||
// Doesn't fit: dashes, the hardware overflow convention. All-8s would read
|
||||
// as a legitimate value.
|
||||
if (cellCount(text) > digits) text = ''.padStart(digits, '-');
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type {
|
|||
ChangeSource,
|
||||
} from './core/types';
|
||||
export { mixColors, sampleGradient, type GradientStop } from './core/colors';
|
||||
export type { DisplayZone } from './digital/segments';
|
||||
export {
|
||||
clamp,
|
||||
roundTo,
|
||||
|
|
@ -68,9 +69,21 @@ export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFade
|
|||
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 { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
|
||||
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
|
||||
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';
|
||||
export { SegmentSwitch, type SegmentSwitchProps } from './skins/SegmentSwitch';
|
||||
export {
|
||||
TransportButton,
|
||||
type TransportButtonProps,
|
||||
type TransportKind,
|
||||
} from './skins/TransportButton';
|
||||
export { ScrubField, type ScrubFieldProps } from './skins/ScrubField';
|
||||
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
|
||||
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
||||
|
||||
// Layout
|
||||
export { LabeledField, type LabeledFieldProps } from './layout/LabeledField';
|
||||
export { Rack, type RackProps } from './layout/Rack';
|
||||
|
|
|
|||
91
packages/dreamknob/src/layout/LabeledField.tsx
Normal file
91
packages/dreamknob/src/layout/LabeledField.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
|
||||
export interface LabeledFieldProps {
|
||||
/** Field name printed beside the control. */
|
||||
label: string;
|
||||
/** The control (a knob, fader, ScrubField, ToggleSwitch, …). */
|
||||
children: React.ReactNode;
|
||||
/** Unit printed after the control, e.g. "px", "dB". */
|
||||
unit?: string;
|
||||
/**
|
||||
* Label column width — set the same value on a stack of fields so their
|
||||
* controls line up. Number = px. Default: auto.
|
||||
*/
|
||||
labelWidth?: number | string;
|
||||
/** 'row' (label · control · unit, default) or 'column' (label above). */
|
||||
orientation?: 'row' | 'column';
|
||||
/** Push the control to the right edge of the row. Default: false. */
|
||||
spread?: boolean;
|
||||
gap?: number;
|
||||
labelColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* One aligned inspector row: a label, a control and an optional unit. Give a
|
||||
* stack of fields the same `labelWidth` and their controls line up — the
|
||||
* boilerplate every properties panel rewrites, owned in one place.
|
||||
*/
|
||||
export const LabeledField: React.FC<LabeledFieldProps> = ({
|
||||
label,
|
||||
children,
|
||||
unit,
|
||||
labelWidth,
|
||||
orientation = 'row',
|
||||
spread = false,
|
||||
gap = 10,
|
||||
labelColor,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const column = orientation === 'column';
|
||||
const labelEl = (
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
width: column ? undefined : labelWidth,
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
color: labelColor ?? theme.label,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: column ? 'column' : 'row',
|
||||
alignItems: column ? 'flex-start' : 'center',
|
||||
gap: column ? 4 : gap,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{labelEl}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginLeft: spread && !column ? 'auto' : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{unit && (
|
||||
<span style={{ fontFamily: theme.fontMono, fontSize: 11, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
96
packages/dreamknob/src/layout/Rack.tsx
Normal file
96
packages/dreamknob/src/layout/Rack.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
|
||||
export interface RackProps {
|
||||
/** 'row' lays out strips side by side (a rack); 'column' stacks a strip. Default: 'row'. */
|
||||
orientation?: 'row' | 'column';
|
||||
/** Panel caption printed at the top of the chrome. */
|
||||
title?: string;
|
||||
/** Spacing between children in px. Default: 14. */
|
||||
gap?: number;
|
||||
/** Inner padding in px. Default: 14. */
|
||||
padding?: number;
|
||||
/** Draw the panel chrome (border + background). Default: true. */
|
||||
chrome?: boolean;
|
||||
/** Cross-axis alignment. Default: 'stretch' for column, 'flex-start' for row. */
|
||||
align?: React.CSSProperties['alignItems'];
|
||||
accentColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rack / channel-strip container that owns the panel chrome and spacing, so a
|
||||
* console section is declarative: nest `Rack`s (a row of column strips) and the
|
||||
* borders, background and gaps come for free.
|
||||
*/
|
||||
export const Rack: React.FC<RackProps> = ({
|
||||
orientation = 'row',
|
||||
title,
|
||||
gap = 14,
|
||||
padding = 14,
|
||||
chrome = true,
|
||||
align,
|
||||
accentColor,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const column = orientation === 'column';
|
||||
return (
|
||||
<section
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
gap: title ? 10 : 0,
|
||||
padding: chrome ? padding : 0,
|
||||
borderRadius: 10,
|
||||
background: chrome ? theme.panel : undefined,
|
||||
border: chrome ? '1px solid rgba(128,128,128,0.18)' : undefined,
|
||||
boxShadow: chrome ? 'inset 0 1px 0 rgba(255,255,255,0.04)' : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.14em',
|
||||
textTransform: 'uppercase',
|
||||
color: theme.label,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: accentColor ?? theme.accent ?? theme.zoneGood,
|
||||
flex: 'none',
|
||||
}}
|
||||
/>
|
||||
{title}
|
||||
</header>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: column ? 'column' : 'row',
|
||||
alignItems: align ?? (column ? 'stretch' : 'flex-start'),
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
@ -66,34 +66,68 @@ export interface KnobLabelProps {
|
|||
fontFamily?: string;
|
||||
/** Vertical offset from center; defaults to just below the knob body. */
|
||||
dy?: number;
|
||||
/**
|
||||
* Secondary caption line rendered under the main label — a unit or live
|
||||
* descriptor (`SEED` over `world #7`). Not uppercased or letter-spaced.
|
||||
*/
|
||||
sublabel?: React.ReactNode;
|
||||
/** Sub-caption font size. Default: 0.82× the label size. */
|
||||
subFontSize?: number;
|
||||
/** Sub-caption color. Default: the label color at reduced opacity. */
|
||||
subColor?: string;
|
||||
textProps?: React.SVGProps<SVGTextElement>;
|
||||
}
|
||||
|
||||
/** Small caption, e.g. the parameter name. */
|
||||
/** Small caption, e.g. the parameter name — with an optional second line. */
|
||||
export const KnobLabel: React.FC<KnobLabelProps> = ({
|
||||
children,
|
||||
fontSize,
|
||||
color = 'currentColor',
|
||||
fontFamily,
|
||||
dy,
|
||||
sublabel,
|
||||
subFontSize,
|
||||
subColor,
|
||||
textProps,
|
||||
}) => {
|
||||
const { size, center } = useKnobContext();
|
||||
const fs = fontSize ?? size * 0.12;
|
||||
const y = center + (dy ?? size * 0.38);
|
||||
const subFs = subFontSize ?? fs * 0.82;
|
||||
return (
|
||||
<text
|
||||
data-part="label"
|
||||
x={center}
|
||||
y={center + (dy ?? size * 0.38)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={fontSize ?? size * 0.12}
|
||||
fill={color}
|
||||
fontFamily={fontFamily}
|
||||
letterSpacing="0.08em"
|
||||
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
|
||||
{...textProps}
|
||||
>
|
||||
{children}
|
||||
</text>
|
||||
<>
|
||||
<text
|
||||
data-part="label"
|
||||
x={center}
|
||||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={fs}
|
||||
fill={color}
|
||||
fontFamily={fontFamily}
|
||||
letterSpacing="0.08em"
|
||||
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
|
||||
{...textProps}
|
||||
>
|
||||
{children}
|
||||
</text>
|
||||
{sublabel != null && sublabel !== '' && (
|
||||
<text
|
||||
data-part="sublabel"
|
||||
x={center}
|
||||
y={y + fs * 0.72 + subFs * 0.72}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={subFs}
|
||||
fill={subColor ?? color}
|
||||
fontFamily={fontFamily}
|
||||
opacity={subColor ? 1 : 0.7}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
{...textProps}
|
||||
>
|
||||
{sublabel}
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
|||
focusRing?: string | false;
|
||||
/** 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`. Removes wrapper
|
||||
* math in resizable dock panels.
|
||||
*/
|
||||
fill?: boolean;
|
||||
/** Cross-axis size in px. Default: 44. */
|
||||
breadth?: number;
|
||||
color?: string;
|
||||
|
|
@ -49,6 +55,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
orientation = 'vertical',
|
||||
focusRing,
|
||||
length = 160,
|
||||
fill = false,
|
||||
breadth = 44,
|
||||
color: colorProp,
|
||||
trackColor,
|
||||
|
|
@ -97,6 +104,9 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
const mid = breadth / 2;
|
||||
// Fill stretches the travel axis; the cross axis stays at `breadth`.
|
||||
const ctrlW = fill && !vertical ? '100%' : w;
|
||||
const ctrlH = fill && vertical ? '100%' : h;
|
||||
|
||||
// Handle center position along the travel axis.
|
||||
const travel = length - capMain;
|
||||
|
|
@ -186,14 +196,21 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
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: 8,
|
||||
...(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>
|
||||
{capColor ? (
|
||||
// Custom cap: solid base with a neutral shading overlay so any
|
||||
|
|
@ -277,7 +294,8 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
|||
gap: 6,
|
||||
// Fixed width ('top' mode) so a widening readout never reflows the
|
||||
// layout; 'end' mode sizes to content with a min-width readout slot.
|
||||
width: atEnd && !vertical ? undefined : w,
|
||||
width: fill && !vertical ? '100%' : atEnd && !vertical ? undefined : w,
|
||||
height: fill && vertical ? '100%' : undefined,
|
||||
opacity: core.disabled ? 0.45 : undefined,
|
||||
...style,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
|
|||
arcFrom = 'min',
|
||||
arcThickness,
|
||||
label,
|
||||
sublabel,
|
||||
labelSize,
|
||||
showValue = true,
|
||||
unit,
|
||||
format,
|
||||
|
|
@ -81,7 +83,8 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
|
|||
color={labelColor ?? theme.label}
|
||||
fontFamily={theme.fontUI}
|
||||
dy={size * 0.42}
|
||||
fontSize={size * 0.1}
|
||||
fontSize={labelSize ?? size * 0.1}
|
||||
sublabel={sublabel}
|
||||
>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
|
|
|
|||
179
packages/dreamknob/src/skins/Gauge.tsx
Normal file
179
packages/dreamknob/src/skins/Gauge.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import * as React from 'react';
|
||||
import { angleFromNormalized, clamp, describeArc, linearTaper, polarToCartesian } from '../core/math';
|
||||
import type { Taper } from '../core/math';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
||||
import type { LEDFaderZone } from './LEDFader';
|
||||
|
||||
export interface GaugeProps {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
taper?: Taper;
|
||||
/** Diameter in px. Default: 120. */
|
||||
size?: number;
|
||||
/** Arc thickness in px. Default: size * 0.09. */
|
||||
thickness?: number;
|
||||
/** Start angle (deg clockwise from 12 o'clock) and sweep. Default: 225 / 270. */
|
||||
angleOffset?: number;
|
||||
angleRange?: number;
|
||||
/** Single arc color. Ignored when `zones` is set. Defaults to theme zones. */
|
||||
color?: string;
|
||||
/** Color the arc from the value (green/amber/red load). Wins over color/zones. */
|
||||
valueColor?: (value: number) => string;
|
||||
/** Normalized color zones (like Meter). Defaults to theme zoneGood/Warn/Hot. */
|
||||
zones?: readonly LEDFaderZone[];
|
||||
trackColor?: string;
|
||||
/** Draw a needle pointing at the value. Default: true. */
|
||||
needle?: boolean;
|
||||
/** Center numeric readout. Default: true. */
|
||||
showValue?: boolean;
|
||||
digits?: number;
|
||||
displayDecimals?: number;
|
||||
/** Unit suffix under the readout, e.g. "%". */
|
||||
unit?: string;
|
||||
/** Caption under the gauge. */
|
||||
label?: string;
|
||||
labelColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only radial arc meter — the round sibling of `Meter`, for CPU / RAM /
|
||||
* load and any single gauge value. Colors itself by zone as the value climbs.
|
||||
*/
|
||||
export const Gauge: React.FC<GaugeProps> = ({
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
taper = linearTaper,
|
||||
size = 120,
|
||||
thickness,
|
||||
angleOffset = 225,
|
||||
angleRange = 270,
|
||||
color,
|
||||
valueColor,
|
||||
zones,
|
||||
trackColor,
|
||||
needle = true,
|
||||
showValue = true,
|
||||
digits = 3,
|
||||
displayDecimals = 0,
|
||||
unit,
|
||||
label,
|
||||
labelColor,
|
||||
className,
|
||||
style,
|
||||
...aria
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const t = thickness ?? Math.max(4, size * 0.09);
|
||||
const c = size / 2;
|
||||
const r = c - t / 2 - 1;
|
||||
const clamped = clamp(value, min, max);
|
||||
const n = clamp(taper.toNormalized(clamped, min, max), 0, 1);
|
||||
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
|
||||
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||
if (color) return [{ upTo: 1, color }];
|
||||
return [
|
||||
{ upTo: 0.72, color: theme.zoneGood },
|
||||
{ upTo: 0.9, color: theme.zoneWarn },
|
||||
{ upTo: 1, color: theme.zoneHot },
|
||||
];
|
||||
}, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]);
|
||||
const zoneAt = (norm: number) =>
|
||||
(zoneList.find(z => norm <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||
const litColor = valueColor ? valueColor(clamped) : zoneAt(n);
|
||||
|
||||
const valueAngle = angleFromNormalized(n, angleOffset, angleRange);
|
||||
const trackPath = describeArc(c, c, r, angleOffset, angleOffset + angleRange);
|
||||
const valuePath = n > 0.001 ? describeArc(c, c, r, angleOffset, valueAngle) : '';
|
||||
const tip = polarToCartesian(c, c, r - t * 0.1, valueAngle);
|
||||
const tail = polarToCartesian(c, c, t * 0.4, valueAngle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="meter"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={clamped}
|
||||
aria-label={aria['aria-label'] ?? label}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
width: size,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<path d={trackPath} fill="none" stroke={trackColor ?? theme.track} strokeWidth={t} strokeLinecap="round" />
|
||||
{valuePath && (
|
||||
<path d={valuePath} fill="none" stroke={litColor} strokeWidth={t} strokeLinecap="round" />
|
||||
)}
|
||||
{needle && (
|
||||
<line
|
||||
x1={tail.x}
|
||||
y1={tail.y}
|
||||
x2={tip.x}
|
||||
y2={tip.y}
|
||||
stroke={litColor}
|
||||
strokeWidth={Math.max(2, size * 0.022)}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{needle && <circle cx={c} cy={c} r={Math.max(2.5, size * 0.04)} fill={litColor} />}
|
||||
</svg>
|
||||
{showValue && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: needle ? '58%' : '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<SegmentDisplay
|
||||
value={clamped}
|
||||
digits={digits}
|
||||
decimals={displayDecimals}
|
||||
height={size * 0.16}
|
||||
color={litColor}
|
||||
background="none"
|
||||
ghostOpacity={0.06}
|
||||
/>
|
||||
{unit && (
|
||||
<span style={{ fontFamily: theme.fontMono, fontSize: size * 0.08, color: theme.label }}>{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{label && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: labelColor ?? theme.label,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -92,6 +92,8 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
|||
labelColor,
|
||||
faceColor = '#0b0d0e',
|
||||
label,
|
||||
sublabel,
|
||||
labelSize,
|
||||
unit,
|
||||
focusRing,
|
||||
className,
|
||||
|
|
@ -150,7 +152,8 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
|||
color={labelColor ?? theme.label}
|
||||
fontFamily={theme.fontUI}
|
||||
dy={size * 0.45}
|
||||
fontSize={size * 0.095}
|
||||
fontSize={labelSize ?? size * 0.095}
|
||||
sublabel={sublabel}
|
||||
>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
|
|
|
|||
134
packages/dreamknob/src/skins/LampRow.tsx
Normal file
134
packages/dreamknob/src/skins/LampRow.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
|
||||
export interface LampRowItem {
|
||||
label: string;
|
||||
on: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface LampRowProps {
|
||||
lamps: readonly LampRowItem[];
|
||||
/** Lamp diameter in px. Default: 9. */
|
||||
size?: number;
|
||||
/** Caption placement relative to each lamp. Default: 'right'. */
|
||||
labelPosition?: 'right' | 'below';
|
||||
/** Make lamps clickable (a compact indicator toggle bank). */
|
||||
onToggle?: (index: number, on: boolean) => void;
|
||||
/** Spacing between lamp cells in px. Default: 14. */
|
||||
gap?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of small indicator lamps with console-print captions — the
|
||||
* `● CUE ● MUTE` bank hardware panels use, standardized. Read-only status by
|
||||
* default; pass `onToggle` to make the lamps pressable.
|
||||
*/
|
||||
export const LampRow: React.FC<LampRowProps> = ({
|
||||
lamps,
|
||||
size = 9,
|
||||
labelPosition = 'right',
|
||||
onToggle,
|
||||
gap = 14,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
...aria
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const below = labelPosition === 'below';
|
||||
const interactive = !!onToggle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="group"
|
||||
aria-label={aria['aria-label']}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap,
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{lamps.map((lamp, i) => {
|
||||
const accent = lamp.color ?? theme.accent ?? theme.zoneGood;
|
||||
const dot = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
flex: 'none',
|
||||
borderRadius: '50%',
|
||||
background: lamp.on ? accent : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(0,0,0,0.5)',
|
||||
boxShadow: lamp.on
|
||||
? `0 0 ${size * 0.8}px ${accent}, inset 0 0 ${size * 0.25}px rgba(255,255,255,0.5)`
|
||||
: 'inset 0 1px 2px rgba(0,0,0,0.6)',
|
||||
transition: 'background 80ms, box-shadow 80ms',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const caption = (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 9.5,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: lamp.on ? theme.text : theme.label,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 80ms',
|
||||
}}
|
||||
>
|
||||
{lamp.label}
|
||||
</span>
|
||||
);
|
||||
const inner = (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: below ? 'column' : 'row',
|
||||
alignItems: 'center',
|
||||
gap: below ? 4 : 6,
|
||||
}}
|
||||
>
|
||||
{dot}
|
||||
{caption}
|
||||
</span>
|
||||
);
|
||||
|
||||
return interactive ? (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={lamp.on}
|
||||
aria-label={lamp.label}
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle!(i, !lamp.on)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<React.Fragment key={i}>{inner}</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -33,6 +33,8 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
|
|||
showArc = true,
|
||||
tickCount = 21,
|
||||
label,
|
||||
sublabel,
|
||||
labelSize,
|
||||
showValue = false,
|
||||
unit,
|
||||
format,
|
||||
|
|
@ -137,7 +139,8 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
|
|||
color={labelColor ?? theme.label}
|
||||
fontFamily={theme.fontUI}
|
||||
dy={size * 0.44}
|
||||
fontSize={size * 0.1}
|
||||
fontSize={labelSize ?? size * 0.1}
|
||||
sublabel={sublabel}
|
||||
>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ export interface MeterProps {
|
|||
orientation?: 'vertical' | 'horizontal';
|
||||
/** Travel length in px. Default: 160. */
|
||||
length?: number;
|
||||
/**
|
||||
* Scale to fill the container instead of a fixed px footprint. The SVG keeps
|
||||
* its `length`×`breadth` aspect ratio; a horizontal meter fills width, a
|
||||
* vertical one fills height. Great for resizable dock panels.
|
||||
*/
|
||||
fill?: boolean;
|
||||
/** Dim the meter uniformly (matches disabled controls). */
|
||||
disabled?: boolean;
|
||||
/** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */
|
||||
breadth?: number;
|
||||
/** Number of LED segments. Default: 28. */
|
||||
|
|
@ -81,6 +89,8 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
taper = linearTaper,
|
||||
orientation = 'vertical',
|
||||
length = 160,
|
||||
fill = false,
|
||||
disabled = false,
|
||||
breadth = 16,
|
||||
segments = 28,
|
||||
color,
|
||||
|
|
@ -225,12 +235,15 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
aria-valuenow={hottestShown}
|
||||
aria-label={aria['aria-label']}
|
||||
data-clipped={clipped ? '' : undefined}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
display: fill ? 'flex' : 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
width: w,
|
||||
width: fill && !vertical ? '100%' : w,
|
||||
height: fill && vertical ? '100%' : undefined,
|
||||
opacity: disabled ? 0.45 : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
|
@ -252,7 +265,14 @@ export const Meter: React.FC<MeterProps> = ({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<svg
|
||||
width={fill && !vertical ? '100%' : w}
|
||||
height={fill && vertical ? '100%' : h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio={fill ? (vertical ? 'xMidYMax meet' : 'xMinYMid meet') : undefined}
|
||||
style={{ display: 'block' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
|
||||
{bars}
|
||||
{showClip && (
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
|
|||
labelColor,
|
||||
arcFrom = 'min',
|
||||
label,
|
||||
sublabel,
|
||||
labelSize,
|
||||
showValue = true,
|
||||
unit,
|
||||
format,
|
||||
|
|
@ -85,7 +87,8 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
|
|||
color={labelColor ?? theme.label}
|
||||
fontFamily={theme.fontUI}
|
||||
dy={size * 0.45}
|
||||
fontSize={size * 0.1}
|
||||
fontSize={labelSize ?? size * 0.1}
|
||||
sublabel={sublabel}
|
||||
>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export interface PushButtonProps {
|
|||
disabled?: boolean;
|
||||
/** Render a hidden form input carrying "on"/"off". */
|
||||
name?: string;
|
||||
/** Icon rendered before the caption (e.g. a lucide glyph), tinted to the LED. */
|
||||
leadingIcon?: React.ReactNode;
|
||||
/** Caption, e.g. "MUTE". */
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
|
|
@ -44,6 +46,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
width,
|
||||
disabled = false,
|
||||
name,
|
||||
leadingIcon,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
|
|
@ -167,7 +170,23 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{(leadingIcon || children) && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, lineHeight: 1 }}>
|
||||
{leadingIcon && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
color: on ? accent : theme.label,
|
||||
transition: 'color 80ms',
|
||||
}}
|
||||
>
|
||||
{leadingIcon}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
|
|||
labelColor,
|
||||
arcFrom = 'min',
|
||||
label,
|
||||
sublabel,
|
||||
labelSize,
|
||||
showValue = false,
|
||||
unit,
|
||||
format,
|
||||
|
|
@ -103,7 +105,8 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
|
|||
color={labelColor ?? theme.label}
|
||||
fontFamily={theme.fontUI}
|
||||
dy={size * 0.44}
|
||||
fontSize={size * 0.1}
|
||||
fontSize={labelSize ?? size * 0.1}
|
||||
sublabel={sublabel}
|
||||
>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
|
|||
textColor,
|
||||
labelColor,
|
||||
label,
|
||||
sublabel,
|
||||
labelSize,
|
||||
showValue = true,
|
||||
format,
|
||||
focusRing,
|
||||
|
|
@ -106,7 +108,8 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
|
|||
color={labelColor ?? theme.label}
|
||||
fontFamily={theme.fontUI}
|
||||
dy={size * 0.45}
|
||||
fontSize={size * 0.095}
|
||||
fontSize={labelSize ?? size * 0.095}
|
||||
sublabel={sublabel}
|
||||
>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
|
|
|
|||
195
packages/dreamknob/src/skins/ToggleSwitch.tsx
Normal file
195
packages/dreamknob/src/skins/ToggleSwitch.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
/** Controlled on-state. Pair with `onChange`. */
|
||||
on?: boolean;
|
||||
defaultOn?: boolean;
|
||||
onChange?: (on: boolean) => void;
|
||||
/** Accent color when on. Defaults to the theme accent. */
|
||||
color?: string;
|
||||
/** Track height in px (the knob is sized from it). Default: 22. */
|
||||
size?: number;
|
||||
/** Caption beside the switch. */
|
||||
label?: string;
|
||||
labelPosition?: 'right' | 'left';
|
||||
/** Tiny OFF/ON legend printed on the track. Default: false. */
|
||||
showStateLabel?: boolean;
|
||||
/** Text for the on/off legend when `showStateLabel`. Default: 'ON'/'OFF'. */
|
||||
onLabel?: string;
|
||||
offLabel?: string;
|
||||
disabled?: boolean;
|
||||
/** Render a hidden form input carrying "on"/"off". */
|
||||
name?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rocker/slider switch — the most literal boolean idiom. Renders
|
||||
* `role="switch"`; the knob slides between OFF and ON. Space/Enter toggles,
|
||||
* ←/→ set off/on.
|
||||
*/
|
||||
export const ToggleSwitch = React.forwardRef<HTMLButtonElement, ToggleSwitchProps>(
|
||||
function ToggleSwitch(
|
||||
{
|
||||
on,
|
||||
defaultOn = false,
|
||||
onChange,
|
||||
color,
|
||||
size = 22,
|
||||
label,
|
||||
labelPosition = 'right',
|
||||
showStateLabel = false,
|
||||
onLabel = 'ON',
|
||||
offLabel = 'OFF',
|
||||
disabled = false,
|
||||
name,
|
||||
className,
|
||||
style,
|
||||
...aria
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const theme = useKnobTheme();
|
||||
const accent = color ?? theme.accent ?? '#4cc2ff';
|
||||
const isControlled = on !== undefined;
|
||||
const [internal, setInternal] = React.useState(defaultOn);
|
||||
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||
const lit = isControlled ? (on as boolean) : internal;
|
||||
|
||||
const set = (next: boolean) => {
|
||||
if (next === lit) return;
|
||||
if (!isControlled) setInternal(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const trackW = size * 1.85;
|
||||
const trackH = size;
|
||||
const knobD = size - 6;
|
||||
const pad = 3;
|
||||
const knobX = lit ? trackW - knobD - pad : pad;
|
||||
|
||||
const legend = (side: 'on' | 'off') => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
[side === 'on' ? 'left' : 'right']: knobD + pad + 2,
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: Math.max(7, size * 0.34),
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
color: side === 'on' ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.4)',
|
||||
opacity: (side === 'on') === lit ? 1 : 0,
|
||||
transition: 'opacity 120ms',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{side === 'on' ? onLabel : offLabel}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={lit}
|
||||
aria-label={aria['aria-label'] ?? label}
|
||||
disabled={disabled}
|
||||
data-on={lit ? '' : undefined}
|
||||
data-focus-visible={focusVisible ? '' : undefined}
|
||||
className={className}
|
||||
onClick={() => set(!lit)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
set(false);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
set(true);
|
||||
}
|
||||
}}
|
||||
onFocus={e => {
|
||||
try {
|
||||
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||
} catch {
|
||||
setFocusVisible(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setFocusVisible(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: labelPosition === 'left' ? 'row-reverse' : 'row',
|
||||
gap: Math.max(6, size * 0.4),
|
||||
padding: 3,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
touchAction: 'manipulation',
|
||||
outline: 'none',
|
||||
boxShadow: focusVisible ? `0 0 0 2px ${theme.focusRing}` : undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
flex: 'none',
|
||||
width: trackW,
|
||||
height: trackH,
|
||||
borderRadius: trackH / 2,
|
||||
background: lit ? accent : 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(0,0,0,0.55)',
|
||||
boxShadow: lit
|
||||
? `inset 0 0 ${size * 0.3}px rgba(0,0,0,0.25)`
|
||||
: 'inset 0 1px 3px rgba(0,0,0,0.6)',
|
||||
transition: 'background 120ms',
|
||||
}}
|
||||
>
|
||||
{showStateLabel && legend('on')}
|
||||
{showStateLabel && legend('off')}
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: pad,
|
||||
left: knobX,
|
||||
width: knobD,
|
||||
height: knobD,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(180deg, #f4f5f7, #cfd1d6)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
transition: 'left 130ms cubic-bezier(0.3, 0.7, 0.4, 1)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
{label && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: Math.max(11, size * 0.55),
|
||||
letterSpacing: '0.04em',
|
||||
color: lit ? theme.text : theme.label,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 120ms',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{name && <input type="hidden" name={name} value={lit ? 'on' : 'off'} readOnly />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
185
packages/dreamknob/src/skins/TransportButton.tsx
Normal file
185
packages/dreamknob/src/skins/TransportButton.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
|
||||
export type TransportKind =
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'record'
|
||||
| 'rewind'
|
||||
| 'forward'
|
||||
| 'panic';
|
||||
|
||||
export interface TransportButtonProps {
|
||||
kind: TransportKind;
|
||||
/** Engaged/lit state (playing, armed, recording). Reflects app state. */
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
/** Pulse the glyph while active (auto-on for `record`). Reduced-motion aware. */
|
||||
blink?: boolean;
|
||||
/** Override the glyph/LED color. Defaults per kind (play green, record red…). */
|
||||
color?: string;
|
||||
/** Button size in px (square). Default: 38. */
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
const LABEL: Record<TransportKind, string> = {
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
stop: 'Stop',
|
||||
record: 'Record',
|
||||
rewind: 'Rewind',
|
||||
forward: 'Forward',
|
||||
panic: 'Panic — all notes off',
|
||||
};
|
||||
|
||||
/** Glyph in a 24×24 box, filled with `currentColor`. */
|
||||
const Glyph: React.FC<{ kind: TransportKind }> = ({ kind }) => {
|
||||
switch (kind) {
|
||||
case 'play':
|
||||
return <path d="M8 5.5 L19 12 L8 18.5 Z" />;
|
||||
case 'pause':
|
||||
return (
|
||||
<>
|
||||
<rect x="7" y="5.5" width="3.6" height="13" rx="1" />
|
||||
<rect x="13.4" y="5.5" width="3.6" height="13" rx="1" />
|
||||
</>
|
||||
);
|
||||
case 'stop':
|
||||
return <rect x="6.5" y="6.5" width="11" height="11" rx="1.5" />;
|
||||
case 'record':
|
||||
return <circle cx="12" cy="12" r="5.2" />;
|
||||
case 'rewind':
|
||||
return (
|
||||
<>
|
||||
<path d="M11.5 6 L11.5 18 L4 12 Z" />
|
||||
<path d="M20 6 L20 18 L12.5 12 Z" />
|
||||
</>
|
||||
);
|
||||
case 'forward':
|
||||
return (
|
||||
<>
|
||||
<path d="M4 6 L11.5 12 L4 18 Z" />
|
||||
<path d="M12.5 6 L20 12 L12.5 18 Z" />
|
||||
</>
|
||||
);
|
||||
case 'panic':
|
||||
return (
|
||||
<>
|
||||
<rect x="10.6" y="5" width="2.8" height="8.4" rx="1.4" />
|
||||
<circle cx="12" cy="17.4" r="1.7" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A record / play / stop / pause / panic transport button, styled to sit
|
||||
* alongside dreamknob controls. `active` lights it; `record` blinks while
|
||||
* armed. Reduced-motion aware.
|
||||
*/
|
||||
export const TransportButton = React.forwardRef<HTMLButtonElement, TransportButtonProps>(
|
||||
function TransportButton(
|
||||
{ kind, active = false, onClick, blink, color, size = 38, disabled = false, className, style, ...aria },
|
||||
ref,
|
||||
) {
|
||||
const theme = useKnobTheme();
|
||||
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||
const glyphRef = React.useRef<SVGSVGElement>(null);
|
||||
|
||||
const accent =
|
||||
color ??
|
||||
(kind === 'record' || kind === 'panic'
|
||||
? theme.zoneHot
|
||||
: kind === 'play'
|
||||
? theme.zoneGood
|
||||
: kind === 'pause'
|
||||
? theme.zoneWarn
|
||||
: theme.text);
|
||||
const lit = active ? accent : theme.label;
|
||||
const shouldBlink = (blink ?? kind === 'record') && active;
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = glyphRef.current;
|
||||
if (!el || !shouldBlink) return;
|
||||
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
return;
|
||||
const anim = el.animate([{ opacity: 1 }, { opacity: 0.3 }], {
|
||||
duration: 560,
|
||||
iterations: Infinity,
|
||||
direction: 'alternate',
|
||||
easing: 'ease-in-out',
|
||||
});
|
||||
return () => anim.cancel();
|
||||
}, [shouldBlink]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={aria['aria-label'] ?? LABEL[kind]}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
data-active={active ? '' : undefined}
|
||||
data-kind={kind}
|
||||
data-focus-visible={focusVisible ? '' : undefined}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
onFocus={e => {
|
||||
try {
|
||||
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||
} catch {
|
||||
setFocusVisible(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setFocusVisible(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size,
|
||||
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}` : ''
|
||||
}`,
|
||||
outline: 'none',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
touchAction: 'manipulation',
|
||||
transition: 'background 80ms, box-shadow 80ms',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={glyphRef}
|
||||
width={size * 0.55}
|
||||
height={size * 0.55}
|
||||
viewBox="0 0 24 24"
|
||||
fill={lit}
|
||||
style={{
|
||||
display: 'block',
|
||||
filter: active ? `drop-shadow(0 0 ${size * 0.12}px ${accent})` : undefined,
|
||||
transition: 'fill 80ms',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Glyph kind={kind} />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -103,6 +103,8 @@ export const VintageKnob = React.forwardRef<HTMLDivElement, VintageKnobProps>(fu
|
|||
scaleLabels,
|
||||
indicatorColor,
|
||||
label,
|
||||
sublabel,
|
||||
labelSize,
|
||||
focusRing,
|
||||
className,
|
||||
style,
|
||||
|
|
@ -132,7 +134,8 @@ export const VintageKnob = React.forwardRef<HTMLDivElement, VintageKnobProps>(fu
|
|||
color={labelColor ?? theme.label}
|
||||
fontFamily={theme.fontUI}
|
||||
dy={size * 0.46}
|
||||
fontSize={size * 0.095}
|
||||
fontSize={labelSize ?? size * 0.095}
|
||||
sublabel={sublabel}
|
||||
>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ export interface SkinProps extends KnobCoreProps {
|
|||
size?: number;
|
||||
/** Caption drawn in the travel gap at the bottom of the knob. */
|
||||
label?: string;
|
||||
/**
|
||||
* Secondary caption line under the label — a unit or live descriptor
|
||||
* (`SEED` over `world #7`). Rendered smaller and un-uppercased.
|
||||
*/
|
||||
sublabel?: React.ReactNode;
|
||||
/** Label font size in px, independent of `size` (small knobs, readable labels). */
|
||||
labelSize?: number;
|
||||
/** Show the numeric readout. Default varies per skin. */
|
||||
showValue?: boolean;
|
||||
/** Unit suffix for the readout, e.g. "dB", "Hz", "%". */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue