@digilogiclabs/saas-factory-ui 2.4.0 → 2.6.0

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.mts CHANGED
@@ -5015,8 +5015,14 @@ interface RandomPlayerPickerProps {
5015
5015
  darkMode?: boolean;
5016
5016
  /** Wheel diameter in px. Default 380. */
5017
5017
  size?: number;
5018
- /** Called when a winner is locked in. */
5019
- onPick?: (winner: RandomPlayerParticipant, history: RandomPlayerParticipant[]) => void;
5018
+ /**
5019
+ * Called when a winner is locked in. `info.lastStanding` is true for
5020
+ * the automatic final pick of a draft — the survivor promoted when
5021
+ * pops drain the pool to one.
5022
+ */
5023
+ onPick?: (winner: RandomPlayerParticipant, history: RandomPlayerParticipant[], info?: {
5024
+ lastStanding: boolean;
5025
+ }) => void;
5020
5026
  /** Fired the moment a spin begins (before the animation). */
5021
5027
  onSpinStart?: () => void;
5022
5028
  /**
@@ -5050,14 +5056,37 @@ interface RandomPlayerPickerProps {
5050
5056
  renderWinner?: (winner: RandomPlayerParticipant) => ReactNode;
5051
5057
  /** Imperative handle. `spinRef.current?.()` triggers a spin. */
5052
5058
  spinRef?: MutableRefObject<(() => void) | null>;
5059
+ /**
5060
+ * Imperative reset handle. `resetRef.current?.()` restores the pool to
5061
+ * the full `participants` list and clears history/winner — the escape
5062
+ * hatch for consumers that hide the built-in controls
5063
+ * (`showControls={false}`) but still need a "reset draft" affordance
5064
+ * once draft-mode pops drain the pool.
5065
+ */
5066
+ resetRef?: MutableRefObject<(() => void) | null>;
5067
+ /**
5068
+ * Fired whenever the internal pool changes (mount, structural sync,
5069
+ * draft-mode pop, spin-again flush, reset). Draft-mode consumers use
5070
+ * this to mirror the live pool — e.g. to disable an external spin
5071
+ * trigger when the pool drains below 2 instead of presenting a dead
5072
+ * button, and to offer a reset once the draft completes.
5073
+ */
5074
+ onPoolChange?: (pool: RandomPlayerParticipant[]) => void;
5075
+ /**
5076
+ * Caption shown in place of `placeholderCaption` when draft-mode pops
5077
+ * drain the pool below 2 while the roster itself still has 2+ members.
5078
+ * Default: "Draft complete — reset to spin again".
5079
+ */
5080
+ draftCompleteCaption?: string;
5053
5081
  /**
5054
5082
  * Visual intensity preset. Default "normal".
5055
- * - "subtle" / "normal": current behavior — shorter spins, standard
5056
- * ease-out curve.
5057
- * - "dramatic": physics-like spin 2.5× duration, inertia-style
5058
- * curve so motion continues through the full duration with a slow
5059
- * winding-down tail, +12 extra revolutions, plus more
5060
- * particles/glow/shake on reveal.
5083
+ * - "subtle" / "normal": shorter spins, standard CSS ease-out curve.
5084
+ * - "dramatic": physics-driven spin — 2.5× duration, rAF-driven
5085
+ * exponential velocity decay (v ∝ e^(-4t)) that keeps the wheel
5086
+ * visibly winding down through the FULL duration and lands exactly
5087
+ * at the end, +12 extra revolutions, ticks fired on actual
5088
+ * wedge/row crossings, plus more particles/glow/shake on reveal.
5089
+ * Falls back to a 250ms CSS settle under prefers-reduced-motion.
5061
5090
  */
5062
5091
  intensity?: RandomPlayerIntensity;
5063
5092
  /**
@@ -5100,7 +5129,7 @@ interface RandomPlayerPickerProps {
5100
5129
  /** Accessible label for the wheel root. */
5101
5130
  ariaLabel?: string;
5102
5131
  }
5103
- declare function RandomPlayerPicker({ participants, mode, reelVisibleRows, reelRowHeight, theme, darkMode, size, onPick, onSpinStart, onTick, trigger, label, removeWinners, spinDuration, minRevolutions, maxRevolutions, showHistory, showControls, showStats, renderWinner, spinRef, intensity, enableHaptics, tickSoundUrl, winSoundUrl, soundVolume, placeholderMode, placeholderCaption, className, style, ariaLabel, }: RandomPlayerPickerProps): react_jsx_runtime.JSX.Element;
5132
+ declare function RandomPlayerPicker({ participants, mode, reelVisibleRows, reelRowHeight, theme, darkMode, size, onPick, onSpinStart, onTick, trigger, label, removeWinners, spinDuration, minRevolutions, maxRevolutions, showHistory, showControls, showStats, renderWinner, spinRef, resetRef, onPoolChange, draftCompleteCaption, intensity, enableHaptics, tickSoundUrl, winSoundUrl, soundVolume, placeholderMode, placeholderCaption, className, style, ariaLabel, }: RandomPlayerPickerProps): react_jsx_runtime.JSX.Element;
5104
5133
 
5105
5134
  type HeroBannerTransition = "fade" | "slide" | "zoom" | "blur" | "reveal" | "push";
5106
5135
  type HeroBannerImageFilter = "none" | "cinematic" | "dramatic" | "cool" | "warm" | "desaturated" | "noir" | "vibrant";
@@ -5666,10 +5695,20 @@ interface TeamGeneratorProps {
5666
5695
  maxPlayers?: number;
5667
5696
  /** Initial roster. Uncontrolled — the component manages names internally. */
5668
5697
  defaultNames?: string[];
5698
+ /** Initial generated teams (e.g. restored from a saved session).
5699
+ * Uncontrolled — the component takes over after mount. An empty array
5700
+ * is treated as "no teams". */
5701
+ defaultTeams?: string[][];
5669
5702
  /** Called whenever the name pool changes. */
5670
5703
  onNamesChange?: (names: string[]) => void;
5671
5704
  /** Called whenever teams are (re)generated. */
5672
5705
  onGenerate?: (teams: string[][]) => void;
5706
+ /** Called whenever the generated teams change for ANY reason — a
5707
+ * (re)generation, or invalidation to `null` when a player is removed,
5708
+ * the pool is cleared, or the team count changes. Unlike `onGenerate`,
5709
+ * this mirrors the full lifecycle of the on-screen split so hosts can
5710
+ * persist it and restore via `defaultTeams`. */
5711
+ onTeamsChange?: (teams: string[][] | null) => void;
5673
5712
  /** Called whenever the team count selection changes. */
5674
5713
  onTeamCountChange?: (count: number) => void;
5675
5714
  /** Localized / customized labels. */
@@ -5682,7 +5721,7 @@ interface TeamGeneratorProps {
5682
5721
  * CSV / TSV roster from a spreadsheet. Default `true`. */
5683
5722
  enableImport?: boolean;
5684
5723
  }
5685
- declare function TeamGenerator({ theme, darkMode, teamCountOptions, defaultTeamCount, minPlayersPerTeam, maxPlayers, defaultNames, onNamesChange, onGenerate, onTeamCountChange, labels, ariaLabel, className, enableImport, }: TeamGeneratorProps): react_jsx_runtime.JSX.Element;
5724
+ declare function TeamGenerator({ theme, darkMode, teamCountOptions, defaultTeamCount, minPlayersPerTeam, maxPlayers, defaultNames, defaultTeams, onNamesChange, onGenerate, onTeamsChange, onTeamCountChange, labels, ariaLabel, className, enableImport, }: TeamGeneratorProps): react_jsx_runtime.JSX.Element;
5686
5725
  declare namespace TeamGenerator {
5687
5726
  var displayName: string;
5688
5727
  }
package/dist/index.d.ts CHANGED
@@ -5015,8 +5015,14 @@ interface RandomPlayerPickerProps {
5015
5015
  darkMode?: boolean;
5016
5016
  /** Wheel diameter in px. Default 380. */
5017
5017
  size?: number;
5018
- /** Called when a winner is locked in. */
5019
- onPick?: (winner: RandomPlayerParticipant, history: RandomPlayerParticipant[]) => void;
5018
+ /**
5019
+ * Called when a winner is locked in. `info.lastStanding` is true for
5020
+ * the automatic final pick of a draft — the survivor promoted when
5021
+ * pops drain the pool to one.
5022
+ */
5023
+ onPick?: (winner: RandomPlayerParticipant, history: RandomPlayerParticipant[], info?: {
5024
+ lastStanding: boolean;
5025
+ }) => void;
5020
5026
  /** Fired the moment a spin begins (before the animation). */
5021
5027
  onSpinStart?: () => void;
5022
5028
  /**
@@ -5050,14 +5056,37 @@ interface RandomPlayerPickerProps {
5050
5056
  renderWinner?: (winner: RandomPlayerParticipant) => ReactNode;
5051
5057
  /** Imperative handle. `spinRef.current?.()` triggers a spin. */
5052
5058
  spinRef?: MutableRefObject<(() => void) | null>;
5059
+ /**
5060
+ * Imperative reset handle. `resetRef.current?.()` restores the pool to
5061
+ * the full `participants` list and clears history/winner — the escape
5062
+ * hatch for consumers that hide the built-in controls
5063
+ * (`showControls={false}`) but still need a "reset draft" affordance
5064
+ * once draft-mode pops drain the pool.
5065
+ */
5066
+ resetRef?: MutableRefObject<(() => void) | null>;
5067
+ /**
5068
+ * Fired whenever the internal pool changes (mount, structural sync,
5069
+ * draft-mode pop, spin-again flush, reset). Draft-mode consumers use
5070
+ * this to mirror the live pool — e.g. to disable an external spin
5071
+ * trigger when the pool drains below 2 instead of presenting a dead
5072
+ * button, and to offer a reset once the draft completes.
5073
+ */
5074
+ onPoolChange?: (pool: RandomPlayerParticipant[]) => void;
5075
+ /**
5076
+ * Caption shown in place of `placeholderCaption` when draft-mode pops
5077
+ * drain the pool below 2 while the roster itself still has 2+ members.
5078
+ * Default: "Draft complete — reset to spin again".
5079
+ */
5080
+ draftCompleteCaption?: string;
5053
5081
  /**
5054
5082
  * Visual intensity preset. Default "normal".
5055
- * - "subtle" / "normal": current behavior — shorter spins, standard
5056
- * ease-out curve.
5057
- * - "dramatic": physics-like spin 2.5× duration, inertia-style
5058
- * curve so motion continues through the full duration with a slow
5059
- * winding-down tail, +12 extra revolutions, plus more
5060
- * particles/glow/shake on reveal.
5083
+ * - "subtle" / "normal": shorter spins, standard CSS ease-out curve.
5084
+ * - "dramatic": physics-driven spin — 2.5× duration, rAF-driven
5085
+ * exponential velocity decay (v ∝ e^(-4t)) that keeps the wheel
5086
+ * visibly winding down through the FULL duration and lands exactly
5087
+ * at the end, +12 extra revolutions, ticks fired on actual
5088
+ * wedge/row crossings, plus more particles/glow/shake on reveal.
5089
+ * Falls back to a 250ms CSS settle under prefers-reduced-motion.
5061
5090
  */
5062
5091
  intensity?: RandomPlayerIntensity;
5063
5092
  /**
@@ -5100,7 +5129,7 @@ interface RandomPlayerPickerProps {
5100
5129
  /** Accessible label for the wheel root. */
5101
5130
  ariaLabel?: string;
5102
5131
  }
5103
- declare function RandomPlayerPicker({ participants, mode, reelVisibleRows, reelRowHeight, theme, darkMode, size, onPick, onSpinStart, onTick, trigger, label, removeWinners, spinDuration, minRevolutions, maxRevolutions, showHistory, showControls, showStats, renderWinner, spinRef, intensity, enableHaptics, tickSoundUrl, winSoundUrl, soundVolume, placeholderMode, placeholderCaption, className, style, ariaLabel, }: RandomPlayerPickerProps): react_jsx_runtime.JSX.Element;
5132
+ declare function RandomPlayerPicker({ participants, mode, reelVisibleRows, reelRowHeight, theme, darkMode, size, onPick, onSpinStart, onTick, trigger, label, removeWinners, spinDuration, minRevolutions, maxRevolutions, showHistory, showControls, showStats, renderWinner, spinRef, resetRef, onPoolChange, draftCompleteCaption, intensity, enableHaptics, tickSoundUrl, winSoundUrl, soundVolume, placeholderMode, placeholderCaption, className, style, ariaLabel, }: RandomPlayerPickerProps): react_jsx_runtime.JSX.Element;
5104
5133
 
5105
5134
  type HeroBannerTransition = "fade" | "slide" | "zoom" | "blur" | "reveal" | "push";
5106
5135
  type HeroBannerImageFilter = "none" | "cinematic" | "dramatic" | "cool" | "warm" | "desaturated" | "noir" | "vibrant";
@@ -5666,10 +5695,20 @@ interface TeamGeneratorProps {
5666
5695
  maxPlayers?: number;
5667
5696
  /** Initial roster. Uncontrolled — the component manages names internally. */
5668
5697
  defaultNames?: string[];
5698
+ /** Initial generated teams (e.g. restored from a saved session).
5699
+ * Uncontrolled — the component takes over after mount. An empty array
5700
+ * is treated as "no teams". */
5701
+ defaultTeams?: string[][];
5669
5702
  /** Called whenever the name pool changes. */
5670
5703
  onNamesChange?: (names: string[]) => void;
5671
5704
  /** Called whenever teams are (re)generated. */
5672
5705
  onGenerate?: (teams: string[][]) => void;
5706
+ /** Called whenever the generated teams change for ANY reason — a
5707
+ * (re)generation, or invalidation to `null` when a player is removed,
5708
+ * the pool is cleared, or the team count changes. Unlike `onGenerate`,
5709
+ * this mirrors the full lifecycle of the on-screen split so hosts can
5710
+ * persist it and restore via `defaultTeams`. */
5711
+ onTeamsChange?: (teams: string[][] | null) => void;
5673
5712
  /** Called whenever the team count selection changes. */
5674
5713
  onTeamCountChange?: (count: number) => void;
5675
5714
  /** Localized / customized labels. */
@@ -5682,7 +5721,7 @@ interface TeamGeneratorProps {
5682
5721
  * CSV / TSV roster from a spreadsheet. Default `true`. */
5683
5722
  enableImport?: boolean;
5684
5723
  }
5685
- declare function TeamGenerator({ theme, darkMode, teamCountOptions, defaultTeamCount, minPlayersPerTeam, maxPlayers, defaultNames, onNamesChange, onGenerate, onTeamCountChange, labels, ariaLabel, className, enableImport, }: TeamGeneratorProps): react_jsx_runtime.JSX.Element;
5724
+ declare function TeamGenerator({ theme, darkMode, teamCountOptions, defaultTeamCount, minPlayersPerTeam, maxPlayers, defaultNames, defaultTeams, onNamesChange, onGenerate, onTeamsChange, onTeamCountChange, labels, ariaLabel, className, enableImport, }: TeamGeneratorProps): react_jsx_runtime.JSX.Element;
5686
5725
  declare namespace TeamGenerator {
5687
5726
  var displayName: string;
5688
5727
  }
package/dist/index.js CHANGED
@@ -33535,6 +33535,11 @@ var WEDGE_PALETTE = [
33535
33535
  ];
33536
33536
  var DEFAULT_EASING = "cubic-bezier(0.25, 0.46, 0.45, 0.94)";
33537
33537
  var DRAMATIC_EASING = "cubic-bezier(0.39, 0.575, 0.565, 1)";
33538
+ var PHYSICS_DECAY_K = 4;
33539
+ function physicsRampDown(t) {
33540
+ return (1 - Math.exp(-PHYSICS_DECAY_K * t)) / (1 - Math.exp(-PHYSICS_DECAY_K));
33541
+ }
33542
+ var REEL_LEAD_IN = 32;
33538
33543
  var INTENSITY_CONFIG = {
33539
33544
  subtle: {
33540
33545
  particles: 14,
@@ -33606,6 +33611,9 @@ function ReelStage({
33606
33611
  winner,
33607
33612
  spinning,
33608
33613
  reelY,
33614
+ displayY,
33615
+ cssTransition,
33616
+ tapeRef,
33609
33617
  reelVisibleRows,
33610
33618
  reelRowHeight,
33611
33619
  size,
@@ -33629,17 +33637,16 @@ function ReelStage({
33629
33637
  const stripHeight = reelVisibleRows * reelRowHeight;
33630
33638
  const centerOffsetPx = (reelVisibleRows - 1) / 2 * reelRowHeight;
33631
33639
  const P = pool.length;
33632
- const leadIn = 32;
33633
33640
  const tapeRows = Math.max(
33634
- reelVisibleRows + leadIn + 1,
33635
- Math.ceil((reelY + stripHeight) / reelRowHeight) + leadIn + 1
33641
+ reelVisibleRows + REEL_LEAD_IN + 1,
33642
+ Math.ceil((reelY + stripHeight) / reelRowHeight) + REEL_LEAD_IN + 1
33636
33643
  );
33637
33644
  const tape = Array.from({ length: tapeRows }, (_, i) => {
33638
- const poolIdx = P === 0 ? 0 : ((i - leadIn) % P + P) % P;
33645
+ const poolIdx = P === 0 ? 0 : ((i - REEL_LEAD_IN) % P + P) % P;
33639
33646
  return pool[poolIdx];
33640
33647
  });
33641
- const effectiveY = reelY + leadIn * reelRowHeight;
33642
- const transition = spinning ? `transform ${reducedMotion ? 250 : Math.round(spinDuration * intensityCfg.durationMult)}ms ${intensityCfg.easing}` : "none";
33648
+ const effectiveY = displayY + REEL_LEAD_IN * reelRowHeight;
33649
+ const transition = spinning && cssTransition ? `transform ${reducedMotion ? 250 : Math.round(spinDuration * intensityCfg.durationMult)}ms ${intensityCfg.easing}` : "none";
33643
33650
  return /* @__PURE__ */ (0, import_jsx_runtime117.jsxs)(
33644
33651
  "div",
33645
33652
  {
@@ -33684,6 +33691,7 @@ function ReelStage({
33684
33691
  ) : /* @__PURE__ */ (0, import_jsx_runtime117.jsx)(
33685
33692
  "div",
33686
33693
  {
33694
+ ref: tapeRef,
33687
33695
  "aria-hidden": "true",
33688
33696
  style: {
33689
33697
  position: "absolute",
@@ -33899,6 +33907,9 @@ function RandomPlayerPicker({
33899
33907
  showStats = true,
33900
33908
  renderWinner,
33901
33909
  spinRef,
33910
+ resetRef,
33911
+ onPoolChange,
33912
+ draftCompleteCaption = "Draft complete \u2014 reset to spin again",
33902
33913
  intensity = "normal",
33903
33914
  enableHaptics = false,
33904
33915
  tickSoundUrl,
@@ -33913,6 +33924,7 @@ function RandomPlayerPicker({
33913
33924
  const dark = useDarkMode(darkMode);
33914
33925
  const reducedMotion = usePrefersReducedMotion();
33915
33926
  const intensityCfg = INTENSITY_CONFIG[intensity];
33927
+ const physicsDriven = intensity === "dramatic" && !reducedMotion;
33916
33928
  const [pool, setPool] = (0, import_react71.useState)(participants);
33917
33929
  const [rotation, setRotation] = (0, import_react71.useState)(0);
33918
33930
  const [reelY, setReelY] = (0, import_react71.useState)(0);
@@ -33931,6 +33943,11 @@ function RandomPlayerPicker({
33931
33943
  const winAudioRef = (0, import_react71.useRef)(null);
33932
33944
  const containerRef = (0, import_react71.useRef)(null);
33933
33945
  const prevIdsRef = (0, import_react71.useRef)("");
33946
+ const wheelSvgRef = (0, import_react71.useRef)(null);
33947
+ const reelTapeRef = (0, import_react71.useRef)(null);
33948
+ const rafRef = (0, import_react71.useRef)(null);
33949
+ const visualRotationRef = (0, import_react71.useRef)(null);
33950
+ const visualReelYRef = (0, import_react71.useRef)(null);
33934
33951
  (0, import_react71.useEffect)(() => {
33935
33952
  if (typeof window === "undefined" || !tickSoundUrl) {
33936
33953
  tickAudioRef.current = null;
@@ -33984,6 +34001,12 @@ function RandomPlayerPicker({
33984
34001
  clearTimeout(popTimer.current);
33985
34002
  popTimer.current = null;
33986
34003
  }
34004
+ if (rafRef.current) {
34005
+ cancelAnimationFrame(rafRef.current);
34006
+ rafRef.current = null;
34007
+ }
34008
+ visualRotationRef.current = null;
34009
+ visualReelYRef.current = null;
33987
34010
  pendingPopIdxRef.current = null;
33988
34011
  setSpinning(false);
33989
34012
  setPool(participants);
@@ -34056,7 +34079,20 @@ function RandomPlayerPicker({
34056
34079
  }
34057
34080
  }
34058
34081
  const activeN = activePool.length;
34059
- if (activeN < 2) return;
34082
+ if (activeN < 2) {
34083
+ if (removeWinnersRef.current && activePool.length === 1 && history.length > 0) {
34084
+ const survivor = activePool[0];
34085
+ if (survivor) {
34086
+ const promoted = [survivor, ...history].slice(0, 20);
34087
+ setPool([]);
34088
+ setWinner(survivor);
34089
+ setRevealKey((k) => k + 1);
34090
+ setHistory(promoted);
34091
+ onPick?.(survivor, promoted, { lastStanding: true });
34092
+ }
34093
+ }
34094
+ return;
34095
+ }
34060
34096
  const activeAnglePerWedge = 2 * Math.PI / activeN;
34061
34097
  const activeRotation = rotation;
34062
34098
  const activeReelY = reelY;
@@ -34095,7 +34131,56 @@ function RandomPlayerPicker({
34095
34131
  } else {
34096
34132
  setRotation(finalRotation);
34097
34133
  }
34098
- if ((onTick || tickAudioRef.current) && !reducedMotion) {
34134
+ const fireTick = () => {
34135
+ onTick?.();
34136
+ const ta = tickAudioRef.current;
34137
+ if (ta) {
34138
+ try {
34139
+ ta.currentTime = 0;
34140
+ void ta.play().catch(() => {
34141
+ });
34142
+ } catch {
34143
+ }
34144
+ }
34145
+ };
34146
+ if (physicsDriven) {
34147
+ const from = mode === "reel" ? activeReelY : activeRotation;
34148
+ const to = mode === "reel" ? finalReelY : finalRotation;
34149
+ const crossUnit = mode === "reel" ? reelRowHeight : activeAnglePerWedge * 180 / Math.PI;
34150
+ if (mode === "reel") visualReelYRef.current = from;
34151
+ else visualRotationRef.current = from;
34152
+ let crossings = 0;
34153
+ const t0 = performance.now();
34154
+ const step = (now) => {
34155
+ const t = Math.min(1, (now - t0) / effectiveDuration);
34156
+ const value = from + (to - from) * physicsRampDown(t);
34157
+ if (mode === "reel") {
34158
+ visualReelYRef.current = value;
34159
+ const el = reelTapeRef.current;
34160
+ if (el) {
34161
+ el.style.transform = `translateY(${-(value + REEL_LEAD_IN * reelRowHeight)}px)`;
34162
+ }
34163
+ } else {
34164
+ visualRotationRef.current = value;
34165
+ const el = wheelSvgRef.current;
34166
+ if (el) el.style.transform = `rotate(${value}deg)`;
34167
+ }
34168
+ const crossed = Math.floor(Math.abs(value - from) / crossUnit);
34169
+ if (crossed > crossings) {
34170
+ crossings = crossed;
34171
+ fireTick();
34172
+ }
34173
+ if (t < 1) {
34174
+ rafRef.current = requestAnimationFrame(step);
34175
+ } else {
34176
+ rafRef.current = null;
34177
+ visualRotationRef.current = null;
34178
+ visualReelYRef.current = null;
34179
+ }
34180
+ };
34181
+ rafRef.current = requestAnimationFrame(step);
34182
+ }
34183
+ if (!physicsDriven && (onTick || tickAudioRef.current) && !reducedMotion) {
34099
34184
  const wedges = mode === "reel" ? Math.max(1, reelRevRows + targetRowOffset) : (() => {
34100
34185
  const totalDelta = deltaMod + revolutions * 360;
34101
34186
  const wedgeDeg = activeAnglePerWedge * 180 / Math.PI;
@@ -34113,16 +34198,7 @@ function RandomPlayerPicker({
34113
34198
  const eased = 1 - Math.pow(1 - progress, 3);
34114
34199
  const interval = startInterval + (endInterval - startInterval) * eased;
34115
34200
  tickInterval.current = setTimeout(() => {
34116
- onTick?.();
34117
- const ta = tickAudioRef.current;
34118
- if (ta) {
34119
- try {
34120
- ta.currentTime = 0;
34121
- void ta.play().catch(() => {
34122
- });
34123
- } catch {
34124
- }
34125
- }
34201
+ fireTick();
34126
34202
  elapsed += interval;
34127
34203
  schedule();
34128
34204
  }, interval);
@@ -34181,8 +34257,19 @@ function RandomPlayerPicker({
34181
34257
  popTimer.current = null;
34182
34258
  pendingPopIdxRef.current = null;
34183
34259
  if (!removeWinnersRef.current) return;
34184
- setPool((p) => p.filter((_, i) => i !== finalWinnerIdx));
34185
- setWinner(null);
34260
+ const survivors = activePool.filter((_, i) => i !== finalWinnerIdx);
34261
+ const survivor = survivors.length === 1 ? survivors[0] : void 0;
34262
+ if (survivor) {
34263
+ const promoted = [survivor, ...nextHistory].slice(0, 20);
34264
+ setPool([]);
34265
+ setWinner(survivor);
34266
+ setRevealKey((k) => k + 1);
34267
+ setHistory(promoted);
34268
+ onPick?.(survivor, promoted, { lastStanding: true });
34269
+ } else {
34270
+ setPool(survivors);
34271
+ setWinner(null);
34272
+ }
34186
34273
  setRotation(0);
34187
34274
  setReelY(0);
34188
34275
  }, 2800);
@@ -34205,6 +34292,7 @@ function RandomPlayerPicker({
34205
34292
  maxRevolutions,
34206
34293
  history,
34207
34294
  reducedMotion,
34295
+ physicsDriven,
34208
34296
  enableHaptics,
34209
34297
  intensityCfg.particles,
34210
34298
  intensityCfg.durationMult,
@@ -34216,12 +34304,20 @@ function RandomPlayerPicker({
34216
34304
  if (spinRef && spinRef.current === spin) spinRef.current = null;
34217
34305
  };
34218
34306
  }, [spin, spinRef]);
34307
+ const onPoolChangeRef = (0, import_react71.useRef)(onPoolChange);
34308
+ (0, import_react71.useEffect)(() => {
34309
+ onPoolChangeRef.current = onPoolChange;
34310
+ });
34311
+ (0, import_react71.useEffect)(() => {
34312
+ onPoolChangeRef.current?.(pool);
34313
+ }, [pool]);
34219
34314
  (0, import_react71.useEffect)(
34220
34315
  () => () => {
34221
34316
  if (winnerTimer.current) clearTimeout(winnerTimer.current);
34222
34317
  if (particleTimer.current) clearTimeout(particleTimer.current);
34223
34318
  if (popTimer.current) clearTimeout(popTimer.current);
34224
34319
  if (tickInterval.current) clearTimeout(tickInterval.current);
34320
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
34225
34321
  },
34226
34322
  []
34227
34323
  );
@@ -34251,6 +34347,12 @@ function RandomPlayerPicker({
34251
34347
  clearTimeout(popTimer.current);
34252
34348
  popTimer.current = null;
34253
34349
  }
34350
+ if (rafRef.current) {
34351
+ cancelAnimationFrame(rafRef.current);
34352
+ rafRef.current = null;
34353
+ }
34354
+ visualRotationRef.current = null;
34355
+ visualReelYRef.current = null;
34254
34356
  pendingPopIdxRef.current = null;
34255
34357
  setSpinning(false);
34256
34358
  setPool(participants);
@@ -34260,9 +34362,17 @@ function RandomPlayerPicker({
34260
34362
  setReelY(0);
34261
34363
  setParticles([]);
34262
34364
  }, [participants]);
34365
+ (0, import_react71.useEffect)(() => {
34366
+ if (resetRef) resetRef.current = reset;
34367
+ return () => {
34368
+ if (resetRef && resetRef.current === reset) resetRef.current = null;
34369
+ };
34370
+ }, [reset, resetRef]);
34263
34371
  const isIdle = n < 2;
34372
+ const drainedByDraft = isIdle && participants.length >= 2;
34373
+ const effectiveIdleCaption = drainedByDraft ? draftCompleteCaption : placeholderCaption;
34264
34374
  const placeholderDiscColor = dark ? "#2a3344" : "#e2e8f0";
34265
- if (isIdle && placeholderMode === "message") {
34375
+ if (isIdle && !drainedByDraft && placeholderMode === "message") {
34266
34376
  return /* @__PURE__ */ (0, import_jsx_runtime117.jsx)(
34267
34377
  "div",
34268
34378
  {
@@ -34284,6 +34394,8 @@ function RandomPlayerPicker({
34284
34394
  const shakeKeyframe = `rppShake_${intensity}`;
34285
34395
  const glowKeyframe = `rppGlow_${intensity}`;
34286
34396
  const pulseKeyframe = `rppPulse_${intensity}`;
34397
+ const displayRotation = visualRotationRef.current ?? rotation;
34398
+ const displayReelY = visualReelYRef.current ?? reelY;
34287
34399
  return /* @__PURE__ */ (0, import_jsx_runtime117.jsxs)(
34288
34400
  "div",
34289
34401
  {
@@ -34330,13 +34442,33 @@ function RandomPlayerPicker({
34330
34442
  [data-rpp-anim] { animation: none !important; }
34331
34443
  }
34332
34444
  ` }),
34333
- mode === "reel" ? /* @__PURE__ */ (0, import_jsx_runtime117.jsx)(
34445
+ isIdle && placeholderMode === "message" ? (
34446
+ /* Drained draft in "message" mode — compact box in the wheel's
34447
+ place; the winner card / stats / controls / history below stay
34448
+ mounted so the endgame isn't a dead end. */
34449
+ /* @__PURE__ */ (0, import_jsx_runtime117.jsx)(
34450
+ "div",
34451
+ {
34452
+ style: {
34453
+ padding: "2rem 1rem",
34454
+ textAlign: "center",
34455
+ color: c.dim,
34456
+ borderRadius: 12,
34457
+ border: `1px dashed ${c.border}`
34458
+ },
34459
+ children: effectiveIdleCaption
34460
+ }
34461
+ )
34462
+ ) : mode === "reel" ? /* @__PURE__ */ (0, import_jsx_runtime117.jsx)(
34334
34463
  ReelStage,
34335
34464
  {
34336
34465
  pool,
34337
34466
  winner,
34338
34467
  spinning,
34339
34468
  reelY,
34469
+ displayY: displayReelY,
34470
+ cssTransition: !physicsDriven,
34471
+ tapeRef: reelTapeRef,
34340
34472
  reelVisibleRows,
34341
34473
  reelRowHeight,
34342
34474
  size,
@@ -34345,7 +34477,7 @@ function RandomPlayerPicker({
34345
34477
  dark,
34346
34478
  isIdle,
34347
34479
  placeholderMode,
34348
- placeholderCaption,
34480
+ placeholderCaption: effectiveIdleCaption,
34349
34481
  placeholderDiscColor,
34350
34482
  spinDuration,
34351
34483
  intensityCfg,
@@ -34402,6 +34534,7 @@ function RandomPlayerPicker({
34402
34534
  /* @__PURE__ */ (0, import_jsx_runtime117.jsxs)(
34403
34535
  "svg",
34404
34536
  {
34537
+ ref: wheelSvgRef,
34405
34538
  width: size,
34406
34539
  height: size,
34407
34540
  "aria-hidden": "true",
@@ -34410,8 +34543,8 @@ function RandomPlayerPicker({
34410
34543
  top: 36,
34411
34544
  left: 0,
34412
34545
  transformOrigin: `${cx}px ${cy}px`,
34413
- transform: `rotate(${rotation}deg)`,
34414
- transition: spinning ? `transform ${reducedMotion ? 250 : Math.round(spinDuration * intensityCfg.durationMult)}ms ${intensityCfg.easing}` : "none",
34546
+ transform: `rotate(${displayRotation}deg)`,
34547
+ transition: spinning && !physicsDriven ? `transform ${reducedMotion ? 250 : Math.round(spinDuration * intensityCfg.durationMult)}ms ${intensityCfg.easing}` : "none",
34415
34548
  filter: `drop-shadow(0 8px 28px ${dark ? "rgba(0,0,0,0.55)" : "rgba(0,0,0,0.15)"})`
34416
34549
  },
34417
34550
  children: [
@@ -34564,7 +34697,7 @@ function RandomPlayerPicker({
34564
34697
  pointerEvents: "none",
34565
34698
  zIndex: 3
34566
34699
  },
34567
- children: placeholderCaption
34700
+ children: effectiveIdleCaption
34568
34701
  }
34569
34702
  ),
34570
34703
  particles.map((p) => /* @__PURE__ */ (0, import_jsx_runtime117.jsx)(
@@ -38738,6 +38871,33 @@ function GameTimer({
38738
38871
  if (rafRef.current) cancelAnimationFrame(rafRef.current);
38739
38872
  };
38740
38873
  }, [running, tick]);
38874
+ (0, import_react74.useEffect)(() => {
38875
+ if (!running || mode !== "countdown") return;
38876
+ let timeoutId = null;
38877
+ const disarm = () => {
38878
+ if (timeoutId !== null) {
38879
+ clearTimeout(timeoutId);
38880
+ timeoutId = null;
38881
+ }
38882
+ };
38883
+ const arm = () => {
38884
+ disarm();
38885
+ const remainingUntilDone = durationMs - elapsedRef.current;
38886
+ if (remainingUntilDone > 0) {
38887
+ timeoutId = setTimeout(tick, remainingUntilDone + 50);
38888
+ }
38889
+ };
38890
+ const onVisibility = () => {
38891
+ if (document.hidden) arm();
38892
+ else disarm();
38893
+ };
38894
+ document.addEventListener("visibilitychange", onVisibility);
38895
+ if (document.hidden) arm();
38896
+ return () => {
38897
+ disarm();
38898
+ document.removeEventListener("visibilitychange", onVisibility);
38899
+ };
38900
+ }, [running, mode, durationMs, tick]);
38741
38901
  const start = (0, import_react74.useCallback)(() => {
38742
38902
  if (completed && mode === "countdown") return;
38743
38903
  setRunning(true);
@@ -40249,8 +40409,10 @@ function TeamGenerator({
40249
40409
  minPlayersPerTeam = 2,
40250
40410
  maxPlayers = Number.POSITIVE_INFINITY,
40251
40411
  defaultNames = [],
40412
+ defaultTeams,
40252
40413
  onNamesChange,
40253
40414
  onGenerate,
40415
+ onTeamsChange,
40254
40416
  onTeamCountChange,
40255
40417
  labels,
40256
40418
  ariaLabel = "Team generator",
@@ -40265,12 +40427,17 @@ function TeamGenerator({
40265
40427
  const [names, setNames] = (0, import_react76.useState)(defaultNames);
40266
40428
  const [input, setInput] = (0, import_react76.useState)("");
40267
40429
  const [teamCount, setTeamCount] = (0, import_react76.useState)(initialCount);
40268
- const [teams, setTeams] = (0, import_react76.useState)(null);
40430
+ const [teams, setTeams] = (0, import_react76.useState)(
40431
+ defaultTeams && defaultTeams.length > 0 ? defaultTeams : null
40432
+ );
40269
40433
  const inputRef = (0, import_react76.useRef)(null);
40270
40434
  const minNames = teamCount * minPlayersPerTeam;
40271
40435
  (0, import_react76.useEffect)(() => {
40272
40436
  onNamesChange?.(names);
40273
40437
  }, [names, onNamesChange]);
40438
+ (0, import_react76.useEffect)(() => {
40439
+ onTeamsChange?.(teams);
40440
+ }, [teams, onTeamsChange]);
40274
40441
  const addName = (0, import_react76.useCallback)(() => {
40275
40442
  const trimmed = input.trim();
40276
40443
  if (!trimmed) return;