@brett_lamy/docstream 1.11.0 → 1.13.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.13.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,231 @@
1
+ /* A masonry grid of modules you can rearrange and resize. Columns follow the container's width; every module spans
2
+ some of them and as many 8px rows as its content (or the height you dragged it to) needs, and `dense` flow packs
3
+ the gaps. Drag a module's grip to move it (others reflow under the pointer); drag its corner to resize. Both
4
+ handles are keyboard-operable: arrows move or resize, Home/End move to the ends. */
5
+ import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"
6
+
7
+ export interface GridItem {
8
+ key: string
9
+ label: string
10
+ node: ReactNode
11
+ /** Default width in twelfths of the grid (12 = full width). */
12
+ span?: number
13
+ }
14
+
15
+ export interface GridLayout {
16
+ order: string[]
17
+ /** Width in twelfths, and an optional minimum height in px, per item key. */
18
+ sizes: Record<string, { w: number; h?: number }>
19
+ }
20
+
21
+ export interface RackGridProps {
22
+ items: GridItem[]
23
+ layout: GridLayout
24
+ onLayoutChange: (layout: GridLayout) => void
25
+ }
26
+
27
+ const ROW = 8
28
+ const GAP = 10
29
+
30
+ /** Items in layout order: known keys first, new ones appended. */
31
+ function ordered(items: GridItem[], order: string[]): GridItem[] {
32
+ const byKey = new Map(items.map((i) => [i.key, i]))
33
+ const out = order.map((k) => byKey.get(k)).filter((i): i is GridItem => !!i)
34
+ for (const i of items) if (!order.includes(i.key)) out.push(i)
35
+ return out
36
+ }
37
+
38
+ export function RackGrid({ items, layout, onLayoutChange }: RackGridProps) {
39
+ const gridRef = useRef<HTMLDivElement>(null)
40
+ const [cols, setCols] = useState(12)
41
+ const [heights, setHeights] = useState<Record<string, number>>({})
42
+ const [dragging, setDragging] = useState<string | null>(null)
43
+ const drag = useRef<{ key: string; dx: number; dy: number; x: number; y: number } | null>(null)
44
+ const elements = useRef(new Map<string, HTMLDivElement>())
45
+ const list = ordered(items, layout.order)
46
+ const keys = list.map((i) => i.key)
47
+ const layoutRef = useRef(layout)
48
+ layoutRef.current = layout
49
+
50
+ // Columns: one per ~84px of width, at most 12 — spans are stored in twelfths and scaled.
51
+ useLayoutEffect(() => {
52
+ const grid = gridRef.current
53
+ if (!grid) return
54
+ const measure = () => setCols(Math.max(2, Math.min(12, Math.floor((grid.clientWidth + GAP) / 84))))
55
+ measure()
56
+ const ro = new ResizeObserver(measure)
57
+ ro.observe(grid)
58
+ return () => ro.disconnect()
59
+ }, [])
60
+
61
+ // Masonry: each module spans as many rows as its content is tall.
62
+ useEffect(() => {
63
+ const ro = new ResizeObserver((entries) => {
64
+ setHeights((prev) => {
65
+ let next = prev
66
+ for (const e of entries) {
67
+ const key = (e.target as HTMLElement).dataset.content!
68
+ const h = Math.ceil((e.target as HTMLElement).offsetHeight)
69
+ if (prev[key] !== h) next = { ...next, [key]: h }
70
+ }
71
+ return next
72
+ })
73
+ })
74
+ elements.current.forEach((el) => {
75
+ const content = el.querySelector<HTMLElement>("[data-content]")
76
+ if (content) ro.observe(content)
77
+ })
78
+ return () => ro.disconnect()
79
+ }, [keys.join("|")])
80
+
81
+ const span = (item: GridItem) => {
82
+ const w = layout.sizes[item.key]?.w ?? item.span ?? 6
83
+ return Math.max(1, Math.min(cols, Math.round((w / 12) * cols)))
84
+ }
85
+ const rows = (item: GridItem) => {
86
+ const h = Math.max(heights[item.key] ?? 120, layout.sizes[item.key]?.h ?? 0)
87
+ return Math.ceil((h + GAP) / (ROW + GAP))
88
+ }
89
+ const setSize = (key: string, size: { w?: number; h?: number }) => {
90
+ const cur = layoutRef.current
91
+ onLayoutChange({ order: keys, sizes: { ...cur.sizes, [key]: { ...cur.sizes[key], w: cur.sizes[key]?.w ?? 6, ...size } } })
92
+ }
93
+ const move = (key: string, to: number) => {
94
+ const from = keys.indexOf(key)
95
+ const next = keys.filter((k) => k !== key)
96
+ next.splice(Math.max(0, Math.min(next.length, to)), 0, key)
97
+ if (from !== next.indexOf(key)) onLayoutChange({ ...layoutRef.current, order: next })
98
+ }
99
+
100
+ // Moving: the module follows the pointer; hovering another module takes its place.
101
+ const place = (key: string, x: number, y: number) => {
102
+ const el = elements.current.get(key)
103
+ const grid = gridRef.current
104
+ const d = drag.current
105
+ if (!el || !grid || !d) return
106
+ const box = grid.getBoundingClientRect()
107
+ el.style.transform = `translate(${x - d.dx - box.left - el.offsetLeft}px, ${y - d.dy - box.top - el.offsetTop}px)`
108
+ }
109
+ const onGripDown = (key: string) => (e: PointerEvent<HTMLElement>) => {
110
+ if (e.button !== 0) return
111
+ const el = elements.current.get(key)!
112
+ const r = el.getBoundingClientRect()
113
+ e.currentTarget.setPointerCapture(e.pointerId)
114
+ drag.current = { key, dx: e.clientX - r.left, dy: e.clientY - r.top, x: e.clientX, y: e.clientY }
115
+ setDragging(key)
116
+ }
117
+ const onGripMove = (e: PointerEvent<HTMLElement>) => {
118
+ const d = drag.current
119
+ if (!d) return
120
+ d.x = e.clientX
121
+ d.y = e.clientY
122
+ place(d.key, e.clientX, e.clientY)
123
+ const dragged = elements.current.get(d.key)!
124
+ dragged.style.pointerEvents = "none"
125
+ const under = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-grid-key]")
126
+ dragged.style.pointerEvents = ""
127
+ const target = under?.dataset.gridKey
128
+ if (target && target !== d.key) move(d.key, keys.indexOf(target))
129
+ }
130
+ const onGripUp = () => {
131
+ const d = drag.current
132
+ if (!d) return
133
+ const el = elements.current.get(d.key)
134
+ if (el) el.style.transform = ""
135
+ drag.current = null
136
+ setDragging(null)
137
+ }
138
+ // After a reorder the module's slot moved: keep it under the pointer.
139
+ useLayoutEffect(() => {
140
+ const d = drag.current
141
+ if (d) place(d.key, d.x, d.y)
142
+ })
143
+
144
+ const onGripKey = (key: string) => (e: KeyboardEvent<HTMLElement>) => {
145
+ const i = keys.indexOf(key)
146
+ const to = { ArrowLeft: i - 1, ArrowUp: i - 1, ArrowRight: i + 1, ArrowDown: i + 1, Home: 0, End: keys.length - 1 }[e.key]
147
+ if (to === undefined) return
148
+ e.preventDefault()
149
+ move(key, to)
150
+ }
151
+
152
+ // Resizing: the corner sets the width in columns and a minimum height.
153
+ const resize = useRef<{ key: string; left: number; top: number } | null>(null)
154
+ const onCornerDown = (key: string) => (e: PointerEvent<HTMLElement>) => {
155
+ const r = elements.current.get(key)!.getBoundingClientRect()
156
+ e.currentTarget.setPointerCapture(e.pointerId)
157
+ resize.current = { key, left: r.left, top: r.top }
158
+ }
159
+ const onCornerMove = (e: PointerEvent<HTMLElement>) => {
160
+ const r = resize.current
161
+ const grid = gridRef.current
162
+ if (!r || !grid) return
163
+ const colWidth = (grid.clientWidth - GAP * (cols - 1)) / cols
164
+ const c = Math.max(1, Math.min(cols, Math.round((e.clientX - r.left + GAP) / (colWidth + GAP))))
165
+ setSize(r.key, { w: Math.round((c / cols) * 12 * 100) / 100, h: Math.max(60, Math.round(e.clientY - r.top)) })
166
+ }
167
+ const onCornerUp = () => {
168
+ resize.current = null
169
+ }
170
+ const onCornerKey = (item: GridItem) => (e: KeyboardEvent<HTMLElement>) => {
171
+ const w = layout.sizes[item.key]?.w ?? item.span ?? 6
172
+ const h = layout.sizes[item.key]?.h ?? heights[item.key] ?? 120
173
+ const step = 12 / cols
174
+ const next = {
175
+ ArrowRight: { w: Math.min(12, w + step) },
176
+ ArrowLeft: { w: Math.max(step, w - step) },
177
+ ArrowDown: { h: h + 24 },
178
+ ArrowUp: { h: Math.max(0, h - 24) },
179
+ }[e.key]
180
+ if (!next) return
181
+ e.preventDefault()
182
+ setSize(item.key, next)
183
+ }
184
+
185
+ return (
186
+ <div ref={gridRef} className="strudel-grid" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gridAutoRows: ROW, rowGap: GAP, columnGap: GAP }}>
187
+ {list.map((item) => {
188
+ const h = layout.sizes[item.key]?.h
189
+ return (
190
+ <div
191
+ key={item.key}
192
+ ref={(el) => {
193
+ if (el) elements.current.set(item.key, el)
194
+ else elements.current.delete(item.key)
195
+ }}
196
+ className="strudel-grid__item"
197
+ data-grid-key={item.key}
198
+ data-dragging={dragging === item.key || undefined}
199
+ style={{ gridColumn: `span ${span(item)}`, gridRow: `span ${rows(item)}` }}
200
+ >
201
+ <div data-content={item.key} className="strudel-grid__content" style={h ? { minHeight: h } : undefined}>
202
+ {item.node}
203
+ </div>
204
+ <button
205
+ type="button"
206
+ className="strudel-grid__grip"
207
+ aria-label={`Move ${item.label}`}
208
+ title="Drag to move (arrow keys too)"
209
+ onPointerDown={onGripDown(item.key)}
210
+ onPointerMove={onGripMove}
211
+ onPointerUp={onGripUp}
212
+ onPointerCancel={onGripUp}
213
+ onKeyDown={onGripKey(item.key)}
214
+ />
215
+ <button
216
+ type="button"
217
+ className="strudel-grid__corner"
218
+ aria-label={`Resize ${item.label}`}
219
+ title="Drag to resize (arrow keys too)"
220
+ onPointerDown={onCornerDown(item.key)}
221
+ onPointerMove={onCornerMove}
222
+ onPointerUp={onCornerUp}
223
+ onPointerCancel={onCornerUp}
224
+ onKeyDown={onCornerKey(item)}
225
+ />
226
+ </div>
227
+ )
228
+ })}
229
+ </div>
230
+ )
231
+ }
@@ -9,6 +9,7 @@ import { findCall, parseStrudel, StrudelSyntaxError, type StrudelArg, type Strud
9
9
  import { formatNumber, isAudible, mixCode, removeMethod, setMethod, setMiniAtom, setMiniSource, setTempo, type StrudelMix } from "./ast/edit"
10
10
  import { Fader, Knob, defaultFormat } from "./controls"
11
11
  import { StepGrid } from "./StepGrid"
12
+ import { RackGrid, type GridLayout } from "./RackGrid"
12
13
 
13
14
  export interface StrudelRackProps {
14
15
  code: string
@@ -16,6 +17,24 @@ export interface StrudelRackProps {
16
17
  /** The code to play for the current mute/solo mix (the written code with silenced tracks). */
17
18
  onPlayableChange?: (code: string) => void
18
19
  className?: string
20
+ /** "rack": modules stacked full width. "grid": a masonry grid you drag and resize. Defaults to "rack". */
21
+ layout?: "rack" | "grid"
22
+ /** Remember the layout (mode, order and sizes) in localStorage under this key. */
23
+ persistKey?: string
24
+ }
25
+
26
+ interface Saved {
27
+ mode: "rack" | "grid"
28
+ grid: GridLayout
29
+ }
30
+
31
+ function load(key: string | undefined): Partial<Saved> {
32
+ if (!key) return {}
33
+ try {
34
+ return JSON.parse(localStorage.getItem(`strudel-rack:${key}`) ?? "{}") as Partial<Saved>
35
+ } catch {
36
+ return {}
37
+ }
19
38
  }
20
39
 
21
40
  interface Param {
@@ -120,7 +139,8 @@ function Device({
120
139
  return (
121
140
  <section className="strudel-device" data-kind={track.kind} data-audible={audible} aria-label={name}>
122
141
  <header className="strudel-device__head">
123
- <Icon size={15} aria-hidden="true" />
142
+ <span className="strudel-device__led" aria-hidden="true" />
143
+ <Icon size={14} aria-hidden="true" />
124
144
  <span className="strudel-device__name" title={code.slice(track.start, track.end)}>
125
145
  {name}
126
146
  </span>
@@ -145,7 +165,7 @@ function Device({
145
165
  />
146
166
  <div className="strudel-device__main">
147
167
  {(synth || scaleMatch) && (
148
- <div className="strudel-device__selects">
168
+ <div className="strudel-device__selects strudel-lcd">
149
169
  {synth && (
150
170
  <label>
151
171
  <span>Sound</span>
@@ -229,7 +249,18 @@ function Device({
229
249
  }
230
250
 
231
251
  /** Equipment generated from Strudel code. Controlled: `code` in, edited code out through `onChange`. */
232
- export function StrudelRack({ code, onChange, onPlayableChange, className }: StrudelRackProps) {
252
+ export function StrudelRack({ code, onChange, onPlayableChange, className, layout = "rack", persistKey }: StrudelRackProps) {
253
+ const [saved] = useState(() => load(persistKey))
254
+ const [mode, setMode] = useState<"rack" | "grid">(saved.mode ?? layout)
255
+ const [grid, setGrid] = useState<GridLayout>(saved.grid ?? { order: [], sizes: {} })
256
+ useEffect(() => {
257
+ if (!persistKey) return
258
+ try {
259
+ localStorage.setItem(`strudel-rack:${persistKey}`, JSON.stringify({ mode, grid } satisfies Saved))
260
+ } catch {
261
+ // Storage unavailable (private mode): the layout just isn't remembered.
262
+ }
263
+ }, [persistKey, mode, grid])
233
264
  const parsed = useMemo((): { song: StrudelSong } | { error: string } => {
234
265
  try {
235
266
  return { song: parseStrudel(code) }
@@ -265,11 +296,18 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
265
296
  const master = song.masterChain
266
297
  const postgain = master ? firstNumber(findCall(master, "postgain")) : null
267
298
 
268
- return (
269
- <div className={`strudel-rack ${className ?? ""}`.trim()}>
299
+ const masterStrip = (
270
300
  <div className="strudel-rack__master" role="group" aria-label="Master">
271
- <span className="strudel-rack__title">Master</span>
301
+ <span className="strudel-rack__title">
302
+ Master
303
+ <small>Strudel</small>
304
+ </span>
305
+ <output className="strudel-lcd strudel-lcd--bpm" aria-label="Tempo">
306
+ {Math.round((cps && Number.isFinite(cps) ? cps : 0.5) * 2400) / 10}
307
+ <small>BPM</small>
308
+ </output>
272
309
  <Knob
310
+ size="lg"
273
311
  label="Tempo"
274
312
  value={cps && Number.isFinite(cps) ? cps : 0.5}
275
313
  min={0.1}
@@ -280,6 +318,7 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
280
318
  />
281
319
  {master && (
282
320
  <Knob
321
+ size="lg"
283
322
  label="Output"
284
323
  value={postgain ?? 1}
285
324
  min={0}
@@ -293,18 +332,43 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
293
332
  {song.tracks.length} track{song.tracks.length === 1 ? "" : "s"}
294
333
  {song.layout === "stack" ? " · stack" : song.layout === "labels" ? " · $:" : ""}
295
334
  </span>
335
+ <div className="strudel-rack__mode" role="radiogroup" aria-label="Layout">
336
+ {(["rack", "grid"] as const).map((m) => (
337
+ <button key={m} type="button" role="radio" aria-checked={mode === m} onClick={() => setMode(m)}>
338
+ {m}
339
+ </button>
340
+ ))}
341
+ </div>
296
342
  </div>
343
+ )
344
+ const devices = song.tracks.map((t) => ({
345
+ key: `track-${t.index}`,
346
+ label: trackName(t),
347
+ span: 6,
348
+ node: (
349
+ <Device
350
+ song={song}
351
+ track={t}
352
+ mix={mix}
353
+ onChange={onChange}
354
+ onMix={(kind) => (kind === "mute" ? setMuted(toggle(muted, t.index)) : setSolo(toggle(solo, t.index)))}
355
+ />
356
+ ),
357
+ }))
358
+
359
+ return (
360
+ <div className={`strudel-rack ${className ?? ""}`.trim()} data-mode={mode}>
361
+ {mode === "grid" ? (
362
+ <RackGrid items={[{ key: "master", label: "Master", span: 12, node: masterStrip }, ...devices]} layout={grid} onLayoutChange={setGrid} />
363
+ ) : (
364
+ <>
365
+ {masterStrip}
366
+ {devices.map((d) => (
367
+ <div key={d.key}>{d.node}</div>
368
+ ))}
369
+ </>
370
+ )}
297
371
  {song.tracks.length === 0 && <p className="strudel-rack__error">No pattern found.</p>}
298
- {song.tracks.map((t) => (
299
- <Device
300
- key={t.index}
301
- song={song}
302
- track={t}
303
- mix={mix}
304
- onChange={onChange}
305
- onMix={(kind) => (kind === "mute" ? setMuted(toggle(muted, t.index)) : setSolo(toggle(solo, t.index)))}
306
- />
307
- ))}
308
372
  </div>
309
373
  )
310
374
  }
@@ -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>
@@ -3,6 +3,8 @@ export type { StrudelPlayerProps } from "./StrudelPlayer"
3
3
  export { StrudelRack, STRUDEL_PARAMS, trackName } from "./StrudelRack"
4
4
  export type { StrudelRackProps } from "./StrudelRack"
5
5
  export { StepGrid } from "./StepGrid"
6
+ export { RackGrid } from "./RackGrid"
7
+ export type { GridItem, GridLayout, RackGridProps } from "./RackGrid"
6
8
  export type { StepGridProps } from "./StepGrid"
7
9
  export { Fader, Knob } from "./controls"
8
10
  export type { ControlProps } from "./controls"