diff --git a/README.md b/README.md index d40beb9..546f3ec 100644 --- a/README.md +++ b/README.md @@ -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**: `` + 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): diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx index 20ff93c..2d4f086 100644 --- a/apps/docs/src/sections/ApiDocs.tsx +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -128,24 +128,32 @@ export const ApiDocs: React.FC = () => ( - + - + - + - - + + + + + + + +

- Every knob skin and fader also takes label, showValue,{' '} - unit, format, className,{' '} - style, name plus every core prop above, and forwards a - ref to its root element (VintageKnob has no readout, so it omits{' '} + Every knob skin and fader also takes label, sublabel{' '} + (a second caption line — SEED over world #7),{' '} + labelSize (caption size independent of the dial diameter),{' '} + showValue, unit, format,{' '} + className, style, name plus every core prop + above, and forwards a ref to its root element (VintageKnob has no readout, so it omits{' '} showValue/unit/format; the display components Meter/SegmentDisplay/AlphaDisplay{' '} 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 }} > @@ -234,6 +245,18 @@ function MyKnob() { }`} /> +

Small readouts

+

+ Segment displays keep their proportions down to about{' '} + 11 px tall; below that, raise weight 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{' '} + 17.5 and 1:23:45 stay readable in a status bar. Widen{' '} + gap when packed tokens like 18.1/24 crowd. Colons and{' '} + $/% are in the charset, so a whole token can live in one + display instead of text beside it. +

+

Recipes

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 -`} + + +// Resource gauge — colors itself green → amber → red as load climbs + + +// Self-coloring resource readout — no app-side color math + + +// Aligned inspector rows — share labelWidth so controls line up + + + + + + + +// A declarative channel strip + + + + +`} />

Precision & number handling

diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx index eebd097..050b87f 100644 --- a/apps/docs/src/sections/Gallery.tsx +++ b/apps/docs/src/sections/Gallery.tsx @@ -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 ( +
+ + +
+ ); +}; + +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 ( +
+
+ + +
+
+ + + +
+
+ ); +}; + +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 ( + +
+ + +
+
+ setPlaying(p => !p)} size={34} /> + setPlaying(false)} size={34} /> + setArmed(a => !a)} size={34} /> + { setPlaying(false); setArmed(false); }} size={34} /> +
+ + + + + +
+ ); +}; + const ImageKnobDemo: React.FC = () => { const src = useGeneratedStrip(); if (!src) return null; @@ -429,6 +524,18 @@ export const Gallery: React.FC = () => ( + + + + + + + + + + + + ( ) · peer deps: react >= 18, react-dom >= 18`} + code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`} />

Installs straight from the{' '} - v1.1.0 release + v1.1.1 release {' '} on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No CSS file to import — everything is SVG and inline styles. diff --git a/apps/docs/src/sections/Hero.tsx b/apps/docs/src/sections/Hero.tsx index bac156d..f71020d 100644 --- a/apps/docs/src/sections/Hero.tsx +++ b/apps/docs/src/sections/Hero.tsx @@ -25,10 +25,10 @@ export const Hero: React.FC = () => { range, any decimal precision, and colors you fully control.

- pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz + pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.1/dreamknob-1.1.1.tgz + ) : ( + {inner} + ); + })} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/MetalKnob.tsx b/packages/dreamknob/src/skins/MetalKnob.tsx index 26690e0..e45afa9 100644 --- a/packages/dreamknob/src/skins/MetalKnob.tsx +++ b/packages/dreamknob/src/skins/MetalKnob.tsx @@ -33,6 +33,8 @@ export const MetalKnob = React.forwardRef(functi showArc = true, tickCount = 21, label, + sublabel, + labelSize, showValue = false, unit, format, @@ -137,7 +139,8 @@ export const MetalKnob = React.forwardRef(functi color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={size * 0.1} + fontSize={labelSize ?? size * 0.1} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx index d088b48..ac9fb3b 100644 --- a/packages/dreamknob/src/skins/Meter.tsx +++ b/packages/dreamknob/src/skins/Meter.tsx @@ -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 = ({ taper = linearTaper, orientation = 'vertical', length = 160, + fill = false, + disabled = false, breadth = 16, segments = 28, color, @@ -225,12 +235,15 @@ export const Meter: React.FC = ({ 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 = ({ )} )} - + {leadingIcon && ( + + )} + {children} + + )} {name && } ); diff --git a/packages/dreamknob/src/skins/RubberKnob.tsx b/packages/dreamknob/src/skins/RubberKnob.tsx index 92b2cfa..1f1cc09 100644 --- a/packages/dreamknob/src/skins/RubberKnob.tsx +++ b/packages/dreamknob/src/skins/RubberKnob.tsx @@ -25,6 +25,8 @@ export const RubberKnob = React.forwardRef(func labelColor, arcFrom = 'min', label, + sublabel, + labelSize, showValue = false, unit, format, @@ -103,7 +105,8 @@ export const RubberKnob = React.forwardRef(func color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.44} - fontSize={size * 0.1} + fontSize={labelSize ?? size * 0.1} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/SteppedKnob.tsx b/packages/dreamknob/src/skins/SteppedKnob.tsx index b07fed0..91dfa50 100644 --- a/packages/dreamknob/src/skins/SteppedKnob.tsx +++ b/packages/dreamknob/src/skins/SteppedKnob.tsx @@ -29,6 +29,8 @@ export const SteppedKnob = React.forwardRef(fu textColor, labelColor, label, + sublabel, + labelSize, showValue = true, format, focusRing, @@ -106,7 +108,8 @@ export const SteppedKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.45} - fontSize={size * 0.095} + fontSize={labelSize ?? size * 0.095} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/ToggleSwitch.tsx b/packages/dreamknob/src/skins/ToggleSwitch.tsx new file mode 100644 index 0000000..e6ffa8d --- /dev/null +++ b/packages/dreamknob/src/skins/ToggleSwitch.tsx @@ -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( + 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') => ( + + ); + + return ( + + ); + }, +); diff --git a/packages/dreamknob/src/skins/TransportButton.tsx b/packages/dreamknob/src/skins/TransportButton.tsx new file mode 100644 index 0000000..9ce6917 --- /dev/null +++ b/packages/dreamknob/src/skins/TransportButton.tsx @@ -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 = { + 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 ; + case 'pause': + return ( + <> + + + + ); + case 'stop': + return ; + case 'record': + return ; + case 'rewind': + return ( + <> + + + + ); + case 'forward': + return ( + <> + + + + ); + case 'panic': + return ( + <> + + + + ); + } +}; + +/** + * 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( + 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(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 ( + + ); + }, +); diff --git a/packages/dreamknob/src/skins/VintageKnob.tsx b/packages/dreamknob/src/skins/VintageKnob.tsx index 123a1f4..8c97c64 100644 --- a/packages/dreamknob/src/skins/VintageKnob.tsx +++ b/packages/dreamknob/src/skins/VintageKnob.tsx @@ -103,6 +103,8 @@ export const VintageKnob = React.forwardRef(fu scaleLabels, indicatorColor, label, + sublabel, + labelSize, focusRing, className, style, @@ -132,7 +134,8 @@ export const VintageKnob = React.forwardRef(fu color={labelColor ?? theme.label} fontFamily={theme.fontUI} dy={size * 0.46} - fontSize={size * 0.095} + fontSize={labelSize ?? size * 0.095} + sublabel={sublabel} > {label} diff --git a/packages/dreamknob/src/skins/shared.ts b/packages/dreamknob/src/skins/shared.ts index ddadc36..5eb989a 100644 --- a/packages/dreamknob/src/skins/shared.ts +++ b/packages/dreamknob/src/skins/shared.ts @@ -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", "%". */