@signalsandsorcery/plugin-sdk 2.26.1 → 2.28.1

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/dist/index.js CHANGED
@@ -47,6 +47,8 @@ __export(index_exports, {
47
47
  FX_DISPLAY_LABELS: () => FX_DISPLAY_LABELS,
48
48
  FX_ENGINE_PLUGIN_NAMES: () => FX_ENGINE_PLUGIN_NAMES,
49
49
  FX_PRESET_CONFIGS: () => FX_PRESET_CONFIGS,
50
+ FadeModal: () => FadeModal,
51
+ FadeTrackRow: () => FadeTrackRow,
50
52
  FxToggleBar: () => FxToggleBar,
51
53
  GUTTER_W: () => GUTTER_W,
52
54
  ImportTrackModal: () => ImportTrackModal,
@@ -65,6 +67,7 @@ __export(index_exports, {
65
67
  SamplePackCTACard: () => SamplePackCTACard,
66
68
  ScrollingWaveform: () => ScrollingWaveform,
67
69
  SorceryProgressBar: () => SorceryProgressBar,
70
+ TEXTURAL_ROLES: () => TEXTURAL_ROLES,
68
71
  TrackDrawer: () => TrackDrawer,
69
72
  TrackMeterStrip: () => TrackMeterStrip,
70
73
  TrackRow: () => TrackRow,
@@ -72,17 +75,21 @@ __export(index_exports, {
72
75
  WaveformView: () => WaveformView,
73
76
  analyzeWavPeak: () => analyzeWavPeak,
74
77
  asCrossfadeMeta: () => asCrossfadeMeta,
78
+ asFadeMeta: () => asFadeMeta,
75
79
  buildCrossfadeInpaintPrompt: () => buildCrossfadeInpaintPrompt,
76
80
  buildCrossfadeVolumeCurves: () => buildCrossfadeVolumeCurves,
81
+ buildFadeVolumeCurve: () => buildFadeVolumeCurve,
77
82
  calculateTimeBasedTarget: () => calculateTimeBasedTarget,
78
83
  cellToPx: () => cellToPx,
79
84
  centerScrollTop: () => centerScrollTop,
80
85
  computePeaks: () => computePeaks,
81
86
  dbToSlider: () => dbToSlider,
87
+ defaultFadeGesture: () => defaultFadeGesture,
82
88
  drawWaveform: () => drawWaveform,
83
89
  formatConcurrentTracks: () => formatConcurrentTracks,
84
90
  moveItem: () => moveItem,
85
91
  parseCrossfadePairs: () => parseCrossfadePairs,
92
+ parseFades: () => parseFades,
86
93
  pickTopKWeighted: () => pickTopKWeighted,
87
94
  pitchToName: () => pitchToName,
88
95
  pxToCell: () => pxToCell,
@@ -2675,9 +2682,448 @@ function buildCrossfadeInpaintPrompt(input) {
2675
2682
  return lines.join("\n");
2676
2683
  }
2677
2684
 
2678
- // src/components/ImportTrackModal.tsx
2679
- var import_react11 = require("react");
2685
+ // src/fade-meta.ts
2686
+ function asFadeMeta(val) {
2687
+ if (!val || typeof val !== "object") return null;
2688
+ const m = val;
2689
+ if (m.direction !== "in" && m.direction !== "out") return null;
2690
+ if (m.gesture !== "volume" && m.gesture !== "build") return null;
2691
+ return {
2692
+ direction: m.direction,
2693
+ gesture: m.gesture,
2694
+ sourceTrackDbId: typeof m.sourceTrackDbId === "string" ? m.sourceTrackDbId : "",
2695
+ sourceSceneId: typeof m.sourceSceneId === "string" ? m.sourceSceneId : "",
2696
+ sourceName: typeof m.sourceName === "string" ? m.sourceName : "",
2697
+ soundLabel: typeof m.soundLabel === "string" ? m.soundLabel : "",
2698
+ sliderPos: typeof m.sliderPos === "number" ? m.sliderPos : 0.5
2699
+ };
2700
+ }
2701
+ function parseFades(sceneData) {
2702
+ const out = [];
2703
+ for (const [key, val] of Object.entries(sceneData)) {
2704
+ const match = /^track:(.+):fade$/.exec(key);
2705
+ if (!match) continue;
2706
+ const meta = asFadeMeta(val);
2707
+ if (!meta) continue;
2708
+ out.push({ dbId: match[1], meta });
2709
+ }
2710
+ return out;
2711
+ }
2712
+ function buildFadeVolumeCurve(bars, bpm, direction, sliderPos, gesture, steps = 32) {
2713
+ const durationSeconds = bars * 4 * 60 / Math.max(1, bpm);
2714
+ if (gesture === "build") {
2715
+ return [
2716
+ { time: 0, db: 0 },
2717
+ { time: Math.round(durationSeconds * 1e3) / 1e3, db: 0 }
2718
+ ];
2719
+ }
2720
+ const s = Math.min(0.98, Math.max(0.02, sliderPos));
2721
+ const round = (n) => Math.round(n * 1e3) / 1e3;
2722
+ const points = [];
2723
+ for (let i = 0; i <= steps; i++) {
2724
+ const x = i / steps;
2725
+ const time = round(x * durationSeconds);
2726
+ const theta = x <= s ? x / s * (Math.PI / 4) : Math.PI / 4 + (x - s) / (1 - s) * (Math.PI / 4);
2727
+ const gain = direction === "out" ? Math.cos(theta) : Math.sin(theta);
2728
+ points.push({ time, db: Math.round(gainToDb(gain) * 100) / 100 });
2729
+ }
2730
+ return points;
2731
+ }
2732
+ var TEXTURAL_ROLES = /* @__PURE__ */ new Set([
2733
+ "pads",
2734
+ "pad",
2735
+ "strings",
2736
+ "atmospheres",
2737
+ "atmosphere",
2738
+ "atmos",
2739
+ "drones",
2740
+ "drone",
2741
+ "soundscapes",
2742
+ "soundscape"
2743
+ ]);
2744
+ function defaultFadeGesture(role) {
2745
+ if (!role) return "build";
2746
+ const norm = role.toLowerCase().replace(/[\s_-]+/g, " ").trim();
2747
+ if (TEXTURAL_ROLES.has(norm)) return "volume";
2748
+ for (const token of norm.split(" ")) {
2749
+ if (TEXTURAL_ROLES.has(token)) return "volume";
2750
+ }
2751
+ return "build";
2752
+ }
2753
+
2754
+ // src/components/FadeTrackRow.tsx
2755
+ var import_react11 = __toESM(require("react"));
2680
2756
  var import_jsx_runtime13 = require("react/jsx-runtime");
2757
+ function FadeCaption({
2758
+ layer,
2759
+ direction,
2760
+ gesture
2761
+ }) {
2762
+ const tag = direction === "in" ? "Fade in" : "Fade out";
2763
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-1.5 min-w-0 px-2 py-0.5", children: [
2764
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-[9px] font-bold uppercase tracking-wide text-sas-accent flex-shrink-0", children: tag }),
2765
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-[11px] text-sas-text truncate", title: layer.sourceName ?? layer.name, children: layer.sourceName ?? layer.name }),
2766
+ layer.soundLabel && /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-[9px] text-sas-muted/60 truncate flex-shrink-0", title: layer.soundLabel, children: [
2767
+ "\xB7 ",
2768
+ layer.soundLabel
2769
+ ] }),
2770
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", title: `Fade gesture: ${gesture}`, children: [
2771
+ "\xB7 ",
2772
+ gesture
2773
+ ] })
2774
+ ] });
2775
+ }
2776
+ function FadeTrackRow({
2777
+ layer,
2778
+ direction,
2779
+ gesture,
2780
+ sliderPos = 0.5,
2781
+ onMuteToggle,
2782
+ onSoloToggle,
2783
+ onVolumeChange,
2784
+ onPanChange,
2785
+ onDelete,
2786
+ onSliderChange,
2787
+ levels,
2788
+ accentColor = "#9333EA"
2789
+ }) {
2790
+ const [confirmDelete, setConfirmDelete] = import_react11.default.useState(false);
2791
+ const leftLabel = direction === "in" ? "(silent)" : layer.sourceName ?? layer.name;
2792
+ const rightLabel = direction === "in" ? layer.sourceName ?? layer.name : "(silent)";
2793
+ const badge = direction === "in" ? "\u2197 Fade in" : "\u2198 Fade out";
2794
+ return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
2795
+ "div",
2796
+ {
2797
+ "data-testid": "fade-track-row",
2798
+ className: "w-full rounded-sm border border-sas-border bg-sas-panel/40 overflow-hidden",
2799
+ style: { borderLeftColor: accentColor, borderLeftWidth: "3px" },
2800
+ children: [
2801
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center justify-between px-2 py-1 bg-sas-panel-alt/60", children: [
2802
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2803
+ "span",
2804
+ {
2805
+ "data-testid": "fade-direction-badge",
2806
+ className: "text-[10px] font-bold uppercase tracking-wide",
2807
+ style: { color: accentColor },
2808
+ children: badge
2809
+ }
2810
+ ),
2811
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2812
+ "button",
2813
+ {
2814
+ "data-testid": "fade-delete-button",
2815
+ onClick: () => setConfirmDelete(true),
2816
+ className: "text-sas-danger/70 hover:text-sas-danger px-1 transition-colors text-sm",
2817
+ title: "Delete fade",
2818
+ "aria-label": "Delete fade",
2819
+ children: "x"
2820
+ }
2821
+ )
2822
+ ] }),
2823
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2824
+ TrackRow,
2825
+ {
2826
+ track: { id: layer.trackId, name: "", role: layer.role },
2827
+ runtimeState: layer.runtimeState,
2828
+ fxDetailState: EMPTY_FX_DETAIL_STATE,
2829
+ drawerOpen: false,
2830
+ drawerTab: "fx",
2831
+ levels,
2832
+ accentColor,
2833
+ contentSlot: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(FadeCaption, { layer, direction, gesture }),
2834
+ onMuteToggle,
2835
+ onSoloToggle,
2836
+ onVolumeChange,
2837
+ onPanChange
2838
+ }
2839
+ ),
2840
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-2 px-3 py-1.5", "data-testid": "fade-slider-row", children: [
2841
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2842
+ "span",
2843
+ {
2844
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] text-right flex-shrink-0",
2845
+ title: leftLabel,
2846
+ children: leftLabel
2847
+ }
2848
+ ),
2849
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2850
+ "input",
2851
+ {
2852
+ type: "range",
2853
+ "data-testid": "fade-slider",
2854
+ min: 0,
2855
+ max: 1,
2856
+ step: 0.01,
2857
+ value: sliderPos,
2858
+ disabled: !onSliderChange,
2859
+ onChange: onSliderChange ? (e) => onSliderChange(Number(e.target.value)) : void 0,
2860
+ style: { accentColor },
2861
+ className: "flex-1 disabled:opacity-60 disabled:cursor-not-allowed",
2862
+ "aria-label": "Fade position"
2863
+ }
2864
+ ),
2865
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2866
+ "span",
2867
+ {
2868
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] flex-shrink-0",
2869
+ title: rightLabel,
2870
+ children: rightLabel
2871
+ }
2872
+ )
2873
+ ] }),
2874
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2875
+ ConfirmDialog,
2876
+ {
2877
+ open: confirmDelete,
2878
+ title: "Delete fade?",
2879
+ message: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_jsx_runtime13.Fragment, { children: "This fade track will be permanently removed from this scene. This cannot be undone." }),
2880
+ confirmLabel: "Delete",
2881
+ onConfirm: () => {
2882
+ setConfirmDelete(false);
2883
+ onDelete();
2884
+ },
2885
+ onCancel: () => setConfirmDelete(false),
2886
+ testIdPrefix: "fade-delete-confirm"
2887
+ }
2888
+ )
2889
+ ]
2890
+ }
2891
+ );
2892
+ }
2893
+
2894
+ // src/components/FadeModal.tsx
2895
+ var import_react12 = require("react");
2896
+ var import_jsx_runtime14 = require("react/jsx-runtime");
2897
+ function shortId(dbId) {
2898
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
2899
+ }
2900
+ var normRole = (r) => (r ?? "").toLowerCase().trim();
2901
+ function computeOrphans(from, to, excludeSet) {
2902
+ const bucket = (list) => {
2903
+ const m = /* @__PURE__ */ new Map();
2904
+ for (const t of list) {
2905
+ const k = normRole(t.role);
2906
+ const arr = m.get(k);
2907
+ if (arr) arr.push(t);
2908
+ else m.set(k, [t]);
2909
+ }
2910
+ return m;
2911
+ };
2912
+ const fromByRole = bucket(from);
2913
+ const toByRole = bucket(to);
2914
+ const roles = /* @__PURE__ */ new Set([...fromByRole.keys(), ...toByRole.keys()]);
2915
+ const fadeOut = [];
2916
+ const fadeIn = [];
2917
+ for (const role of roles) {
2918
+ const f = fromByRole.get(role) ?? [];
2919
+ const t = toByRole.get(role) ?? [];
2920
+ const shared = Math.min(f.length, t.length);
2921
+ fadeOut.push(...f.slice(shared));
2922
+ fadeIn.push(...t.slice(shared));
2923
+ }
2924
+ return {
2925
+ fadeOut: fadeOut.filter((x) => !excludeSet.has(x.dbId)),
2926
+ fadeIn: fadeIn.filter((x) => !excludeSet.has(x.dbId))
2927
+ };
2928
+ }
2929
+ function OrphanRow({
2930
+ track,
2931
+ gesture,
2932
+ selected,
2933
+ disabled,
2934
+ onSelect,
2935
+ testId
2936
+ }) {
2937
+ const primary = track.prompt?.trim() || track.name;
2938
+ const meta = [track.role, shortId(track.dbId), gesture].filter(Boolean).join(" \xB7 ");
2939
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
2940
+ "button",
2941
+ {
2942
+ type: "button",
2943
+ role: "radio",
2944
+ "aria-checked": selected,
2945
+ "data-testid": testId,
2946
+ "data-value": track.dbId,
2947
+ onClick: onSelect,
2948
+ disabled,
2949
+ className: `w-full text-left px-2 py-1.5 rounded-sm border transition-colors disabled:opacity-50 ${selected ? "bg-sas-accent/15 border-sas-accent" : "bg-sas-panel border-sas-border hover:border-sas-accent/50"}`,
2950
+ children: [
2951
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
2952
+ meta && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
2953
+ ]
2954
+ }
2955
+ );
2956
+ }
2957
+ function FadeModal({
2958
+ host,
2959
+ open,
2960
+ fromSceneId,
2961
+ toSceneId,
2962
+ fromSceneName,
2963
+ toSceneName,
2964
+ excludeSourceDbIds,
2965
+ onClose,
2966
+ onCreate,
2967
+ testIdPrefix = "fade-modal"
2968
+ }) {
2969
+ const [load, setLoad] = (0, import_react12.useState)({ status: "loading" });
2970
+ const [selectedDbId, setSelectedDbId] = (0, import_react12.useState)("");
2971
+ const [isCreating, setIsCreating] = (0, import_react12.useState)(false);
2972
+ const [error, setError] = (0, import_react12.useState)(null);
2973
+ const [fromName, setFromName] = (0, import_react12.useState)(null);
2974
+ const [toName, setToName] = (0, import_react12.useState)(null);
2975
+ const cancelRef = (0, import_react12.useRef)(null);
2976
+ const refresh = (0, import_react12.useCallback)(async () => {
2977
+ if (!host.listSceneFamilyTracks) {
2978
+ setLoad({ status: "error", message: "This host does not support fades." });
2979
+ return;
2980
+ }
2981
+ setLoad({ status: "loading" });
2982
+ try {
2983
+ const [from, to, fName, tName] = await Promise.all([
2984
+ host.listSceneFamilyTracks(fromSceneId),
2985
+ host.listSceneFamilyTracks(toSceneId),
2986
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
2987
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null)
2988
+ ]);
2989
+ setFromName(fName);
2990
+ setToName(tName);
2991
+ setLoad({ status: "ready", from, to });
2992
+ } catch (err) {
2993
+ setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2994
+ }
2995
+ }, [host, fromSceneId, toSceneId]);
2996
+ (0, import_react12.useEffect)(() => {
2997
+ if (open) {
2998
+ setError(null);
2999
+ setIsCreating(false);
3000
+ setSelectedDbId("");
3001
+ void refresh();
3002
+ }
3003
+ }, [open, refresh]);
3004
+ const excludeSet = (0, import_react12.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3005
+ const { fadeOut, fadeIn } = (0, import_react12.useMemo)(
3006
+ () => load.status === "ready" ? computeOrphans(load.from, load.to, excludeSet) : { fadeOut: [], fadeIn: [] },
3007
+ [load, excludeSet]
3008
+ );
3009
+ const allOrphans = (0, import_react12.useMemo)(
3010
+ () => [
3011
+ ...fadeOut.map((t) => ({ track: t, direction: "out" })),
3012
+ ...fadeIn.map((t) => ({ track: t, direction: "in" }))
3013
+ ],
3014
+ [fadeOut, fadeIn]
3015
+ );
3016
+ (0, import_react12.useEffect)(() => {
3017
+ if (!allOrphans.some((o) => o.track.dbId === selectedDbId)) {
3018
+ setSelectedDbId(allOrphans[0]?.track.dbId ?? "");
3019
+ }
3020
+ }, [allOrphans, selectedDbId]);
3021
+ const selected = allOrphans.find((o) => o.track.dbId === selectedDbId) ?? null;
3022
+ const canCreate = !isCreating && !!selected;
3023
+ const handleClose = (0, import_react12.useCallback)(() => {
3024
+ if (!isCreating) onClose();
3025
+ }, [isCreating, onClose]);
3026
+ const handleCreate = (0, import_react12.useCallback)(async () => {
3027
+ if (!selected) return;
3028
+ setIsCreating(true);
3029
+ setError(null);
3030
+ try {
3031
+ await onCreate(
3032
+ { dbId: selected.track.dbId, name: selected.track.name, role: selected.track.role },
3033
+ selected.direction,
3034
+ defaultFadeGesture(selected.track.role)
3035
+ );
3036
+ onClose();
3037
+ } catch (err) {
3038
+ setError(err instanceof Error ? err.message : "Failed to create fade.");
3039
+ setIsCreating(false);
3040
+ }
3041
+ }, [selected, onCreate, onClose]);
3042
+ const fromLabel = fromName ?? fromSceneName ?? null;
3043
+ const toLabel = toName ?? toSceneName ?? null;
3044
+ if (!open) return null;
3045
+ const renderSection = (heading, list, section) => {
3046
+ if (list.length === 0) return null;
3047
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "block", children: [
3048
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: heading }),
3049
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3050
+ "div",
3051
+ {
3052
+ role: "radiogroup",
3053
+ "aria-label": heading,
3054
+ "data-testid": `${testIdPrefix}-${section === "out" ? "fade-out" : "fade-in"}-list`,
3055
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3056
+ children: list.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3057
+ OrphanRow,
3058
+ {
3059
+ track: t,
3060
+ gesture: defaultFadeGesture(t.role),
3061
+ selected: t.dbId === selectedDbId,
3062
+ disabled: isCreating,
3063
+ onSelect: () => setSelectedDbId(t.dbId),
3064
+ testId: `${testIdPrefix}-option-${t.dbId}`
3065
+ },
3066
+ t.dbId
3067
+ ))
3068
+ }
3069
+ )
3070
+ ] });
3071
+ };
3072
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3073
+ "div",
3074
+ {
3075
+ className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
3076
+ onClick: (e) => e.stopPropagation(),
3077
+ "data-testid": `${testIdPrefix}-box`,
3078
+ children: [
3079
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add fade" }),
3080
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3081
+ "Tracks with no counterpart between",
3082
+ " ",
3083
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
3084
+ " and",
3085
+ " ",
3086
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
3087
+ " can gracefully fade out (leaving) or fade in (entering) across this transition."
3088
+ ] }),
3089
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3090
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3091
+ load.status === "ready" && (allOrphans.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", "data-testid": `${testIdPrefix}-empty`, children: "Every track has a counterpart in the other scene \u2014 nothing to fade. Use \u201C+ Crossfade\u201D to bridge matching tracks." }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
3092
+ renderSection(`Fade out${fromLabel ? ` (from ${fromLabel})` : ""}`, fadeOut, "out"),
3093
+ renderSection(`Fade in${toLabel ? ` (to ${toLabel})` : ""}`, fadeIn, "in")
3094
+ ] })),
3095
+ error && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3096
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3097
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3098
+ "button",
3099
+ {
3100
+ ref: cancelRef,
3101
+ "data-testid": `${testIdPrefix}-cancel`,
3102
+ onClick: onClose,
3103
+ disabled: isCreating,
3104
+ className: "px-3 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-text disabled:opacity-50",
3105
+ children: "Cancel"
3106
+ }
3107
+ ),
3108
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3109
+ "button",
3110
+ {
3111
+ "data-testid": `${testIdPrefix}-confirm`,
3112
+ onClick: handleCreate,
3113
+ disabled: !canCreate,
3114
+ className: `px-3 py-1 text-xs rounded-sm border transition-colors ${canCreate ? "bg-sas-accent/20 border-sas-accent text-sas-accent hover:bg-sas-accent hover:text-sas-bg" : "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed"}`,
3115
+ children: isCreating ? "Generating fade\u2026" : "Create fade"
3116
+ }
3117
+ )
3118
+ ] })
3119
+ ]
3120
+ }
3121
+ ) });
3122
+ }
3123
+
3124
+ // src/components/ImportTrackModal.tsx
3125
+ var import_react13 = require("react");
3126
+ var import_jsx_runtime15 = require("react/jsx-runtime");
2681
3127
  function ImportTrackModal({
2682
3128
  host,
2683
3129
  open,
@@ -2689,10 +3135,10 @@ function ImportTrackModal({
2689
3135
  onPick,
2690
3136
  onPortTrack
2691
3137
  }) {
2692
- const [load, setLoad] = (0, import_react11.useState)({ status: "loading" });
2693
- const [selectedSceneId, setSelectedSceneId] = (0, import_react11.useState)(null);
2694
- const [importingTrackId, setImportingTrackId] = (0, import_react11.useState)(null);
2695
- const refresh = (0, import_react11.useCallback)(async () => {
3138
+ const [load, setLoad] = (0, import_react13.useState)({ status: "loading" });
3139
+ const [selectedSceneId, setSelectedSceneId] = (0, import_react13.useState)(null);
3140
+ const [importingTrackId, setImportingTrackId] = (0, import_react13.useState)(null);
3141
+ const refresh = (0, import_react13.useCallback)(async () => {
2696
3142
  if (!host.listImportableTracks) {
2697
3143
  setLoad({ status: "error", message: "This host does not support importing tracks." });
2698
3144
  return;
@@ -2708,14 +3154,14 @@ function ImportTrackModal({
2708
3154
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load scenes." });
2709
3155
  }
2710
3156
  }, [host, mode, onPortTrack]);
2711
- (0, import_react11.useEffect)(() => {
3157
+ (0, import_react13.useEffect)(() => {
2712
3158
  if (open) {
2713
3159
  setSelectedSceneId(null);
2714
3160
  setImportingTrackId(null);
2715
3161
  void refresh();
2716
3162
  }
2717
3163
  }, [open, refresh]);
2718
- const handleImport = (0, import_react11.useCallback)(
3164
+ const handleImport = (0, import_react13.useCallback)(
2719
3165
  async (track, sourceSceneId, sceneName, isSameScene) => {
2720
3166
  if (isSameScene && onPortTrack) {
2721
3167
  if (!track.importable) return;
@@ -2756,16 +3202,16 @@ function ImportTrackModal({
2756
3202
  if (!open) return null;
2757
3203
  const scenes = load.status === "ready" ? load.scenes : [];
2758
3204
  const selectedScene = scenes.find((s) => s.sceneId === selectedSceneId) ?? null;
2759
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3205
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2760
3206
  "div",
2761
3207
  {
2762
3208
  className: "w-[420px] max-h-[70vh] overflow-hidden flex flex-col rounded-md border border-sas-border bg-sas-panel shadow-xl",
2763
3209
  onClick: (e) => e.stopPropagation(),
2764
3210
  "data-testid": `${testIdPrefix}-modal`,
2765
3211
  children: [
2766
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
2767
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex items-center gap-2", children: [
2768
- selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
3212
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
3213
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex items-center gap-2", children: [
3214
+ selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2769
3215
  "button",
2770
3216
  {
2771
3217
  className: "text-sas-muted hover:text-sas-accent text-xs",
@@ -2774,9 +3220,9 @@ function ImportTrackModal({
2774
3220
  children: "\u2190"
2775
3221
  }
2776
3222
  ),
2777
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
3223
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
2778
3224
  ] }),
2779
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
3225
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2780
3226
  "button",
2781
3227
  {
2782
3228
  className: "text-sas-muted hover:text-sas-accent text-sm",
@@ -2786,30 +3232,30 @@ function ImportTrackModal({
2786
3232
  }
2787
3233
  )
2788
3234
  ] }),
2789
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "overflow-y-auto p-2 flex-1", children: [
2790
- load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
2791
- load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
2792
- load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-empty`, children: mode === "sound" ? "No other scenes have a sound to import." : "No other scenes have a compatible track to import." }),
2793
- load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3235
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "overflow-y-auto p-2 flex-1", children: [
3236
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
3237
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
3238
+ load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-empty`, children: mode === "sound" ? "No other scenes have a sound to import." : "No other scenes have a compatible track to import." }),
3239
+ load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2794
3240
  "button",
2795
3241
  {
2796
3242
  className: "w-full flex items-center justify-between px-2 py-1.5 rounded-sm border border-sas-border bg-sas-panel-alt text-left text-xs text-sas-text hover:border-sas-accent hover:text-sas-accent transition-colors",
2797
3243
  onClick: () => setSelectedSceneId(scene.sceneId),
2798
3244
  "data-testid": `${testIdPrefix}-scene`,
2799
3245
  children: [
2800
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "truncate", children: scene.sceneName }),
2801
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-sas-muted", children: [
3246
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "truncate", children: scene.sceneName }),
3247
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "text-sas-muted", children: [
2802
3248
  scene.tracks.length,
2803
3249
  " \u2192"
2804
3250
  ] })
2805
3251
  ]
2806
3252
  }
2807
3253
  ) }, scene.sceneId)) }),
2808
- selectedScene && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
3254
+ selectedScene && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
2809
3255
  const busy = importingTrackId === track.trackId;
2810
3256
  const gated = mode === "track" && !track.importable;
2811
3257
  const disabled = gated || busy;
2812
- return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
3258
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
2813
3259
  "button",
2814
3260
  {
2815
3261
  className: `w-full flex items-center justify-between px-2 py-1.5 rounded-sm border text-left text-xs transition-colors ${disabled ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-text hover:border-sas-accent hover:text-sas-accent"}`,
@@ -2819,14 +3265,14 @@ function ImportTrackModal({
2819
3265
  "data-testid": `${testIdPrefix}-track`,
2820
3266
  "data-importable": mode === "sound" || track.importable ? "true" : "false",
2821
3267
  children: [
2822
- /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "truncate", children: [
3268
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "truncate", children: [
2823
3269
  track.name,
2824
- track.role ? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "text-sas-muted", children: [
3270
+ track.role ? /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "text-sas-muted", children: [
2825
3271
  " \xB7 ",
2826
3272
  track.role
2827
3273
  ] }) : null
2828
3274
  ] }),
2829
- busy ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "text-sas-muted", children: "\u2298" }) : null
3275
+ busy ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sas-muted", children: "\u2298" }) : null
2830
3276
  ]
2831
3277
  }
2832
3278
  ) }, track.dbId);
@@ -2838,8 +3284,38 @@ function ImportTrackModal({
2838
3284
  }
2839
3285
 
2840
3286
  // src/components/CrossfadeModal.tsx
2841
- var import_react12 = require("react");
2842
- var import_jsx_runtime14 = require("react/jsx-runtime");
3287
+ var import_react14 = require("react");
3288
+ var import_jsx_runtime16 = require("react/jsx-runtime");
3289
+ function shortId2(dbId) {
3290
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3291
+ }
3292
+ function CandidateRow({
3293
+ track,
3294
+ selected,
3295
+ disabled,
3296
+ onSelect,
3297
+ testId
3298
+ }) {
3299
+ const primary = track.prompt?.trim() || track.name;
3300
+ const meta = [track.role, shortId2(track.dbId)].filter(Boolean).join(" \xB7 ");
3301
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
3302
+ "button",
3303
+ {
3304
+ type: "button",
3305
+ role: "radio",
3306
+ "aria-checked": selected,
3307
+ "data-testid": testId,
3308
+ "data-value": track.dbId,
3309
+ onClick: onSelect,
3310
+ disabled,
3311
+ className: `w-full text-left px-2 py-1.5 rounded-sm border transition-colors disabled:opacity-50 ${selected ? "bg-sas-accent/15 border-sas-accent" : "bg-sas-panel border-sas-border hover:border-sas-accent/50"}`,
3312
+ children: [
3313
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-text truncate", title: primary, children: primary }),
3314
+ meta && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", title: track.dbId, children: meta })
3315
+ ]
3316
+ }
3317
+ );
3318
+ }
2843
3319
  function CrossfadeModal({
2844
3320
  host,
2845
3321
  open,
@@ -2852,15 +3328,15 @@ function CrossfadeModal({
2852
3328
  onCreate,
2853
3329
  testIdPrefix = "crossfade-modal"
2854
3330
  }) {
2855
- const [load, setLoad] = (0, import_react12.useState)({ status: "loading" });
2856
- const [originDbId, setOriginDbId] = (0, import_react12.useState)("");
2857
- const [targetDbId, setTargetDbId] = (0, import_react12.useState)("");
2858
- const [isCreating, setIsCreating] = (0, import_react12.useState)(false);
2859
- const [error, setError] = (0, import_react12.useState)(null);
2860
- const [fromName, setFromName] = (0, import_react12.useState)(null);
2861
- const [toName, setToName] = (0, import_react12.useState)(null);
2862
- const cancelRef = (0, import_react12.useRef)(null);
2863
- const refresh = (0, import_react12.useCallback)(async () => {
3331
+ const [load, setLoad] = (0, import_react14.useState)({ status: "loading" });
3332
+ const [originDbId, setOriginDbId] = (0, import_react14.useState)("");
3333
+ const [targetDbId, setTargetDbId] = (0, import_react14.useState)("");
3334
+ const [isCreating, setIsCreating] = (0, import_react14.useState)(false);
3335
+ const [error, setError] = (0, import_react14.useState)(null);
3336
+ const [fromName, setFromName] = (0, import_react14.useState)(null);
3337
+ const [toName, setToName] = (0, import_react14.useState)(null);
3338
+ const cancelRef = (0, import_react14.useRef)(null);
3339
+ const refresh = (0, import_react14.useCallback)(async () => {
2864
3340
  if (!host.listSceneFamilyTracks) {
2865
3341
  setLoad({ status: "error", message: "This host does not support crossfade tracks." });
2866
3342
  return;
@@ -2880,7 +3356,7 @@ function CrossfadeModal({
2880
3356
  setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2881
3357
  }
2882
3358
  }, [host, fromSceneId, toSceneId]);
2883
- (0, import_react12.useEffect)(() => {
3359
+ (0, import_react14.useEffect)(() => {
2884
3360
  if (open) {
2885
3361
  setError(null);
2886
3362
  setIsCreating(false);
@@ -2889,21 +3365,21 @@ function CrossfadeModal({
2889
3365
  void refresh();
2890
3366
  }
2891
3367
  }, [open, refresh]);
2892
- const excludeSet = (0, import_react12.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
2893
- const originCandidates = (0, import_react12.useMemo)(
3368
+ const excludeSet = (0, import_react14.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3369
+ const originCandidates = (0, import_react14.useMemo)(
2894
3370
  () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
2895
3371
  [load, excludeSet]
2896
3372
  );
2897
- const targetCandidates = (0, import_react12.useMemo)(
3373
+ const targetCandidates = (0, import_react14.useMemo)(
2898
3374
  () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
2899
3375
  [load, excludeSet]
2900
3376
  );
2901
- (0, import_react12.useEffect)(() => {
3377
+ (0, import_react14.useEffect)(() => {
2902
3378
  if (!originCandidates.some((t) => t.dbId === originDbId)) {
2903
3379
  setOriginDbId(originCandidates[0]?.dbId ?? "");
2904
3380
  }
2905
3381
  }, [originCandidates, originDbId]);
2906
- (0, import_react12.useEffect)(() => {
3382
+ (0, import_react14.useEffect)(() => {
2907
3383
  if (!targetCandidates.some((t) => t.dbId === targetDbId)) {
2908
3384
  setTargetDbId(targetCandidates[0]?.dbId ?? "");
2909
3385
  }
@@ -2911,10 +3387,10 @@ function CrossfadeModal({
2911
3387
  const originTrack = originCandidates.find((t) => t.dbId === originDbId) ?? null;
2912
3388
  const targetTrack = targetCandidates.find((t) => t.dbId === targetDbId) ?? null;
2913
3389
  const canCreate = !isCreating && !!originTrack && !!targetTrack;
2914
- const handleClose = (0, import_react12.useCallback)(() => {
3390
+ const handleClose = (0, import_react14.useCallback)(() => {
2915
3391
  if (!isCreating) onClose();
2916
3392
  }, [isCreating, onClose]);
2917
- const handleCreate = (0, import_react12.useCallback)(async () => {
3393
+ const handleCreate = (0, import_react14.useCallback)(async () => {
2918
3394
  if (!originTrack || !targetTrack) return;
2919
3395
  setIsCreating(true);
2920
3396
  setError(null);
@@ -2932,26 +3408,26 @@ function CrossfadeModal({
2932
3408
  const fromLabel = fromName ?? fromSceneName ?? null;
2933
3409
  const toLabel = toName ?? toSceneName ?? null;
2934
3410
  if (!open) return null;
2935
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3411
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
2936
3412
  "div",
2937
3413
  {
2938
3414
  className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2939
3415
  onClick: (e) => e.stopPropagation(),
2940
3416
  "data-testid": `${testIdPrefix}-box`,
2941
3417
  children: [
2942
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
2943
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
3418
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
3419
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2944
3420
  "Bridge a track from",
2945
3421
  " ",
2946
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
3422
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "text-sas-text", children: fromLabel ?? "the origin scene" }),
2947
3423
  " into one from",
2948
3424
  " ",
2949
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
3425
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { className: "text-sas-text", children: toLabel ?? "the target scene" }),
2950
3426
  ". Both layers share one generated part; each keeps its own preset."
2951
3427
  ] }),
2952
- load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2953
- load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2954
- load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
3428
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
3429
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
3430
+ load.status === "ready" && (originCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
2955
3431
  "div",
2956
3432
  {
2957
3433
  className: "text-xs text-sas-muted py-4 text-center",
@@ -2962,55 +3438,67 @@ function CrossfadeModal({
2962
3438
  ". Add one (or free one from another crossfade) first."
2963
3439
  ]
2964
3440
  }
2965
- ) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
2966
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2967
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3441
+ ) : /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_jsx_runtime16.Fragment, { children: [
3442
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "block", children: [
3443
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2968
3444
  "Origin ",
2969
3445
  fromLabel ? `(${fromLabel})` : "(top)"
2970
3446
  ] }),
2971
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2972
- "select",
3447
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3448
+ "div",
2973
3449
  {
2974
- "data-testid": `${testIdPrefix}-origin-select`,
2975
- value: originDbId,
2976
- onChange: (e) => setOriginDbId(e.target.value),
2977
- disabled: isCreating,
2978
- className: "sas-input w-full mt-0.5 text-xs",
2979
- children: originCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
2980
- t.name,
2981
- t.role ? ` \xB7 ${t.role}` : ""
2982
- ] }, t.dbId))
3450
+ role: "radiogroup",
3451
+ "aria-label": "Origin track",
3452
+ "data-testid": `${testIdPrefix}-origin-list`,
3453
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3454
+ children: originCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3455
+ CandidateRow,
3456
+ {
3457
+ track: t,
3458
+ selected: t.dbId === originDbId,
3459
+ disabled: isCreating,
3460
+ onSelect: () => setOriginDbId(t.dbId),
3461
+ testId: `${testIdPrefix}-origin-option-${t.dbId}`
3462
+ },
3463
+ t.dbId
3464
+ ))
2983
3465
  }
2984
3466
  )
2985
3467
  ] }),
2986
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "block", children: [
2987
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
3468
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "block", children: [
3469
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2988
3470
  "Target ",
2989
3471
  toLabel ? `(${toLabel})` : "(bottom)"
2990
3472
  ] }),
2991
- targetCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
3473
+ targetCandidates.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
2992
3474
  "No available tracks in ",
2993
3475
  toLabel ?? "the target scene",
2994
3476
  " to crossfade into."
2995
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2996
- "select",
3477
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3478
+ "div",
2997
3479
  {
2998
- "data-testid": `${testIdPrefix}-target-select`,
2999
- value: targetDbId,
3000
- onChange: (e) => setTargetDbId(e.target.value),
3001
- disabled: isCreating,
3002
- className: "sas-input w-full mt-0.5 text-xs",
3003
- children: targetCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("option", { value: t.dbId, children: [
3004
- t.name,
3005
- t.role ? ` \xB7 ${t.role}` : ""
3006
- ] }, t.dbId))
3480
+ role: "radiogroup",
3481
+ "aria-label": "Target track",
3482
+ "data-testid": `${testIdPrefix}-target-list`,
3483
+ className: "mt-1 space-y-1 max-h-40 overflow-y-auto pr-0.5",
3484
+ children: targetCandidates.map((t) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3485
+ CandidateRow,
3486
+ {
3487
+ track: t,
3488
+ selected: t.dbId === targetDbId,
3489
+ disabled: isCreating,
3490
+ onSelect: () => setTargetDbId(t.dbId),
3491
+ testId: `${testIdPrefix}-target-option-${t.dbId}`
3492
+ },
3493
+ t.dbId
3494
+ ))
3007
3495
  }
3008
3496
  )
3009
3497
  ] })
3010
3498
  ] })),
3011
- error && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3012
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3013
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3499
+ error && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
3500
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "flex justify-end gap-2 pt-1", children: [
3501
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3014
3502
  "button",
3015
3503
  {
3016
3504
  ref: cancelRef,
@@ -3021,7 +3509,7 @@ function CrossfadeModal({
3021
3509
  children: "Cancel"
3022
3510
  }
3023
3511
  ),
3024
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
3512
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3025
3513
  "button",
3026
3514
  {
3027
3515
  "data-testid": `${testIdPrefix}-confirm`,
@@ -3038,8 +3526,8 @@ function CrossfadeModal({
3038
3526
  }
3039
3527
 
3040
3528
  // src/components/DownloadPackButton.tsx
3041
- var import_react13 = require("react");
3042
- var import_jsx_runtime15 = require("react/jsx-runtime");
3529
+ var import_react15 = require("react");
3530
+ var import_jsx_runtime17 = require("react/jsx-runtime");
3043
3531
  function formatSize(bytes) {
3044
3532
  if (!bytes || bytes <= 0) return "";
3045
3533
  const gb = bytes / 1024 ** 3;
@@ -3055,10 +3543,10 @@ var DownloadPackButton = ({
3055
3543
  variant = "compact",
3056
3544
  onDownloadComplete
3057
3545
  }) => {
3058
- const [status, setStatus] = (0, import_react13.useState)("idle");
3059
- const [progress, setProgress] = (0, import_react13.useState)(0);
3060
- const [errorMessage, setErrorMessage] = (0, import_react13.useState)(null);
3061
- (0, import_react13.useEffect)(() => {
3546
+ const [status, setStatus] = (0, import_react15.useState)("idle");
3547
+ const [progress, setProgress] = (0, import_react15.useState)(0);
3548
+ const [errorMessage, setErrorMessage] = (0, import_react15.useState)(null);
3549
+ (0, import_react15.useEffect)(() => {
3062
3550
  const unsub = host.onSamplePackProgress(packId, (p) => {
3063
3551
  setStatus(p.status);
3064
3552
  setProgress(p.progress);
@@ -3073,7 +3561,7 @@ var DownloadPackButton = ({
3073
3561
  });
3074
3562
  return unsub;
3075
3563
  }, [host, packId, onDownloadComplete]);
3076
- const handleClick = (0, import_react13.useCallback)(async () => {
3564
+ const handleClick = (0, import_react15.useCallback)(async () => {
3077
3565
  if (status !== "idle" && status !== "error") return;
3078
3566
  try {
3079
3567
  setStatus("downloading");
@@ -3127,8 +3615,8 @@ var DownloadPackButton = ({
3127
3615
  } else {
3128
3616
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
3129
3617
  }
3130
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { children: [
3131
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
3618
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { children: [
3619
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
3132
3620
  "button",
3133
3621
  {
3134
3622
  "data-testid": `download-pack-button-${packId}`,
@@ -3139,12 +3627,12 @@ var DownloadPackButton = ({
3139
3627
  children: buttonLabel
3140
3628
  }
3141
3629
  ),
3142
- variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3630
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3143
3631
  ] });
3144
3632
  };
3145
3633
 
3146
3634
  // src/components/SamplePackCTACard.tsx
3147
- var import_jsx_runtime16 = require("react/jsx-runtime");
3635
+ var import_jsx_runtime18 = require("react/jsx-runtime");
3148
3636
  var SamplePackCTACard = ({
3149
3637
  host,
3150
3638
  pack,
@@ -3152,7 +3640,7 @@ var SamplePackCTACard = ({
3152
3640
  onDownloadComplete
3153
3641
  }) => {
3154
3642
  if (status === "checking") {
3155
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3643
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3156
3644
  "div",
3157
3645
  {
3158
3646
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -3163,16 +3651,16 @@ var SamplePackCTACard = ({
3163
3651
  }
3164
3652
  const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
3165
3653
  const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
3166
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
3654
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3167
3655
  "div",
3168
3656
  {
3169
3657
  "data-testid": `sample-pack-cta-${pack.packId}`,
3170
3658
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
3171
3659
  children: [
3172
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3173
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
3174
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3175
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3660
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3661
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
3662
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3663
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3176
3664
  DownloadPackButton,
3177
3665
  {
3178
3666
  host,
@@ -3189,7 +3677,7 @@ var SamplePackCTACard = ({
3189
3677
  };
3190
3678
 
3191
3679
  // src/components/WaveformView.tsx
3192
- var import_react14 = require("react");
3680
+ var import_react16 = require("react");
3193
3681
 
3194
3682
  // src/components/waveform.ts
3195
3683
  function computePeaks(audioBuffer, bins, targetSamples) {
@@ -3252,7 +3740,7 @@ function drawWaveform(canvas, peaks, options = {}) {
3252
3740
  }
3253
3741
 
3254
3742
  // src/components/WaveformView.tsx
3255
- var import_jsx_runtime17 = require("react/jsx-runtime");
3743
+ var import_jsx_runtime19 = require("react/jsx-runtime");
3256
3744
  var WaveformView = ({
3257
3745
  host,
3258
3746
  filePath,
@@ -3261,9 +3749,9 @@ var WaveformView = ({
3261
3749
  fillStyle,
3262
3750
  targetSamples
3263
3751
  }) => {
3264
- const canvasRef = (0, import_react14.useRef)(null);
3265
- const [peaks, setPeaks] = (0, import_react14.useState)(null);
3266
- (0, import_react14.useEffect)(() => {
3752
+ const canvasRef = (0, import_react16.useRef)(null);
3753
+ const [peaks, setPeaks] = (0, import_react16.useState)(null);
3754
+ (0, import_react16.useEffect)(() => {
3267
3755
  let cancelled = false;
3268
3756
  let audioContext = null;
3269
3757
  (async () => {
@@ -3289,7 +3777,7 @@ var WaveformView = ({
3289
3777
  cancelled = true;
3290
3778
  };
3291
3779
  }, [host, filePath, bins, targetSamples]);
3292
- (0, import_react14.useEffect)(() => {
3780
+ (0, import_react16.useEffect)(() => {
3293
3781
  if (!peaks) return;
3294
3782
  const canvas = canvasRef.current;
3295
3783
  if (!canvas) return;
@@ -3300,7 +3788,7 @@ var WaveformView = ({
3300
3788
  observer.observe(canvas);
3301
3789
  return () => observer.disconnect();
3302
3790
  }, [peaks, fillStyle]);
3303
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
3791
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
3304
3792
  "canvas",
3305
3793
  {
3306
3794
  ref: canvasRef,
@@ -3311,8 +3799,8 @@ var WaveformView = ({
3311
3799
  };
3312
3800
 
3313
3801
  // src/components/ScrollingWaveform.tsx
3314
- var import_react15 = require("react");
3315
- var import_jsx_runtime18 = require("react/jsx-runtime");
3802
+ var import_react17 = require("react");
3803
+ var import_jsx_runtime20 = require("react/jsx-runtime");
3316
3804
  var ScrollingWaveform = ({
3317
3805
  getPeakDb,
3318
3806
  active,
@@ -3320,11 +3808,11 @@ var ScrollingWaveform = ({
3320
3808
  className,
3321
3809
  fillStyle
3322
3810
  }) => {
3323
- const canvasRef = (0, import_react15.useRef)(null);
3324
- const ringRef = (0, import_react15.useRef)(new Float32Array(columns));
3325
- const writeIdxRef = (0, import_react15.useRef)(0);
3326
- const rafRef = (0, import_react15.useRef)(null);
3327
- (0, import_react15.useEffect)(() => {
3811
+ const canvasRef = (0, import_react17.useRef)(null);
3812
+ const ringRef = (0, import_react17.useRef)(new Float32Array(columns));
3813
+ const writeIdxRef = (0, import_react17.useRef)(0);
3814
+ const rafRef = (0, import_react17.useRef)(null);
3815
+ (0, import_react17.useEffect)(() => {
3328
3816
  if (ringRef.current.length !== columns) {
3329
3817
  const next = new Float32Array(columns);
3330
3818
  const prev = ringRef.current;
@@ -3336,7 +3824,7 @@ var ScrollingWaveform = ({
3336
3824
  writeIdxRef.current = writeIdxRef.current % columns;
3337
3825
  }
3338
3826
  }, [columns]);
3339
- (0, import_react15.useEffect)(() => {
3827
+ (0, import_react17.useEffect)(() => {
3340
3828
  if (!active) {
3341
3829
  if (rafRef.current !== null) {
3342
3830
  cancelAnimationFrame(rafRef.current);
@@ -3388,7 +3876,7 @@ var ScrollingWaveform = ({
3388
3876
  }
3389
3877
  };
3390
3878
  }, [active, getPeakDb, fillStyle]);
3391
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3879
+ return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
3392
3880
  "canvas",
3393
3881
  {
3394
3882
  ref: canvasRef,
@@ -3399,8 +3887,8 @@ var ScrollingWaveform = ({
3399
3887
  };
3400
3888
 
3401
3889
  // src/components/OffsetScrubber.tsx
3402
- var import_react16 = require("react");
3403
- var import_jsx_runtime19 = require("react/jsx-runtime");
3890
+ var import_react18 = require("react");
3891
+ var import_jsx_runtime21 = require("react/jsx-runtime");
3404
3892
  var SLIDER_HEIGHT_PX = 28;
3405
3893
  var TICK_HEIGHT_PX = 14;
3406
3894
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -3413,40 +3901,40 @@ function OffsetScrubber({
3413
3901
  onChange,
3414
3902
  disabled = false
3415
3903
  }) {
3416
- const trackRef = (0, import_react16.useRef)(null);
3417
- const [draftOffset, setDraftOffset] = (0, import_react16.useState)(offsetSamples);
3418
- const [isDragging, setIsDragging] = (0, import_react16.useState)(false);
3419
- (0, import_react16.useEffect)(() => {
3904
+ const trackRef = (0, import_react18.useRef)(null);
3905
+ const [draftOffset, setDraftOffset] = (0, import_react18.useState)(offsetSamples);
3906
+ const [isDragging, setIsDragging] = (0, import_react18.useState)(false);
3907
+ (0, import_react18.useEffect)(() => {
3420
3908
  if (!isDragging) setDraftOffset(offsetSamples);
3421
3909
  }, [offsetSamples, isDragging]);
3422
3910
  const sampleRate = cuePoints?.sample_rate ?? 44100;
3423
3911
  const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
3424
- const beatsForRange = (0, import_react16.useMemo)(() => {
3912
+ const beatsForRange = (0, import_react18.useMemo)(() => {
3425
3913
  return Math.round(60 / projectBpm * sampleRate);
3426
3914
  }, [projectBpm, sampleRate]);
3427
3915
  const rangeSamples = beatsForRange * meter;
3428
- const sampleToFraction = (0, import_react16.useCallback)(
3916
+ const sampleToFraction = (0, import_react18.useCallback)(
3429
3917
  (sample) => {
3430
3918
  const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
3431
3919
  return (clamped + rangeSamples) / (2 * rangeSamples);
3432
3920
  },
3433
3921
  [rangeSamples]
3434
3922
  );
3435
- const fractionToSample = (0, import_react16.useCallback)(
3923
+ const fractionToSample = (0, import_react18.useCallback)(
3436
3924
  (fraction) => {
3437
3925
  const clamped = Math.max(0, Math.min(1, fraction));
3438
3926
  return Math.round(clamped * 2 * rangeSamples - rangeSamples);
3439
3927
  },
3440
3928
  [rangeSamples]
3441
3929
  );
3442
- const snapTargets = (0, import_react16.useMemo)(() => {
3930
+ const snapTargets = (0, import_react18.useMemo)(() => {
3443
3931
  if (!cuePoints || cuePoints.beats.length === 0) return [];
3444
3932
  const downbeat = cuePoints.beats[0];
3445
3933
  const positives = cuePoints.beats.map((b) => b - downbeat);
3446
3934
  const negatives = positives.slice(1).map((p) => -p);
3447
3935
  return [...negatives, ...positives].sort((a, b) => a - b);
3448
3936
  }, [cuePoints]);
3449
- const snapToBeat = (0, import_react16.useCallback)(
3937
+ const snapToBeat = (0, import_react18.useCallback)(
3450
3938
  (sample) => {
3451
3939
  if (snapTargets.length === 0) return sample;
3452
3940
  let best = snapTargets[0];
@@ -3462,7 +3950,7 @@ function OffsetScrubber({
3462
3950
  },
3463
3951
  [snapTargets]
3464
3952
  );
3465
- const handlePointerDown = (0, import_react16.useCallback)(
3953
+ const handlePointerDown = (0, import_react18.useCallback)(
3466
3954
  (e) => {
3467
3955
  if (disabled || !cuePoints) return;
3468
3956
  e.preventDefault();
@@ -3496,7 +3984,7 @@ function OffsetScrubber({
3496
3984
  },
3497
3985
  [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
3498
3986
  );
3499
- const handleResetToZero = (0, import_react16.useCallback)(() => {
3987
+ const handleResetToZero = (0, import_react18.useCallback)(() => {
3500
3988
  if (disabled) return;
3501
3989
  setDraftOffset(0);
3502
3990
  onChange(0);
@@ -3504,7 +3992,7 @@ function OffsetScrubber({
3504
3992
  const thumbFraction = sampleToFraction(draftOffset);
3505
3993
  const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
3506
3994
  const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
3507
- const ticks = (0, import_react16.useMemo)(() => {
3995
+ const ticks = (0, import_react18.useMemo)(() => {
3508
3996
  if (!cuePoints) return [];
3509
3997
  const downbeat = cuePoints.beats[0] ?? 0;
3510
3998
  return cuePoints.beats.map((b, i) => {
@@ -3515,9 +4003,9 @@ function OffsetScrubber({
3515
4003
  });
3516
4004
  }, [cuePoints, sampleToFraction]);
3517
4005
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
3518
- return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
3519
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
3520
- /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
4006
+ return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
4007
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
4008
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
3521
4009
  "div",
3522
4010
  {
3523
4011
  ref: trackRef,
@@ -3533,7 +4021,7 @@ function OffsetScrubber({
3533
4021
  "aria-valuenow": draftOffset,
3534
4022
  "aria-disabled": isDisabled,
3535
4023
  children: [
3536
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4024
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3537
4025
  "div",
3538
4026
  {
3539
4027
  "aria-hidden": "true",
@@ -3541,7 +4029,7 @@ function OffsetScrubber({
3541
4029
  style: { left: "50%" }
3542
4030
  }
3543
4031
  ),
3544
- ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4032
+ ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3545
4033
  "div",
3546
4034
  {
3547
4035
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -3556,7 +4044,7 @@ function OffsetScrubber({
3556
4044
  },
3557
4045
  t.i
3558
4046
  )),
3559
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4047
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3560
4048
  "div",
3561
4049
  {
3562
4050
  "data-testid": "offset-scrubber-thumb",
@@ -3573,7 +4061,7 @@ function OffsetScrubber({
3573
4061
  ]
3574
4062
  }
3575
4063
  ),
3576
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4064
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3577
4065
  "span",
3578
4066
  {
3579
4067
  "data-testid": "offset-scrubber-readout",
@@ -3581,7 +4069,7 @@ function OffsetScrubber({
3581
4069
  children: formatOffset(draftOffset, sampleRate)
3582
4070
  }
3583
4071
  ),
3584
- /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4072
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3585
4073
  "button",
3586
4074
  {
3587
4075
  type: "button",
@@ -3593,7 +4081,7 @@ function OffsetScrubber({
3593
4081
  children: "\u2316"
3594
4082
  }
3595
4083
  ),
3596
- bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4084
+ bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3597
4085
  "span",
3598
4086
  {
3599
4087
  "data-testid": "offset-bpm-mismatch",
@@ -3665,13 +4153,13 @@ function synthesizeCuePoints({
3665
4153
  }
3666
4154
 
3667
4155
  // src/hooks/useSceneState.ts
3668
- var import_react17 = require("react");
4156
+ var import_react19 = require("react");
3669
4157
  function useSceneState(activeSceneId, initialValue) {
3670
- const [stateMap, setStateMap] = (0, import_react17.useState)(() => /* @__PURE__ */ new Map());
3671
- const activeSceneIdRef = (0, import_react17.useRef)(activeSceneId);
4158
+ const [stateMap, setStateMap] = (0, import_react19.useState)(() => /* @__PURE__ */ new Map());
4159
+ const activeSceneIdRef = (0, import_react19.useRef)(activeSceneId);
3672
4160
  activeSceneIdRef.current = activeSceneId;
3673
4161
  const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
3674
- const setForCurrentScene = (0, import_react17.useCallback)((value) => {
4162
+ const setForCurrentScene = (0, import_react19.useCallback)((value) => {
3675
4163
  const sid = activeSceneIdRef.current;
3676
4164
  if (sid === null) return;
3677
4165
  setStateMap((prev) => {
@@ -3682,7 +4170,7 @@ function useSceneState(activeSceneId, initialValue) {
3682
4170
  return newMap;
3683
4171
  });
3684
4172
  }, [initialValue]);
3685
- const setForScene = (0, import_react17.useCallback)((sceneId, value) => {
4173
+ const setForScene = (0, import_react19.useCallback)((sceneId, value) => {
3686
4174
  setStateMap((prev) => {
3687
4175
  const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
3688
4176
  const next = typeof value === "function" ? value(current) : value;
@@ -3695,10 +4183,10 @@ function useSceneState(activeSceneId, initialValue) {
3695
4183
  }
3696
4184
 
3697
4185
  // src/hooks/useAnySolo.ts
3698
- var import_react18 = require("react");
4186
+ var import_react20 = require("react");
3699
4187
  function useAnySolo(host) {
3700
- const [anySolo, setAnySolo] = (0, import_react18.useState)(false);
3701
- (0, import_react18.useEffect)(() => {
4188
+ const [anySolo, setAnySolo] = (0, import_react20.useState)(false);
4189
+ (0, import_react20.useEffect)(() => {
3702
4190
  let active = true;
3703
4191
  const refresh = () => {
3704
4192
  host.isAnySoloActive().then((v) => {
@@ -3717,7 +4205,7 @@ function useAnySolo(host) {
3717
4205
  }
3718
4206
 
3719
4207
  // src/hooks/useSoundHistory.ts
3720
- var import_react19 = require("react");
4208
+ var import_react21 = require("react");
3721
4209
  var EMPTY = { entries: [], cursor: -1 };
3722
4210
  function sameDescriptor(a, b) {
3723
4211
  if (a === b) return true;
@@ -3729,14 +4217,14 @@ function sameDescriptor(a, b) {
3729
4217
  }
3730
4218
  function useSoundHistory(applySound, opts = {}) {
3731
4219
  const max = Math.max(2, opts.max ?? 24);
3732
- const applyRef = (0, import_react19.useRef)(applySound);
4220
+ const applyRef = (0, import_react21.useRef)(applySound);
3733
4221
  applyRef.current = applySound;
3734
- const onChangeRef = (0, import_react19.useRef)(opts.onChange);
4222
+ const onChangeRef = (0, import_react21.useRef)(opts.onChange);
3735
4223
  onChangeRef.current = opts.onChange;
3736
- const dataRef = (0, import_react19.useRef)({});
3737
- const [, setVersion] = (0, import_react19.useState)(0);
3738
- const bump = (0, import_react19.useCallback)(() => setVersion((v) => v + 1), []);
3739
- const commit = (0, import_react19.useCallback)(
4224
+ const dataRef = (0, import_react21.useRef)({});
4225
+ const [, setVersion] = (0, import_react21.useState)(0);
4226
+ const bump = (0, import_react21.useCallback)(() => setVersion((v) => v + 1), []);
4227
+ const commit = (0, import_react21.useCallback)(
3740
4228
  (trackId, next, notify) => {
3741
4229
  dataRef.current = { ...dataRef.current, [trackId]: next };
3742
4230
  bump();
@@ -3744,7 +4232,7 @@ function useSoundHistory(applySound, opts = {}) {
3744
4232
  },
3745
4233
  [bump]
3746
4234
  );
3747
- const record = (0, import_react19.useCallback)(
4235
+ const record = (0, import_react21.useCallback)(
3748
4236
  (trackId, descriptor, label) => {
3749
4237
  const h = dataRef.current[trackId];
3750
4238
  const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
@@ -3759,7 +4247,7 @@ function useSoundHistory(applySound, opts = {}) {
3759
4247
  },
3760
4248
  [max, commit]
3761
4249
  );
3762
- const restoreTo = (0, import_react19.useCallback)(
4250
+ const restoreTo = (0, import_react21.useCallback)(
3763
4251
  async (trackId, index) => {
3764
4252
  const h = dataRef.current[trackId];
3765
4253
  if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
@@ -3769,7 +4257,7 @@ function useSoundHistory(applySound, opts = {}) {
3769
4257
  },
3770
4258
  [commit]
3771
4259
  );
3772
- const undo = (0, import_react19.useCallback)(
4260
+ const undo = (0, import_react21.useCallback)(
3773
4261
  (trackId) => {
3774
4262
  const h = dataRef.current[trackId];
3775
4263
  if (!h || h.cursor <= 0) return Promise.resolve(false);
@@ -3777,7 +4265,7 @@ function useSoundHistory(applySound, opts = {}) {
3777
4265
  },
3778
4266
  [restoreTo]
3779
4267
  );
3780
- const toggleFavorite = (0, import_react19.useCallback)(
4268
+ const toggleFavorite = (0, import_react21.useCallback)(
3781
4269
  (trackId, index) => {
3782
4270
  const h = dataRef.current[trackId];
3783
4271
  if (!h || index < 0 || index >= h.entries.length) return;
@@ -3786,7 +4274,7 @@ function useSoundHistory(applySound, opts = {}) {
3786
4274
  },
3787
4275
  [commit]
3788
4276
  );
3789
- const restore = (0, import_react19.useCallback)(
4277
+ const restore = (0, import_react21.useCallback)(
3790
4278
  (trackId, state) => {
3791
4279
  const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
3792
4280
  const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
@@ -3795,15 +4283,15 @@ function useSoundHistory(applySound, opts = {}) {
3795
4283
  },
3796
4284
  [commit]
3797
4285
  );
3798
- const list = (0, import_react19.useCallback)(
4286
+ const list = (0, import_react21.useCallback)(
3799
4287
  (trackId) => dataRef.current[trackId] ?? EMPTY,
3800
4288
  []
3801
4289
  );
3802
- const canUndo = (0, import_react19.useCallback)((trackId) => {
4290
+ const canUndo = (0, import_react21.useCallback)((trackId) => {
3803
4291
  const h = dataRef.current[trackId];
3804
4292
  return !!h && h.cursor > 0;
3805
4293
  }, []);
3806
- const clear = (0, import_react19.useCallback)(
4294
+ const clear = (0, import_react21.useCallback)(
3807
4295
  (trackId) => {
3808
4296
  if (dataRef.current[trackId]) {
3809
4297
  const next = { ...dataRef.current };
@@ -3815,18 +4303,18 @@ function useSoundHistory(applySound, opts = {}) {
3815
4303
  },
3816
4304
  [bump]
3817
4305
  );
3818
- const reset = (0, import_react19.useCallback)(() => {
4306
+ const reset = (0, import_react21.useCallback)(() => {
3819
4307
  dataRef.current = {};
3820
4308
  bump();
3821
4309
  }, [bump]);
3822
- return (0, import_react19.useMemo)(
4310
+ return (0, import_react21.useMemo)(
3823
4311
  () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
3824
4312
  [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
3825
4313
  );
3826
4314
  }
3827
4315
 
3828
4316
  // src/hooks/useTrackReorder.ts
3829
- var import_react20 = require("react");
4317
+ var import_react22 = require("react");
3830
4318
  function moveItem(arr, from, to) {
3831
4319
  const next = arr.slice();
3832
4320
  if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
@@ -3843,12 +4331,12 @@ function useTrackReorder({
3843
4331
  getId,
3844
4332
  onError
3845
4333
  }) {
3846
- const [draggingIndex, setDraggingIndex] = (0, import_react20.useState)(null);
3847
- const [dragOverIndex, setDragOverIndex] = (0, import_react20.useState)(null);
3848
- const fromRef = (0, import_react20.useRef)(null);
3849
- const itemsRef = (0, import_react20.useRef)(items);
4334
+ const [draggingIndex, setDraggingIndex] = (0, import_react22.useState)(null);
4335
+ const [dragOverIndex, setDragOverIndex] = (0, import_react22.useState)(null);
4336
+ const fromRef = (0, import_react22.useRef)(null);
4337
+ const itemsRef = (0, import_react22.useRef)(items);
3850
4338
  itemsRef.current = items;
3851
- const dragPropsFor = (0, import_react20.useCallback)(
4339
+ const dragPropsFor = (0, import_react22.useCallback)(
3852
4340
  (index) => ({
3853
4341
  handleProps: {
3854
4342
  draggable: true,
@@ -3910,7 +4398,7 @@ function useTrackReorder({
3910
4398
  }
3911
4399
 
3912
4400
  // src/constants/sdk-version.ts
3913
- var PLUGIN_SDK_VERSION = "2.26.0";
4401
+ var PLUGIN_SDK_VERSION = "2.28.0";
3914
4402
 
3915
4403
  // src/utils/format-concurrent-tracks.ts
3916
4404
  function formatConcurrentTracks(ctx) {
@@ -4071,6 +4559,8 @@ function pickTopKWeighted(scored, options = {}) {
4071
4559
  FX_DISPLAY_LABELS,
4072
4560
  FX_ENGINE_PLUGIN_NAMES,
4073
4561
  FX_PRESET_CONFIGS,
4562
+ FadeModal,
4563
+ FadeTrackRow,
4074
4564
  FxToggleBar,
4075
4565
  GUTTER_W,
4076
4566
  ImportTrackModal,
@@ -4089,6 +4579,7 @@ function pickTopKWeighted(scored, options = {}) {
4089
4579
  SamplePackCTACard,
4090
4580
  ScrollingWaveform,
4091
4581
  SorceryProgressBar,
4582
+ TEXTURAL_ROLES,
4092
4583
  TrackDrawer,
4093
4584
  TrackMeterStrip,
4094
4585
  TrackRow,
@@ -4096,17 +4587,21 @@ function pickTopKWeighted(scored, options = {}) {
4096
4587
  WaveformView,
4097
4588
  analyzeWavPeak,
4098
4589
  asCrossfadeMeta,
4590
+ asFadeMeta,
4099
4591
  buildCrossfadeInpaintPrompt,
4100
4592
  buildCrossfadeVolumeCurves,
4593
+ buildFadeVolumeCurve,
4101
4594
  calculateTimeBasedTarget,
4102
4595
  cellToPx,
4103
4596
  centerScrollTop,
4104
4597
  computePeaks,
4105
4598
  dbToSlider,
4599
+ defaultFadeGesture,
4106
4600
  drawWaveform,
4107
4601
  formatConcurrentTracks,
4108
4602
  moveItem,
4109
4603
  parseCrossfadePairs,
4604
+ parseFades,
4110
4605
  pickTopKWeighted,
4111
4606
  pitchToName,
4112
4607
  pxToCell,