@zylem/ui 0.8.8 → 0.8.11

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.
Files changed (81) hide show
  1. package/dist/chunk-V47CGFWX.js +1 -0
  2. package/dist/index.css +1 -1
  3. package/dist/index.d.ts +15 -0
  4. package/dist/index.js +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.js +1 -1
  7. package/package.json +22 -11
  8. package/src/components/Accordion/Accordion.tsx +10 -1
  9. package/src/components/AddTile/AddTile.css.ts +95 -0
  10. package/src/components/AddTile/AddTile.tsx +45 -0
  11. package/src/components/BuildLabel/BuildLabel.css.ts +31 -0
  12. package/src/components/BuildLabel/BuildLabel.tsx +37 -0
  13. package/src/components/BuildLabel/mount-build-label.ts +77 -0
  14. package/src/components/BuildLabel/resolve-build-version.ts +47 -0
  15. package/src/components/Callout/Callout.css.ts +45 -0
  16. package/src/components/Callout/Callout.tsx +32 -0
  17. package/src/components/Card/Card.css.ts +18 -0
  18. package/src/components/Card/Card.tsx +25 -2
  19. package/src/components/ColorPalette/ColorPalette.css.ts +51 -0
  20. package/src/components/ColorPalette/ColorPalette.tsx +78 -0
  21. package/src/components/Dialog/Dialog.css.ts +69 -2
  22. package/src/components/Dialog/Dialog.tsx +35 -7
  23. package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
  24. package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
  25. package/src/components/EmptyState/EmptyState.css.ts +9 -0
  26. package/src/components/EmptyState/EmptyState.tsx +3 -1
  27. package/src/components/FrameStrip/FrameStrip.css.ts +138 -0
  28. package/src/components/FrameStrip/FrameStrip.tsx +146 -0
  29. package/src/components/Menubar/Menubar.css.ts +52 -0
  30. package/src/components/Menubar/Menubar.tsx +77 -0
  31. package/src/components/Mixer/Knob.tsx +188 -0
  32. package/src/components/Mixer/Mixer.css.ts +246 -0
  33. package/src/components/Mixer/MixerStrip.tsx +150 -0
  34. package/src/components/NumberField/NumberField.tsx +5 -1
  35. package/src/components/Panel/Panel.tsx +6 -5
  36. package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
  37. package/src/components/PianoRoll/PianoRoll.tsx +353 -0
  38. package/src/components/PianoRoll/piano-roll-math.ts +89 -0
  39. package/src/components/PixelGrid/PixelGrid.css.ts +30 -0
  40. package/src/components/PixelGrid/PixelGrid.tsx +48 -0
  41. package/src/components/PlaybackControls/PlaybackControls.css.ts +53 -0
  42. package/src/components/PlaybackControls/PlaybackControls.tsx +80 -0
  43. package/src/components/Property/Property.css.ts +1 -1
  44. package/src/components/Property/Property.tsx +3 -0
  45. package/src/components/Sidebar/Sidebar.css.ts +8 -0
  46. package/src/components/Sidebar/Sidebar.tsx +10 -2
  47. package/src/components/Slider/Slider.tsx +6 -0
  48. package/src/components/SpriteSizePicker/SpriteSizePicker.css.ts +103 -0
  49. package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +172 -0
  50. package/src/components/SpriteSizePicker/snap-size.ts +28 -0
  51. package/src/components/Tabs/Tabs.css.ts +48 -0
  52. package/src/components/Tabs/Tabs.tsx +41 -3
  53. package/src/components/TextField/TextField.tsx +49 -5
  54. package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
  55. package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
  56. package/src/components/TimelineLanes/lanes-math.ts +182 -0
  57. package/src/components/TimelineTrack/TimelineTrack.css.ts +163 -0
  58. package/src/components/TimelineTrack/TimelineTrack.tsx +90 -0
  59. package/src/components/TimelineTrack/timeline-math.ts +43 -0
  60. package/src/components/Toast/Toast.css.ts +48 -0
  61. package/src/components/Toast/Toast.tsx +37 -0
  62. package/src/components/ToolButton/ToolButton.tsx +92 -0
  63. package/src/components/ToolGroup/ToolGroup.css.ts +35 -0
  64. package/src/components/ToolGroup/ToolGroup.tsx +29 -0
  65. package/src/components/ToolboxButton/ToolboxButton.css.ts +131 -0
  66. package/src/components/ToolboxButton/ToolboxButton.tsx +88 -0
  67. package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
  68. package/src/components/TrackHeader/TrackHeader.tsx +145 -0
  69. package/src/components/TransportBar/TransportBar.css.ts +65 -0
  70. package/src/components/TransportBar/TransportBar.tsx +154 -0
  71. package/src/components/Waveform/Waveform.css.ts +77 -0
  72. package/src/components/Waveform/Waveform.tsx +195 -0
  73. package/src/components/Waveform/peaks.ts +49 -0
  74. package/src/components/index.ts +122 -0
  75. package/src/global/accordion.css.ts +23 -0
  76. package/src/global/hyperglass-base.css.ts +9 -6
  77. package/src/styles.ts +23 -0
  78. package/src/theme.css.ts +15 -0
  79. package/vite/collect-zylem-versions.ts +82 -0
  80. package/vite/index.ts +30 -0
  81. package/dist/chunk-3TP2SNNW.js +0 -1
@@ -0,0 +1,353 @@
1
+ import { createMemo, createSignal, For, Index, Show } from 'solid-js';
2
+ import { clamp } from '../TimelineLanes/lanes-math';
3
+ import {
4
+ cellStart,
5
+ isBlackKey,
6
+ type PianoRollNote,
7
+ pitchName,
8
+ pitchToRow,
9
+ resolveNoteDrag,
10
+ rowToPitch,
11
+ } from './piano-roll-math';
12
+
13
+ export type { PianoRollNote } from './piano-roll-math';
14
+
15
+ const DEFAULT_PPB = 36;
16
+ const DEFAULT_ROW = 14;
17
+ const KEYS_WIDTH = 56;
18
+ const END_PADDING = 24;
19
+ const VELOCITY_HEIGHT = 48;
20
+
21
+ export interface PianoRollProps {
22
+ notes: readonly PianoRollNote[];
23
+ /** Clip length in beats. */
24
+ length: number;
25
+ beatsPerBar?: number;
26
+ pixelsPerBeat?: number;
27
+ rowHeight?: number;
28
+ /** Lowest visible pitch (default C2 = 36). */
29
+ minPitch?: number;
30
+ /** Highest visible pitch (default C7 = 96). */
31
+ maxPitch?: number;
32
+ /** Grid snap in beats (default a sixteenth). */
33
+ snap?: number;
34
+ /** Duration of notes added by double-click, in beats (default one beat). */
35
+ newNoteDuration?: number;
36
+ playhead?: number;
37
+ selectedIds?: readonly string[];
38
+ onSelect?: (ids: string[]) => void;
39
+ /** Double-click on an empty cell. */
40
+ onAddNote?: (note: { pitch: number; start: number; duration: number }) => void;
41
+ /** Fires continuously while notes are dragged, with the changed notes. */
42
+ onChangeNotes?: (notes: PianoRollNote[]) => void;
43
+ /** Fires once when a drag or a keyboard edit ends, with the changed notes. */
44
+ onCommitNotes?: (notes: PianoRollNote[]) => void;
45
+ /** Delete / Backspace with a selection. */
46
+ onDeleteNotes?: (ids: string[]) => void;
47
+ /** Clicking a key on the left, for auditioning. */
48
+ onPreviewPitch?: (pitch: number) => void;
49
+ /** Show the velocity lane under the grid. */
50
+ showVelocity?: boolean;
51
+ disabled?: boolean;
52
+ class?: string;
53
+ }
54
+
55
+ /**
56
+ * Piano roll: pitch rows against a beat grid. Double-click adds a note, drag
57
+ * moves (pitch and time), the right edge trims, Delete removes, arrows nudge
58
+ * and transpose, and the optional velocity lane edits loudness per note.
59
+ */
60
+ export function PianoRoll(props: PianoRollProps) {
61
+ const ppb = () => props.pixelsPerBeat ?? DEFAULT_PPB;
62
+ const rowHeight = () => props.rowHeight ?? DEFAULT_ROW;
63
+ const minPitch = () => props.minPitch ?? 36;
64
+ const maxPitch = () => props.maxPitch ?? 96;
65
+ const snap = () => props.snap ?? 0.25;
66
+ const bpb = () => props.beatsPerBar ?? 4;
67
+ const rows = () => Math.max(1, maxPitch() - minPitch() + 1);
68
+ const gridWidth = () => props.length * ppb() + END_PADDING;
69
+ const gridHeight = () => rows() * rowHeight();
70
+ const selected = createMemo(() => new Set(props.selectedIds ?? []));
71
+ const [dragging, setDragging] = createSignal(false);
72
+ let grid: HTMLDivElement | undefined;
73
+ let root: HTMLDivElement | undefined;
74
+
75
+ const pointerCell = (event: PointerEvent | MouseEvent) => {
76
+ if (!grid) return null;
77
+ const rect = grid.getBoundingClientRect();
78
+ const beats = (event.clientX - rect.left) / ppb();
79
+ const row = Math.floor((event.clientY - rect.top) / rowHeight());
80
+ return {
81
+ start: cellStart(beats, snap(), props.length),
82
+ pitch: clamp(rowToPitch(row, maxPitch()), minPitch(), maxPitch()),
83
+ };
84
+ };
85
+
86
+ const select = (ids: string[]) => props.onSelect?.(ids);
87
+
88
+ const beginNoteDrag = (note: PianoRollNote, mode: 'move' | 'resize') => (event: PointerEvent) => {
89
+ if (event.button !== 0) return;
90
+ event.stopPropagation();
91
+ root?.focus({ preventScroll: true });
92
+ let ids: string[];
93
+ if (event.shiftKey) {
94
+ ids = selected().has(note.id)
95
+ ? [...selected()].filter((id) => id !== note.id)
96
+ : [...selected(), note.id];
97
+ } else {
98
+ ids = selected().has(note.id) ? [...selected()] : [note.id];
99
+ }
100
+ select(ids);
101
+ if (props.disabled) return;
102
+ event.preventDefault();
103
+ const target = event.currentTarget as HTMLElement;
104
+ target.setPointerCapture?.(event.pointerId);
105
+ const idSet = new Set(ids);
106
+ const origin = props.notes.filter((candidate) => idSet.has(candidate.id));
107
+ const originX = event.clientX;
108
+ const originY = event.clientY;
109
+ let last: PianoRollNote[] = origin;
110
+ let moved = false;
111
+ setDragging(true);
112
+ const move = (moveEvent: PointerEvent) => {
113
+ moved = true;
114
+ last = resolveNoteDrag({
115
+ mode,
116
+ deltaBeats: (moveEvent.clientX - originX) / ppb(),
117
+ deltaRows: (moveEvent.clientY - originY) / rowHeight(),
118
+ origin,
119
+ length: props.length,
120
+ minPitch: minPitch(),
121
+ maxPitch: maxPitch(),
122
+ snap: snap(),
123
+ });
124
+ props.onChangeNotes?.(last);
125
+ };
126
+ const stop = () => {
127
+ target.removeEventListener('pointermove', move);
128
+ target.removeEventListener('pointerup', stop);
129
+ target.removeEventListener('pointercancel', stop);
130
+ target.releasePointerCapture?.(event.pointerId);
131
+ setDragging(false);
132
+ if (moved) props.onCommitNotes?.(last);
133
+ };
134
+ target.addEventListener('pointermove', move);
135
+ target.addEventListener('pointerup', stop);
136
+ target.addEventListener('pointercancel', stop);
137
+ };
138
+
139
+ const beginVelocityDrag = (note: PianoRollNote) => (event: PointerEvent) => {
140
+ if (event.button !== 0 || props.disabled) return;
141
+ event.preventDefault();
142
+ event.stopPropagation();
143
+ if (!selected().has(note.id)) select([note.id]);
144
+ const target = event.currentTarget as HTMLElement;
145
+ const lane = target.parentElement!;
146
+ target.setPointerCapture?.(event.pointerId);
147
+ let last = note;
148
+ const apply = (moveEvent: PointerEvent) => {
149
+ const rect = lane.getBoundingClientRect();
150
+ const velocity = clamp(1 - (moveEvent.clientY - rect.top) / rect.height, 0.05, 1);
151
+ last = { ...note, velocity: Math.round(velocity * 100) / 100 };
152
+ props.onChangeNotes?.([last]);
153
+ };
154
+ apply(event);
155
+ const stop = () => {
156
+ target.removeEventListener('pointermove', apply);
157
+ target.removeEventListener('pointerup', stop);
158
+ target.removeEventListener('pointercancel', stop);
159
+ target.releasePointerCapture?.(event.pointerId);
160
+ props.onCommitNotes?.([last]);
161
+ };
162
+ target.addEventListener('pointermove', apply);
163
+ target.addEventListener('pointerup', stop);
164
+ target.addEventListener('pointercancel', stop);
165
+ };
166
+
167
+ const handleGridDoubleClick = (event: MouseEvent) => {
168
+ if (props.disabled || event.target !== grid) return;
169
+ const cell = pointerCell(event);
170
+ if (!cell) return;
171
+ const duration = Math.min(props.newNoteDuration ?? 1, Math.max(snap(), props.length - cell.start));
172
+ props.onAddNote?.({ pitch: cell.pitch, start: cell.start, duration });
173
+ };
174
+
175
+ const handleKeyDown = (event: KeyboardEvent) => {
176
+ if (props.disabled) return;
177
+ const ids = [...selected()];
178
+ if (event.key === 'Escape') {
179
+ select([]);
180
+ return;
181
+ }
182
+ if (ids.length === 0) return;
183
+ if (event.key === 'Delete' || event.key === 'Backspace') {
184
+ event.preventDefault();
185
+ props.onDeleteNotes?.(ids);
186
+ return;
187
+ }
188
+ const arrows: Record<string, { beats: number; rows: number }> = {
189
+ ArrowLeft: { beats: -snap(), rows: 0 },
190
+ ArrowRight: { beats: snap(), rows: 0 },
191
+ ArrowUp: { beats: 0, rows: event.shiftKey ? -12 : -1 },
192
+ ArrowDown: { beats: 0, rows: event.shiftKey ? 12 : 1 },
193
+ };
194
+ const arrow = arrows[event.key];
195
+ if (!arrow) return;
196
+ event.preventDefault();
197
+ const idSet = new Set(ids);
198
+ const origin = props.notes.filter((note) => idSet.has(note.id));
199
+ props.onCommitNotes?.(
200
+ resolveNoteDrag({
201
+ mode: 'move',
202
+ deltaBeats: arrow.beats,
203
+ deltaRows: arrow.rows,
204
+ origin,
205
+ length: props.length,
206
+ minPitch: minPitch(),
207
+ maxPitch: maxPitch(),
208
+ snap: snap(),
209
+ }),
210
+ );
211
+ };
212
+
213
+ const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index));
214
+
215
+ return (
216
+ <div
217
+ ref={root}
218
+ class={props.class ? `zylem-piano-roll ${props.class}` : 'zylem-piano-roll'}
219
+ tabIndex={0}
220
+ role="application"
221
+ aria-label="Piano roll"
222
+ data-disabled={props.disabled ? '' : undefined}
223
+ data-dragging={dragging() ? '' : undefined}
224
+ onKeyDown={handleKeyDown}
225
+ >
226
+ <div
227
+ class="zylem-piano-roll-scroll"
228
+ style={{
229
+ '--zylem-pr-ppb': `${ppb()}px`,
230
+ '--zylem-pr-bar': `${ppb() * bpb()}px`,
231
+ '--zylem-pr-snap': `${ppb() * snap()}px`,
232
+ '--zylem-pr-row': `${rowHeight()}px`,
233
+ }}
234
+ >
235
+ <div
236
+ class="zylem-piano-roll-content"
237
+ style={{ width: `${KEYS_WIDTH + gridWidth()}px`, height: `${gridHeight()}px` }}
238
+ >
239
+ <div class="zylem-piano-roll-keys" style={{ width: `${KEYS_WIDTH}px` }}>
240
+ <Index each={rowIndexes()}>
241
+ {(row) => {
242
+ const pitch = () => rowToPitch(row(), maxPitch());
243
+ return (
244
+ <button
245
+ type="button"
246
+ class="zylem-piano-roll-key"
247
+ style={{ height: `${rowHeight()}px` }}
248
+ data-black={isBlackKey(pitch()) ? '' : undefined}
249
+ data-c={pitch() % 12 === 0 ? '' : undefined}
250
+ aria-label={pitchName(pitch())}
251
+ tabIndex={-1}
252
+ disabled={props.disabled}
253
+ onPointerDown={(event) => {
254
+ event.preventDefault();
255
+ props.onPreviewPitch?.(pitch());
256
+ }}
257
+ >
258
+ <Show when={pitch() % 12 === 0}>
259
+ <span>{pitchName(pitch())}</span>
260
+ </Show>
261
+ </button>
262
+ );
263
+ }}
264
+ </Index>
265
+ </div>
266
+ <div
267
+ ref={grid}
268
+ class="zylem-piano-roll-grid"
269
+ style={{ width: `${gridWidth()}px`, height: `${gridHeight()}px` }}
270
+ onPointerDown={(event) => {
271
+ if (event.target === grid && !event.shiftKey) select([]);
272
+ root?.focus({ preventScroll: true });
273
+ }}
274
+ onDblClick={handleGridDoubleClick}
275
+ >
276
+ <Index each={rowIndexes()}>
277
+ {(row) => (
278
+ <div
279
+ class="zylem-piano-roll-row"
280
+ style={{ top: `${row() * rowHeight()}px`, height: `${rowHeight()}px` }}
281
+ data-black={isBlackKey(rowToPitch(row(), maxPitch())) ? '' : undefined}
282
+ data-c={rowToPitch(row(), maxPitch()) % 12 === 0 ? '' : undefined}
283
+ />
284
+ )}
285
+ </Index>
286
+ <div class="zylem-piano-roll-end" style={{ left: `${props.length * ppb()}px` }} />
287
+ <For each={props.notes}>
288
+ {(note) => (
289
+ <div
290
+ class="zylem-piano-roll-note"
291
+ role="button"
292
+ tabIndex={-1}
293
+ aria-label={`${pitchName(note.pitch)} at beat ${note.start + 1}`}
294
+ aria-pressed={selected().has(note.id) ? 'true' : 'false'}
295
+ data-selected={selected().has(note.id) ? '' : undefined}
296
+ style={{
297
+ left: `${note.start * ppb()}px`,
298
+ top: `${pitchToRow(note.pitch, maxPitch()) * rowHeight()}px`,
299
+ width: `${Math.max(3, note.duration * ppb() - 1)}px`,
300
+ height: `${rowHeight() - 1}px`,
301
+ '--zylem-pr-velocity': String(note.velocity ?? 0.8),
302
+ }}
303
+ onPointerDown={beginNoteDrag(note, 'move')}
304
+ >
305
+ <Show when={note.duration * ppb() > 28}>
306
+ <span class="zylem-piano-roll-note-label">{pitchName(note.pitch)}</span>
307
+ </Show>
308
+ <div
309
+ class="zylem-piano-roll-note-handle"
310
+ onPointerDown={beginNoteDrag(note, 'resize')}
311
+ />
312
+ </div>
313
+ )}
314
+ </For>
315
+ <Show when={props.playhead !== undefined}>
316
+ <div
317
+ class="zylem-piano-roll-playhead"
318
+ style={{ left: `${(props.playhead ?? 0) * ppb()}px` }}
319
+ aria-hidden="true"
320
+ />
321
+ </Show>
322
+ </div>
323
+ </div>
324
+ <Show when={props.showVelocity}>
325
+ <div
326
+ class="zylem-piano-roll-velocity"
327
+ style={{ width: `${KEYS_WIDTH + gridWidth()}px`, height: `${VELOCITY_HEIGHT}px` }}
328
+ >
329
+ <div class="zylem-piano-roll-velocity-label" style={{ width: `${KEYS_WIDTH}px` }}>
330
+ Vel
331
+ </div>
332
+ <div class="zylem-piano-roll-velocity-lane" style={{ width: `${gridWidth()}px` }}>
333
+ <For each={props.notes}>
334
+ {(note) => (
335
+ <div
336
+ class="zylem-piano-roll-velocity-bar"
337
+ data-selected={selected().has(note.id) ? '' : undefined}
338
+ style={{
339
+ left: `${note.start * ppb()}px`,
340
+ height: `${Math.round((note.velocity ?? 0.8) * 100)}%`,
341
+ }}
342
+ title={`Velocity ${Math.round((note.velocity ?? 0.8) * 100)}`}
343
+ onPointerDown={beginVelocityDrag(note)}
344
+ />
345
+ )}
346
+ </For>
347
+ </div>
348
+ </div>
349
+ </Show>
350
+ </div>
351
+ </div>
352
+ );
353
+ }
@@ -0,0 +1,89 @@
1
+ import { clamp, snapTime } from '../TimelineLanes/lanes-math';
2
+
3
+ export interface PianoRollNote {
4
+ id: string;
5
+ /** MIDI pitch, 0–127. */
6
+ pitch: number;
7
+ /** Beats from the clip start. */
8
+ start: number;
9
+ /** Beats. */
10
+ duration: number;
11
+ /** 0–1; defaults to 0.8 when absent. */
12
+ velocity?: number;
13
+ }
14
+
15
+ const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;
16
+
17
+ /** `60` → `C4`. */
18
+ export function pitchName(pitch: number): string {
19
+ const index = ((Math.round(pitch) % 12) + 12) % 12;
20
+ return `${NOTE_NAMES[index]}${Math.floor(Math.round(pitch) / 12) - 1}`;
21
+ }
22
+
23
+ export function isBlackKey(pitch: number): boolean {
24
+ const index = ((Math.round(pitch) % 12) + 12) % 12;
25
+ return index === 1 || index === 3 || index === 6 || index === 8 || index === 10;
26
+ }
27
+
28
+ /** Highest pitch is the top row. */
29
+ export function pitchToRow(pitch: number, maxPitch: number): number {
30
+ return maxPitch - pitch;
31
+ }
32
+
33
+ export function rowToPitch(row: number, maxPitch: number): number {
34
+ return maxPitch - row;
35
+ }
36
+
37
+ export interface NoteDragOptions {
38
+ mode: 'move' | 'resize';
39
+ /** Pointer travel in beats. */
40
+ deltaBeats: number;
41
+ /** Pointer travel in rows (positive = down = lower pitch). */
42
+ deltaRows: number;
43
+ /** The notes being dragged, as they were when the drag began. */
44
+ origin: readonly PianoRollNote[];
45
+ /** Clip length in beats. */
46
+ length: number;
47
+ minPitch: number;
48
+ maxPitch: number;
49
+ snap?: number;
50
+ minDuration?: number;
51
+ }
52
+
53
+ /**
54
+ * Where a set of notes lands after a drag. Moves shift every note by the same
55
+ * snapped amount, clamped so the group stays inside the clip and the pitch
56
+ * range; resizes change every note's duration by the same snapped amount.
57
+ */
58
+ export function resolveNoteDrag(options: NoteDragOptions): PianoRollNote[] {
59
+ const { origin, length, minPitch, maxPitch } = options;
60
+ if (origin.length === 0) return [];
61
+ const snap = options.snap;
62
+ if (options.mode === 'resize') {
63
+ const minDuration = Math.max(options.minDuration ?? snap ?? 0.0625, 0.0001);
64
+ const delta = snapTime(options.deltaBeats, snap);
65
+ return origin.map((note) => ({
66
+ ...note,
67
+ duration: clamp(note.duration + delta, minDuration, Math.max(minDuration, length - note.start)),
68
+ }));
69
+ }
70
+ const first = origin.reduce((min, note) => Math.min(min, note.start), Number.POSITIVE_INFINITY);
71
+ const last = origin.reduce((max, note) => Math.max(max, note.start + note.duration), 0);
72
+ const lowest = origin.reduce((min, note) => Math.min(min, note.pitch), Number.POSITIVE_INFINITY);
73
+ const highest = origin.reduce((max, note) => Math.max(max, note.pitch), Number.NEGATIVE_INFINITY);
74
+ const rawShift = snapTime(first + options.deltaBeats, snap) - first;
75
+ const shift = clamp(rawShift, -first, Math.max(-first, length - last));
76
+ const rawTranspose = -Math.round(options.deltaRows);
77
+ const transpose = clamp(rawTranspose, minPitch - lowest, maxPitch - highest);
78
+ return origin.map((note) => ({
79
+ ...note,
80
+ start: note.start + shift,
81
+ pitch: note.pitch + transpose,
82
+ }));
83
+ }
84
+
85
+ /** Snaps a beat position down to the grid so a new note starts on the cell under the pointer. */
86
+ export function cellStart(beats: number, snap: number | undefined, length: number): number {
87
+ const grid = snap && snap > 0 ? snap : 0.25;
88
+ return clamp(Math.floor(beats / grid + 1e-9) * grid, 0, Math.max(0, length - grid));
89
+ }
@@ -0,0 +1,30 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ /**
5
+ * Checker pattern shared by every "transparent" surface in the art tools:
6
+ * the pixel grid, the transparent palette swatch, and empty frame thumbs.
7
+ * Cell size comes from `--zylem-pixel-grid-cell` so hosts can scale it.
8
+ */
9
+ export const checkerBackground = `repeating-conic-gradient(${vars.art.checkerLight} 0 25%, ${vars.art.checkerDark} 0 50%) 0 0 / calc(var(--zylem-pixel-grid-cell, 8px) * 2) calc(var(--zylem-pixel-grid-cell, 8px) * 2)`;
10
+
11
+ globalStyle('.zylem-pixel-grid', {
12
+ boxSizing: 'content-box',
13
+ position: 'relative',
14
+ flex: 'none',
15
+ background: checkerBackground,
16
+ border: `1px solid ${vars.art.canvasBorder}`,
17
+ imageRendering: 'pixelated',
18
+ overflow: 'hidden',
19
+ });
20
+
21
+ // Overlays fill the surface; a consumer canvas keeps its own pixel size and
22
+ // stretches to the zoomed box without smoothing.
23
+ globalStyle('.zylem-pixel-grid > canvas, .zylem-pixel-grid > img, .zylem-pixel-grid > svg', {
24
+ position: 'absolute',
25
+ inset: 0,
26
+ width: '100%',
27
+ height: '100%',
28
+ display: 'block',
29
+ imageRendering: 'pixelated',
30
+ });
@@ -0,0 +1,48 @@
1
+ import { type JSX, splitProps } from 'solid-js';
2
+
3
+ export interface PixelGridProps extends JSX.HTMLAttributes<HTMLDivElement> {
4
+ /** Sprite width in source pixels. */
5
+ width: number;
6
+ /** Sprite height in source pixels. */
7
+ height: number;
8
+ /** Screen pixels per source pixel. */
9
+ zoom?: number;
10
+ /** Checker cell size in screen pixels. */
11
+ checkerCell?: number;
12
+ /** Overlay content (a canvas, selection chrome); fills the surface. */
13
+ children?: JSX.Element;
14
+ }
15
+
16
+ /**
17
+ * Transparent-pixel checkerboard for the sprite editor. Sized to the sprite
18
+ * at the given zoom with a thin blue edge, so a consumer's canvas can sit on
19
+ * top with a transparent background and read against the checker.
20
+ */
21
+ export function PixelGrid(props: PixelGridProps) {
22
+ const [local, rest] = splitProps(props, [
23
+ 'width',
24
+ 'height',
25
+ 'zoom',
26
+ 'checkerCell',
27
+ 'children',
28
+ 'class',
29
+ 'style',
30
+ ]);
31
+ const zoom = () => local.zoom ?? 1;
32
+ const inlineStyle = (): JSX.CSSProperties => ({
33
+ width: `${local.width * zoom()}px`,
34
+ height: `${local.height * zoom()}px`,
35
+ '--zylem-pixel-grid-cell': `${local.checkerCell ?? 8}px`,
36
+ ...(typeof local.style === 'object' ? local.style : {}),
37
+ });
38
+
39
+ return (
40
+ <div
41
+ class={local.class ? `zylem-pixel-grid ${local.class}` : 'zylem-pixel-grid'}
42
+ style={inlineStyle()}
43
+ {...rest}
44
+ >
45
+ {local.children}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,53 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ globalStyle('.zylem-playback', {
5
+ display: 'flex',
6
+ alignItems: 'center',
7
+ gap: vars.spacing.md,
8
+ minWidth: 0,
9
+ fontFamily: vars.typography.fontFamily,
10
+ });
11
+
12
+ globalStyle('.zylem-playback-transport', {
13
+ display: 'flex',
14
+ alignItems: 'center',
15
+ gap: vars.spacing.xs,
16
+ });
17
+
18
+ globalStyle('.zylem-playback-btn', {
19
+ width: '34px',
20
+ padding: 0,
21
+ });
22
+
23
+ // Play is the primary control: a touch wider so the strip reads as a
24
+ // transport rather than three identical tool buttons.
25
+ globalStyle('.zylem-playback-btn--play', {
26
+ width: '44px',
27
+ });
28
+
29
+ globalStyle('.zylem-playback-btn .zylem-icon', {
30
+ fill: 'currentColor',
31
+ fillOpacity: 0.85,
32
+ });
33
+
34
+ globalStyle('.zylem-playback-loop .zylem-icon', {
35
+ width: '14px',
36
+ height: '14px',
37
+ stroke: 'currentColor',
38
+ });
39
+
40
+ globalStyle('.zylem-playback-extra', {
41
+ display: 'flex',
42
+ alignItems: 'center',
43
+ gap: vars.spacing.sm,
44
+ minWidth: 0,
45
+ flex: '1 1 auto',
46
+ color: vars.colors.textSecondary,
47
+ fontSize: `calc(${vars.typography.fontSize} - 1px)`,
48
+ });
49
+
50
+ // Compact fields inside the transport (FPS, duration).
51
+ globalStyle('.zylem-playback-extra .zylem-field-root', {
52
+ width: '72px',
53
+ });
@@ -0,0 +1,80 @@
1
+ import FastForward from 'lucide-solid/icons/fast-forward';
2
+ import Pause from 'lucide-solid/icons/pause';
3
+ import Play from 'lucide-solid/icons/play';
4
+ import Repeat from 'lucide-solid/icons/repeat';
5
+ import Rewind from 'lucide-solid/icons/rewind';
6
+ import { type JSX, Show } from 'solid-js';
7
+ import { Button } from '../Button/Button';
8
+ import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
9
+
10
+ export interface PlaybackControlsProps {
11
+ playing: boolean;
12
+ onTogglePlay: () => void;
13
+ onRewind?: () => void;
14
+ onFastForward?: () => void;
15
+ /** When defined, a Loop toggle follows the transport buttons. */
16
+ loop?: boolean;
17
+ onToggleLoop?: () => void;
18
+ disabled?: boolean;
19
+ /** Trailing controls: an FPS field, a time readout, a duration field. */
20
+ children?: JSX.Element;
21
+ class?: string;
22
+ }
23
+
24
+ /**
25
+ * Transport strip: rewind, play/pause, fast-forward as glass toolbar
26
+ * buttons, an optional Loop toggle, then whatever the host wants alongside.
27
+ */
28
+ export function PlaybackControls(props: PlaybackControlsProps) {
29
+ return (
30
+ <div
31
+ class={props.class ? `zylem-playback ${props.class}` : 'zylem-playback'}
32
+ role="group"
33
+ aria-label="Playback"
34
+ >
35
+ <div class="zylem-playback-transport">
36
+ <ToolbarButton
37
+ label="Rewind"
38
+ disabled={props.disabled || !props.onRewind}
39
+ onClick={() => props.onRewind?.()}
40
+ class="zylem-playback-btn"
41
+ >
42
+ <Rewind class="zylem-icon" />
43
+ </ToolbarButton>
44
+ <ToolbarButton
45
+ label={props.playing ? 'Pause' : 'Play'}
46
+ selected={props.playing}
47
+ disabled={props.disabled}
48
+ onClick={() => props.onTogglePlay()}
49
+ class="zylem-playback-btn zylem-playback-btn--play"
50
+ >
51
+ {props.playing ? <Pause class="zylem-icon" /> : <Play class="zylem-icon" />}
52
+ </ToolbarButton>
53
+ <ToolbarButton
54
+ label="Fast forward"
55
+ disabled={props.disabled || !props.onFastForward}
56
+ onClick={() => props.onFastForward?.()}
57
+ class="zylem-playback-btn"
58
+ >
59
+ <FastForward class="zylem-icon" />
60
+ </ToolbarButton>
61
+ </div>
62
+ <Show when={props.loop !== undefined}>
63
+ <Button
64
+ size="sm"
65
+ variant={props.loop ? 'active' : 'default'}
66
+ aria-pressed={props.loop ? 'true' : 'false'}
67
+ disabled={props.disabled}
68
+ onClick={() => props.onToggleLoop?.()}
69
+ class="zylem-playback-loop"
70
+ >
71
+ <Repeat class="zylem-icon" aria-hidden="true" />
72
+ Loop
73
+ </Button>
74
+ </Show>
75
+ <Show when={props.children}>
76
+ <div class="zylem-playback-extra">{props.children}</div>
77
+ </Show>
78
+ </div>
79
+ );
80
+ }
@@ -17,7 +17,7 @@ globalStyle('.zylem-property-list', {
17
17
  globalStyle('.zylem-property-row', {
18
18
  minHeight: '30px',
19
19
  display: 'grid',
20
- gridTemplateColumns: '112px 1fr',
20
+ gridTemplateColumns: 'var(--zylem-property-label-width, 112px) minmax(0, 1fr)',
21
21
  alignItems: 'center',
22
22
  gap: vars.spacing.sm,
23
23
  padding: `${vars.spacing.xs} ${vars.spacing.sm}`,
@@ -4,6 +4,8 @@ import type { JSX } from 'solid-js';
4
4
  export interface PropertyListProps {
5
5
  children: JSX.Element;
6
6
  class?: string;
7
+ /** Width of the label column (default `112px`); narrow it for slim inspectors. */
8
+ labelWidth?: string;
7
9
  }
8
10
 
9
11
  /**
@@ -17,6 +19,7 @@ export function PropertyList(props: PropertyListProps) {
17
19
  ? `zylem-property-list ${props.class}`
18
20
  : 'zylem-property-list'
19
21
  }
22
+ style={props.labelWidth ? { '--zylem-property-label-width': props.labelWidth } : undefined}
20
23
  >
21
24
  {props.children}
22
25
  </div>