@zylem/ui 0.8.9 → 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 (51) hide show
  1. package/dist/styles.css +1 -1
  2. package/package.json +12 -10
  3. package/src/components/Accordion/Accordion.tsx +10 -1
  4. package/src/components/AddTile/AddTile.css.ts +17 -0
  5. package/src/components/AddTile/AddTile.tsx +15 -7
  6. package/src/components/Callout/Callout.css.ts +45 -0
  7. package/src/components/Callout/Callout.tsx +32 -0
  8. package/src/components/Card/Card.css.ts +18 -0
  9. package/src/components/Card/Card.tsx +25 -2
  10. package/src/components/Dialog/Dialog.css.ts +69 -2
  11. package/src/components/Dialog/Dialog.tsx +35 -7
  12. package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
  13. package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
  14. package/src/components/EmptyState/EmptyState.css.ts +9 -0
  15. package/src/components/EmptyState/EmptyState.tsx +3 -1
  16. package/src/components/FrameStrip/FrameStrip.css.ts +6 -0
  17. package/src/components/FrameStrip/FrameStrip.tsx +30 -2
  18. package/src/components/Menubar/Menubar.css.ts +52 -0
  19. package/src/components/Menubar/Menubar.tsx +77 -0
  20. package/src/components/Mixer/Knob.tsx +188 -0
  21. package/src/components/Mixer/Mixer.css.ts +246 -0
  22. package/src/components/Mixer/MixerStrip.tsx +150 -0
  23. package/src/components/NumberField/NumberField.tsx +5 -1
  24. package/src/components/Panel/Panel.tsx +6 -5
  25. package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
  26. package/src/components/PianoRoll/PianoRoll.tsx +353 -0
  27. package/src/components/PianoRoll/piano-roll-math.ts +89 -0
  28. package/src/components/Property/Property.css.ts +1 -1
  29. package/src/components/Property/Property.tsx +3 -0
  30. package/src/components/Sidebar/Sidebar.css.ts +8 -0
  31. package/src/components/Sidebar/Sidebar.tsx +10 -2
  32. package/src/components/Slider/Slider.tsx +6 -0
  33. package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +15 -4
  34. package/src/components/Tabs/Tabs.css.ts +48 -0
  35. package/src/components/Tabs/Tabs.tsx +41 -3
  36. package/src/components/TextField/TextField.tsx +49 -5
  37. package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
  38. package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
  39. package/src/components/TimelineLanes/lanes-math.ts +182 -0
  40. package/src/components/Toast/Toast.css.ts +48 -0
  41. package/src/components/Toast/Toast.tsx +37 -0
  42. package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
  43. package/src/components/TrackHeader/TrackHeader.tsx +145 -0
  44. package/src/components/TransportBar/TransportBar.css.ts +65 -0
  45. package/src/components/TransportBar/TransportBar.tsx +154 -0
  46. package/src/components/Waveform/Waveform.css.ts +77 -0
  47. package/src/components/Waveform/Waveform.tsx +195 -0
  48. package/src/components/Waveform/peaks.ts +49 -0
  49. package/src/components/index.ts +55 -1
  50. package/src/global/accordion.css.ts +23 -0
  51. package/src/styles.ts +11 -0
@@ -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
+ }
@@ -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>
@@ -19,6 +19,14 @@ globalStyle('.sidebar', {
19
19
  },
20
20
  });
21
21
 
22
+ globalStyle('.sidebar--borderless', {
23
+ height: 'auto',
24
+ overflow: 'visible',
25
+ background: 'none',
26
+ borderRight: 'none',
27
+ backdropFilter: 'none',
28
+ });
29
+
22
30
  globalStyle('.sidebar-section', {
23
31
  display: 'flex',
24
32
  flexDirection: 'column',
@@ -3,6 +3,12 @@ import type { JSX } from 'solid-js';
3
3
 
4
4
  export interface SidebarProps extends JSX.HTMLAttributes<HTMLElement> {
5
5
  children?: JSX.Element;
6
+ /**
7
+ * Drop the glass surface and trailing border so the list sits inside a
8
+ * panel that already draws them (an inspector rail, a dialog column).
9
+ * It also stops filling its container, so it stacks with siblings.
10
+ */
11
+ borderless?: boolean;
6
12
  }
7
13
 
8
14
  /**
@@ -10,10 +16,12 @@ export interface SidebarProps extends JSX.HTMLAttributes<HTMLElement> {
10
16
  * and `SidebarItem`.
11
17
  */
12
18
  export function Sidebar(props: SidebarProps) {
13
- const [local, rest] = splitProps(props, ['children', 'class']);
19
+ const [local, rest] = splitProps(props, ['children', 'class', 'borderless']);
14
20
  return (
15
21
  <aside
16
- class={local.class ? `sidebar zylem-scroll ${local.class}` : 'sidebar zylem-scroll'}
22
+ class={['sidebar', 'zylem-scroll', local.borderless ? 'sidebar--borderless' : '', local.class ?? '']
23
+ .filter(Boolean)
24
+ .join(' ')}
17
25
  {...rest}
18
26
  >
19
27
  {local.children}
@@ -5,6 +5,11 @@ export interface SliderProps {
5
5
  label?: string;
6
6
  value?: number;
7
7
  onChange?: (value: number) => void;
8
+ /**
9
+ * Fires once when a drag or key press finishes, with the final value. Lets
10
+ * an editor record one undo step per gesture while `onChange` previews.
11
+ */
12
+ onCommit?: (value: number) => void;
8
13
  minValue?: number;
9
14
  maxValue?: number;
10
15
  step?: number;
@@ -23,6 +28,7 @@ export function Slider(props: SliderProps) {
23
28
  class={props.class ? `zylem-slider-root ${props.class}` : 'zylem-slider-root'}
24
29
  value={props.value !== undefined ? [props.value] : undefined}
25
30
  onChange={(values) => props.onChange?.(values[0])}
31
+ onChangeEnd={(values) => props.onCommit?.(values[0])}
26
32
  minValue={props.minValue}
27
33
  maxValue={props.maxValue}
28
34
  step={props.step}
@@ -12,8 +12,15 @@ export interface SpriteSizePickerProps {
12
12
  presets?: readonly number[];
13
13
  /** Smallest dimension reachable by dragging or typing. */
14
14
  min?: number;
15
- /** Largest dimension; also sets the size of the 1:1 preview stage. */
15
+ /** Largest dimension; also sets the size of the 1:1 preview stage unless `stageSize` is given. */
16
16
  max?: number;
17
+ /**
18
+ * Side of the square preview stage in pixels. Defaults to `max`, which is
19
+ * right when the limit is small; a picker allowing 1024px sprites sets this
20
+ * so the stage still fits a dialog. Sprites larger than the stage are shown
21
+ * cropped at 1:1 with the readout carrying the true size.
22
+ */
23
+ stageSize?: number;
17
24
  /** Drag increment in source pixels. */
18
25
  step?: number;
19
26
  /** Render Width / Height number fields under the preview. */
@@ -87,7 +94,11 @@ export function SpriteSizePicker(props: SpriteSizePickerProps) {
87
94
  event.preventDefault();
88
95
  };
89
96
 
90
- const stageSize = () => `${max() + STAGE_GUTTER}px`;
97
+ const stage = () => props.stageSize ?? max();
98
+ const stageSize = () => `${stage() + STAGE_GUTTER}px`;
99
+ // The frame is the sprite at 1:1, cropped to the stage when it outgrows it.
100
+ const frameWidth = () => Math.min(props.width, stage());
101
+ const frameHeight = () => Math.min(props.height, stage());
91
102
 
92
103
  return (
93
104
  <div class={props.class ? `zylem-size-picker ${props.class}` : 'zylem-size-picker'}>
@@ -109,9 +120,9 @@ export function SpriteSizePicker(props: SpriteSizePickerProps) {
109
120
  <div class="zylem-size-picker-stage" style={{ width: stageSize(), height: stageSize() }}>
110
121
  <div
111
122
  class="zylem-size-picker-frame"
112
- style={{ width: `${props.width}px`, height: `${props.height}px` }}
123
+ style={{ width: `${frameWidth()}px`, height: `${frameHeight()}px` }}
113
124
  >
114
- <PixelGrid width={props.width} height={props.height} checkerCell={4} />
125
+ <PixelGrid width={frameWidth()} height={frameHeight()} checkerCell={4} />
115
126
  <div
116
127
  class="zylem-size-picker-handle"
117
128
  data-edge="right"
@@ -59,6 +59,54 @@ globalStyle('.zylem-tabs-trigger[data-disabled]', {
59
59
  cursor: 'not-allowed',
60
60
  });
61
61
 
62
+ globalStyle('.zylem-tabs-trigger-label', {
63
+ minWidth: 0,
64
+ overflow: 'hidden',
65
+ textOverflow: 'ellipsis',
66
+ whiteSpace: 'nowrap',
67
+ });
68
+
69
+ // Unsaved marker: a small live dot.
70
+ globalStyle('.zylem-tabs-dirty', {
71
+ flex: 'none',
72
+ width: '6px',
73
+ height: '6px',
74
+ borderRadius: '50%',
75
+ background: vars.colors.active,
76
+ boxShadow: vars.effects.glowActive,
77
+ });
78
+
79
+ // Close affordance: flat at rest, accent on hover (like the window close).
80
+ globalStyle('.zylem-tabs-close', {
81
+ display: 'inline-flex',
82
+ alignItems: 'center',
83
+ justifyContent: 'center',
84
+ flex: 'none',
85
+ width: '16px',
86
+ height: '16px',
87
+ marginRight: `calc(${vars.spacing.xs} * -1)`,
88
+ borderRadius: '4px',
89
+ color: vars.colors.textSecondary,
90
+ cursor: 'pointer',
91
+ transition: `background ${vars.motion.fast} ${vars.motion.easeOut}, color ${vars.motion.fast} ${vars.motion.easeOut}`,
92
+ });
93
+
94
+ globalStyle('.zylem-tabs-close svg', {
95
+ width: '11px',
96
+ height: '11px',
97
+ stroke: 'currentColor',
98
+ });
99
+
100
+ globalStyle('.zylem-tabs-close:hover', {
101
+ background: vars.colors.accent,
102
+ color: '#FFF4F2',
103
+ });
104
+
105
+ globalStyle('.zylem-tabs-close:focus-visible', {
106
+ outline: 'none',
107
+ boxShadow: vars.effects.focusRing,
108
+ });
109
+
62
110
  globalStyle('.zylem-tabs-content', {
63
111
  flex: 1,
64
112
  minHeight: 0,