@brett_lamy/docstream 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -120,7 +120,8 @@ function Device({
120
120
  return (
121
121
  <section className="strudel-device" data-kind={track.kind} data-audible={audible} aria-label={name}>
122
122
  <header className="strudel-device__head">
123
- <Icon size={15} aria-hidden="true" />
123
+ <span className="strudel-device__led" aria-hidden="true" />
124
+ <Icon size={14} aria-hidden="true" />
124
125
  <span className="strudel-device__name" title={code.slice(track.start, track.end)}>
125
126
  {name}
126
127
  </span>
@@ -145,7 +146,7 @@ function Device({
145
146
  />
146
147
  <div className="strudel-device__main">
147
148
  {(synth || scaleMatch) && (
148
- <div className="strudel-device__selects">
149
+ <div className="strudel-device__selects strudel-lcd">
149
150
  {synth && (
150
151
  <label>
151
152
  <span>Sound</span>
@@ -268,8 +269,16 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
268
269
  return (
269
270
  <div className={`strudel-rack ${className ?? ""}`.trim()}>
270
271
  <div className="strudel-rack__master" role="group" aria-label="Master">
271
- <span className="strudel-rack__title">Master</span>
272
+ <span className="strudel-rack__title">
273
+ Master
274
+ <small>Strudel</small>
275
+ </span>
276
+ <output className="strudel-lcd strudel-lcd--bpm" aria-label="Tempo">
277
+ {Math.round((cps && Number.isFinite(cps) ? cps : 0.5) * 2400) / 10}
278
+ <small>BPM</small>
279
+ </output>
272
280
  <Knob
281
+ size="lg"
273
282
  label="Tempo"
274
283
  value={cps && Number.isFinite(cps) ? cps : 0.5}
275
284
  min={0.1}
@@ -280,6 +289,7 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
280
289
  />
281
290
  {master && (
282
291
  <Knob
292
+ size="lg"
283
293
  label="Output"
284
294
  value={postgain ?? 1}
285
295
  min={0}
@@ -1,6 +1,7 @@
1
- /* The rack's hardware: a rotary knob and a fader. Both are ARIA sliders — drag (Shift for fine), arrow keys,
2
- Page Up/Down, Home/End — and double-click resets to the default. Values map logarithmically when `log`. */
3
- import { useRef, type KeyboardEvent, type PointerEvent } from "react"
1
+ /* The rack's hardware: a rotary knob (rubber skirt, machined cap, LED ring) and a channel fader (slotted
2
+ track, tick scale, chunky cap). Both are ARIA sliders — drag (Shift for fine), arrow keys, Page Up/Down,
3
+ Home/End — and double-click resets to the default. Values map logarithmically when `log`. */
4
+ import { useId, useRef, type KeyboardEvent, type PointerEvent } from "react"
4
5
 
5
6
  export interface ControlProps {
6
7
  label: string
@@ -16,6 +17,8 @@ export interface ControlProps {
16
17
  /** Shown instead of the value, e.g. "∿" for a modulated parameter. */
17
18
  display?: string
18
19
  title?: string
20
+ /** Knob only: "lg" for master controls. */
21
+ size?: "md" | "lg"
19
22
  }
20
23
 
21
24
  const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
@@ -38,20 +41,19 @@ export function defaultFormat(v: number): string {
38
41
  return v.toFixed(2)
39
42
  }
40
43
 
41
- function useDrag(props: ControlProps, axis: "x" | "y", travel: number) {
44
+ function useDrag(props: ControlProps, travel: number) {
42
45
  const start = useRef<{ pos: number; unit: number } | null>(null)
43
46
  const set = (unit: number) => props.onChange(fromUnit(unit, props))
44
47
  const onPointerDown = (e: PointerEvent<HTMLElement>) => {
45
48
  if (props.disabled) return
46
49
  e.currentTarget.setPointerCapture(e.pointerId)
47
50
  e.currentTarget.focus()
48
- start.current = { pos: axis === "y" ? e.clientY : e.clientX, unit: toUnit(props.value, props) }
51
+ start.current = { pos: e.clientY, unit: toUnit(props.value, props) }
49
52
  }
50
53
  const onPointerMove = (e: PointerEvent<HTMLElement>) => {
51
54
  const s = start.current
52
55
  if (!s) return
53
- const delta = axis === "y" ? s.pos - e.clientY : e.clientX - s.pos
54
- set(s.unit + (delta / travel) * (e.shiftKey ? 0.2 : 1))
56
+ set(s.unit + ((s.pos - e.clientY) / travel) * (e.shiftKey ? 0.2 : 1))
55
57
  }
56
58
  const onPointerUp = () => {
57
59
  start.current = null
@@ -95,31 +97,57 @@ function aria(props: ControlProps, format: (v: number) => string) {
95
97
  }
96
98
 
97
99
  const ARC = 270
98
- const polar = (deg: number, r: number) => {
100
+ const LEDS = 15
101
+ const polar = (deg: number, r: number, c = 30) => {
99
102
  const a = ((deg - 90) * Math.PI) / 180
100
- return [20 + r * Math.cos(a), 20 + r * Math.sin(a)] as const
101
- }
102
- const arc = (from: number, to: number, r: number) => {
103
- const [x1, y1] = polar(from, r)
104
- const [x2, y2] = polar(to, r)
105
- return `M ${x1} ${y1} A ${r} ${r} 0 ${to - from > 180 ? 1 : 0} 1 ${x2} ${y2}`
103
+ return [c + r * Math.cos(a), c + r * Math.sin(a)] as const
106
104
  }
107
105
 
108
106
  export function Knob(props: ControlProps) {
107
+ const id = useId().replace(/:/g, "")
109
108
  const format = props.format ?? defaultFormat
110
- const handlers = useDrag(props, "y", 160)
109
+ const handlers = useDrag(props, 160)
111
110
  const u = toUnit(props.value, props)
112
- const from = -ARC / 2
113
- const to = from + ARC * u
114
- const [px, py] = polar(to, 11)
111
+ const angle = -ARC / 2 + ARC * u
112
+ // A bipolar parameter (pan, speed) lights from the centre; the rest light from the left.
113
+ const bipolar = props.min < 0 || props.label === "Pan"
114
+ const zero = bipolar ? toUnit(props.min < 0 ? 0 : (props.min + props.max) / 2, props) : 0
115
+ const lit = (i: number) => {
116
+ const t = i / (LEDS - 1)
117
+ return !props.disabled && (bipolar ? (t >= Math.min(zero, u) - 0.001 && t <= Math.max(zero, u) + 0.001) : t <= u + 0.001)
118
+ }
115
119
  return (
116
- <div className="strudel-knob" data-disabled={props.disabled || undefined}>
120
+ <div className="strudel-knob" data-size={props.size ?? "md"} data-disabled={props.disabled || undefined}>
117
121
  <div className="strudel-knob__dial" {...aria(props, format)} {...handlers}>
118
- <svg viewBox="0 0 40 40" aria-hidden="true">
119
- <path d={arc(from, from + ARC, 16)} className="strudel-knob__track" />
120
- {!props.disabled && u > 0.001 && <path d={arc(from, to, 16)} className="strudel-knob__fill" />}
121
- <circle cx="20" cy="20" r="11.5" className="strudel-knob__cap" />
122
- <line x1="20" y1="20" x2={px} y2={py} className="strudel-knob__pointer" />
122
+ <svg viewBox="0 0 60 60" aria-hidden="true">
123
+ <defs>
124
+ <radialGradient id={`${id}-skirt`} cx="50%" cy="35%" r="70%">
125
+ <stop offset="0" stopColor="#4a4d55" />
126
+ <stop offset=".6" stopColor="#1d1f24" />
127
+ <stop offset="1" stopColor="#0b0c0e" />
128
+ </radialGradient>
129
+ <linearGradient id={`${id}-cap`} x1="0" y1="0" x2="0" y2="1">
130
+ <stop offset="0" stopColor="#f2f3f5" />
131
+ <stop offset=".45" stopColor="#a9adb5" />
132
+ <stop offset=".55" stopColor="#8a8f98" />
133
+ <stop offset="1" stopColor="#d7dae0" />
134
+ </linearGradient>
135
+ </defs>
136
+ {Array.from({ length: LEDS }, (_, i) => {
137
+ const [x, y] = polar(-ARC / 2 + (ARC * i) / (LEDS - 1), 27)
138
+ return <circle key={i} cx={x} cy={y} r="1.7" className="strudel-knob__led" data-on={lit(i) || undefined} />
139
+ })}
140
+ <circle cx="30" cy="31.5" r="19.5" className="strudel-knob__shadow" />
141
+ <circle cx="30" cy="30" r="19" fill={`url(#${id}-skirt)`} className="strudel-knob__skirt" />
142
+ {/* Grip ridges on the skirt, then the machined cap and its indicator. */}
143
+ <g transform={`rotate(${angle} 30 30)`}>
144
+ {Array.from({ length: 24 }, (_, i) => (
145
+ <line key={i} x1="30" y1="11.6" x2="30" y2="14" transform={`rotate(${i * 15} 30 30)`} className="strudel-knob__ridge" />
146
+ ))}
147
+ <circle cx="30" cy="30" r="12.5" fill={`url(#${id}-cap)`} className="strudel-knob__cap" />
148
+ <circle cx="30" cy="30" r="12.5" className="strudel-knob__brush" />
149
+ <line x1="30" y1="19.5" x2="30" y2="26" className="strudel-knob__pointer" />
150
+ </g>
123
151
  </svg>
124
152
  </div>
125
153
  <span className="strudel-knob__value">{props.display ?? format(props.value)}</span>
@@ -128,16 +156,25 @@ export function Knob(props: ControlProps) {
128
156
  )
129
157
  }
130
158
 
159
+ const TICKS = [10, 8, 6, 4, 2, 0]
160
+
131
161
  export function Fader(props: ControlProps) {
132
162
  const format = props.format ?? defaultFormat
133
- const handlers = useDrag(props, "y", 90)
163
+ const handlers = useDrag(props, 104)
134
164
  const u = toUnit(props.value, props)
135
165
  return (
136
166
  <div className="strudel-fader" data-disabled={props.disabled || undefined}>
137
167
  <span className="strudel-fader__value">{props.display ?? format(props.value)}</span>
138
- <div className="strudel-fader__track" {...aria(props, format)} {...handlers} aria-orientation="vertical">
139
- <div className="strudel-fader__fill" style={{ height: `${u * 100}%` }} />
140
- <div className="strudel-fader__thumb" style={{ bottom: `calc(${u * 100}% - 5px)` }} />
168
+ <div className="strudel-fader__body">
169
+ <div className="strudel-fader__scale" aria-hidden="true">
170
+ {TICKS.map((t) => (
171
+ <span key={t}>{t}</span>
172
+ ))}
173
+ </div>
174
+ <div className="strudel-fader__track" {...aria(props, format)} {...handlers} aria-orientation="vertical">
175
+ <div className="strudel-fader__slot" />
176
+ <div className="strudel-fader__cap" style={{ bottom: `calc(${u} * (100% - 22px))` }} />
177
+ </div>
141
178
  </div>
142
179
  <span className="strudel-fader__label">{props.label}</span>
143
180
  </div>