@zylem/ui 0.8.9 → 0.8.12

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 (61) hide show
  1. package/README.md +3 -0
  2. package/dist/styles.css +1 -1
  3. package/package.json +5 -1
  4. package/src/components/Accordion/Accordion.tsx +10 -1
  5. package/src/components/AddTile/AddTile.css.ts +17 -0
  6. package/src/components/AddTile/AddTile.tsx +15 -7
  7. package/src/components/Callout/Callout.css.ts +45 -0
  8. package/src/components/Callout/Callout.tsx +32 -0
  9. package/src/components/Card/Card.css.ts +18 -0
  10. package/src/components/Card/Card.tsx +25 -2
  11. package/src/components/Dialog/Dialog.css.ts +69 -2
  12. package/src/components/Dialog/Dialog.tsx +35 -7
  13. package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
  14. package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
  15. package/src/components/EmptyState/EmptyState.css.ts +9 -0
  16. package/src/components/EmptyState/EmptyState.tsx +3 -1
  17. package/src/components/FrameStrip/FrameStrip.css.ts +6 -0
  18. package/src/components/FrameStrip/FrameStrip.tsx +30 -2
  19. package/src/components/Menubar/Menubar.css.ts +52 -0
  20. package/src/components/Menubar/Menubar.tsx +77 -0
  21. package/src/components/Mixer/Knob.tsx +188 -0
  22. package/src/components/Mixer/Mixer.css.ts +246 -0
  23. package/src/components/Mixer/MixerStrip.tsx +150 -0
  24. package/src/components/NumberField/NumberField.tsx +5 -1
  25. package/src/components/Panel/Panel.tsx +6 -5
  26. package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
  27. package/src/components/PianoRoll/PianoRoll.tsx +353 -0
  28. package/src/components/PianoRoll/piano-roll-math.ts +89 -0
  29. package/src/components/Property/Property.css.ts +1 -1
  30. package/src/components/Property/Property.tsx +3 -0
  31. package/src/components/Sidebar/Sidebar.css.ts +8 -0
  32. package/src/components/Sidebar/Sidebar.tsx +10 -2
  33. package/src/components/Slider/Slider.tsx +6 -0
  34. package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +15 -4
  35. package/src/components/Tabs/Tabs.css.ts +48 -0
  36. package/src/components/Tabs/Tabs.tsx +41 -3
  37. package/src/components/TextField/TextField.tsx +49 -5
  38. package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
  39. package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
  40. package/src/components/TimelineLanes/lanes-math.ts +182 -0
  41. package/src/components/Toast/Toast.css.ts +48 -0
  42. package/src/components/Toast/Toast.tsx +37 -0
  43. package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
  44. package/src/components/TrackHeader/TrackHeader.tsx +145 -0
  45. package/src/components/TransportBar/TransportBar.css.ts +65 -0
  46. package/src/components/TransportBar/TransportBar.tsx +154 -0
  47. package/src/components/Waveform/Waveform.css.ts +77 -0
  48. package/src/components/Waveform/Waveform.tsx +195 -0
  49. package/src/components/Waveform/peaks.ts +49 -0
  50. package/src/components/index.ts +72 -1
  51. package/src/global/accordion.css.ts +23 -0
  52. package/src/icons/create-icon.tsx +71 -0
  53. package/src/icons/icon-nodes.ts +75 -0
  54. package/src/icons/index.ts +54 -0
  55. package/src/icons/svg/camera-isometric.svg +16 -0
  56. package/src/icons/svg/free-perspective.svg +8 -0
  57. package/src/icons/svg/game-camera.svg +6 -0
  58. package/src/icons/svg/perspective.svg +5 -0
  59. package/src/icons/svg/sideview-perspective.svg +5 -0
  60. package/src/icons/svg/top-down-perspective.svg +5 -0
  61. package/src/styles.ts +11 -0
@@ -0,0 +1,48 @@
1
+ import { globalStyle, keyframes } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ const enter = keyframes({
5
+ from: { opacity: 0, transform: 'translate(-50%, 6px)' },
6
+ to: { opacity: 1, transform: 'translate(-50%, 0)' },
7
+ });
8
+
9
+ globalStyle('.zylem-toast', {
10
+ position: 'absolute',
11
+ left: '50%',
12
+ bottom: vars.spacing.lg,
13
+ transform: 'translateX(-50%)',
14
+ zIndex: vars.layers.panel,
15
+ maxWidth: 'calc(100% - 32px)',
16
+ padding: `${vars.spacing.sm} ${vars.spacing.md}`,
17
+ borderRadius: vars.radii.control,
18
+ border: '1px solid rgba(150, 210, 255, 0.45)',
19
+ background: vars.material.glassPanelDark,
20
+ boxShadow: vars.effects.shadowPanel,
21
+ backdropFilter: `blur(${vars.effects.blurSm})`,
22
+ color: vars.colors.text,
23
+ fontFamily: vars.typography.fontFamily,
24
+ fontSize: `calc(${vars.typography.fontSize} - 1px)`,
25
+ lineHeight: 1.4,
26
+ textAlign: 'center',
27
+ pointerEvents: 'none',
28
+ animation: `${enter} 0.16s ${vars.motion.easeOut}`,
29
+ '@supports': {
30
+ 'not (backdrop-filter: blur(1px))': {
31
+ background: vars.colors.surface,
32
+ },
33
+ },
34
+ });
35
+
36
+ globalStyle('.zylem-toast[data-position="top"]', {
37
+ top: vars.spacing.lg,
38
+ bottom: 'auto',
39
+ });
40
+
41
+ globalStyle('.zylem-toast[data-tone="success"]', {
42
+ borderColor: 'rgba(120, 255, 150, 0.55)',
43
+ });
44
+
45
+ globalStyle('.zylem-toast[data-tone="error"]', {
46
+ borderColor: 'rgba(255, 132, 114, 0.65)',
47
+ color: '#FFD9D3',
48
+ });
@@ -0,0 +1,37 @@
1
+ import { Show } from 'solid-js';
2
+ import type { JSX } from 'solid-js';
3
+
4
+ export type ToastTone = 'info' | 'success' | 'error';
5
+
6
+ export interface ToastProps {
7
+ /** The message. Nothing renders while this is empty. */
8
+ message?: JSX.Element | null | false;
9
+ tone?: ToastTone;
10
+ /** Edge of the positioned parent to float against. Defaults to `bottom`. */
11
+ position?: 'top' | 'bottom';
12
+ class?: string;
13
+ }
14
+
15
+ /**
16
+ * Transient glass message floated over a stage (a game view, an editor
17
+ * surface). It positions itself against the nearest positioned ancestor and
18
+ * ignores the pointer, so it never gets in the way of what it reports on.
19
+ *
20
+ * Timing is the caller's: show it by passing a message and hide it by passing
21
+ * none, which is how a store's `flashHint()` already works.
22
+ */
23
+ export function Toast(props: ToastProps) {
24
+ return (
25
+ <Show when={props.message}>
26
+ <div
27
+ class={props.class ? `zylem-toast ${props.class}` : 'zylem-toast'}
28
+ role="status"
29
+ aria-live="polite"
30
+ data-tone={props.tone}
31
+ data-position={props.position}
32
+ >
33
+ {props.message}
34
+ </div>
35
+ </Show>
36
+ );
37
+ }
@@ -0,0 +1,162 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ globalStyle('.zylem-track-header', {
5
+ display: 'flex',
6
+ alignItems: 'stretch',
7
+ gap: vars.spacing.xs,
8
+ width: '100%',
9
+ minWidth: 0,
10
+ boxSizing: 'border-box',
11
+ padding: `0 ${vars.spacing.xs} 0 0`,
12
+ fontFamily: vars.typography.fontFamily,
13
+ color: vars.colors.text,
14
+ cursor: 'default',
15
+ });
16
+
17
+ globalStyle('.zylem-track-header[data-disabled]', {
18
+ opacity: 0.6,
19
+ });
20
+
21
+ globalStyle('.zylem-track-header-swatch', {
22
+ flex: 'none',
23
+ width: '4px',
24
+ background: 'var(--zylem-track-color)',
25
+ boxShadow: '0 0 6px color-mix(in srgb, var(--zylem-track-color) 60%, transparent)',
26
+ });
27
+
28
+ globalStyle('.zylem-track-header-main', {
29
+ flex: '1 1 auto',
30
+ minWidth: 0,
31
+ display: 'flex',
32
+ flexDirection: 'column',
33
+ justifyContent: 'center',
34
+ gap: '1px',
35
+ padding: `0 ${vars.spacing.xs}`,
36
+ });
37
+
38
+ globalStyle('.zylem-track-header-title', {
39
+ display: 'flex',
40
+ alignItems: 'center',
41
+ gap: vars.spacing.xs,
42
+ minWidth: 0,
43
+ });
44
+
45
+ globalStyle('.zylem-track-header-icon', {
46
+ display: 'inline-flex',
47
+ flex: 'none',
48
+ color: vars.colors.textSecondary,
49
+ });
50
+
51
+ globalStyle('.zylem-track-header-icon > svg', {
52
+ width: '13px',
53
+ height: '13px',
54
+ });
55
+
56
+ globalStyle('.zylem-track-header-name', {
57
+ fontSize: `calc(${vars.typography.fontSize} - 1px)`,
58
+ fontWeight: 600,
59
+ whiteSpace: 'nowrap',
60
+ overflow: 'hidden',
61
+ textOverflow: 'ellipsis',
62
+ });
63
+
64
+ globalStyle('.zylem-track-header[data-muted] .zylem-track-header-name', {
65
+ color: vars.colors.textSecondary,
66
+ });
67
+
68
+ globalStyle('.zylem-track-header-input', {
69
+ width: '100%',
70
+ minWidth: 0,
71
+ boxSizing: 'border-box',
72
+ padding: '1px 4px',
73
+ font: 'inherit',
74
+ fontSize: `calc(${vars.typography.fontSize} - 1px)`,
75
+ color: vars.colors.text,
76
+ background: vars.material.fieldGlass,
77
+ border: '1px solid rgba(97, 166, 232, 0.5)',
78
+ borderRadius: '4px',
79
+ outline: 'none',
80
+ });
81
+
82
+ globalStyle('.zylem-track-header-input:focus-visible', {
83
+ boxShadow: vars.effects.focusRing,
84
+ });
85
+
86
+ globalStyle('.zylem-track-header-detail', {
87
+ fontSize: '10px',
88
+ color: vars.colors.textSecondary,
89
+ whiteSpace: 'nowrap',
90
+ overflow: 'hidden',
91
+ textOverflow: 'ellipsis',
92
+ });
93
+
94
+ globalStyle('.zylem-track-header-toggles', {
95
+ flex: 'none',
96
+ display: 'flex',
97
+ alignItems: 'center',
98
+ gap: '3px',
99
+ });
100
+
101
+ globalStyle('.zylem-track-toggle', {
102
+ display: 'inline-flex',
103
+ alignItems: 'center',
104
+ justifyContent: 'center',
105
+ width: '18px',
106
+ height: '18px',
107
+ padding: 0,
108
+ fontFamily: vars.typography.fontFamily,
109
+ fontSize: '10px',
110
+ fontWeight: 700,
111
+ lineHeight: 1,
112
+ color: vars.colors.textSecondary,
113
+ background: 'rgba(8, 19, 31, 0.6)',
114
+ border: '1px solid rgba(97, 166, 232, 0.28)',
115
+ borderRadius: '4px',
116
+ boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.08)',
117
+ cursor: 'pointer',
118
+ outline: 'none',
119
+ transition: `background ${vars.motion.fast} ${vars.motion.easeOut}, color ${vars.motion.fast} ${vars.motion.easeOut}`,
120
+ });
121
+
122
+ globalStyle('.zylem-track-toggle > svg', {
123
+ width: '11px',
124
+ height: '11px',
125
+ });
126
+
127
+ globalStyle('.zylem-track-toggle:hover:not(:disabled)', {
128
+ color: vars.colors.text,
129
+ background: 'rgba(97, 166, 232, 0.22)',
130
+ });
131
+
132
+ globalStyle('.zylem-track-toggle:focus-visible', {
133
+ boxShadow: vars.effects.focusRing,
134
+ });
135
+
136
+ globalStyle('.zylem-track-toggle:disabled', {
137
+ cursor: 'default',
138
+ opacity: 0.5,
139
+ });
140
+
141
+ globalStyle('.zylem-track-toggle[data-kind="mute"][aria-pressed="true"]', {
142
+ color: '#2b1a08',
143
+ background: 'linear-gradient(180deg, rgba(255, 220, 130, 0.95), rgba(230, 150, 40, 0.95))',
144
+ borderColor: 'rgba(255, 230, 170, 0.8)',
145
+ });
146
+
147
+ globalStyle('.zylem-track-toggle[data-kind="solo"][aria-pressed="true"]', {
148
+ color: '#062010',
149
+ background: 'linear-gradient(180deg, rgba(170, 255, 190, 0.95), rgba(28, 174, 63, 0.95))',
150
+ borderColor: 'rgba(190, 255, 203, 0.8)',
151
+ });
152
+
153
+ globalStyle('.zylem-track-toggle[data-kind="lock"][aria-pressed="true"]', {
154
+ color: vars.colors.text,
155
+ background: 'rgba(97, 166, 232, 0.35)',
156
+ borderColor: 'rgba(178, 219, 255, 0.7)',
157
+ });
158
+
159
+ globalStyle('.zylem-track-header-actions', {
160
+ display: 'inline-flex',
161
+ alignItems: 'center',
162
+ });
@@ -0,0 +1,145 @@
1
+ import Lock from 'lucide-solid/icons/lock';
2
+ import LockOpen from 'lucide-solid/icons/lock-open';
3
+ import { createSignal, type JSX, Show } from 'solid-js';
4
+
5
+ export interface TrackHeaderProps {
6
+ name: string;
7
+ /** Swatch color shown as a bar on the left edge. */
8
+ color?: string;
9
+ /** Small glyph before the name (an instrument or camera icon). */
10
+ icon?: JSX.Element;
11
+ /** Secondary line under the name (instrument preset, camera kind). */
12
+ detail?: string;
13
+ selected?: boolean;
14
+ onSelect?: () => void;
15
+ /** Double-click the name to rename; the new name arrives here on Enter/blur. */
16
+ onRename?: (name: string) => void;
17
+ /** Mute / solo / lock toggles render only when their handler is given. */
18
+ muted?: boolean;
19
+ onToggleMute?: () => void;
20
+ solo?: boolean;
21
+ onToggleSolo?: () => void;
22
+ locked?: boolean;
23
+ onToggleLock?: () => void;
24
+ /** Trailing controls (a menu button, an add button). */
25
+ actions?: JSX.Element;
26
+ disabled?: boolean;
27
+ class?: string;
28
+ }
29
+
30
+ /**
31
+ * Header cell for a timeline lane: color bar, name (rename on double-click),
32
+ * an optional detail line, and M / S / lock toggles.
33
+ */
34
+ export function TrackHeader(props: TrackHeaderProps) {
35
+ const [editing, setEditing] = createSignal(false);
36
+ const [draft, setDraft] = createSignal('');
37
+
38
+ const startRename = () => {
39
+ if (!props.onRename || props.disabled) return;
40
+ setDraft(props.name);
41
+ setEditing(true);
42
+ };
43
+ const commitRename = () => {
44
+ if (!editing()) return;
45
+ setEditing(false);
46
+ const next = draft().trim();
47
+ if (next && next !== props.name) props.onRename?.(next);
48
+ };
49
+
50
+ return (
51
+ <div
52
+ class={props.class ? `zylem-track-header ${props.class}` : 'zylem-track-header'}
53
+ style={{ '--zylem-track-color': props.color ?? 'var(--zylem-color-primary)' }}
54
+ data-selected={props.selected ? '' : undefined}
55
+ data-muted={props.muted ? '' : undefined}
56
+ data-disabled={props.disabled ? '' : undefined}
57
+ onPointerDown={() => props.onSelect?.()}
58
+ >
59
+ <span class="zylem-track-header-swatch" aria-hidden="true" />
60
+ <div class="zylem-track-header-main">
61
+ <div class="zylem-track-header-title">
62
+ <Show when={props.icon}>
63
+ <span class="zylem-track-header-icon">{props.icon}</span>
64
+ </Show>
65
+ <Show
66
+ when={editing()}
67
+ fallback={
68
+ <span class="zylem-track-header-name" onDblClick={startRename} title={props.name}>
69
+ {props.name}
70
+ </span>
71
+ }
72
+ >
73
+ <input
74
+ class="zylem-track-header-input"
75
+ value={draft()}
76
+ aria-label="Track name"
77
+ ref={(element) => queueMicrotask(() => element.select())}
78
+ onInput={(event) => setDraft(event.currentTarget.value)}
79
+ onBlur={commitRename}
80
+ onKeyDown={(event) => {
81
+ if (event.key === 'Enter') commitRename();
82
+ if (event.key === 'Escape') setEditing(false);
83
+ }}
84
+ />
85
+ </Show>
86
+ </div>
87
+ <Show when={props.detail}>
88
+ <span class="zylem-track-header-detail">{props.detail}</span>
89
+ </Show>
90
+ </div>
91
+ <div class="zylem-track-header-toggles">
92
+ <Show when={props.onToggleMute}>
93
+ <button
94
+ type="button"
95
+ class="zylem-track-toggle"
96
+ data-kind="mute"
97
+ aria-pressed={props.muted ? 'true' : 'false'}
98
+ aria-label={props.muted ? 'Unmute' : 'Mute'}
99
+ title={props.muted ? 'Unmute' : 'Mute'}
100
+ disabled={props.disabled}
101
+ onPointerDown={(event) => event.stopPropagation()}
102
+ onClick={() => props.onToggleMute?.()}
103
+ >
104
+ M
105
+ </button>
106
+ </Show>
107
+ <Show when={props.onToggleSolo}>
108
+ <button
109
+ type="button"
110
+ class="zylem-track-toggle"
111
+ data-kind="solo"
112
+ aria-pressed={props.solo ? 'true' : 'false'}
113
+ aria-label={props.solo ? 'Unsolo' : 'Solo'}
114
+ title={props.solo ? 'Unsolo' : 'Solo'}
115
+ disabled={props.disabled}
116
+ onPointerDown={(event) => event.stopPropagation()}
117
+ onClick={() => props.onToggleSolo?.()}
118
+ >
119
+ S
120
+ </button>
121
+ </Show>
122
+ <Show when={props.onToggleLock}>
123
+ <button
124
+ type="button"
125
+ class="zylem-track-toggle"
126
+ data-kind="lock"
127
+ aria-pressed={props.locked ? 'true' : 'false'}
128
+ aria-label={props.locked ? 'Unlock' : 'Lock'}
129
+ title={props.locked ? 'Unlock' : 'Lock'}
130
+ disabled={props.disabled}
131
+ onPointerDown={(event) => event.stopPropagation()}
132
+ onClick={() => props.onToggleLock?.()}
133
+ >
134
+ {props.locked ? <Lock class="zylem-icon" /> : <LockOpen class="zylem-icon" />}
135
+ </button>
136
+ </Show>
137
+ <Show when={props.actions}>
138
+ <span class="zylem-track-header-actions" onPointerDown={(event) => event.stopPropagation()}>
139
+ {props.actions}
140
+ </span>
141
+ </Show>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,65 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ globalStyle('.zylem-transport', {
5
+ display: 'flex',
6
+ alignItems: 'center',
7
+ width: '100%',
8
+ minWidth: 0,
9
+ boxSizing: 'border-box',
10
+ fontFamily: vars.typography.fontFamily,
11
+ color: vars.colors.text,
12
+ });
13
+
14
+ globalStyle('.zylem-transport > .zylem-playback', {
15
+ width: '100%',
16
+ });
17
+
18
+ globalStyle('.zylem-transport .zylem-playback-extra', {
19
+ flex: '1 1 auto',
20
+ minWidth: 0,
21
+ gap: vars.spacing.sm,
22
+ });
23
+
24
+ globalStyle('.zylem-transport-track', {
25
+ flex: '1 1 auto',
26
+ minWidth: '120px',
27
+ });
28
+
29
+ globalStyle('.zylem-transport-time', {
30
+ flex: 'none',
31
+ minWidth: '9ch',
32
+ fontSize: `calc(${vars.typography.fontSize} - 1px)`,
33
+ fontWeight: 600,
34
+ fontVariantNumeric: 'tabular-nums',
35
+ whiteSpace: 'nowrap',
36
+ textShadow: '0 1px 1px rgba(0,0,0,0.6)',
37
+ });
38
+
39
+ globalStyle('.zylem-transport-time-total', {
40
+ fontWeight: 400,
41
+ color: vars.colors.textSecondary,
42
+ });
43
+
44
+ globalStyle('.zylem-transport-field', {
45
+ display: 'inline-flex',
46
+ alignItems: 'center',
47
+ gap: vars.spacing.xs,
48
+ flex: 'none',
49
+ });
50
+
51
+ globalStyle('.zylem-transport-field-label', {
52
+ fontSize: '10px',
53
+ letterSpacing: '0.08em',
54
+ textTransform: 'uppercase',
55
+ color: vars.colors.textSecondary,
56
+ });
57
+
58
+ globalStyle('.zylem-transport-bpm', {
59
+ width: '84px',
60
+ });
61
+
62
+ globalStyle('.zylem-transport-signature', {
63
+ width: '84px',
64
+ flex: 'none',
65
+ });
@@ -0,0 +1,154 @@
1
+ import Square from 'lucide-solid/icons/square';
2
+ import { type JSX, Show } from 'solid-js';
3
+ import { NumberField } from '../NumberField/NumberField';
4
+ import { PlaybackControls } from '../PlaybackControls/PlaybackControls';
5
+ import { Select } from '../Select/Select';
6
+ import { formatBeats, formatClock, type TimeUnit } from '../TimelineLanes/lanes-math';
7
+ import { TimelineTrack } from '../TimelineTrack/TimelineTrack';
8
+ import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
9
+
10
+ export interface TransportTimeSignature {
11
+ numerator: number;
12
+ denominator: number;
13
+ }
14
+
15
+ export interface TransportBarProps {
16
+ playing: boolean;
17
+ onTogglePlay: () => void;
18
+ /** Adds a Stop button (pause and return to the start). */
19
+ onStop?: () => void;
20
+ onRewind?: () => void;
21
+ onFastForward?: () => void;
22
+ loop?: boolean;
23
+ onToggleLoop?: () => void;
24
+ /** Playhead, in `unit`s. */
25
+ time: number;
26
+ /** Timeline length, in `unit`s. */
27
+ length: number;
28
+ onSeek?: (time: number) => void;
29
+ unit?: TimeUnit;
30
+ /** Beats per bar, for the beat readout. */
31
+ beatsPerBar?: number;
32
+ /** Shows a BPM field when set. */
33
+ bpm?: number;
34
+ onBpmChange?: (bpm: number) => void;
35
+ /** Shows a time-signature select when set. */
36
+ timeSignature?: TransportTimeSignature;
37
+ onTimeSignatureChange?: (signature: TransportTimeSignature) => void;
38
+ /** Extra controls after the built-in ones. */
39
+ children?: JSX.Element;
40
+ disabled?: boolean;
41
+ class?: string;
42
+ }
43
+
44
+ const SIGNATURES: TransportTimeSignature[] = [
45
+ { numerator: 2, denominator: 4 },
46
+ { numerator: 3, denominator: 4 },
47
+ { numerator: 4, denominator: 4 },
48
+ { numerator: 5, denominator: 4 },
49
+ { numerator: 6, denominator: 8 },
50
+ { numerator: 7, denominator: 8 },
51
+ { numerator: 12, denominator: 8 },
52
+ ];
53
+
54
+ const signatureKey = (signature: TransportTimeSignature) =>
55
+ `${signature.numerator}/${signature.denominator}`;
56
+
57
+ /** Readout for the current position: `m:ss.ff` for seconds, `bar.beat` for beats. */
58
+ export function formatTransportTime(time: number, unit: TimeUnit, beatsPerBar = 4): string {
59
+ return unit === 'beats' ? formatBeats(time, beatsPerBar) : formatClock(time);
60
+ }
61
+
62
+ /**
63
+ * Transport strip for the Director and Music editors: playback controls, a
64
+ * scrub track with a position readout, and optional BPM / time signature.
65
+ */
66
+ export function TransportBar(props: TransportBarProps) {
67
+ const unit = () => props.unit ?? 'seconds';
68
+ const bpb = () => props.beatsPerBar ?? props.timeSignature?.numerator ?? 4;
69
+ const signatureOptions = () => {
70
+ const options = SIGNATURES.map((signature) => ({
71
+ value: signatureKey(signature),
72
+ label: signatureKey(signature),
73
+ }));
74
+ const current = props.timeSignature ? signatureKey(props.timeSignature) : null;
75
+ if (current && !options.some((option) => option.value === current)) {
76
+ options.push({ value: current, label: current });
77
+ }
78
+ return options;
79
+ };
80
+
81
+ return (
82
+ <div
83
+ class={props.class ? `zylem-transport ${props.class}` : 'zylem-transport'}
84
+ data-unit={unit()}
85
+ >
86
+ <PlaybackControls
87
+ playing={props.playing}
88
+ onTogglePlay={props.onTogglePlay}
89
+ onRewind={props.onRewind}
90
+ onFastForward={props.onFastForward}
91
+ loop={props.loop}
92
+ onToggleLoop={props.onToggleLoop}
93
+ disabled={props.disabled}
94
+ >
95
+ <Show when={props.onStop}>
96
+ <ToolbarButton
97
+ label="Stop"
98
+ disabled={props.disabled}
99
+ onClick={() => props.onStop?.()}
100
+ class="zylem-transport-stop"
101
+ >
102
+ <Square class="zylem-icon" />
103
+ </ToolbarButton>
104
+ </Show>
105
+ <TimelineTrack
106
+ value={props.time}
107
+ max={props.length}
108
+ onChange={(value) => props.onSeek?.(value)}
109
+ disabled={props.disabled || !props.onSeek}
110
+ class="zylem-transport-track"
111
+ />
112
+ <span class="zylem-transport-time" aria-live="off">
113
+ {formatTransportTime(props.time, unit(), bpb())}
114
+ <span class="zylem-transport-time-total">
115
+ {' / '}
116
+ {formatTransportTime(props.length, unit(), bpb())}
117
+ </span>
118
+ </span>
119
+ <Show when={props.bpm !== undefined}>
120
+ <label class="zylem-transport-field">
121
+ <span class="zylem-transport-field-label">BPM</span>
122
+ <NumberField
123
+ value={props.bpm}
124
+ minValue={20}
125
+ maxValue={300}
126
+ step={1}
127
+ disabled={props.disabled || !props.onBpmChange}
128
+ onChange={(value) => props.onBpmChange?.(value)}
129
+ class="zylem-transport-bpm"
130
+ />
131
+ </label>
132
+ </Show>
133
+ <Show when={props.timeSignature}>
134
+ {(signature) => (
135
+ <Select
136
+ options={signatureOptions()}
137
+ value={signatureKey(signature())}
138
+ disabled={props.disabled || !props.onTimeSignatureChange}
139
+ onChange={(value) => {
140
+ if (!value) return;
141
+ const [numerator, denominator] = value.split('/').map(Number);
142
+ if (numerator && denominator) {
143
+ props.onTimeSignatureChange?.({ numerator, denominator });
144
+ }
145
+ }}
146
+ class="zylem-transport-signature"
147
+ />
148
+ )}
149
+ </Show>
150
+ {props.children}
151
+ </PlaybackControls>
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,77 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ globalStyle('.zylem-waveform', {
5
+ position: 'relative',
6
+ width: '100%',
7
+ minWidth: 0,
8
+ boxSizing: 'border-box',
9
+ overflow: 'hidden',
10
+ borderRadius: vars.radii.control,
11
+ border: '1px solid rgba(97, 166, 232, 0.22)',
12
+ background: vars.material.fieldGlass,
13
+ boxShadow: vars.effects.shadowInset,
14
+ userSelect: 'none',
15
+ });
16
+
17
+ globalStyle('.zylem-waveform[data-disabled]', {
18
+ opacity: 0.6,
19
+ });
20
+
21
+ globalStyle('.zylem-waveform-canvas', {
22
+ display: 'block',
23
+ width: '100%',
24
+ height: '100%',
25
+ });
26
+
27
+ globalStyle('.zylem-waveform-shade', {
28
+ position: 'absolute',
29
+ top: 0,
30
+ bottom: 0,
31
+ background: 'rgba(0, 0, 0, 0.45)',
32
+ pointerEvents: 'none',
33
+ });
34
+
35
+ globalStyle('.zylem-waveform-edge', {
36
+ position: 'absolute',
37
+ top: 0,
38
+ bottom: 0,
39
+ width: '10px',
40
+ marginLeft: '-5px',
41
+ cursor: 'ew-resize',
42
+ });
43
+
44
+ globalStyle('.zylem-waveform-edge::before', {
45
+ content: '""',
46
+ position: 'absolute',
47
+ top: 0,
48
+ bottom: 0,
49
+ left: '4px',
50
+ width: '2px',
51
+ background: 'rgba(255, 244, 200, 0.95)',
52
+ boxShadow: '0 0 6px rgba(240, 180, 60, 0.7)',
53
+ });
54
+
55
+ globalStyle('.zylem-waveform-edge::after', {
56
+ content: '""',
57
+ position: 'absolute',
58
+ top: '50%',
59
+ left: '1px',
60
+ width: '8px',
61
+ height: '16px',
62
+ marginTop: '-8px',
63
+ borderRadius: '3px',
64
+ background: 'linear-gradient(180deg, rgba(255, 244, 200, 0.98), rgba(240, 180, 60, 0.95))',
65
+ border: '1px solid rgba(255,255,255,0.7)',
66
+ boxSizing: 'border-box',
67
+ });
68
+
69
+ globalStyle('.zylem-waveform-playhead', {
70
+ position: 'absolute',
71
+ top: 0,
72
+ bottom: 0,
73
+ width: '1px',
74
+ background: 'rgba(255, 236, 170, 0.9)',
75
+ boxShadow: '0 0 6px rgba(240, 180, 60, 0.7)',
76
+ pointerEvents: 'none',
77
+ });