@lumiastream/ui 0.2.8-alpha.1 → 0.2.8-alpha.2

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.d.ts CHANGED
@@ -485,7 +485,7 @@ declare const resolvePlatformChatterProfileUrl: ({ platform, username, displayna
485
485
  declare const normalizeHttpUrl: (value: unknown) => string | null;
486
486
  declare const resolveChatterProfileUrlWithResolvers: ({ platform, username, displayname, userId, extraSettings, resolvers, }: ResolveChatterProfileUrlWithResolversInput) => Promise<string | null>;
487
487
 
488
- type ModuleType = 'alert' | 'emotebox' | 'emotealert' | 'text' | 'image' | 'audio' | 'video' | 'camera' | 'slideshow' | 'shape' | 'svg' | 'colorbox' | 'goal' | 'timer' | 'gradientlight' | 'browsersource' | 'chatbox' | 'credits' | 'eventlist' | 'eventrotate' | 'hfx' | 'hfxalert' | 'clip' | 'youtube' | 'spinwheel' | 'raffle' | 'viewerqueue' | 'loyaltyleaderboard' | 'viewerprofiles' | 'achievements' | 'poll' | 'tournaments' | 'spotify' | 'nowplaying' | 'youtubemusic' | 'brbscreen' | 'vlc' | 'tts' | 'livecaption' | 'custom' | 'group';
488
+ type ModuleType = 'alert' | 'emotebox' | 'emotealert' | 'text' | 'image' | 'audio' | 'video' | 'camera' | 'slideshow' | 'shape' | 'svg' | 'colorbox' | 'goal' | 'timer' | 'gradientlight' | 'browsersource' | 'chatbox' | 'credits' | 'eventlist' | 'eventrotate' | 'hfx' | 'hfxalert' | 'clip' | 'youtube' | 'spinwheel' | 'raffle' | 'viewerqueue' | 'loyaltyleaderboard' | 'viewerprofiles' | 'achievements' | 'poll' | 'tournaments' | 'spotify' | 'nowplaying' | 'youtubemusic' | 'brbscreen' | 'vlc' | 'tts' | 'livecaption' | 'custom' | 'tipjar' | 'hypetrain' | 'streamboss' | 'songrequest' | 'group';
489
489
  type OverlayLayerType = 'layer' | 'group';
490
490
  type OverlayLayerBounds = {
491
491
  height: number;
@@ -689,11 +689,8 @@ declare const SE_WIDGET_TO_MARKETPLACE_CANDIDATES: Record<string, number[]>;
689
689
  declare function getMarketplaceCandidates(seWidgetType: string): number[];
690
690
  declare function hasMarketplaceCandidates(seWidgetType: string): boolean;
691
691
 
692
- declare const SE_IMPORT_FLAGS: {
693
- readonly IMPORT_MERCH_PRODUCTS: false;
694
- readonly IMPORT_SEASONAL: false;
695
- };
696
- declare function isWidgetFlaggedOff(seType: string): boolean;
692
+ declare const SE_IMPORT_FLAGS: {};
693
+ declare function isWidgetFlaggedOff(_seType: string): boolean;
697
694
  declare const FLAG_OFF_REASONS: Record<string, string>;
698
695
 
699
696
  interface SEAccountClaims {
package/dist/index.js CHANGED
@@ -3126,7 +3126,11 @@ var SE_WIDGET_TYPE_LABELS = {
3126
3126
  "se-widget-hype-cup": "Hype Cup",
3127
3127
  "se-widget-custom-event-list": "Custom widget",
3128
3128
  "se-widget-credit-roll": "Credits",
3129
- "se-widget-media-share": "Media share"
3129
+ "se-widget-media-share": "Media share",
3130
+ // Imports as an empty slideshow — Lumia has no Fourthwall catalog API, so
3131
+ // the streamer has to add product images by hand. The friendly title makes
3132
+ // that obvious in the layers panel; see REVIEW_REASONS for the full prompt.
3133
+ "se-widget-merch-products-rotator": "Merch products (add images manually)"
3130
3134
  };
3131
3135
  function defaultLabelForSEWidget(widget) {
3132
3136
  const trimmed = typeof widget.name === "string" ? widget.name.trim() : "";
@@ -3273,7 +3277,22 @@ var LUMIA_DEFAULT_SIZES = {
3273
3277
  emotebox: { width: 400, height: 400 },
3274
3278
  nowplaying: { width: 600, height: 250 },
3275
3279
  spotify: { width: 600, height: 250 },
3276
- youtubemusic: { width: 600, height: 250 }
3280
+ youtubemusic: { width: 600, height: 250 },
3281
+ tipjar: { width: 600, height: 700 },
3282
+ // Hype Train: wide-and-short by default to fit a horizontal progress bar.
3283
+ // All four built-in themes (subtle / train / rocket / inferno) render
3284
+ // fine at this aspect — rocket internally uses a vertical gauge but
3285
+ // still works at this footprint. Users can resize per their overlay.
3286
+ hypetrain: { width: 740, height: 140 },
3287
+ // Stream Boss: matches SE's bit-boss default (440×86) for a 1:1 import
3288
+ // experience, lightly padded so Lumia's Card-theme avatar fits without
3289
+ // resizing. Users can swap to Bar-Only and shrink height further.
3290
+ streamboss: { width: 480, height: 100 },
3291
+ // Song Request: SE's media-share default is 480×292. Lumia's now-playing
3292
+ // card + 5-item queue list needs a bit more vertical room, so we add ~80px
3293
+ // for the queue list. Users can hide the queue list and shrink back to
3294
+ // just the card if they want SE's exact footprint.
3295
+ songrequest: { width: 480, height: 380 }
3277
3296
  };
3278
3297
  function translateVariationCondition(seType, seCondition, requirement) {
3279
3298
  if (seType === "gift") return { conditionType: "IS_GIFT", condition: 1 };
@@ -3370,6 +3389,17 @@ function mapVideo(widget) {
3370
3389
  }
3371
3390
  });
3372
3391
  }
3392
+ var LUMIA_SNOW_WEBM = "https://storage.lumiastream.com/overlays/global/seasonal/snow.webm";
3393
+ function mapSnow(widget) {
3394
+ return buildUnit(widget, "video", {
3395
+ content: {
3396
+ src: LUMIA_SNOW_WEBM,
3397
+ volume: 0,
3398
+ loop: true,
3399
+ muted: true
3400
+ }
3401
+ });
3402
+ }
3373
3403
  function mapReadout(widget, fallbackVar) {
3374
3404
  const explicitListener = widget.variables?.listener;
3375
3405
  const explicit = typeof explicitListener === "string" ? explicitListener : null;
@@ -3539,6 +3569,9 @@ function currentForListener(listener) {
3539
3569
  if (listener.startsWith("subscriber-")) return "{{twitch_total_subscriber_count}}";
3540
3570
  if (listener.startsWith("cheer-")) return "{{twitch_total_bits_count}}";
3541
3571
  if (listener.startsWith("tip-")) return "{{total_donation_amount}}";
3572
+ if (listener === "merch-goal-orders") return "{{fourthwall_total_order_count}}";
3573
+ if (listener === "merch-goal-items") return "{{fourthwall_total_items_count}}";
3574
+ if (listener === "merch-goal-total") return "{{fourthwall_total_order_amount}}";
3542
3575
  if (listener.startsWith("merch-")) return "{{fourthwall_total_order_count}}";
3543
3576
  return "0";
3544
3577
  }
@@ -3639,77 +3672,6 @@ function mapCustom(widget) {
3639
3672
  }
3640
3673
  });
3641
3674
  }
3642
- var SEASONAL_TEMPLATE_HTML = '<canvas id="particles"></canvas>';
3643
- var SEASONAL_TEMPLATE_CSS = `html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: transparent; overflow: hidden; }
3644
- #particles { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; }`;
3645
- var SEASONAL_TEMPLATE_JS = `const SEASONS = {
3646
- snow: { emoji: '\\u2744', count: 80, sizeMin: 14, sizeMax: 28, gravity: 1.0, drift: 0.6, sway: 1.2 },
3647
- halloween: { emoji: '\\ud83d\\udc7b', count: 25, sizeMin: 28, sizeMax: 44, gravity: 0.6, drift: 0.4, sway: 0.8 },
3648
- xmas: { emoji: '\\u2744', count: 120, sizeMin: 16, sizeMax: 32, gravity: 1.1, drift: 0.7, sway: 1.3 },
3649
- valentine: { emoji: '\\u2764\\ufe0f', count: 50, sizeMin: 18, sizeMax: 34, gravity: 0.8, drift: 0.5, sway: 1.0 },
3650
- easter: { emoji: '\\ud83e\\udd5a', count: 30, sizeMin: 22, sizeMax: 38, gravity: 0.9, drift: 0.3, sway: 0.4 },
3651
- worldcup: { emoji: '\\u26bd', count: 25, sizeMin: 24, sizeMax: 40, gravity: 1.1, drift: 0.4, sway: 0.5 },
3652
- };
3653
- const cfg = SEASONS[(window.DATA && window.DATA.season) || 'snow'] || SEASONS.snow;
3654
- const canvas = document.getElementById('particles');
3655
- const ctx = canvas.getContext('2d');
3656
- const dpr = window.devicePixelRatio || 1;
3657
- function resize() {
3658
- canvas.width = window.innerWidth * dpr;
3659
- canvas.height = window.innerHeight * dpr;
3660
- canvas.style.width = window.innerWidth + 'px';
3661
- canvas.style.height = window.innerHeight + 'px';
3662
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3663
- }
3664
- resize();
3665
- window.addEventListener('resize', resize);
3666
- const particles = [];
3667
- for (let i = 0; i < cfg.count; i++) {
3668
- particles.push({
3669
- x: Math.random() * window.innerWidth,
3670
- y: Math.random() * window.innerHeight - window.innerHeight,
3671
- size: cfg.sizeMin + Math.random() * (cfg.sizeMax - cfg.sizeMin),
3672
- speed: 0.5 + Math.random() * 1.5,
3673
- phase: Math.random() * Math.PI * 2,
3674
- });
3675
- }
3676
- function tick(now) {
3677
- ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
3678
- const t = now / 1000;
3679
- for (const p of particles) {
3680
- p.y += p.speed * cfg.gravity;
3681
- const sway = Math.sin(t + p.phase) * cfg.sway;
3682
- if (p.y > window.innerHeight + 40) {
3683
- p.y = -40;
3684
- p.x = Math.random() * window.innerWidth;
3685
- }
3686
- ctx.font = p.size + 'px serif';
3687
- ctx.fillText(cfg.emoji, p.x + sway * 20, p.y);
3688
- }
3689
- requestAnimationFrame(tick);
3690
- }
3691
- requestAnimationFrame(tick);`;
3692
- function mapSeasonal(widget, season) {
3693
- return buildUnit(widget, "custom", {
3694
- content: {
3695
- codeId: `seasonal-${season}-${widget.id}`,
3696
- html: SEASONAL_TEMPLATE_HTML,
3697
- css: SEASONAL_TEMPLATE_CSS,
3698
- js: SEASONAL_TEMPLATE_JS,
3699
- configs: [
3700
- {
3701
- key: "season",
3702
- label: "Season",
3703
- type: "dropdown",
3704
- value: season,
3705
- options: { snow: "Snow", halloween: "Halloween", xmas: "Christmas", valentine: "Valentine's", easter: "Easter", worldcup: "World Cup" }
3706
- }
3707
- ],
3708
- data: { season },
3709
- flavor: "lumia-seasonal"
3710
- }
3711
- });
3712
- }
3713
3675
 
3714
3676
  // src/se-import/mappers/misc.ts
3715
3677
  function mapChatbox(widget) {
@@ -4012,6 +3974,49 @@ function mapNowPlaying(widget) {
4012
3974
  }
4013
3975
  });
4014
3976
  }
3977
+ function mapMediaShare(widget) {
3978
+ const v = widget.variables ?? {};
3979
+ return buildUnit(widget, "songrequest", {
3980
+ content: {
3981
+ version: 1,
3982
+ // SE always shows the now-playing card; honour its cosmetic toggles
3983
+ // for the rest. `showTitle` defaults true; the others default to
3984
+ // SE's documented behavior (tipper visible, stats hidden).
3985
+ showNowPlayingCard: true,
3986
+ showQueueList: true,
3987
+ showSkipButton: true,
3988
+ showPauseButton: true,
3989
+ showTitle: v.showTitle !== false,
3990
+ showTipper: v.showTipper !== false,
3991
+ showAmount: v.showAmount === true,
3992
+ showStats: v.showStats === true,
3993
+ // SE doesn't have a queue-size knob — pick a sensible default
3994
+ // matching what fits in the SE widget's ~292px tall footprint.
3995
+ maxQueueItemsVisible: 5,
3996
+ // SE color knobs translate directly. Fall back to Lumia's defaults
3997
+ // (purple accent, near-black card background) if SE values are
3998
+ // missing — matches what the module ships with for native creates.
3999
+ accentColor: v.accentColor ?? "#9146ff",
4000
+ primaryColor: v.primaryColor ?? "#1a1a1a",
4001
+ // SE's widget hides the YT player by default; mirror that for
4002
+ // audio-only overlay use.
4003
+ playerVisible: false,
4004
+ defaultVolume: 70,
4005
+ autoAdvanceOnEnd: true,
4006
+ // SE-only knob preserved for round-trip provenance; Lumia ignores
4007
+ // it (LumiaStream auto-advance fires on `durationSeconds + grace`).
4008
+ se_timeLeft: v.timeLeft === true
4009
+ },
4010
+ css: {
4011
+ fontFamily: "Roboto",
4012
+ fontWeight: "bold",
4013
+ color: "#ffffff",
4014
+ textShadow: "rgb(0, 0, 0) 1px 1px 1px",
4015
+ background: "transparent",
4016
+ fontSize: 18
4017
+ }
4018
+ });
4019
+ }
4015
4020
  var SE_KAPPAGEN_EVENT_TO_LUMIA_ALERT = {
4016
4021
  follower: "twitch-follower",
4017
4022
  subscriber: "twitch-subscriber",
@@ -4155,6 +4160,75 @@ function mapGiveaway(widget) {
4155
4160
  }
4156
4161
  });
4157
4162
  }
4163
+ var HYPETRAIN_LISTENER_KEYS = /* @__PURE__ */ new Set([
4164
+ "hypetrain-latest",
4165
+ "hypetrain-level-progress",
4166
+ "hypetrain-level-goal",
4167
+ "hypetrain-total",
4168
+ "hypetrain-latest-top-contributors",
4169
+ // SE alert-name variants some community widgets subscribe to as well.
4170
+ "hypetrain-start",
4171
+ "hypetrain-end",
4172
+ "hypetrain-cooldown-start",
4173
+ "hypetrain-cooldown-end"
4174
+ ]);
4175
+ function isHypetrainCustomWidget(widget) {
4176
+ const enabled = [];
4177
+ const listeners = widget.listeners;
4178
+ if (listeners && typeof listeners === "object" && !Array.isArray(listeners)) {
4179
+ for (const [k, v] of Object.entries(listeners)) {
4180
+ if (v) enabled.push(k);
4181
+ }
4182
+ } else if (Array.isArray(listeners)) {
4183
+ enabled.push(...listeners);
4184
+ }
4185
+ const explicit = widget.listener;
4186
+ if (typeof explicit === "string" && explicit && !enabled.includes(explicit)) {
4187
+ enabled.push(explicit);
4188
+ }
4189
+ if (enabled.length === 0) return false;
4190
+ return enabled.every((listener) => HYPETRAIN_LISTENER_KEYS.has(listener));
4191
+ }
4192
+ function mapHypetrain(widget) {
4193
+ const v = widget.variables ?? {};
4194
+ return buildUnit(widget, "hypetrain", {
4195
+ content: {
4196
+ // Twitch-native is the right default — SE hype-train widgets always
4197
+ // reflected the Twitch native train. Users can flip to 'synthetic'
4198
+ // or 'hybrid' from Settings if they want cross-platform pooling.
4199
+ sourceMode: "twitch-native",
4200
+ sources: null,
4201
+ // default money-only whitelist; ignored in twitch-native anyway
4202
+ customValues: {},
4203
+ pretrainThreshold: 5,
4204
+ pretrainDurationMs: 5 * 60 * 1e3,
4205
+ levelDurationMs: 5 * 60 * 1e3,
4206
+ levelStep: 5,
4207
+ levelThresholds: [],
4208
+ // Pick a moderately energetic default theme — SE hype-train widgets
4209
+ // were never subtle. Users can switch in Settings.
4210
+ theme: "train",
4211
+ primaryColor: typeof v.primaryColor === "string" ? v.primaryColor : "#ff6b00",
4212
+ accentColor: typeof v.accentColor === "string" ? v.accentColor : "#ffd84d",
4213
+ textColor: typeof v.textColor === "string" ? v.textColor : "#ffffff",
4214
+ showLevel: true,
4215
+ showTotal: true,
4216
+ showTimer: true,
4217
+ showTopContributor: true,
4218
+ showLeaderboard: true,
4219
+ leaderboardLimit: 5,
4220
+ leaderboardDurationMs: 30 * 1e3,
4221
+ levelUpSoundUrl: "https://storage.lumiastream.com/overlays/lumia/audio/crowdClap.mp3",
4222
+ trainEndSoundUrl: "https://storage.lumiastream.com/overlays/lumia/audio/crowdClap.mp3",
4223
+ audioVolume: 0.5,
4224
+ // SE config preserved verbatim as provenance — no Lumia equivalent for
4225
+ // most of these knobs (per-widget animation tunables, sound URLs the
4226
+ // streamer might want to migrate manually, etc.). Users can inspect
4227
+ // these via the overlay JSON if they want to recreate a specific look.
4228
+ se_variables: v
4229
+ }
4230
+ });
4231
+ }
4158
4232
  function mapUnsupportedAsText(widget) {
4159
4233
  return buildUnit(widget, "text", {
4160
4234
  content: {
@@ -4172,44 +4246,258 @@ function mapUnsupportedAsText(widget) {
4172
4246
  });
4173
4247
  }
4174
4248
 
4249
+ // src/se-import/mappers/hypecup.ts
4250
+ var SE_TYPE_TO_CATEGORY = {
4251
+ cheer: "bits",
4252
+ sub: "subs",
4253
+ follower: "follows",
4254
+ raid: "raids",
4255
+ tip: "tips"
4256
+ };
4257
+ function liftSESpritesIntoTiers(types) {
4258
+ const out = {};
4259
+ if (!types) return out;
4260
+ for (const [seKey, block] of Object.entries(types)) {
4261
+ const cat = SE_TYPE_TO_CATEGORY[seKey];
4262
+ if (!cat) continue;
4263
+ const variations = block?.variations ?? [];
4264
+ const tiers = variations.filter((v) => typeof v?.src === "string" && v.src.length > 0).map((v) => ({
4265
+ minAmount: typeof v.amount === "number" && v.amount > 0 ? v.amount : 1,
4266
+ imageUrl: v.src,
4267
+ scale: typeof v.cheerSize === "number" && v.cheerSize > 0 ? v.cheerSize : 1
4268
+ }));
4269
+ if (tiers.length > 0) out[cat] = tiers;
4270
+ }
4271
+ return out;
4272
+ }
4273
+ var LISTENER_TO_CATEGORY = {
4274
+ "follower-latest": "follows",
4275
+ "subscriber-latest": "subs",
4276
+ "tip-latest": "tips",
4277
+ "cheer-latest": "bits",
4278
+ "raid-latest": "raids"
4279
+ };
4280
+ function categoriesFromListeners(listeners) {
4281
+ const events = {
4282
+ tips: false,
4283
+ bits: false,
4284
+ subs: false,
4285
+ resubs: false,
4286
+ gifts: false,
4287
+ superchats: false,
4288
+ superstickers: false,
4289
+ kicks: false,
4290
+ charity: false,
4291
+ follows: false,
4292
+ raids: false,
4293
+ redemptions: false,
4294
+ chat: false
4295
+ };
4296
+ if (!listeners) return events;
4297
+ const onListeners = Array.isArray(listeners) ? listeners : Object.entries(listeners).filter(([, on]) => on === true).map(([k]) => k);
4298
+ for (const l of onListeners) {
4299
+ const cat = LISTENER_TO_CATEGORY[l];
4300
+ if (cat) events[cat] = true;
4301
+ }
4302
+ if (events.subs) events.resubs = true;
4303
+ return events;
4304
+ }
4305
+ function mapHypeCup(widget) {
4306
+ const v = widget.variables ?? {};
4307
+ const cupX = typeof v.cupPosition?.x === "number" ? clamp01(v.cupPosition.x) : 0.5;
4308
+ const cupY = typeof v.cupPosition?.y === "number" ? clamp01(v.cupPosition.y) : 0.7;
4309
+ const bounce = typeof v.bounce === "number" ? Math.min(1, Math.max(0, v.bounce)) : 0.3;
4310
+ const maxBodies = typeof v.recentEvents === "number" && v.recentEvents > 0 ? Math.min(300, Math.max(10, v.recentEvents)) : 80;
4311
+ const duration = typeof v.duration === "number" && v.duration > 0 ? Math.min(15, Math.max(1, v.duration)) : 3;
4312
+ const sprites = liftSESpritesIntoTiers(v.types);
4313
+ return buildUnit(widget, "tipjar", {
4314
+ content: {
4315
+ version: 1,
4316
+ // Phase 1 ships a single preset; SE's per-cup polygon set isn't lifted
4317
+ // yet (it'd require multi-cup support which we deliberately deferred).
4318
+ // The streamer can swap a custom cup image + wireframe via Settings.
4319
+ preset: "glass-mug",
4320
+ customCup: null,
4321
+ cup: {
4322
+ x: cupX,
4323
+ y: cupY,
4324
+ scale: 1,
4325
+ opacity: 1
4326
+ },
4327
+ physics: {
4328
+ gravity: 1,
4329
+ bounce,
4330
+ friction: 0.06,
4331
+ maxBodies
4332
+ },
4333
+ tokens: {
4334
+ defaultRadius: 26
4335
+ },
4336
+ events: categoriesFromListeners(widget.listeners),
4337
+ chat: {
4338
+ useViewerAvatars: true
4339
+ },
4340
+ display: {
4341
+ showWireframes: !!v.showWireframes,
4342
+ showEventMessages: !!v.textEnabled,
4343
+ messageDuration: duration
4344
+ },
4345
+ sprites,
4346
+ // Phase 2 defaults for goal/break — disabled by import. Streamer
4347
+ // can enable in Settings; SE has no equivalent so nothing to lift.
4348
+ goal: { active: false, currency: "tips", target: 100, current: 0, readoutTemplate: "{{current}} / {{target}}", textColor: "#ffffff", hitAt: null },
4349
+ break: { enabled: false, command: "!breakjar", rebuildDelay: 5e3 },
4350
+ // SE-only knobs that don't have a Lumia equivalent — preserved as
4351
+ // provenance for future phases (per-cup polygons + enter/movement
4352
+ // physics tweaks). `se_types` removed — now consumed into sprites above.
4353
+ se_enter_from: v.enterFrom ?? null,
4354
+ se_prevent_movement: !!v.preventMovement,
4355
+ se_fullscreen: !!v.fullscreen,
4356
+ se_cups: v.cups ?? null
4357
+ },
4358
+ css: {
4359
+ background: "transparent"
4360
+ }
4361
+ });
4362
+ }
4363
+ function clamp01(n) {
4364
+ if (!Number.isFinite(n)) return 0.5;
4365
+ return Math.min(1, Math.max(0, n));
4366
+ }
4367
+
4368
+ // src/se-import/mappers/streamboss.ts
4369
+ var SE_LISTENER_TO_ALERT = {
4370
+ "follower-latest": "twitch-follower",
4371
+ "subscriber-latest": "twitch-subscriber",
4372
+ "tip-latest": "lumiastream-donation",
4373
+ "cheer-latest": "twitch-bits",
4374
+ "raid-latest": "twitch-raid"
4375
+ // `purchase-latest` and `subscriber-gifted-latest` are intentionally absent.
4376
+ // SE rolled gifted subs into `subscriber-latest`; Lumia splits them via
4377
+ // extraSettings.isGift, which the runtime already handles consistently.
4378
+ };
4379
+ var SE_LISTENER_MODE = {
4380
+ "follower-latest": "flat",
4381
+ "subscriber-latest": "flat",
4382
+ "tip-latest": "multiplier",
4383
+ // 100 means "100 dmg per $"
4384
+ "cheer-latest": "multiplier",
4385
+ // 1 means "1 dmg per bit"
4386
+ "raid-latest": "multiplier"
4387
+ // 10 means "10 dmg per viewer"
4388
+ };
4389
+ function listenerIsOn(listeners, key) {
4390
+ if (!listeners) return false;
4391
+ if (Array.isArray(listeners)) return listeners.includes(key);
4392
+ return listeners[key] === true;
4393
+ }
4394
+ function mapStreamBoss(widget) {
4395
+ const v = widget.variables ?? {};
4396
+ const baseHp = typeof v.baseHP === "number" && v.baseHP > 0 ? Math.round(v.baseHP) : 1e3;
4397
+ const seDamage = v.damage ?? {};
4398
+ const damageMap = {};
4399
+ const selectedEvents = [];
4400
+ for (const [seListener, alertKey] of Object.entries(SE_LISTENER_TO_ALERT)) {
4401
+ if (!listenerIsOn(widget.listeners, seListener)) continue;
4402
+ const raw = seDamage[seListener];
4403
+ const value = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : null;
4404
+ if (value === null) continue;
4405
+ damageMap[alertKey] = { value, mode: SE_LISTENER_MODE[seListener] };
4406
+ selectedEvents.push(alertKey);
4407
+ }
4408
+ const barColor = typeof v.colors?.frame === "string" && v.colors.frame ? v.colors.frame : "#2bff00";
4409
+ return buildUnit(widget, "streamboss", {
4410
+ content: {
4411
+ version: 1,
4412
+ // Theme: SE bit-boss with `showImage: true` reads as the Card layout
4413
+ // (avatar + name + bar). When images are hidden it's effectively
4414
+ // our Bar-Only theme.
4415
+ theme: v.showImage === false ? "bar-only" : "card",
4416
+ initialBossName: "Stream Boss",
4417
+ // SE has no stage cycling — every takeover resets to baseHP. We
4418
+ // honor that by shipping a single-element healthStages array; the
4419
+ // runtime cycles modulo length so a one-element array stays put.
4420
+ healthStages: [baseHp],
4421
+ currentBoss: "Stream Boss",
4422
+ currentBossAvatar: "",
4423
+ currentHP: baseHp,
4424
+ maxHP: baseHp,
4425
+ currentStageIndex: 0,
4426
+ damageMap,
4427
+ // Concrete selectedEvents means "user has opted in to exactly these
4428
+ // events". Empty arrays would disable everything — only happens if
4429
+ // SE had all listeners off, which mirrors what the imported overlay
4430
+ // should do.
4431
+ selectedEvents,
4432
+ bossHealsOnEventTrigger: v.selfHeal !== false,
4433
+ newBossCommand: "!newboss",
4434
+ eventQueueDelayMs: 3e3,
4435
+ defeatedDelayMs: 3e3,
4436
+ showAvatar: v.showImage !== false,
4437
+ showFloatingNumbers: true,
4438
+ showEventMessages: v.showText !== false,
4439
+ barColor,
4440
+ barBackgroundColor: "#333333",
4441
+ attackColor: "#ff007f",
4442
+ healColor: "#00ff00",
4443
+ // Phase 2 defaults — off by default so imports stay close to
4444
+ // SE's behaviour and don't surprise the streamer with new effects.
4445
+ lastResetAt: 0,
4446
+ playSounds: true,
4447
+ audioVolume: 50,
4448
+ damageSounds: [],
4449
+ healSound: "",
4450
+ newBossSound: "",
4451
+ defeatSound: "",
4452
+ megaBossChance: 0,
4453
+ particleScatter: true,
4454
+ showCrown: true,
4455
+ matchBossChatColor: true,
4456
+ bossNameColor: "",
4457
+ botChat: "off",
4458
+ chatAsSelf: false,
4459
+ // SE-only knobs preserved as provenance for future passes. None
4460
+ // have direct Lumia analogs today:
4461
+ // - `right` anchors the boss to the right edge of its bounds;
4462
+ // Lumia uses layer position instead, so this is informational.
4463
+ // - `bossName` controls vertical name position in SE; our themes
4464
+ // don't expose that level of fine-grained tweaking yet.
4465
+ // - `mode` SE's gameplay mode hint ('overkill', etc.).
4466
+ se_right: !!v.right,
4467
+ se_bossNamePosition: typeof v.bossName === "string" ? v.bossName : null,
4468
+ se_mode: typeof v.mode === "string" ? v.mode : null,
4469
+ se_showName: v.showName !== false
4470
+ },
4471
+ css: {
4472
+ background: "transparent"
4473
+ }
4474
+ });
4475
+ }
4476
+
4175
4477
  // src/se-import/feature-flags.ts
4176
4478
  var SE_IMPORT_FLAGS = {
4177
4479
  // (Removed) IMPORT_CUSTOM_EMBED: custom-event-list now routes to native eventlist
4178
4480
  // by default with a one-click "switch to custom HTML" option in the review step.
4179
4481
  // (Removed) IMPORT_MERCH_GOAL: Fourthwall integration provides session/total order
4180
4482
  // counters now (see fourthwall.manager.ts ORDER_PLACED handler); merch goal maps
4181
- // to {{fourthwall_total_order_count}} via the goal mapper.
4182
- // Merch products rotator Lumia has no Fourthwall product-list API yet. The
4183
- // Fourthwall integration is webhook-driven (events flow in) but doesn't fetch
4184
- // the user's shop catalog. Adding it requires a separate Fourthwall API auth
4185
- // flow + product-fetch endpoint + periodic refresh. Until then a static stub
4186
- // is unhelpful, so users are routed to review for AI/skip/marketplace-when-
4187
- // available replacements.
4188
- IMPORT_MERCH_PRODUCTS: false,
4189
- // Seasonal effects (snow/halloween/xmas/valentine/easter/worldcup). The
4190
- // generic canvas-particle template is approximate, not pixel-accurate vs
4191
- // SE's polished versions. Flagging off so users get the option to grab a
4192
- // AI-generated replacement or a curated marketplace seasonal overlay when one
4193
- // is configured.
4194
- IMPORT_SEASONAL: false
4483
+ // to {{fourthwall_total_*}} via the goal mapper (per-listener: orders → order_count,
4484
+ // items items_count, total order_amount).
4485
+ // (Removed) IMPORT_SEASONAL: seasonal widgets now route per-widget into existing
4486
+ // modules (image / slideshow / video) rather than a single approximate canvas-
4487
+ // particle template. See dispatcher.ts SEASONAL_IMAGE_TYPES comment for the
4488
+ // rationale and /SE/05-widget-to-module-mapping.md §D for the current mapping.
4489
+ // (Removed) IMPORT_MERCH_PRODUCTS: the merch-products-rotator now imports as an
4490
+ // empty slideshow with a friendly layer title prompting the user to add product
4491
+ // images manually. This is a degraded but honest UX — better than a placeholder
4492
+ // text layer for the (rare) streamer who uses SE's merch rotator. A Fourthwall
4493
+ // catalog fetcher would unlock fully-automatic import, but that's a multi-day
4494
+ // project gated on a new Fourthwall API auth scope, an endpoint, and a refresh
4495
+ // cadence.
4195
4496
  };
4196
- function isWidgetFlaggedOff(seType) {
4197
- if (seType === "se-widget-merch-products-rotator") return !SE_IMPORT_FLAGS.IMPORT_MERCH_PRODUCTS;
4198
- if (seType === "se-widget-snow" || seType === "se-widget-halloween" || seType === "se-widget-halloween-2019" || seType === "se-widget-xmas" || seType === "se-widget-valentine" || seType === "se-widget-easter" || seType === "se-widget-world-cup") {
4199
- return !SE_IMPORT_FLAGS.IMPORT_SEASONAL;
4200
- }
4497
+ function isWidgetFlaggedOff(_seType) {
4201
4498
  return false;
4202
4499
  }
4203
- var FLAG_OFF_REASONS = {
4204
- "se-widget-merch-products-rotator": "Merch products rotator \u2014 Lumia tracks Fourthwall orders but doesn't fetch the full product catalog yet. Generate a replacement with AI, skip it, or use Marketplace when a curated replacement is configured.",
4205
- "se-widget-snow": "Seasonal effect \u2014 the generic auto-import template is approximate. Generate a replacement with AI, skip it, or use Marketplace when a curated version is configured.",
4206
- "se-widget-halloween": "Seasonal effect \u2014 the generic auto-import template is approximate. Generate a replacement with AI, skip it, or use Marketplace when a curated version is configured.",
4207
- "se-widget-halloween-2019": "Seasonal effect \u2014 the generic auto-import template is approximate. Generate a replacement with AI, skip it, or use Marketplace when a curated version is configured.",
4208
- "se-widget-xmas": "Seasonal effect \u2014 the generic auto-import template is approximate. Generate a replacement with AI, skip it, or use Marketplace when a curated version is configured.",
4209
- "se-widget-valentine": "Seasonal effect \u2014 the generic auto-import template is approximate. Generate a replacement with AI, skip it, or use Marketplace when a curated version is configured.",
4210
- "se-widget-easter": "Seasonal effect \u2014 the generic auto-import template is approximate. Generate a replacement with AI, skip it, or use Marketplace when a curated version is configured.",
4211
- "se-widget-world-cup": "Seasonal effect \u2014 the generic auto-import template is approximate. Generate a replacement with AI, skip it, or use Marketplace when a curated version is configured."
4212
- };
4500
+ var FLAG_OFF_REASONS = {};
4213
4501
 
4214
4502
  // src/se-import/dispatcher.ts
4215
4503
  var READOUT_TYPES = {
@@ -4238,15 +4526,7 @@ var READOUT_TYPES = {
4238
4526
  // twitch_session_chat_count, so we render it as a text layer with the variable inline.
4239
4527
  "se-widget-botcounter": "twitch_session_chat_count"
4240
4528
  };
4241
- var SEASONAL_TYPES = {
4242
- "se-widget-snow": "snow",
4243
- "se-widget-halloween": "halloween",
4244
- "se-widget-halloween-2019": "halloween",
4245
- "se-widget-xmas": "xmas",
4246
- "se-widget-valentine": "valentine",
4247
- "se-widget-easter": "easter",
4248
- "se-widget-world-cup": "worldcup"
4249
- };
4529
+ var SEASONAL_IMAGE_TYPES = /* @__PURE__ */ new Set(["se-widget-halloween", "se-widget-xmas", "se-widget-valentine", "se-widget-easter", "se-widget-world-cup"]);
4250
4530
  var GOAL_TYPES = /* @__PURE__ */ new Set([
4251
4531
  "se-widget-follower-goal",
4252
4532
  "se-widget-subscriber-goal",
@@ -4287,10 +4567,17 @@ function dispatch(widget) {
4287
4567
  if (t === "se-widget-current-song") return { unit: mapNowPlaying(widget), status: "partial", lumiaType: "nowplaying" };
4288
4568
  if (t === "se-widget-kappagen") return { unit: mapKappagen(widget), status: "partial", lumiaType: "emotealert" };
4289
4569
  if (t === "se-widget-giveaway") return { unit: mapGiveaway(widget), status: "direct", lumiaType: "raffle" };
4290
- if (t === "se-widget-custom-event-list") return { unit: mapCustom(widget), status: "partial", lumiaType: "custom" };
4291
- if (t in SEASONAL_TYPES) {
4292
- return { unit: mapSeasonal(widget, SEASONAL_TYPES[t]), status: "direct", lumiaType: "custom" };
4570
+ if (t === "se-widget-media-share") return { unit: mapMediaShare(widget), status: "partial", lumiaType: "songrequest" };
4571
+ if (t === "se-widget-hype-cup") return { unit: mapHypeCup(widget), status: "partial", lumiaType: "tipjar" };
4572
+ if (t === "se-widget-bit-boss") return { unit: mapStreamBoss(widget), status: "partial", lumiaType: "streamboss" };
4573
+ if (t === "se-widget-hype-train") return { unit: mapHypetrain(widget), status: "partial", lumiaType: "hypetrain" };
4574
+ if (t === "se-widget-custom-event-list" && isHypetrainCustomWidget(widget)) {
4575
+ return { unit: mapHypetrain(widget), status: "partial", lumiaType: "hypetrain" };
4293
4576
  }
4577
+ if (t === "se-widget-custom-event-list") return { unit: mapCustom(widget), status: "partial", lumiaType: "custom" };
4578
+ if (t === "se-widget-snow") return { unit: mapSnow(widget), status: "direct", lumiaType: "video" };
4579
+ if (t === "se-widget-halloween-2019") return { unit: mapSlideshow(widget), status: "direct", lumiaType: "slideshow" };
4580
+ if (SEASONAL_IMAGE_TYPES.has(t)) return { unit: mapImage(widget), status: "direct", lumiaType: "image" };
4294
4581
  return { unit: mapUnsupportedAsText(widget), status: "placeholder", lumiaType: "text" };
4295
4582
  }
4296
4583
  function recordCoverage(coverage, result, seType) {
@@ -4477,7 +4764,10 @@ var SE_WIDGET_TO_MARKETPLACE_CANDIDATES = {
4477
4764
  // value for marketplace replacements since AI generation rarely produces
4478
4765
  // satisfying results for these.
4479
4766
  "se-widget-hype-cup": [],
4480
- "se-widget-bit-boss": [],
4767
+ // Note: `se-widget-bit-boss` is auto-mapped onto the native `streamboss`
4768
+ // module by the dispatcher and no longer needs a marketplace fallback.
4769
+ // Re-add a key here if a community curated boss overlay later supersedes
4770
+ // the native module for specific use cases.
4481
4771
  "se-widget-train": [],
4482
4772
  // Medium-difficulty widgets — possible to AI-generate but a curated overlay
4483
4773
  // will usually look better.
@@ -4487,6 +4777,15 @@ var SE_WIDGET_TO_MARKETPLACE_CANDIDATES = {
4487
4777
  // have curated replacements for.
4488
4778
  "se-widget-custom-event-list": [],
4489
4779
  "se-widget-botcounter": [],
4780
+ // Hype Train — the dispatcher already auto-routes `se-widget-hype-train` and
4781
+ // hypetrain-only custom-event-lists onto the native `hypetrain` module. This
4782
+ // entry stays here for two cases the auto-router can't catch:
4783
+ // - Heavily customized hype-train custom widgets where the native module
4784
+ // would lose too much visual fidelity (custom locomotive art, themed
4785
+ // particles, etc.) and the streamer would prefer a curated alternative.
4786
+ // - Community curated overlays that pair the native hypetrain with extra
4787
+ // decorative layers (e.g. a "depot" background, station signage).
4788
+ "se-widget-hype-train": [],
4490
4789
  // Flagged-off in feature-flags.ts — the auto-import is disabled because
4491
4790
  // Lumia has no merch data source. Marketplace overlays here would be
4492
4791
  // curated static merch-style goals/sliders that don't depend on a feed.
@@ -4619,6 +4918,13 @@ async function fetchUsableChannels(client) {
4619
4918
  const me = await client.get("/kappa/v2/users/current");
4620
4919
  return (me.channels ?? []).filter((c) => c.authorized !== false && c.inactive !== true);
4621
4920
  }
4921
+ async function fetchSEOverlays(client, count = 200) {
4922
+ const res = await client.get(`/kappa/v2/overlays/${client.channelId}?count=${count}`);
4923
+ return res.docs ?? [];
4924
+ }
4925
+ async function fetchSEBootstrapForOverlay(client, overlayId) {
4926
+ return client.get(`/kappa/v2/overlays/${overlayId}/bootstrap?isEditor=false&isMobile=false&isObs=false&isObsLive=false&isXsplit=false`);
4927
+ }
4622
4928
 
4623
4929
  // src/se-import/ui/SEImportWizard.tsx
4624
4930
  import { useCallback as useCallback3, useEffect as useEffect6, useMemo as useMemo5, useRef as useRef4, useState as useState6 } from "react";
@@ -4780,6 +5086,7 @@ var imageIcon = new URL("../../assets/img.svg", import.meta.url).href;
4780
5086
  var videoIcon = new URL("../../assets/video.svg", import.meta.url).href;
4781
5087
  var CORE_STEPS = [
4782
5088
  { key: "connect", label: "Connect" },
5089
+ { key: "pick", label: "Overlays" },
4783
5090
  { key: "url", label: "Load" },
4784
5091
  { key: "discovery", label: "Discovery" },
4785
5092
  { key: "options", label: "Options" },
@@ -4932,6 +5239,136 @@ function StepHeader({
4932
5239
  ] })
4933
5240
  ] });
4934
5241
  }
5242
+ var JWT_FEATURES = [
5243
+ "All your overlays \u2014 pick one, many, or all",
5244
+ "Live counters (follower / sub / tip / bits goals)",
5245
+ "Chat bot commands",
5246
+ "Loyalty points + watch time",
5247
+ "Recent activity replay",
5248
+ "Bits leaderboard"
5249
+ ];
5250
+ function ModeCard({
5251
+ primary,
5252
+ title,
5253
+ body,
5254
+ caveat,
5255
+ onClick
5256
+ }) {
5257
+ return /* @__PURE__ */ jsxs7(
5258
+ "button",
5259
+ {
5260
+ type: "button",
5261
+ onClick,
5262
+ style: {
5263
+ display: "flex",
5264
+ flexDirection: "column",
5265
+ alignItems: "flex-start",
5266
+ gap: 14,
5267
+ padding: 24,
5268
+ minHeight: 280,
5269
+ borderRadius: 14,
5270
+ border: primary ? "2px solid var(--se-primary)" : "1px solid var(--se-border)",
5271
+ background: primary ? "rgba(255, 64, 118, 0.08)" : "transparent",
5272
+ color: "var(--se-text)",
5273
+ textAlign: "left",
5274
+ cursor: "pointer",
5275
+ transition: "background 120ms, border-color 120ms, transform 120ms"
5276
+ },
5277
+ onMouseEnter: (e) => {
5278
+ e.currentTarget.style.background = primary ? "rgba(255, 64, 118, 0.14)" : "rgba(255, 255, 255, 0.04)";
5279
+ if (!primary)
5280
+ e.currentTarget.style.borderColor = "var(--se-border-strong)";
5281
+ e.currentTarget.style.transform = "translateY(-2px)";
5282
+ },
5283
+ onMouseLeave: (e) => {
5284
+ e.currentTarget.style.background = primary ? "rgba(255, 64, 118, 0.08)" : "transparent";
5285
+ if (!primary)
5286
+ e.currentTarget.style.borderColor = "var(--se-border)";
5287
+ e.currentTarget.style.transform = "translateY(0)";
5288
+ },
5289
+ children: [
5290
+ /* @__PURE__ */ jsx13(
5291
+ "div",
5292
+ {
5293
+ style: {
5294
+ fontSize: 18,
5295
+ fontWeight: 800,
5296
+ color: primary ? "var(--se-primary)" : "var(--se-text)"
5297
+ },
5298
+ children: title
5299
+ }
5300
+ ),
5301
+ /* @__PURE__ */ jsx13("div", { style: { fontSize: 13.5, lineHeight: 1.55, color: "var(--se-text)", width: "100%" }, children: body }),
5302
+ /* @__PURE__ */ jsx13("div", { style: { fontSize: 12, color: "var(--se-muted)", marginTop: "auto" }, children: caveat })
5303
+ ]
5304
+ }
5305
+ );
5306
+ }
5307
+ function StepModePicker({
5308
+ onChooseJwt,
5309
+ onChooseUrl
5310
+ }) {
5311
+ return /* @__PURE__ */ jsxs7("div", { className: panelClass("se-import-panel--wide"), children: [
5312
+ /* @__PURE__ */ jsx13(
5313
+ StepHeader,
5314
+ {
5315
+ number: 1,
5316
+ title: "What would you like to import?",
5317
+ subtitle: "Pick a starting point. You can change your mind from the Back button on the next step."
5318
+ }
5319
+ ),
5320
+ /* @__PURE__ */ jsxs7(
5321
+ "div",
5322
+ {
5323
+ style: {
5324
+ display: "grid",
5325
+ gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
5326
+ gap: 18,
5327
+ marginTop: 8
5328
+ },
5329
+ children: [
5330
+ /* @__PURE__ */ jsx13(
5331
+ ModeCard,
5332
+ {
5333
+ primary: true,
5334
+ title: "Import everything from StreamElements",
5335
+ body: /* @__PURE__ */ jsxs7(Fragment3, { children: [
5336
+ /* @__PURE__ */ jsx13("div", { style: { marginBottom: 10 }, children: "Connect once and bring over your entire SE setup:" }),
5337
+ /* @__PURE__ */ jsx13(
5338
+ "ul",
5339
+ {
5340
+ style: {
5341
+ margin: 0,
5342
+ paddingLeft: 18,
5343
+ display: "grid",
5344
+ gap: 6,
5345
+ fontSize: 13
5346
+ },
5347
+ children: JWT_FEATURES.map((feat) => /* @__PURE__ */ jsx13("li", { children: feat }, feat))
5348
+ }
5349
+ )
5350
+ ] }),
5351
+ caveat: "Requires pasting your StreamElements JWT. Stored only in memory \u2014 never sent to Lumia.",
5352
+ onClick: onChooseJwt
5353
+ }
5354
+ ),
5355
+ /* @__PURE__ */ jsx13(
5356
+ ModeCard,
5357
+ {
5358
+ title: "Import a single overlay",
5359
+ body: /* @__PURE__ */ jsxs7(Fragment3, { children: [
5360
+ "Paste a single overlay's public preview URL to bring just that overlay over.",
5361
+ /* @__PURE__ */ jsx13("div", { style: { marginTop: 10, color: "var(--se-muted)" }, children: "No login required \u2014 perfect for trying one overlay before deciding whether to migrate your whole account." })
5362
+ ] }),
5363
+ caveat: "The URL contains the access token; treat it like a password.",
5364
+ onClick: onChooseUrl
5365
+ }
5366
+ )
5367
+ ]
5368
+ }
5369
+ )
5370
+ ] });
5371
+ }
4935
5372
  function StepConnect({
4936
5373
  jwt,
4937
5374
  setJwt,
@@ -5057,6 +5494,140 @@ function StepConnect({
5057
5494
  error && /* @__PURE__ */ jsx13(ErrorPanel, { message: error.message, hint: error.hint })
5058
5495
  ] });
5059
5496
  }
5497
+ function StepPickOverlay({
5498
+ overlays,
5499
+ loading,
5500
+ error,
5501
+ selectedIds,
5502
+ toggleId,
5503
+ selectAll,
5504
+ clearAll,
5505
+ onRetry
5506
+ }) {
5507
+ const selectedCount = selectedIds.size;
5508
+ const total = overlays?.length ?? 0;
5509
+ const allSelected = total > 0 && selectedCount === total;
5510
+ const hasAnySelected = selectedCount > 0;
5511
+ return /* @__PURE__ */ jsxs7("div", { className: panelClass("se-import-panel--wide"), children: [
5512
+ /* @__PURE__ */ jsx13(
5513
+ StepHeader,
5514
+ {
5515
+ number: 2,
5516
+ title: "Pick the overlays to import",
5517
+ subtitle: `Select one or more StreamElements overlays from your account. We'll run each one through the discovery \u2192 review \u2192 import pipeline in sequence. ${total > 0 ? `${total} overlay${total === 1 ? "" : "s"} on this channel.` : ""}`
5518
+ }
5519
+ ),
5520
+ loading && /* @__PURE__ */ jsxs7("div", { className: "se-import-help se-import-help--info", children: [
5521
+ /* @__PURE__ */ jsx13("div", { className: "se-import-help__icon", children: "\u2026" }),
5522
+ /* @__PURE__ */ jsx13("div", { children: "Fetching your StreamElements overlays." })
5523
+ ] }),
5524
+ error && /* @__PURE__ */ jsxs7(Fragment3, { children: [
5525
+ /* @__PURE__ */ jsx13(ErrorPanel, { message: error.message, hint: error.hint }),
5526
+ /* @__PURE__ */ jsx13("div", { style: { marginTop: 12 }, children: /* @__PURE__ */ jsx13(LSButton, { type: "button", color: "secondary", onClick: onRetry, label: "Retry" }) })
5527
+ ] }),
5528
+ !loading && !error && overlays && overlays.length === 0 && /* @__PURE__ */ jsxs7("div", { className: "se-import-help se-import-help--info", children: [
5529
+ /* @__PURE__ */ jsx13("div", { className: "se-import-help__icon", children: "i" }),
5530
+ /* @__PURE__ */ jsxs7("div", { children: [
5531
+ /* @__PURE__ */ jsx13("div", { className: "se-import-help__title", children: "No overlays found" }),
5532
+ /* @__PURE__ */ jsx13("p", { children: "This StreamElements account doesn't have any overlays yet. Create one in the SE dashboard, or use the legacy preview URL flow from the Connect step." })
5533
+ ] })
5534
+ ] }),
5535
+ !loading && !error && overlays && overlays.length > 0 && /* @__PURE__ */ jsxs7(Fragment3, { children: [
5536
+ /* @__PURE__ */ jsxs7("div", { className: "se-import-actions se-import-actions--inline", style: { marginTop: 24, marginBottom: 16, justifyContent: "space-between", alignItems: "center", display: "flex" }, children: [
5537
+ /* @__PURE__ */ jsxs7("label", { style: { display: "inline-flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 14, fontWeight: 600 }, children: [
5538
+ /* @__PURE__ */ jsx13(
5539
+ "input",
5540
+ {
5541
+ type: "checkbox",
5542
+ checked: allSelected,
5543
+ ref: (el) => {
5544
+ if (el) el.indeterminate = !allSelected && hasAnySelected;
5545
+ },
5546
+ onChange: () => allSelected ? clearAll() : selectAll()
5547
+ }
5548
+ ),
5549
+ /* @__PURE__ */ jsxs7("span", { children: [
5550
+ allSelected ? "Deselect all" : "Select all",
5551
+ " (",
5552
+ selectedCount,
5553
+ "/",
5554
+ total,
5555
+ ")"
5556
+ ] })
5557
+ ] }),
5558
+ /* @__PURE__ */ jsx13("span", { style: { fontSize: 13, color: "var(--se-muted)" }, children: hasAnySelected ? `${selectedCount} overlay${selectedCount === 1 ? "" : "s"} queued. Each runs through the full import flow in sequence.` : "Click overlays below to select them." })
5559
+ ] }),
5560
+ /* @__PURE__ */ jsx13(
5561
+ "div",
5562
+ {
5563
+ className: "se-import-grid",
5564
+ style: {
5565
+ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
5566
+ gap: 12,
5567
+ maxHeight: 480,
5568
+ overflowY: "auto",
5569
+ padding: 4
5570
+ },
5571
+ children: overlays.map((o) => {
5572
+ const selected = selectedIds.has(o._id);
5573
+ return /* @__PURE__ */ jsxs7(
5574
+ "label",
5575
+ {
5576
+ style: {
5577
+ position: "relative",
5578
+ display: "flex",
5579
+ flexDirection: "column",
5580
+ border: selected ? "2px solid var(--se-primary)" : "1px solid var(--se-border)",
5581
+ borderRadius: 10,
5582
+ padding: 10,
5583
+ background: selected ? "rgba(255, 64, 118, 0.08)" : "rgba(28, 32, 66, 0.45)",
5584
+ cursor: "pointer",
5585
+ transition: "border-color 120ms, background 120ms"
5586
+ },
5587
+ children: [
5588
+ /* @__PURE__ */ jsx13(
5589
+ "input",
5590
+ {
5591
+ type: "checkbox",
5592
+ checked: selected,
5593
+ onChange: () => toggleId(o._id),
5594
+ style: { position: "absolute", top: 10, right: 10, accentColor: "var(--se-primary)", zIndex: 1 }
5595
+ }
5596
+ ),
5597
+ o.preview ? /* @__PURE__ */ jsx13(
5598
+ "img",
5599
+ {
5600
+ src: o.preview,
5601
+ alt: "",
5602
+ style: {
5603
+ width: "100%",
5604
+ height: 110,
5605
+ objectFit: "cover",
5606
+ borderRadius: 6,
5607
+ background: "var(--se-panel-2)"
5608
+ },
5609
+ loading: "lazy"
5610
+ }
5611
+ ) : /* @__PURE__ */ jsx13("div", { style: { width: "100%", height: 110, borderRadius: 6, background: "var(--se-panel-2)" } }),
5612
+ /* @__PURE__ */ jsx13("div", { style: { marginTop: 8, fontSize: 14, fontWeight: 700, color: "var(--se-text)", paddingRight: 28 }, children: o.name ?? "Unnamed overlay" }),
5613
+ /* @__PURE__ */ jsxs7("div", { style: { marginTop: 4, fontSize: 11, color: "var(--se-muted)" }, children: [
5614
+ o.type ?? "regular",
5615
+ o.settings?.width && o.settings?.height ? ` \xB7 ${o.settings.width}\xD7${o.settings.height}` : ""
5616
+ ] }),
5617
+ o.updatedAt && /* @__PURE__ */ jsxs7("div", { style: { marginTop: 2, fontSize: 11, color: "var(--se-muted)" }, children: [
5618
+ "Updated ",
5619
+ new Date(o.updatedAt).toLocaleDateString()
5620
+ ] })
5621
+ ]
5622
+ },
5623
+ o._id
5624
+ );
5625
+ })
5626
+ }
5627
+ )
5628
+ ] })
5629
+ ] });
5630
+ }
5060
5631
  function StepURL({
5061
5632
  url,
5062
5633
  setUrl,
@@ -5077,7 +5648,7 @@ function StepURL({
5077
5648
  {
5078
5649
  label: "Overlay URL",
5079
5650
  value: url,
5080
- onChange: setUrl,
5651
+ onChange: (_, next) => setUrl(String(next ?? "")),
5081
5652
  placeholder: "https://streamelements.com/overlay/<id>/<token>",
5082
5653
  disabled: busy
5083
5654
  }
@@ -5554,13 +6125,23 @@ function SEImportWizard({
5554
6125
  t
5555
6126
  } = bindings;
5556
6127
  const translate = (key, fallback) => t ? t(key) ?? fallback : fallback;
5557
- const [step, setStep] = useState6("connect");
6128
+ const [step, setStep] = useState6(initialUrl ? "url" : "mode");
6129
+ const [entryMode, setEntryMode] = useState6(
6130
+ initialUrl ? "url" : null
6131
+ );
5558
6132
  const [jwt, setJwt] = useState6("");
5559
6133
  const [seClient, setSeClient] = useState6(null);
5560
6134
  const [channels, setChannels] = useState6(null);
5561
6135
  const [selectedChannelId, setSelectedChannelId] = useState6(null);
5562
6136
  const [connectError, setConnectError] = useState6(null);
5563
6137
  const [connecting, setConnecting] = useState6(false);
6138
+ const [overlayList, setOverlayList] = useState6(null);
6139
+ const [overlaysLoading, setOverlaysLoading] = useState6(false);
6140
+ const [overlaysError, setOverlaysError] = useState6(null);
6141
+ const [selectedOverlayIds, setSelectedOverlayIds] = useState6(/* @__PURE__ */ new Set());
6142
+ const [overlayQueue, setOverlayQueue] = useState6([]);
6143
+ const [currentOverlayIndex, setCurrentOverlayIndex] = useState6(0);
6144
+ const [totalOverlaysToImport, setTotalOverlaysToImport] = useState6(0);
5564
6145
  const [url, setUrl] = useState6(initialUrl);
5565
6146
  const [loadError, setLoadError] = useState6(null);
5566
6147
  const [loading, setLoading] = useState6(false);
@@ -5630,6 +6211,73 @@ function SEImportWizard({
5630
6211
  setConnecting(false);
5631
6212
  }
5632
6213
  }, [bindings, jwt]);
6214
+ const loadOverlayList = useCallback3(async (client) => {
6215
+ setOverlaysError(null);
6216
+ setOverlaysLoading(true);
6217
+ try {
6218
+ const list = await fetchSEOverlays(client);
6219
+ setOverlayList(list);
6220
+ } catch (e) {
6221
+ if (e instanceof SEAuthError) {
6222
+ setOverlaysError({ message: e.message, hint: e.code === "unauthorized" ? "Generate a new JWT on streamelements.com/dashboard/account/channels." : void 0 });
6223
+ bindings.onAuthError?.(e);
6224
+ } else {
6225
+ setOverlaysError({ message: e instanceof Error ? e.message : "Could not load your StreamElements overlays." });
6226
+ }
6227
+ } finally {
6228
+ setOverlaysLoading(false);
6229
+ }
6230
+ }, [bindings]);
6231
+ const toggleOverlaySelection = useCallback3((id) => {
6232
+ setSelectedOverlayIds((prev) => {
6233
+ const next = new Set(prev);
6234
+ if (next.has(id)) next.delete(id);
6235
+ else next.add(id);
6236
+ return next;
6237
+ });
6238
+ }, []);
6239
+ const selectAllOverlays = useCallback3(() => {
6240
+ setSelectedOverlayIds(new Set((overlayList ?? []).map((o) => o._id)));
6241
+ }, [overlayList]);
6242
+ const clearAllOverlays = useCallback3(() => {
6243
+ setSelectedOverlayIds(/* @__PURE__ */ new Set());
6244
+ }, []);
6245
+ const loadBootstrapAndKickoff = useCallback3(async (client, overlayId) => {
6246
+ setLoadError(null);
6247
+ setLoading(true);
6248
+ try {
6249
+ const bootstrap = await fetchSEBootstrapForOverlay(client, overlayId);
6250
+ if (!isSEBootstrap(bootstrap)) {
6251
+ throw new Error("StreamElements returned a payload that doesn't look like an overlay bootstrap.");
6252
+ }
6253
+ const imported = importSEBootstrap(bootstrap);
6254
+ setResult(imported);
6255
+ setOptions((o) => ({ ...o, name: imported.overlay.name }));
6256
+ setOriginalReviewItems(imported.reviewItems);
6257
+ setRowStates(Object.fromEntries(imported.reviewItems.map((r) => [r.moduleId, { state: "pending" }])));
6258
+ setReviewIndex(0);
6259
+ setMirrorRows([]);
6260
+ mirrorStartedRef.current = false;
6261
+ setStep("discovery");
6262
+ } catch (e) {
6263
+ setLoadError({
6264
+ message: "Could not load the overlay.",
6265
+ hint: e?.message ?? String(e)
6266
+ });
6267
+ } finally {
6268
+ setLoading(false);
6269
+ }
6270
+ }, []);
6271
+ const handlePickConfirm = useCallback3(async () => {
6272
+ if (!seClient) return;
6273
+ const ids = Array.from(selectedOverlayIds);
6274
+ if (ids.length === 0) return;
6275
+ const [first, ...rest] = ids;
6276
+ setOverlayQueue(rest);
6277
+ setTotalOverlaysToImport(ids.length);
6278
+ setCurrentOverlayIndex(1);
6279
+ await loadBootstrapAndKickoff(seClient, first);
6280
+ }, [seClient, selectedOverlayIds, loadBootstrapAndKickoff]);
5633
6281
  const handleLoad = useCallback3(async () => {
5634
6282
  setLoadError(null);
5635
6283
  const parts = extractSEPreviewParts(url);
@@ -5765,6 +6413,11 @@ function SEImportWizard({
5765
6413
  void runMirror();
5766
6414
  }
5767
6415
  }, [step, runMirror]);
6416
+ useEffect6(() => {
6417
+ if (step === "pick" && seClient && overlayList === null && !overlaysLoading) {
6418
+ void loadOverlayList(seClient);
6419
+ }
6420
+ }, [step, seClient, overlayList, overlaysLoading, loadOverlayList]);
5768
6421
  const advanceReview = useCallback3(() => {
5769
6422
  if (reviewIndex + 1 < originalReviewItems.length)
5770
6423
  setReviewIndex(reviewIndex + 1);
@@ -5898,7 +6551,23 @@ function SEImportWizard({
5898
6551
  setStep("confirm");
5899
6552
  };
5900
6553
  const handleFinalImport = useCallback3(async () => {
5901
- if (!result) return;
6554
+ if (!result || !seClient) {
6555
+ if (!result) return;
6556
+ setImporting(true);
6557
+ try {
6558
+ const body = { name: options.name.trim() || result.overlay.name, description: result.overlay.description, settings: result.overlay.settings };
6559
+ const { uuid } = await saveOverlay(body);
6560
+ notify.success("Imported overlay from StreamElements.");
6561
+ mirrorAbortRef.current?.abort();
6562
+ onClose();
6563
+ if (uuid) onComplete({ overlayId: uuid });
6564
+ } catch (error) {
6565
+ notify.error(`Import failed: ${extractErrorMessage(error)}`);
6566
+ } finally {
6567
+ setImporting(false);
6568
+ }
6569
+ return;
6570
+ }
5902
6571
  setImporting(true);
5903
6572
  try {
5904
6573
  const body = {
@@ -5907,7 +6576,19 @@ function SEImportWizard({
5907
6576
  settings: result.overlay.settings
5908
6577
  };
5909
6578
  const { uuid } = await saveOverlay(body);
5910
- notify.success("Imported overlay from StreamElements.");
6579
+ const hasMore = overlayQueue.length > 0;
6580
+ if (hasMore) {
6581
+ notify.success(`Imported ${currentOverlayIndex} of ${totalOverlaysToImport} overlays from StreamElements.`);
6582
+ const [nextId, ...rest] = overlayQueue;
6583
+ setOverlayQueue(rest);
6584
+ setCurrentOverlayIndex(currentOverlayIndex + 1);
6585
+ setImporting(false);
6586
+ await loadBootstrapAndKickoff(seClient, nextId);
6587
+ return;
6588
+ }
6589
+ notify.success(
6590
+ totalOverlaysToImport > 1 ? `Imported all ${totalOverlaysToImport} overlays from StreamElements.` : "Imported overlay from StreamElements."
6591
+ );
5911
6592
  mirrorAbortRef.current?.abort();
5912
6593
  onClose();
5913
6594
  if (uuid) onComplete({ overlayId: uuid });
@@ -5916,12 +6597,32 @@ function SEImportWizard({
5916
6597
  } finally {
5917
6598
  setImporting(false);
5918
6599
  }
5919
- }, [notify, onClose, onComplete, options.name, result, saveOverlay]);
6600
+ }, [
6601
+ notify,
6602
+ onClose,
6603
+ onComplete,
6604
+ options.name,
6605
+ result,
6606
+ saveOverlay,
6607
+ seClient,
6608
+ overlayQueue,
6609
+ currentOverlayIndex,
6610
+ totalOverlaysToImport,
6611
+ loadBootstrapAndKickoff
6612
+ ]);
5920
6613
  const visibleSteps = useMemo5(() => {
5921
6614
  const showMirrorStep = options.mirrorAssets && (assetUrls.length > 0 || mirrorRows.length > 0 || step === "mirror");
5922
- const steps = CORE_STEPS.filter(
5923
- (item) => item.key !== "mirror" || showMirrorStep
5924
- );
6615
+ if (step === "mode") return [];
6616
+ const onUrlFlow = entryMode === "url" || step === "url";
6617
+ const onJwtFlow = !onUrlFlow;
6618
+ const steps = CORE_STEPS.filter((item) => {
6619
+ if (item.key === "mode") return false;
6620
+ if (item.key === "mirror" && !showMirrorStep) return false;
6621
+ if (item.key === "connect" && !onJwtFlow) return false;
6622
+ if (item.key === "pick" && !onJwtFlow) return false;
6623
+ if (item.key === "url" && !onUrlFlow) return false;
6624
+ return true;
6625
+ });
5925
6626
  if (originalReviewItems.length > 0)
5926
6627
  steps.splice(Math.max(steps.length - 1, 0), 0, {
5927
6628
  key: "review",
@@ -5930,9 +6631,11 @@ function SEImportWizard({
5930
6631
  return steps;
5931
6632
  }, [
5932
6633
  assetUrls.length,
6634
+ entryMode,
5933
6635
  mirrorRows.length,
5934
6636
  options.mirrorAssets,
5935
6637
  originalReviewItems.length,
6638
+ seClient,
5936
6639
  step
5937
6640
  ]);
5938
6641
  const goBack = () => {
@@ -5941,14 +6644,22 @@ function SEImportWizard({
5941
6644
  setReviewIndex(reviewIndex - 1);
5942
6645
  return;
5943
6646
  }
6647
+ if (currentIndex === 0 && (step === "connect" || step === "url")) {
6648
+ setStep("mode");
6649
+ setEntryMode(null);
6650
+ return;
6651
+ }
5944
6652
  if (currentIndex > 0) setStep(visibleSteps[currentIndex - 1].key);
5945
6653
  };
5946
6654
  const goNext = () => {
5947
6655
  if (step === "connect") {
5948
6656
  if (!seClient) return void handleConnect();
5949
- setStep("url");
6657
+ setStep("pick");
5950
6658
  return;
5951
6659
  }
6660
+ if (step === "pick") {
6661
+ return void handlePickConfirm();
6662
+ }
5952
6663
  if (step === "url") return void handleLoad();
5953
6664
  if (step === "review") {
5954
6665
  if (!currentItem) return;
@@ -5971,6 +6682,11 @@ function SEImportWizard({
5971
6682
  const nextLabel = (() => {
5972
6683
  if (step === "connect")
5973
6684
  return connecting ? "Connecting..." : seClient ? "Continue" : "Connect";
6685
+ if (step === "pick") {
6686
+ if (loading) return "Loading overlay...";
6687
+ const n = selectedOverlayIds.size;
6688
+ return n > 1 ? `Import ${n} overlays` : "Import overlay";
6689
+ }
5974
6690
  if (step === "url") return loading ? "Loading..." : "Load overlay";
5975
6691
  if (step === "mirror") return mirrorRunning ? "Working..." : "Continue";
5976
6692
  if (step === "review")
@@ -5982,6 +6698,8 @@ function SEImportWizard({
5982
6698
  const nextDisabled = (() => {
5983
6699
  if (step === "connect")
5984
6700
  return connecting || !seClient && !jwt.trim();
6701
+ if (step === "pick")
6702
+ return loading || overlaysLoading || selectedOverlayIds.size === 0;
5985
6703
  if (step === "url") return loading || !url.trim();
5986
6704
  if (step === "mirror") return mirrorRunning;
5987
6705
  if (step === "review")
@@ -5990,8 +6708,34 @@ function SEImportWizard({
5990
6708
  return false;
5991
6709
  })();
5992
6710
  const activeIndex = visibleSteps.findIndex((item) => item.key === step);
6711
+ const batchActive = totalOverlaysToImport > 1 && step !== "mode" && step !== "pick" && step !== "connect" && step !== "url";
6712
+ const currentOverlayName = overlayList?.find((o) => o._id === Array.from(selectedOverlayIds).filter((id) => !overlayQueue.includes(id))[currentOverlayIndex - 1])?.name;
5993
6713
  return /* @__PURE__ */ jsx13("div", { className: "se-import", children: /* @__PURE__ */ jsxs7("div", { className: "se-import__shell", children: [
5994
- /* @__PURE__ */ jsx13("header", { className: "se-import__header", children: /* @__PURE__ */ jsx13("div", { className: "se-import-stepper", children: visibleSteps.map((item, index) => /* @__PURE__ */ jsxs7(
6714
+ batchActive && /* @__PURE__ */ jsxs7(
6715
+ "div",
6716
+ {
6717
+ style: {
6718
+ marginBottom: 16,
6719
+ padding: "10px 16px",
6720
+ border: "1px solid var(--se-border-strong)",
6721
+ borderRadius: 8,
6722
+ background: "rgba(255, 64, 118, 0.08)",
6723
+ color: "var(--se-text)",
6724
+ fontSize: 13,
6725
+ fontWeight: 600,
6726
+ textAlign: "center"
6727
+ },
6728
+ children: [
6729
+ "Importing overlay ",
6730
+ currentOverlayIndex,
6731
+ " of ",
6732
+ totalOverlaysToImport,
6733
+ currentOverlayName ? ` \xB7 ${currentOverlayName}` : "",
6734
+ overlayQueue.length > 0 ? ` \xB7 ${overlayQueue.length} more queued` : ""
6735
+ ]
6736
+ }
6737
+ ),
6738
+ visibleSteps.length > 0 && /* @__PURE__ */ jsx13("header", { className: "se-import__header", children: /* @__PURE__ */ jsx13("div", { className: "se-import-stepper", children: visibleSteps.map((item, index) => /* @__PURE__ */ jsxs7(
5995
6739
  "div",
5996
6740
  {
5997
6741
  className: stepClass(
@@ -6007,6 +6751,19 @@ function SEImportWizard({
6007
6751
  item.key
6008
6752
  )) }) }),
6009
6753
  /* @__PURE__ */ jsxs7("main", { className: "se-import__content", children: [
6754
+ step === "mode" && /* @__PURE__ */ jsx13(
6755
+ StepModePicker,
6756
+ {
6757
+ onChooseJwt: () => {
6758
+ setEntryMode("jwt");
6759
+ setStep("connect");
6760
+ },
6761
+ onChooseUrl: () => {
6762
+ setEntryMode("url");
6763
+ setStep("url");
6764
+ }
6765
+ }
6766
+ ),
6010
6767
  step === "connect" && /* @__PURE__ */ jsx13(
6011
6768
  StepConnect,
6012
6769
  {
@@ -6017,7 +6774,23 @@ function SEImportWizard({
6017
6774
  channels,
6018
6775
  selectedChannelId,
6019
6776
  setSelectedChannelId,
6020
- onUseUrl: () => setStep("url")
6777
+ onUseUrl: () => {
6778
+ setEntryMode("url");
6779
+ setStep("url");
6780
+ }
6781
+ }
6782
+ ),
6783
+ step === "pick" && /* @__PURE__ */ jsx13(
6784
+ StepPickOverlay,
6785
+ {
6786
+ overlays: overlayList,
6787
+ loading: overlaysLoading,
6788
+ error: overlaysError,
6789
+ selectedIds: selectedOverlayIds,
6790
+ toggleId: toggleOverlaySelection,
6791
+ selectAll: selectAllOverlays,
6792
+ clearAll: clearAllOverlays,
6793
+ onRetry: () => seClient && void loadOverlayList(seClient)
6021
6794
  }
6022
6795
  ),
6023
6796
  step === "url" && /* @__PURE__ */ jsx13(
@@ -6109,7 +6882,7 @@ function SEImportWizard({
6109
6882
  label: translate("action.cancel", "Cancel")
6110
6883
  }
6111
6884
  ),
6112
- activeIndex > 0 && /* @__PURE__ */ jsx13(
6885
+ step !== "mode" && /* @__PURE__ */ jsx13(
6113
6886
  LSButton,
6114
6887
  {
6115
6888
  type: "button",
@@ -6119,7 +6892,7 @@ function SEImportWizard({
6119
6892
  label: "Back"
6120
6893
  }
6121
6894
  ),
6122
- /* @__PURE__ */ jsx13(
6895
+ step !== "mode" && /* @__PURE__ */ jsx13(
6123
6896
  LSButton,
6124
6897
  {
6125
6898
  type: "button",
@@ -6141,7 +6914,10 @@ function extractErrorMessage(err) {
6141
6914
  // src/se-import/index.ts
6142
6915
  var REVIEW_REASONS = {
6143
6916
  "se-widget-bit-boss": "No native Lumia equivalent \u2014 bit boss fight bar with HP/damage states.",
6144
- "se-widget-hype-cup": "No native Lumia equivalent \u2014 Box2D physics stacking animation.",
6917
+ // `se-widget-hype-cup` intentionally omitted it now routes to the native
6918
+ // `tipjar` module (see dispatcher.ts) and lands with `partial` status, so it
6919
+ // never reaches the placeholder-reason fallback. If a future feature flag
6920
+ // disables tipjar auto-import, add an entry to FLAG_OFF_REASONS instead.
6145
6921
  "se-widget-train": "No native Lumia equivalent \u2014 animated subscriber/event train.",
6146
6922
  "se-widget-media-share": "No native Lumia equivalent \u2014 viewer-submitted media player.",
6147
6923
  "se-widget-contest": "No native Lumia equivalent \u2014 SE-specific contest widget.",
@@ -6150,13 +6926,17 @@ var REVIEW_REASONS = {
6150
6926
  "se-widget-top-cheerers-list": 'Lumia loyaltyleaderboard needs a "source" extension before it can render cheer rankings.',
6151
6927
  "se-widget-top-gifters-list": 'Lumia loyaltyleaderboard needs a "source" extension before it can render gifter rankings.',
6152
6928
  "se-widget-top-tippers-list": 'Lumia loyaltyleaderboard needs a "source" extension before it can render tipper rankings.',
6153
- "se-widget-snow": "Seasonal effect \u2014 needs a particle template in Lumia.",
6154
- "se-widget-halloween": "Seasonal effect \u2014 needs a Lumia template.",
6155
- "se-widget-halloween-2019": "Seasonal effect \u2014 needs a Lumia template.",
6156
- "se-widget-xmas": "Seasonal effect \u2014 needs a Lumia template.",
6157
- "se-widget-valentine": "Seasonal effect \u2014 needs a Lumia template.",
6158
- "se-widget-easter": "Seasonal effect \u2014 needs a Lumia template.",
6159
- "se-widget-world-cup": "Seasonal effect \u2014 needs a Lumia template."
6929
+ // Merch products rotator imports as an empty slideshow with the title
6930
+ // "Merch products (add images manually)". Lumia's Fourthwall integration is
6931
+ // webhook-driven (events flow in) but doesn't fetch the user's shop catalog,
6932
+ // so we can't pre-populate the slideshow items. The streamer adds their
6933
+ // product images by hand in the slideshow editor. Status = 'partial' (see
6934
+ // dispatcher.ts), so this string surfaces in the review step.
6935
+ "se-widget-merch-products-rotator": "Imported as an empty slideshow. Lumia doesn't fetch your Fourthwall product catalog yet, so add your product images manually in the slideshow editor \u2014 the layer is pre-positioned and styled to match your SE widget."
6936
+ // Seasonal widgets (snow / halloween / halloween-2019 / xmas / valentine / easter
6937
+ // / world-cup) intentionally omitted — they route directly to image/slideshow/
6938
+ // video modules now (see dispatcher.ts SEASONAL_IMAGE_TYPES), so they always
6939
+ // produce a `direct` status and never need a fallback review reason.
6160
6940
  };
6161
6941
  function reasonFor(seType, status, flaggedOff) {
6162
6942
  if (flaggedOff && FLAG_OFF_REASONS[seType]) return FLAG_OFF_REASONS[seType];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumiastream/ui",
3
- "version": "0.2.8-alpha.1",
3
+ "version": "0.2.8-alpha.2",
4
4
  "author": "Lumia Stream",
5
5
  "license": "ISC",
6
6
  "description": "Lumia UI Kit",