fluxy-bot 0.10.17 → 0.10.19

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.
@@ -88,13 +88,33 @@ export function registerUpdateCommand(program: Command) {
88
88
  fs.cpSync(wsSrc, path.join(DATA_DIR, 'workspace'), { recursive: true });
89
89
  }
90
90
 
91
- // Always update index.html contains splash screen, SW registration,
92
- // and meta tags that ship with each version. Safe to overwrite since
93
- // users don't edit this file (their code lives in src/ and backend/).
94
- const indexSrc = path.join(extracted, 'workspace', 'client', 'index.html');
95
- const indexDst = path.join(DATA_DIR, 'workspace', 'client', 'index.html');
96
- if (fs.existsSync(indexSrc)) {
97
- fs.cpSync(indexSrc, indexDst, { force: true });
91
+ // Always update framework files that ship with each version.
92
+ // These are not user-editable user code lives in src/components/, src/pages/, backend/, etc.
93
+ const frameworkFiles = [
94
+ 'workspace/client/index.html', // splash screen, SW registration, meta tags
95
+ 'workspace/client/src/main.tsx', // React entry point, app-ready signal
96
+ ];
97
+ for (const rel of frameworkFiles) {
98
+ const src = path.join(extracted, rel);
99
+ const dst = path.join(DATA_DIR, rel);
100
+ if (fs.existsSync(src)) {
101
+ fs.cpSync(src, dst, { force: true });
102
+ }
103
+ }
104
+
105
+ // Always update public assets that ship with the framework (animation spritesheet, icons).
106
+ // Only copy specific files — never overwrite user-added public assets.
107
+ const frameworkAssets = [
108
+ 'spritesheet.webp',
109
+ ];
110
+ const publicSrc = path.join(extracted, 'workspace', 'client', 'public');
111
+ const publicDst = path.join(DATA_DIR, 'workspace', 'client', 'public');
112
+ for (const asset of frameworkAssets) {
113
+ const src = path.join(publicSrc, asset);
114
+ const dst = path.join(publicDst, asset);
115
+ if (fs.existsSync(src)) {
116
+ fs.cpSync(src, dst, { force: true });
117
+ }
98
118
  }
99
119
 
100
120
  for (const file of ['package.json', 'vite.config.ts', 'vite.fluxy.config.ts', 'tsconfig.json', 'postcss.config.js', 'components.json']) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.10.17",
3
+ "version": "0.10.19",
4
4
  "releaseNotes": [
5
5
  "Adding a way for users to claim their fluxies on the fluxy.bot dashboard",
6
6
  "2. ",
@@ -34,28 +34,39 @@
34
34
  panel.appendChild(iframe);
35
35
 
36
36
  // ══════════════════════════════════════════════════════════════════
37
- // ── Canvas Particle Animation (Splash + Bubble) ──────────────────
37
+ // ── Sprite Sheet Animation (Splash + Bubble) ─────────────────────
38
38
  // ══════════════════════════════════════════════════════════════════
39
39
 
40
- // ── Config ──
41
- var TARGET_HEIGHT = 40;
42
- var SAMPLE_RES = 60;
43
- var MELT_DURATION = 750;
44
- var REFORM_DURATION = 850;
45
- var TRAVEL_MIN = 400;
46
- var TRAVEL_MAX = 1100;
47
- var TRAVEL_PX_PER_MS = 0.9;
40
+ // ── Sprite config ──
41
+ var COLS = 16;
42
+ var FRAME_W = 125;
43
+ var FRAME_H = 120;
44
+ var DISPLAY_H = 58;
45
+ var DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);
46
+
47
+ // Frame ranges (0-indexed)
48
+ var IDLE_START = 0, IDLE_END = 29;
49
+ var MELT_START = 30, MELT_END = 52;
50
+ var TRAVEL_START = 52, TRAVEL_END = 84;
51
+ var REFORM_START = 84, REFORM_END = 191;
52
+
53
+ // Timing
54
+ var FPS = 29;
55
+ var FRAME_MS = 1000 / FPS;
56
+ var IDLE_FPS = 22;
57
+ var IDLE_FRAME_MS = 1000 / IDLE_FPS;
58
+ var REFORM_FPS = 70;
59
+ var REFORM_FRAME_MS = 1000 / REFORM_FPS;
60
+
61
+ var TRAVEL_PX_PER_MS = 0.65;
62
+ var TRAVEL_MIN = 385;
63
+ var TRAVEL_MAX = 1150;
48
64
  var MIN_SPLASH_MS = 2000;
49
65
  var BUBBLE_SIZE = 60;
50
66
  var BUBBLE_MARGIN = 24;
51
67
 
52
68
  // ── Easing ──
53
- function easeInCubic(t) { return t * t * t; }
54
69
  function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
55
- function easeOutElastic(t) {
56
- if (t === 0 || t === 1) return t;
57
- return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1;
58
- }
59
70
 
60
71
  // ── Canvas setup (starts fullscreen for splash) ──
61
72
  var canvas = document.createElement('canvas');
@@ -66,16 +77,17 @@
66
77
  canvas.style.cssText = 'position:fixed;inset:0;width:100vw;height:100dvh;z-index:9999;pointer-events:none;';
67
78
  document.body.appendChild(canvas);
68
79
 
69
- var bubble = canvas; // alias for toggle/click compatibility
80
+ var bubble = canvas;
70
81
  var ctx = canvas.getContext('2d');
71
82
  var dpr = window.devicePixelRatio || 1;
72
83
  var W = 0, H = 0;
73
84
 
74
- // ── Canvas phase: 'splash' | 'transitioning' | 'bubble' ──
85
+ // ── Canvas phase: 'splash' | 'transitioning' | 'bubble' | 'disabled' ──
75
86
  var canvasPhase = 'splash';
76
87
  var canvasCreatedAt = Date.now();
77
88
  var appReady = false;
78
89
  var hideAfterTransition = false;
90
+ var skipSplash = false;
79
91
 
80
92
  function resizeCanvas() {
81
93
  if (canvasPhase === 'bubble') {
@@ -94,105 +106,36 @@
94
106
  resizeCanvas();
95
107
  window.addEventListener('resize', function () {
96
108
  resizeCanvas();
97
- // Recalculate center position during splash
98
109
  if (canvasPhase === 'splash' && animState === 'idle') {
99
110
  center.x = Math.round(W / 2);
100
111
  center.y = Math.round(H / 2);
101
112
  }
102
113
  });
103
114
 
104
- // ── Particle state ──
105
- var particles = [];
106
- var preRendered = null;
107
- var displayW = 0, displayH = 0;
108
- var particleRadius = 0;
109
- var animState = 'loading'; // particle animation state
110
- var phaseStart = 0;
115
+ // ── Animation state ──
116
+ var spriteSheet = null;
117
+ var animState = 'loading';
118
+ var currentFrame = 0;
119
+ var idleDirection = 1;
120
+ var lastFrameTime = 0;
111
121
  var travelDuration = 0;
122
+ var travelStartTime = 0;
123
+ var travelAngle = 0;
124
+ var travelDrawAngle = 0;
125
+ var travelFlip = 1;
112
126
 
113
127
  var center = { x: 0, y: 0 };
114
128
  var travelA = { x: 0, y: 0 };
115
129
  var target = { x: 0, y: 0 };
116
130
 
117
- // ── Particle constructor ──
118
- function Particle(localX, localY, r, g, b, a) {
119
- this.localX = localX;
120
- this.localY = localY;
121
- this.r = r;
122
- this.g = g;
123
- this.b = b;
124
- this.a = a;
125
- this.x = 0;
126
- this.y = 0;
127
-
128
- var normalizedY = (localY + displayH / 2) / displayH;
129
-
130
- // Melt into an elliptical blob
131
- var angle = Math.random() * Math.PI * 2;
132
- var dist = Math.sqrt(Math.random());
133
- var blobRX = displayW * 0.28;
134
- var blobRY = displayH * 0.18;
135
- this.meltLocalX = Math.cos(angle) * dist * blobRX;
136
- this.meltLocalY = Math.sin(angle) * dist * blobRY + displayH * 0.3;
137
-
138
- // Stagger: top particles melt first
139
- this.meltDelay = normalizedY * 0.35;
140
-
141
- this.wobbleAmp = 0.5 + Math.random() * 1.5;
142
- this.wobbleFreq = 3 + Math.random() * 4;
143
- this.wobblePhase = Math.random() * Math.PI * 2;
144
- }
145
-
146
- // ── Load mascot ──
147
- function loadMascot(onDone, onFail) {
131
+ // ── Load sprite sheet ──
132
+ function loadSprite(onDone, onFail) {
148
133
  var img = new Image();
149
134
  img.onload = function () {
150
- var aspect = img.width / img.height;
151
- displayH = TARGET_HEIGHT;
152
- displayW = TARGET_HEIGHT * aspect;
153
-
154
- // Pre-render at high DPR for crisp display
155
- var preScale = dpr * 2;
156
- var pre = document.createElement('canvas');
157
- var preCtx = pre.getContext('2d');
158
- pre.width = Math.round(displayW * preScale);
159
- pre.height = Math.round(displayH * preScale);
160
- preCtx.imageSmoothingEnabled = true;
161
- preCtx.imageSmoothingQuality = 'high';
162
- preCtx.drawImage(img, 0, 0, pre.width, pre.height);
163
- preRendered = pre;
164
-
165
- // Sample pixels for particles
166
- var sampleH = SAMPLE_RES;
167
- var sampleW = Math.round(SAMPLE_RES * aspect);
168
-
169
- var off = document.createElement('canvas');
170
- var offCtx = off.getContext('2d');
171
- off.width = sampleW;
172
- off.height = sampleH;
173
- offCtx.imageSmoothingEnabled = true;
174
- offCtx.imageSmoothingQuality = 'high';
175
- offCtx.drawImage(img, 0, 0, sampleW, sampleH);
176
-
177
- var data = offCtx.getImageData(0, 0, sampleW, sampleH).data;
178
- var scaleX = displayW / sampleW;
179
- var scaleY = displayH / sampleH;
180
- particleRadius = (scaleX + scaleY) / 2 * 0.7;
181
-
182
- for (var y = 0; y < sampleH; y++) {
183
- for (var x = 0; x < sampleW; x++) {
184
- var i = (y * sampleW + x) * 4;
185
- if (data[i + 3] > 50) {
186
- var lx = (x - sampleW / 2) * scaleX;
187
- var ly = (y - sampleH / 2) * scaleY;
188
- particles.push(new Particle(lx, ly, data[i], data[i+1], data[i+2], data[i+3] / 255));
189
- }
190
- }
191
- }
192
-
193
- // Start idle in center
135
+ spriteSheet = img;
194
136
  center.x = Math.round(W / 2);
195
137
  center.y = Math.round(H / 2);
138
+ currentFrame = IDLE_START;
196
139
  animState = 'idle';
197
140
 
198
141
  // Hide the HTML splash fallback
@@ -204,7 +147,7 @@
204
147
  img.onerror = function () {
205
148
  if (onFail) onFail();
206
149
  };
207
- img.src = '/fluxy.png';
150
+ img.src = '/spritesheet.webp';
208
151
  }
209
152
 
210
153
  // ── Trigger move ──
@@ -214,122 +157,129 @@
214
157
  target.y = ty;
215
158
  travelA.x = center.x;
216
159
  travelA.y = center.y;
160
+
161
+ var dx = target.x - center.x;
162
+ var dy = target.y - center.y;
163
+ var dist = Math.sqrt(dx * dx + dy * dy);
164
+
165
+ travelAngle = Math.atan2(dy, dx);
166
+ travelDrawAngle = travelAngle;
167
+ travelFlip = 1;
168
+ if (travelDrawAngle > Math.PI / 2) { travelDrawAngle -= Math.PI; travelFlip = -1; }
169
+ else if (travelDrawAngle < -Math.PI / 2) { travelDrawAngle += Math.PI; travelFlip = -1; }
170
+ travelDuration = Math.min(TRAVEL_MAX, Math.max(TRAVEL_MIN, dist / TRAVEL_PX_PER_MS));
171
+
172
+ currentFrame = MELT_START;
173
+ lastFrameTime = performance.now();
217
174
  animState = 'melting';
218
- phaseStart = performance.now();
219
175
  }
220
176
 
221
177
  // ── Update ──
222
178
  function update(now) {
223
- var elapsed = now - phaseStart;
224
- var i, p, t, e, cx, cy, wx, wy;
179
+ // Smooth position update during travel (every rAF)
180
+ if (animState === 'traveling') {
181
+ var elapsed = now - travelStartTime;
182
+ var progress = Math.min(1, elapsed / travelDuration);
183
+ var e = easeInOutCubic(progress);
184
+ center.x = travelA.x + (target.x - travelA.x) * e;
185
+ center.y = travelA.y + (target.y - travelA.y) * e;
225
186
 
226
- if (animState === 'melting') {
227
- var progress = elapsed / MELT_DURATION;
228
187
  if (progress >= 1) {
229
- progress = 1;
230
- var dx = target.x - travelA.x;
231
- var dy = target.y - travelA.y;
232
- var dist = Math.sqrt(dx*dx + dy*dy);
233
- travelDuration = Math.min(TRAVEL_MAX, Math.max(TRAVEL_MIN, dist / TRAVEL_PX_PER_MS));
234
- animState = 'traveling';
235
- phaseStart = now;
236
- }
237
- for (i = 0; i < particles.length; i++) {
238
- p = particles[i];
239
- t = Math.max(0, Math.min(1, (progress - p.meltDelay) / (1 - p.meltDelay)));
240
- e = easeInCubic(t);
241
- p.x = center.x + p.localX + (p.meltLocalX - p.localX) * e;
242
- p.y = center.y + p.localY + (p.meltLocalY - p.localY) * e;
243
- }
244
- return;
245
- }
246
-
247
- if (animState === 'traveling') {
248
- var progress2 = elapsed / travelDuration;
249
- if (progress2 >= 1) {
250
- progress2 = 1;
251
188
  center.x = target.x;
252
189
  center.y = target.y;
253
190
  animState = 'reforming';
254
- phaseStart = now;
255
- }
256
- e = easeInOutCubic(progress2);
257
- cx = travelA.x + (target.x - travelA.x) * e;
258
- cy = travelA.y + (target.y - travelA.y) * e;
259
- for (i = 0; i < particles.length; i++) {
260
- p = particles[i];
261
- wx = Math.sin(now * 0.008 * p.wobbleFreq + p.wobblePhase) * p.wobbleAmp;
262
- wy = Math.cos(now * 0.006 * p.wobbleFreq + p.wobblePhase) * p.wobbleAmp * 0.5;
263
- p.x = cx + p.meltLocalX + wx;
264
- p.y = cy + p.meltLocalY + wy;
191
+ currentFrame = REFORM_START;
192
+ lastFrameTime = now;
193
+ return;
265
194
  }
266
- return;
267
195
  }
268
196
 
269
- if (animState === 'reforming') {
270
- var progress3 = elapsed / REFORM_DURATION;
271
- if (progress3 >= 1) {
272
- progress3 = 1;
197
+ // Sprite frame advance (at configured FPS)
198
+ var frameInterval = animState === 'idle' ? IDLE_FRAME_MS
199
+ : animState === 'reforming' ? REFORM_FRAME_MS
200
+ : FRAME_MS;
201
+ var frameDelta = now - lastFrameTime;
202
+ if (frameDelta < frameInterval) return;
203
+ lastFrameTime = now - (frameDelta % frameInterval);
204
+
205
+ if (animState === 'idle') {
206
+ currentFrame += idleDirection;
207
+ if (currentFrame >= IDLE_END) { currentFrame = IDLE_END; idleDirection = -1; }
208
+ else if (currentFrame <= IDLE_START) { currentFrame = IDLE_START; idleDirection = 1; }
209
+ } else if (animState === 'melting') {
210
+ currentFrame++;
211
+ if (currentFrame > MELT_END) {
212
+ animState = 'traveling';
213
+ currentFrame = TRAVEL_START;
214
+ travelStartTime = now;
215
+ }
216
+ } else if (animState === 'traveling') {
217
+ currentFrame++;
218
+ if (currentFrame > TRAVEL_END) currentFrame = TRAVEL_START;
219
+ } else if (animState === 'reforming') {
220
+ currentFrame++;
221
+ if (currentFrame > REFORM_END) {
273
222
  animState = 'idle';
223
+ currentFrame = IDLE_START;
224
+ idleDirection = 1;
274
225
  // Transition canvas to bubble mode
275
226
  if (canvasPhase === 'transitioning') {
276
227
  canvasPhase = 'bubble';
277
- canvas.style.cssText = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;';
228
+ canvas.style.cssText = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;transition:transform .15s ease;-webkit-tap-highlight-color:transparent;';
278
229
  W = BUBBLE_SIZE;
279
230
  H = BUBBLE_SIZE;
280
231
  canvas.width = BUBBLE_SIZE * dpr;
281
232
  canvas.height = BUBBLE_SIZE * dpr;
282
233
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
283
- // Center is now the center of the bubble canvas
284
234
  center.x = BUBBLE_SIZE / 2;
285
235
  center.y = BUBBLE_SIZE / 2;
286
- // Handle onboard hide
287
236
  if (hideAfterTransition) {
288
237
  canvas.style.display = 'none';
289
238
  }
290
239
  }
291
240
  }
292
- e = easeOutElastic(progress3);
293
- for (i = 0; i < particles.length; i++) {
294
- p = particles[i];
295
- p.x = center.x + p.meltLocalX + (p.localX - p.meltLocalX) * e;
296
- p.y = center.y + p.meltLocalY + (p.localY - p.meltLocalY) * e;
297
- }
298
241
  }
299
242
  }
300
243
 
301
244
  // ── Draw ──
245
+ function drawFrame(frame, x, y, rotate, flipX) {
246
+ if (!spriteSheet) return;
247
+ var col = frame % COLS;
248
+ var row = Math.floor(frame / COLS);
249
+ var sx = col * FRAME_W;
250
+ var sy = row * FRAME_H;
251
+
252
+ ctx.save();
253
+ ctx.translate(x, y);
254
+ if (rotate) ctx.rotate(rotate);
255
+ if (flipX === -1) ctx.scale(-1, 1);
256
+ ctx.imageSmoothingEnabled = true;
257
+ ctx.imageSmoothingQuality = 'high';
258
+ ctx.drawImage(
259
+ spriteSheet,
260
+ sx, sy, FRAME_W, FRAME_H,
261
+ -DISPLAY_W / 2, -DISPLAY_H / 2,
262
+ DISPLAY_W, DISPLAY_H
263
+ );
264
+ ctx.restore();
265
+ }
266
+
302
267
  function draw(now) {
303
268
  ctx.clearRect(0, 0, W, H);
269
+ if (!spriteSheet || animState === 'loading') return;
304
270
 
305
- if (animState === 'idle' && preRendered) {
306
- var bob = Math.sin(now * 0.002) * 1;
307
- ctx.imageSmoothingEnabled = true;
308
- ctx.imageSmoothingQuality = 'high';
309
- ctx.drawImage(
310
- preRendered,
311
- center.x - displayW / 2,
312
- center.y - displayH / 2 + bob,
313
- displayW,
314
- displayH
315
- );
316
- } else if (animState !== 'loading') {
317
- var r = particleRadius;
318
- for (var i = 0; i < particles.length; i++) {
319
- var p = particles[i];
320
- // In transitioning/bubble phase, offset particles relative to canvas
321
- var px = p.x;
322
- var py = p.y;
323
- if (canvasPhase === 'splash' || canvasPhase === 'transitioning') {
324
- // Particles are in screen coordinates during splash/transition
325
- }
326
- ctx.globalAlpha = p.a;
327
- ctx.fillStyle = 'rgb(' + p.r + ',' + p.g + ',' + p.b + ')';
328
- ctx.beginPath();
329
- ctx.arc(px, py, r, 0, Math.PI * 2);
330
- ctx.fill();
331
- }
332
- ctx.globalAlpha = 1;
271
+ if (animState === 'melting') {
272
+ var p = (currentFrame - MELT_START) / (MELT_END - MELT_START);
273
+ var eased = p * p;
274
+ drawFrame(currentFrame, center.x, center.y, travelDrawAngle * eased, travelFlip);
275
+ } else if (animState === 'traveling') {
276
+ drawFrame(currentFrame, center.x, center.y, travelDrawAngle, travelFlip);
277
+ } else if (animState === 'reforming') {
278
+ var p2 = (currentFrame - REFORM_START) / (REFORM_END - REFORM_START);
279
+ var eased2 = 1 - (1 - p2) * (1 - p2);
280
+ drawFrame(currentFrame, center.x, center.y, travelDrawAngle * (1 - eased2));
281
+ } else {
282
+ drawFrame(currentFrame, center.x, center.y);
333
283
  }
334
284
  }
335
285
 
@@ -355,34 +305,70 @@
355
305
 
356
306
  function triggerTransition() {
357
307
  canvasPhase = 'transitioning';
358
- // Target: center of the 60x60 bubble area at bottom-right
359
308
  var tx = W - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
360
309
  var ty = H - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
361
310
  moveTo(tx, ty);
362
311
  }
363
312
 
313
+ // Skip splash and go straight to bubble mode (for onboard / instant load)
314
+ function skipToBubble() {
315
+ canvasPhase = 'bubble';
316
+ canvas.style.cssText = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;transition:transform .15s ease;-webkit-tap-highlight-color:transparent;';
317
+ W = BUBBLE_SIZE;
318
+ H = BUBBLE_SIZE;
319
+ canvas.width = BUBBLE_SIZE * dpr;
320
+ canvas.height = BUBBLE_SIZE * dpr;
321
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
322
+ center.x = BUBBLE_SIZE / 2;
323
+ center.y = BUBBLE_SIZE / 2;
324
+ currentFrame = IDLE_START;
325
+ idleDirection = 1;
326
+ animState = 'idle';
327
+ // Hide the HTML splash fallback
328
+ var splash = document.getElementById('splash');
329
+ if (splash) splash.style.display = 'none';
330
+ }
331
+
364
332
  window.addEventListener('fluxy:app-ready', function () {
365
333
  appReady = true;
366
334
  maybeTransition();
367
335
  });
368
- // Race condition guard: check if main.tsx already fired before we registered
369
336
  if (window.__fluxyAppReady) {
370
337
  appReady = true;
371
338
  }
372
339
 
373
- // ── Start ──
374
- loadMascot(
375
- function onSuccess() {
376
- requestAnimationFrame(loop);
377
- // Check if app is already ready
378
- maybeTransition();
379
- },
380
- function onFail() {
381
- // Image failed — leave HTML splash visible, hide canvas
382
- canvas.style.display = 'none';
383
- canvasPhase = 'bubble'; // prevent further transitions
384
- }
385
- );
340
+ // ── Check onboard status, then start ──
341
+ // First-time users get the onboard wizard — skip the splash animation.
342
+ var onboardActive = false;
343
+ fetch('/api/settings')
344
+ .then(function (r) { return r.json(); })
345
+ .then(function (s) {
346
+ if (s.onboard_complete !== 'true') {
347
+ onboardActive = true;
348
+ skipSplash = true;
349
+ }
350
+ startAnimation();
351
+ })
352
+ .catch(function () {
353
+ startAnimation();
354
+ });
355
+
356
+ function startAnimation() {
357
+ loadSprite(
358
+ function onSuccess() {
359
+ if (skipSplash) {
360
+ skipToBubble();
361
+ if (onboardActive) canvas.style.display = 'none';
362
+ }
363
+ requestAnimationFrame(loop);
364
+ maybeTransition();
365
+ },
366
+ function onFail() {
367
+ canvas.style.display = 'none';
368
+ canvasPhase = 'disabled';
369
+ }
370
+ );
371
+ }
386
372
 
387
373
  // ══════════════════════════════════════════════════════════════════
388
374
  // ── Widget Interaction (Panel, Toggle, Messages) ─────────────────
@@ -391,7 +377,7 @@
391
377
  var isOpen = false;
392
378
 
393
379
  function toggle() {
394
- if (canvasPhase !== 'bubble') return; // only allow toggle after animation settles
380
+ if (canvasPhase !== 'bubble') return;
395
381
  isOpen = !isOpen;
396
382
  panel.classList.toggle('open', isOpen);
397
383
  backdrop.classList.toggle('open', isOpen);
@@ -419,10 +405,8 @@
419
405
  window.addEventListener('message', function (e) {
420
406
  if (!e.data || !e.data.type) return;
421
407
 
422
- // Close chat panel
423
408
  if (e.data.type === 'fluxy:close' && isOpen) toggle();
424
409
 
425
- // Install App request from chat iframe
426
410
  if (e.data.type === 'fluxy:install-app') {
427
411
  if (deferredInstallPrompt) {
428
412
  deferredInstallPrompt.prompt();
@@ -430,46 +414,27 @@
430
414
  if (result.outcome === 'accepted') deferredInstallPrompt = null;
431
415
  });
432
416
  } else {
433
- // No native prompt available — tell chat to show instructions modal
434
417
  iframe.contentWindow.postMessage({ type: 'fluxy:show-ios-install' }, '*');
435
418
  }
436
419
  }
420
+
421
+ // Onboard complete — show the bubble
422
+ if (e.data.type === 'fluxy:onboard-complete') {
423
+ onboardActive = false;
424
+ hideAfterTransition = false;
425
+ canvas.style.display = 'block';
426
+ }
437
427
  });
438
428
 
439
- // Restore open state after HMR reload (so chat isn't disrupted)
429
+ // Restore open state after HMR reload
440
430
  try {
441
431
  if (sessionStorage.getItem('fluxy_widget_open') === '1') {
442
432
  sessionStorage.removeItem('fluxy_widget_open');
443
- // Only restore if already in bubble phase
444
433
  if (canvasPhase === 'bubble') toggle();
445
434
  }
446
435
  } catch (e) {}
447
436
 
448
- // Hide widget during initial onboard
449
- try {
450
- fetch('/api/settings')
451
- .then(function (r) { return r.json(); })
452
- .then(function (s) {
453
- if (s.onboard_complete !== 'true') {
454
- if (canvasPhase === 'bubble') {
455
- canvas.style.display = 'none';
456
- } else {
457
- hideAfterTransition = true;
458
- }
459
- window.addEventListener('message', function onMsg(e) {
460
- if (e.data && e.data.type === 'fluxy:onboard-complete') {
461
- hideAfterTransition = false;
462
- canvas.style.display = 'block';
463
- window.removeEventListener('message', onMsg);
464
- }
465
- });
466
- }
467
- })
468
- .catch(function () {});
469
- } catch (e) {}
470
-
471
437
  // Inject app-ws.js — proxies /app/api/* fetch calls through WebSocket
472
- // (works around POST request failures through Cloudflare tunnel)
473
438
  var awsScript = document.createElement('script');
474
439
  awsScript.src = '/fluxy/app-ws.js';
475
440
  document.head.appendChild(awsScript);