sdragly commited on
Commit
76e689f
·
1 Parent(s): 24785ff

Add premade items

Browse files
Files changed (6) hide show
  1. js/dressup.js +240 -48
  2. js/segmentation.js +100 -33
  3. js/stickers.js +184 -0
  4. js/store.js +34 -2
  5. js/upload.js +82 -46
  6. style.css +106 -1
js/dressup.js CHANGED
@@ -1,10 +1,11 @@
1
- import { loadPrincesses, loadClothes, blobToURL } from './store.js';
2
  import { getCategoryInfo, DRAG_DEAD_ZONE_PX, DOUBLE_TAP_MS } from './models.js';
 
3
 
4
  let princesses = [];
5
  let clothes = [];
6
  let currentIdx = 0;
7
- let placedItems = []; // { clothingId, x, y, scale, zIndex, url }
8
  let objectURLs = [];
9
  let clothesURLMap = {}; // id -> objectURL
10
 
@@ -12,14 +13,14 @@ let clothesURLMap = {}; // id -> objectURL
12
  let drag = null;
13
  let ghost = null;
14
 
15
- // Pinch-to-resize state
16
- let pinch = null; // { idx, startDist, startScale }
17
 
18
  // Double-tap tracking
19
  let lastPlacedTap = { idx: -1, time: 0 };
20
 
21
  // Track active pointers on placed items
22
- let activePointers = new Map(); // pointerId -> { x, y, origIdx }
23
 
24
  export async function initDressUp() {
25
  const screen = document.getElementById('dressup-screen');
@@ -49,6 +50,8 @@ export async function initDressUp() {
49
  return;
50
  }
51
 
 
 
52
  screen.innerHTML = `
53
  <div class="dressup-layout">
54
  <h1 id="princess-name" class="princess-title"></h1>
@@ -66,12 +69,18 @@ export async function initDressUp() {
66
  <button id="next-btn" class="switch-btn">\u25B6</button>
67
  </div>
68
 
 
 
 
 
 
69
  <div id="clothing-tray" class="clothing-tray">
70
- ${clothes.length === 0 ? '<p class="tray-empty">Draw some clothes and add photos!</p>' : ''}
71
  </div>
72
 
73
  <div class="bottom-bar">
74
  <a href="#/manage" class="pill">My Collection</a>
 
75
  <a href="#/upload" class="big-button">Add Photo</a>
76
  </div>
77
  </div>
@@ -79,10 +88,26 @@ export async function initDressUp() {
79
 
80
  currentIdx = 0;
81
  renderPrincess();
82
- renderTray();
83
  bindEvents();
84
  }
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  // ---- Rendering ----
87
 
88
  function renderPrincess() {
@@ -99,25 +124,61 @@ function renderPrincess() {
99
  document.getElementById('prev-btn').disabled = currentIdx === 0;
100
  document.getElementById('next-btn').disabled = currentIdx === princesses.length - 1;
101
 
 
102
  placedItems = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  renderPlaced();
104
  }
105
 
106
- function renderTray() {
 
 
 
107
  const tray = document.getElementById('clothing-tray');
108
- if (clothes.length === 0) return;
109
-
110
- tray.innerHTML = clothes.map(c => {
111
- const url = clothesURLMap[c.id];
112
- const catInfo = getCategoryInfo(c.category);
113
- return `
114
- <div class="tray-item" data-id="${c.id}"
115
- data-scale="${c.scale}" data-z="${c.zIndex}">
116
- <img src="${url}" class="tray-thumb" alt="${c.name}" draggable="false"/>
117
- <span class="tray-label">${catInfo ? catInfo.emoji : ''} ${c.name}</span>
 
 
118
  </div>
119
- `;
120
- }).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  }
122
 
123
  function renderPlaced() {
@@ -134,15 +195,13 @@ function renderPlaced() {
134
  left:${item.x * 100}%;
135
  top:${item.y * 100}%;
136
  width:${item.scale * 100}%;
137
- transform:translate(-50%,-50%);
138
  z-index:${item.zIndex};
139
  "
140
  alt=""/>
141
  `).join('');
142
  }
143
 
144
- // Update a single placed item's style without re-rendering the whole layer.
145
- // Avoids flicker during pinch.
146
  function updatePlacedStyle(origIdx) {
147
  const el = document.querySelector(`.placed-item[data-orig-idx="${origIdx}"]`);
148
  if (!el) return;
@@ -151,6 +210,7 @@ function updatePlacedStyle(origIdx) {
151
  el.style.left = `${item.x * 100}%`;
152
  el.style.top = `${item.y * 100}%`;
153
  el.style.width = `${item.scale * 100}%`;
 
154
  }
155
 
156
  // ---- Ghost (for tray drag) ----
@@ -180,8 +240,92 @@ function pointerDist(a, b) {
180
  return Math.sqrt(dx * dx + dy * dy);
181
  }
182
 
183
- function pointerMid(a, b) {
184
- return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  }
186
 
187
  // ---- Events ----
@@ -194,6 +338,12 @@ function bindEvents() {
194
  if (currentIdx < princesses.length - 1) { currentIdx++; renderPrincess(); }
195
  });
196
 
 
 
 
 
 
 
197
  const tray = document.getElementById('clothing-tray');
198
  tray.addEventListener('pointerdown', onTrayPointerDown, { passive: false });
199
 
@@ -203,6 +353,9 @@ function bindEvents() {
203
  window.addEventListener('pointermove', onPointerMove, { passive: false });
204
  window.addEventListener('pointerup', onPointerUp);
205
  window.addEventListener('pointercancel', onPointerUp);
 
 
 
206
  }
207
 
208
  // ---- Tray drag ----
@@ -212,19 +365,39 @@ function onTrayPointerDown(e) {
212
  if (!item) return;
213
  e.preventDefault();
214
 
215
- drag = {
216
- source: 'tray',
217
- id: item.dataset.id,
218
- url: clothesURLMap[item.dataset.id],
219
- scale: parseFloat(item.dataset.scale),
220
- zIndex: parseInt(item.dataset.z),
221
- startX: e.clientX,
222
- startY: e.clientY,
223
- hasMoved: false,
224
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  }
226
 
227
- // ---- Placed item: drag, pinch, double-tap ----
228
 
229
  function onPlacedPointerDown(e) {
230
  const img = e.target.closest('.placed-item');
@@ -235,19 +408,21 @@ function onPlacedPointerDown(e) {
235
  const ptr = { x: e.clientX, y: e.clientY, origIdx };
236
  activePointers.set(e.pointerId, ptr);
237
 
238
- // Check if this is a second finger on the same item -> start pinch
239
  const sameItem = [...activePointers.values()].filter(p => p.origIdx === origIdx);
240
  if (sameItem.length === 2 && !pinch) {
241
- // Cancel any single-finger drag
242
  if (drag && drag.source === 'canvas') {
243
  drag = null;
244
  removeGhost();
245
  }
246
  const d = pointerDist(sameItem[0], sameItem[1]);
 
247
  pinch = {
248
  idx: origIdx,
249
  startDist: d,
250
  startScale: placedItems[origIdx].scale,
 
 
251
  startMidX: (sameItem[0].x + sameItem[1].x) / 2,
252
  startMidY: (sameItem[0].y + sameItem[1].y) / 2,
253
  startItemX: placedItems[origIdx].x,
@@ -256,13 +431,14 @@ function onPlacedPointerDown(e) {
256
  return;
257
  }
258
 
259
- // Single finger: check double-tap first
260
  const now = Date.now();
261
  if (lastPlacedTap.idx === origIdx && (now - lastPlacedTap.time) < DOUBLE_TAP_MS) {
262
  img.classList.add('poof');
263
  setTimeout(() => {
264
  placedItems.splice(origIdx, 1);
265
  renderPlaced();
 
266
  }, 300);
267
  lastPlacedTap = { idx: -1, time: 0 };
268
  activePointers.delete(e.pointerId);
@@ -270,7 +446,7 @@ function onPlacedPointerDown(e) {
270
  }
271
  lastPlacedTap = { idx: origIdx, time: now };
272
 
273
- // Start single-finger drag
274
  const item = placedItems[origIdx];
275
  if (!item) return;
276
 
@@ -279,7 +455,9 @@ function onPlacedPointerDown(e) {
279
  id: item.clothingId,
280
  url: item.url,
281
  scale: item.scale,
 
282
  zIndex: item.zIndex,
 
283
  startX: e.clientX,
284
  startY: e.clientY,
285
  canvasIdx: origIdx,
@@ -288,27 +466,31 @@ function onPlacedPointerDown(e) {
288
  }
289
 
290
  function onPointerMove(e) {
291
- // Update active pointer position
292
  if (activePointers.has(e.pointerId)) {
293
  const ptr = activePointers.get(e.pointerId);
294
  ptr.x = e.clientX;
295
  ptr.y = e.clientY;
296
  }
297
 
298
- // Pinch-to-resize
299
  if (pinch) {
300
  e.preventDefault();
301
  const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
302
  if (sameItem.length >= 2) {
303
  const d = pointerDist(sameItem[0], sameItem[1]);
 
304
  const ratio = d / pinch.startDist;
305
- const newScale = Math.max(0.1, Math.min(2.0, pinch.startScale * ratio));
306
  placedItems[pinch.idx].scale = newScale;
307
 
308
- // Also move item with the midpoint
 
 
 
 
309
  const canvas = document.getElementById('princess-canvas');
310
  const rect = canvas.getBoundingClientRect();
311
- const mid = pointerMid(sameItem[0], sameItem[1]);
312
  const dx = mid.x - pinch.startMidX;
313
  const dy = mid.y - pinch.startMidY;
314
  placedItems[pinch.idx].x = pinch.startItemX + dx / rect.width;
@@ -334,7 +516,6 @@ function onPointerMove(e) {
334
  createGhost(drag.url);
335
  ghost.classList.add('lifting');
336
  } else if (drag.source === 'canvas') {
337
- // Remove from placed and drag as ghost
338
  placedItems.splice(drag.canvasIdx, 1);
339
  renderPlaced();
340
  createGhost(drag.url);
@@ -348,11 +529,11 @@ function onPointerMove(e) {
348
  function onPointerUp(e) {
349
  activePointers.delete(e.pointerId);
350
 
351
- // End pinch if we lost a finger
352
  if (pinch) {
353
  const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
354
  if (sameItem.length < 2) {
355
  pinch = null;
 
356
  }
357
  return;
358
  }
@@ -360,17 +541,19 @@ function onPointerUp(e) {
360
  if (!drag) return;
361
 
362
  if (!drag.hasMoved) {
363
- // Tap on tray -> place at center
364
  if (drag.source === 'tray') {
365
  placedItems.push({
366
  clothingId: drag.id,
367
  x: 0.5,
368
  y: 0.5,
369
  scale: drag.scale,
 
370
  zIndex: drag.zIndex,
371
  url: drag.url,
 
372
  });
373
  renderPlaced();
 
374
  }
375
  drag = null;
376
  return;
@@ -379,6 +562,7 @@ function onPointerUp(e) {
379
  // Drop
380
  const canvas = document.getElementById('princess-canvas');
381
  const rect = canvas.getBoundingClientRect();
 
382
 
383
  if (
384
  e.clientX >= rect.left && e.clientX <= rect.right &&
@@ -391,10 +575,18 @@ function onPointerUp(e) {
391
  x: nx,
392
  y: ny,
393
  scale: drag.scale,
 
394
  zIndex: drag.zIndex,
395
  url: drag.url,
 
396
  });
397
  renderPlaced();
 
 
 
 
 
 
398
  }
399
 
400
  removeGhost();
 
1
+ import { loadPrincesses, loadClothes, blobToURL, savePrincessOutfit } from './store.js';
2
  import { getCategoryInfo, DRAG_DEAD_ZONE_PX, DOUBLE_TAP_MS } from './models.js';
3
+ import { STICKERS } from './stickers.js';
4
 
5
  let princesses = [];
6
  let clothes = [];
7
  let currentIdx = 0;
8
+ let placedItems = []; // { clothingId, x, y, scale, rotation, zIndex, url, isSticker }
9
  let objectURLs = [];
10
  let clothesURLMap = {}; // id -> objectURL
11
 
 
13
  let drag = null;
14
  let ghost = null;
15
 
16
+ // Pinch-to-resize + rotate state
17
+ let pinch = null;
18
 
19
  // Double-tap tracking
20
  let lastPlacedTap = { idx: -1, time: 0 };
21
 
22
  // Track active pointers on placed items
23
+ let activePointers = new Map();
24
 
25
  export async function initDressUp() {
26
  const screen = document.getElementById('dressup-screen');
 
50
  return;
51
  }
52
 
53
+ const hasItems = clothes.length > 0 || STICKERS.length > 0;
54
+
55
  screen.innerHTML = `
56
  <div class="dressup-layout">
57
  <h1 id="princess-name" class="princess-title"></h1>
 
69
  <button id="next-btn" class="switch-btn">\u25B6</button>
70
  </div>
71
 
72
+ <div class="tray-tabs" id="tray-tabs">
73
+ ${clothes.length > 0 ? '<button class="tray-tab active" data-tab="clothes">\u{1F457} Clothes</button>' : ''}
74
+ <button class="tray-tab ${clothes.length === 0 ? 'active' : ''}" data-tab="stickers">\u2728 Stickers</button>
75
+ </div>
76
+
77
  <div id="clothing-tray" class="clothing-tray">
78
+ ${!hasItems ? '<p class="tray-empty">Draw some clothes and add photos!</p>' : ''}
79
  </div>
80
 
81
  <div class="bottom-bar">
82
  <a href="#/manage" class="pill">My Collection</a>
83
+ <button id="screenshot-btn" class="pill">\u{1F4F7} Save</button>
84
  <a href="#/upload" class="big-button">Add Photo</a>
85
  </div>
86
  </div>
 
88
 
89
  currentIdx = 0;
90
  renderPrincess();
91
+ renderTray(clothes.length > 0 ? 'clothes' : 'stickers');
92
  bindEvents();
93
  }
94
 
95
+ // ---- Auto-save ----
96
+
97
+ let saveTimer = null;
98
+
99
+ function autoSave() {
100
+ if (saveTimer) clearTimeout(saveTimer);
101
+ saveTimer = setTimeout(() => {
102
+ const p = princesses[currentIdx];
103
+ if (!p) return;
104
+ const outfit = placedItems.map(({ clothingId, x, y, scale, rotation, zIndex, isSticker }) =>
105
+ ({ clothingId, x, y, scale, rotation: rotation || 0, zIndex, isSticker: isSticker || false })
106
+ );
107
+ savePrincessOutfit(p.id, outfit);
108
+ }, 300);
109
+ }
110
+
111
  // ---- Rendering ----
112
 
113
  function renderPrincess() {
 
124
  document.getElementById('prev-btn').disabled = currentIdx === 0;
125
  document.getElementById('next-btn').disabled = currentIdx === princesses.length - 1;
126
 
127
+ // Restore saved outfit
128
  placedItems = [];
129
+ if (p.outfit && Array.isArray(p.outfit)) {
130
+ for (const item of p.outfit) {
131
+ let url;
132
+ if (item.isSticker) {
133
+ const sticker = STICKERS.find(s => s.id === item.clothingId);
134
+ url = sticker ? sticker.url : null;
135
+ } else {
136
+ url = clothesURLMap[item.clothingId];
137
+ }
138
+ if (url) {
139
+ placedItems.push({ ...item, url, rotation: item.rotation || 0 });
140
+ }
141
+ }
142
+ }
143
  renderPlaced();
144
  }
145
 
146
+ let currentTab = 'clothes';
147
+
148
+ function renderTray(tab) {
149
+ currentTab = tab || currentTab;
150
  const tray = document.getElementById('clothing-tray');
151
+
152
+ // Update tab styling
153
+ document.querySelectorAll('.tray-tab').forEach(t => {
154
+ t.classList.toggle('active', t.dataset.tab === currentTab);
155
+ });
156
+
157
+ if (currentTab === 'stickers') {
158
+ tray.innerHTML = STICKERS.map(s => `
159
+ <div class="tray-item tray-sticker" data-sticker-id="${s.id}"
160
+ data-scale="${s.scale}" data-z="${s.zIndex}">
161
+ <img src="${s.url}" class="tray-thumb" alt="${s.name}" draggable="false"/>
162
+ <span class="tray-label">${s.name}</span>
163
  </div>
164
+ `).join('');
165
+ } else {
166
+ if (clothes.length === 0) {
167
+ tray.innerHTML = '<p class="tray-empty">Draw some clothes and add photos!</p>';
168
+ return;
169
+ }
170
+ tray.innerHTML = clothes.map(c => {
171
+ const url = clothesURLMap[c.id];
172
+ const catInfo = getCategoryInfo(c.category);
173
+ return `
174
+ <div class="tray-item" data-id="${c.id}"
175
+ data-scale="${c.scale}" data-z="${c.zIndex}">
176
+ <img src="${url}" class="tray-thumb" alt="${c.name}" draggable="false"/>
177
+ <span class="tray-label">${catInfo ? catInfo.emoji : ''} ${c.name}</span>
178
+ </div>
179
+ `;
180
+ }).join('');
181
+ }
182
  }
183
 
184
  function renderPlaced() {
 
195
  left:${item.x * 100}%;
196
  top:${item.y * 100}%;
197
  width:${item.scale * 100}%;
198
+ transform:translate(-50%,-50%) rotate(${item.rotation || 0}deg);
199
  z-index:${item.zIndex};
200
  "
201
  alt=""/>
202
  `).join('');
203
  }
204
 
 
 
205
  function updatePlacedStyle(origIdx) {
206
  const el = document.querySelector(`.placed-item[data-orig-idx="${origIdx}"]`);
207
  if (!el) return;
 
210
  el.style.left = `${item.x * 100}%`;
211
  el.style.top = `${item.y * 100}%`;
212
  el.style.width = `${item.scale * 100}%`;
213
+ el.style.transform = `translate(-50%,-50%) rotate(${item.rotation || 0}deg)`;
214
  }
215
 
216
  // ---- Ghost (for tray drag) ----
 
240
  return Math.sqrt(dx * dx + dy * dy);
241
  }
242
 
243
+ function pointerAngle(a, b) {
244
+ return Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
245
+ }
246
+
247
+ // ---- Screenshot ----
248
+
249
+ async function takeScreenshot() {
250
+ const canvasEl = document.getElementById('princess-canvas');
251
+ const princessImg = document.getElementById('princess-img');
252
+ const rect = canvasEl.getBoundingClientRect();
253
+
254
+ // Use a reasonable resolution
255
+ const w = Math.min(rect.width * 2, 1024);
256
+ const scale = w / rect.width;
257
+ const h = rect.height * scale;
258
+
259
+ const canvas = new OffscreenCanvas(w, h);
260
+ const ctx = canvas.getContext('2d');
261
+
262
+ // Draw background
263
+ ctx.fillStyle = '#fff8f0';
264
+ ctx.fillRect(0, 0, w, h);
265
+
266
+ // Draw princess
267
+ const pImg = await loadImage(princessImg.src);
268
+ const pAspect = pImg.width / pImg.height;
269
+ const canvasAspect = w / h;
270
+ let pw, ph, px, py;
271
+ if (pAspect > canvasAspect) {
272
+ pw = w; ph = w / pAspect; px = 0; py = (h - ph) / 2;
273
+ } else {
274
+ ph = h; pw = h * pAspect; px = (w - pw) / 2; py = 0;
275
+ }
276
+ ctx.drawImage(pImg, px, py, pw, ph);
277
+
278
+ // Draw placed items in z-order
279
+ const sorted = [...placedItems]
280
+ .map((item, i) => ({ ...item, i }))
281
+ .sort((a, b) => a.zIndex - b.zIndex);
282
+
283
+ for (const item of sorted) {
284
+ const img = await loadImage(item.url);
285
+ const itemW = item.scale * w;
286
+ const itemH = itemW * (img.height / img.width);
287
+ const ix = item.x * w - itemW / 2;
288
+ const iy = item.y * h - itemH / 2;
289
+
290
+ ctx.save();
291
+ ctx.translate(item.x * w, item.y * h);
292
+ ctx.rotate((item.rotation || 0) * Math.PI / 180);
293
+ ctx.drawImage(img, -itemW / 2, -itemH / 2, itemW, itemH);
294
+ ctx.restore();
295
+ }
296
+
297
+ const blob = await canvas.convertToBlob({ type: 'image/png' });
298
+
299
+ // Try native share, fall back to download
300
+ if (navigator.share && navigator.canShare) {
301
+ const file = new File([blob], 'princess.png', { type: 'image/png' });
302
+ try {
303
+ await navigator.share({ files: [file], title: 'My Princess' });
304
+ return;
305
+ } catch (_) {}
306
+ }
307
+
308
+ // Fallback: download
309
+ const url = URL.createObjectURL(blob);
310
+ const a = document.createElement('a');
311
+ a.href = url;
312
+ a.download = 'princess.png';
313
+ a.click();
314
+ URL.revokeObjectURL(url);
315
+
316
+ // Flash feedback
317
+ canvasEl.classList.add('screenshot-flash');
318
+ setTimeout(() => canvasEl.classList.remove('screenshot-flash'), 400);
319
+ }
320
+
321
+ function loadImage(src) {
322
+ return new Promise((resolve, reject) => {
323
+ const img = new Image();
324
+ img.crossOrigin = 'anonymous';
325
+ img.onload = () => resolve(img);
326
+ img.onerror = reject;
327
+ img.src = src;
328
+ });
329
  }
330
 
331
  // ---- Events ----
 
338
  if (currentIdx < princesses.length - 1) { currentIdx++; renderPrincess(); }
339
  });
340
 
341
+ // Tray tabs
342
+ document.getElementById('tray-tabs').addEventListener('click', (e) => {
343
+ const tab = e.target.closest('.tray-tab');
344
+ if (tab) renderTray(tab.dataset.tab);
345
+ });
346
+
347
  const tray = document.getElementById('clothing-tray');
348
  tray.addEventListener('pointerdown', onTrayPointerDown, { passive: false });
349
 
 
353
  window.addEventListener('pointermove', onPointerMove, { passive: false });
354
  window.addEventListener('pointerup', onPointerUp);
355
  window.addEventListener('pointercancel', onPointerUp);
356
+
357
+ // Screenshot
358
+ document.getElementById('screenshot-btn').addEventListener('click', takeScreenshot);
359
  }
360
 
361
  // ---- Tray drag ----
 
365
  if (!item) return;
366
  e.preventDefault();
367
 
368
+ const isSticker = item.classList.contains('tray-sticker');
369
+
370
+ if (isSticker) {
371
+ const stickerId = item.dataset.stickerId;
372
+ const sticker = STICKERS.find(s => s.id === stickerId);
373
+ if (!sticker) return;
374
+ drag = {
375
+ source: 'tray',
376
+ id: sticker.id,
377
+ url: sticker.url,
378
+ scale: sticker.scale,
379
+ zIndex: sticker.zIndex,
380
+ isSticker: true,
381
+ startX: e.clientX,
382
+ startY: e.clientY,
383
+ hasMoved: false,
384
+ };
385
+ } else {
386
+ drag = {
387
+ source: 'tray',
388
+ id: item.dataset.id,
389
+ url: clothesURLMap[item.dataset.id],
390
+ scale: parseFloat(item.dataset.scale),
391
+ zIndex: parseInt(item.dataset.z),
392
+ isSticker: false,
393
+ startX: e.clientX,
394
+ startY: e.clientY,
395
+ hasMoved: false,
396
+ };
397
+ }
398
  }
399
 
400
+ // ---- Placed item: drag, pinch+rotate, double-tap ----
401
 
402
  function onPlacedPointerDown(e) {
403
  const img = e.target.closest('.placed-item');
 
408
  const ptr = { x: e.clientX, y: e.clientY, origIdx };
409
  activePointers.set(e.pointerId, ptr);
410
 
411
+ // Second finger on same item -> start pinch+rotate
412
  const sameItem = [...activePointers.values()].filter(p => p.origIdx === origIdx);
413
  if (sameItem.length === 2 && !pinch) {
 
414
  if (drag && drag.source === 'canvas') {
415
  drag = null;
416
  removeGhost();
417
  }
418
  const d = pointerDist(sameItem[0], sameItem[1]);
419
+ const a = pointerAngle(sameItem[0], sameItem[1]);
420
  pinch = {
421
  idx: origIdx,
422
  startDist: d,
423
  startScale: placedItems[origIdx].scale,
424
+ startAngle: a,
425
+ startRotation: placedItems[origIdx].rotation || 0,
426
  startMidX: (sameItem[0].x + sameItem[1].x) / 2,
427
  startMidY: (sameItem[0].y + sameItem[1].y) / 2,
428
  startItemX: placedItems[origIdx].x,
 
431
  return;
432
  }
433
 
434
+ // Double-tap to remove
435
  const now = Date.now();
436
  if (lastPlacedTap.idx === origIdx && (now - lastPlacedTap.time) < DOUBLE_TAP_MS) {
437
  img.classList.add('poof');
438
  setTimeout(() => {
439
  placedItems.splice(origIdx, 1);
440
  renderPlaced();
441
+ autoSave();
442
  }, 300);
443
  lastPlacedTap = { idx: -1, time: 0 };
444
  activePointers.delete(e.pointerId);
 
446
  }
447
  lastPlacedTap = { idx: origIdx, time: now };
448
 
449
+ // Single-finger drag
450
  const item = placedItems[origIdx];
451
  if (!item) return;
452
 
 
455
  id: item.clothingId,
456
  url: item.url,
457
  scale: item.scale,
458
+ rotation: item.rotation || 0,
459
  zIndex: item.zIndex,
460
+ isSticker: item.isSticker || false,
461
  startX: e.clientX,
462
  startY: e.clientY,
463
  canvasIdx: origIdx,
 
466
  }
467
 
468
  function onPointerMove(e) {
 
469
  if (activePointers.has(e.pointerId)) {
470
  const ptr = activePointers.get(e.pointerId);
471
  ptr.x = e.clientX;
472
  ptr.y = e.clientY;
473
  }
474
 
475
+ // Pinch-to-resize + rotate
476
  if (pinch) {
477
  e.preventDefault();
478
  const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
479
  if (sameItem.length >= 2) {
480
  const d = pointerDist(sameItem[0], sameItem[1]);
481
+ const a = pointerAngle(sameItem[0], sameItem[1]);
482
  const ratio = d / pinch.startDist;
483
+ const newScale = Math.max(0.05, Math.min(3.0, pinch.startScale * ratio));
484
  placedItems[pinch.idx].scale = newScale;
485
 
486
+ // Rotation
487
+ const angleDelta = a - pinch.startAngle;
488
+ placedItems[pinch.idx].rotation = pinch.startRotation + angleDelta;
489
+
490
+ // Move with midpoint
491
  const canvas = document.getElementById('princess-canvas');
492
  const rect = canvas.getBoundingClientRect();
493
+ const mid = { x: (sameItem[0].x + sameItem[1].x) / 2, y: (sameItem[0].y + sameItem[1].y) / 2 };
494
  const dx = mid.x - pinch.startMidX;
495
  const dy = mid.y - pinch.startMidY;
496
  placedItems[pinch.idx].x = pinch.startItemX + dx / rect.width;
 
516
  createGhost(drag.url);
517
  ghost.classList.add('lifting');
518
  } else if (drag.source === 'canvas') {
 
519
  placedItems.splice(drag.canvasIdx, 1);
520
  renderPlaced();
521
  createGhost(drag.url);
 
529
  function onPointerUp(e) {
530
  activePointers.delete(e.pointerId);
531
 
 
532
  if (pinch) {
533
  const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
534
  if (sameItem.length < 2) {
535
  pinch = null;
536
+ autoSave();
537
  }
538
  return;
539
  }
 
541
  if (!drag) return;
542
 
543
  if (!drag.hasMoved) {
 
544
  if (drag.source === 'tray') {
545
  placedItems.push({
546
  clothingId: drag.id,
547
  x: 0.5,
548
  y: 0.5,
549
  scale: drag.scale,
550
+ rotation: 0,
551
  zIndex: drag.zIndex,
552
  url: drag.url,
553
+ isSticker: drag.isSticker,
554
  });
555
  renderPlaced();
556
+ autoSave();
557
  }
558
  drag = null;
559
  return;
 
562
  // Drop
563
  const canvas = document.getElementById('princess-canvas');
564
  const rect = canvas.getBoundingClientRect();
565
+ let dropped = false;
566
 
567
  if (
568
  e.clientX >= rect.left && e.clientX <= rect.right &&
 
575
  x: nx,
576
  y: ny,
577
  scale: drag.scale,
578
+ rotation: drag.rotation || 0,
579
  zIndex: drag.zIndex,
580
  url: drag.url,
581
+ isSticker: drag.isSticker,
582
  });
583
  renderPlaced();
584
+ autoSave();
585
+ dropped = true;
586
+ }
587
+
588
+ if (!dropped && drag.source === 'canvas') {
589
+ autoSave();
590
  }
591
 
592
  removeGhost();
js/segmentation.js CHANGED
@@ -1,23 +1,33 @@
1
  // Background removal using @huggingface/transformers with RMBG-1.4
2
  // Runs entirely in the browser (WebGPU with WASM fallback)
3
 
4
- let pipeline = null;
5
  let loading = false;
6
  let loadPromise = null;
7
 
8
- /**
9
- * Load the segmentation model (cached after first load).
10
- * @param {function} onProgress - called with {status, progress?} during download
11
- */
12
- export async function loadModel(onProgress) {
13
- if (pipeline) return pipeline;
 
 
 
 
 
 
 
 
 
 
 
14
  if (loadPromise) return loadPromise;
15
 
16
  loading = true;
17
  loadPromise = _doLoad(onProgress);
18
  try {
19
- pipeline = await loadPromise;
20
- return pipeline;
21
  } finally {
22
  loading = false;
23
  loadPromise = null;
@@ -25,12 +35,7 @@ export async function loadModel(onProgress) {
25
  }
26
 
27
  async function _doLoad(onProgress) {
28
- // Dynamic import from CDN
29
- const { AutoModel, AutoProcessor, RawImage, env } = await import(
30
- 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'
31
- );
32
-
33
- env.allowLocalModels = false;
34
 
35
  if (onProgress) onProgress({ status: 'loading', message: 'Loading AI model...' });
36
 
@@ -50,8 +55,53 @@ async function _doLoad(onProgress) {
50
 
51
  const processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
52
 
53
- // Return a function that segments images
54
- return { model, processor, RawImage };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
  /**
@@ -61,43 +111,53 @@ async function _doLoad(onProgress) {
61
  * @returns {Promise<Blob>} - PNG with transparent background
62
  */
63
  export async function removeBackground(imageBlob, onProgress) {
64
- const { model, processor, RawImage } = await loadModel(onProgress);
 
 
 
 
 
 
65
 
66
  if (onProgress) onProgress({ status: 'processing', message: 'Removing background...' });
67
 
68
- // Load image
69
- const url = URL.createObjectURL(imageBlob);
70
  const image = await RawImage.fromURL(url);
71
  URL.revokeObjectURL(url);
72
 
73
- // Process
74
  const { pixel_values } = await processor(image);
75
  const { output } = await model({ input: pixel_values });
76
 
77
- // The mask is a tensor, resize it to original image dimensions
78
  const maskData = output[0].data; // Float32Array
79
  const maskH = output[0].dims[1] || output.dims?.[2];
80
  const maskW = output[0].dims[2] || output.dims?.[3];
81
 
82
- // Draw original image onto canvas
83
- const bitmap = await createImageBitmap(imageBlob);
 
 
 
 
 
 
 
84
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
85
  const ctx = canvas.getContext('2d');
86
  ctx.drawImage(bitmap, 0, 0);
 
87
 
88
- // Apply mask to alpha channel
89
- const imgData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
90
- const scaleX = maskW / bitmap.width;
91
- const scaleY = maskH / bitmap.height;
92
 
93
- for (let y = 0; y < bitmap.height; y++) {
94
- for (let x = 0; x < bitmap.width; x++) {
95
  const mx = Math.min(Math.floor(x * scaleX), maskW - 1);
96
  const my = Math.min(Math.floor(y * scaleY), maskH - 1);
97
  const maskVal = maskData[my * maskW + mx];
98
- // Clamp to 0-255
99
  const alpha = Math.round(Math.max(0, Math.min(1, maskVal)) * 255);
100
- imgData.data[(y * bitmap.width + x) * 4 + 3] = alpha;
101
  }
102
  }
103
  ctx.putImageData(imgData, 0, 0);
@@ -107,8 +167,15 @@ export async function removeBackground(imageBlob, onProgress) {
107
  return canvas.convertToBlob({ type: 'image/png' });
108
  }
109
 
 
 
 
 
 
 
 
110
  export function isModelLoaded() {
111
- return pipeline !== null;
112
  }
113
 
114
  export function isModelLoading() {
 
1
  // Background removal using @huggingface/transformers with RMBG-1.4
2
  // Runs entirely in the browser (WebGPU with WASM fallback)
3
 
4
+ let transformers = null;
5
  let loading = false;
6
  let loadPromise = null;
7
 
8
+ const MODEL_DIM = 512; // resolution for model inference
9
+ const OUTPUT_DIM = 1024; // max resolution for final output
10
+
11
+ async function getTransformers() {
12
+ if (transformers) return transformers;
13
+ transformers = await import(
14
+ 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'
15
+ );
16
+ transformers.env.allowLocalModels = false;
17
+ // Enable browser Cache API on HTTPS (persists model across sessions)
18
+ if (location.protocol === 'https:') {
19
+ transformers.env.useBrowserCache = true;
20
+ }
21
+ return transformers;
22
+ }
23
+
24
+ async function loadModel(onProgress) {
25
  if (loadPromise) return loadPromise;
26
 
27
  loading = true;
28
  loadPromise = _doLoad(onProgress);
29
  try {
30
+ return await loadPromise;
 
31
  } finally {
32
  loading = false;
33
  loadPromise = null;
 
35
  }
36
 
37
  async function _doLoad(onProgress) {
38
+ const { AutoModel, AutoProcessor } = await getTransformers();
 
 
 
 
 
39
 
40
  if (onProgress) onProgress({ status: 'loading', message: 'Loading AI model...' });
41
 
 
55
 
56
  const processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
57
 
58
+ return { model, processor };
59
+ }
60
+
61
+ /**
62
+ * Decode image once and produce two sizes (output + model).
63
+ * Avoids decoding the full-res camera image multiple times.
64
+ */
65
+ async function prepareImages(imageBlob) {
66
+ // Decode full image once just to get dimensions, then close immediately
67
+ const probe = await createImageBitmap(imageBlob);
68
+ const fullW = probe.width;
69
+ const fullH = probe.height;
70
+ probe.close();
71
+
72
+ // Output size (what gets saved)
73
+ const outRatio = Math.min(1, OUTPUT_DIM / Math.max(fullW, fullH));
74
+ const outW = Math.round(fullW * outRatio);
75
+ const outH = Math.round(fullH * outRatio);
76
+
77
+ // Use resizeWidth/resizeHeight to avoid a full-res intermediate bitmap
78
+ const outBitmap = await createImageBitmap(imageBlob, {
79
+ resizeWidth: outW, resizeHeight: outH, resizeQuality: 'medium',
80
+ });
81
+ const outCanvas = new OffscreenCanvas(outW, outH);
82
+ outCanvas.getContext('2d').drawImage(outBitmap, 0, 0);
83
+ outBitmap.close();
84
+ const outputBlob = await outCanvas.convertToBlob({ type: 'image/png' });
85
+
86
+ // Model size (smaller, for inference)
87
+ const modRatio = Math.min(1, MODEL_DIM / Math.max(outW, outH));
88
+ const modW = Math.round(outW * modRatio);
89
+ const modH = Math.round(outH * modRatio);
90
+
91
+ let modelBlob;
92
+ if (modW === outW && modH === outH) {
93
+ modelBlob = outputBlob;
94
+ } else {
95
+ const modBitmap = await createImageBitmap(outputBlob, {
96
+ resizeWidth: modW, resizeHeight: modH, resizeQuality: 'medium',
97
+ });
98
+ const modCanvas = new OffscreenCanvas(modW, modH);
99
+ modCanvas.getContext('2d').drawImage(modBitmap, 0, 0);
100
+ modBitmap.close();
101
+ modelBlob = await modCanvas.convertToBlob({ type: 'image/png' });
102
+ }
103
+
104
+ return { outputBlob, modelBlob };
105
  }
106
 
107
  /**
 
111
  * @returns {Promise<Blob>} - PNG with transparent background
112
  */
113
  export async function removeBackground(imageBlob, onProgress) {
114
+ // 1. Prepare downsized images BEFORE loading model (lower peak memory)
115
+ if (onProgress) onProgress({ status: 'processing', message: 'Preparing image...' });
116
+ const { outputBlob, modelBlob } = await prepareImages(imageBlob);
117
+
118
+ // 2. Load model
119
+ const { model, processor } = await loadModel(onProgress);
120
+ const { RawImage } = await getTransformers();
121
 
122
  if (onProgress) onProgress({ status: 'processing', message: 'Removing background...' });
123
 
124
+ // 3. Run inference on small image
125
+ const url = URL.createObjectURL(modelBlob);
126
  const image = await RawImage.fromURL(url);
127
  URL.revokeObjectURL(url);
128
 
 
129
  const { pixel_values } = await processor(image);
130
  const { output } = await model({ input: pixel_values });
131
 
 
132
  const maskData = output[0].data; // Float32Array
133
  const maskH = output[0].dims[1] || output.dims?.[2];
134
  const maskW = output[0].dims[2] || output.dims?.[3];
135
 
136
+ // Dispose tensors to free WASM memory
137
+ if (pixel_values.dispose) pixel_values.dispose();
138
+ if (output[0].dispose) output[0].dispose();
139
+
140
+ // 4. Free model from JS memory (Cache API keeps weights on disk for HTTPS)
141
+ disposeModel(model);
142
+
143
+ // 5. Apply mask to the output-sized image
144
+ const bitmap = await createImageBitmap(outputBlob);
145
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
146
  const ctx = canvas.getContext('2d');
147
  ctx.drawImage(bitmap, 0, 0);
148
+ bitmap.close();
149
 
150
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
151
+ const scaleX = maskW / canvas.width;
152
+ const scaleY = maskH / canvas.height;
 
153
 
154
+ for (let y = 0; y < canvas.height; y++) {
155
+ for (let x = 0; x < canvas.width; x++) {
156
  const mx = Math.min(Math.floor(x * scaleX), maskW - 1);
157
  const my = Math.min(Math.floor(y * scaleY), maskH - 1);
158
  const maskVal = maskData[my * maskW + mx];
 
159
  const alpha = Math.round(Math.max(0, Math.min(1, maskVal)) * 255);
160
+ imgData.data[(y * canvas.width + x) * 4 + 3] = alpha;
161
  }
162
  }
163
  ctx.putImageData(imgData, 0, 0);
 
167
  return canvas.convertToBlob({ type: 'image/png' });
168
  }
169
 
170
+ function disposeModel(model) {
171
+ try {
172
+ if (model.dispose) model.dispose();
173
+ } catch (_) {}
174
+ loadPromise = null;
175
+ }
176
+
177
  export function isModelLoaded() {
178
+ return loading;
179
  }
180
 
181
  export function isModelLoading() {
js/stickers.js ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Built-in decorative stickers for the dress-up screen.
2
+ // Each sticker is an inline SVG data URL — no downloads needed.
3
+
4
+ function svgURL(svg) {
5
+ return 'data:image/svg+xml,' + encodeURIComponent(svg.trim());
6
+ }
7
+
8
+ export const STICKERS = [
9
+ {
10
+ id: 'stk-sparkle',
11
+ name: 'Sparkles',
12
+ emoji: '\u2728',
13
+ scale: 0.25,
14
+ zIndex: 10,
15
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
16
+ <polygon points="50,5 58,38 95,38 65,58 75,95 50,72 25,95 35,58 5,38 42,38" fill="#FFD700" opacity="0.9"/>
17
+ <polygon points="50,20 55,42 78,42 60,55 67,78 50,65 33,78 40,55 22,42 45,42" fill="#FFF8DC"/>
18
+ <circle cx="25" cy="20" r="4" fill="#FFD700" opacity="0.7"/>
19
+ <circle cx="80" cy="15" r="3" fill="#FFD700" opacity="0.6"/>
20
+ <circle cx="15" cy="75" r="3" fill="#FFD700" opacity="0.5"/>
21
+ <circle cx="85" cy="70" r="4" fill="#FFD700" opacity="0.6"/>
22
+ </svg>`),
23
+ },
24
+ {
25
+ id: 'stk-heart',
26
+ name: 'Heart',
27
+ emoji: '\u{1F496}',
28
+ scale: 0.2,
29
+ zIndex: 10,
30
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
31
+ <path d="M50 88 C25 65 5 50 5 30 A22 22 0 0 1 50 25 A22 22 0 0 1 95 30 C95 50 75 65 50 88Z" fill="#FF69B4"/>
32
+ <path d="M50 80 C30 62 15 50 15 34 A16 16 0 0 1 50 30 A16 16 0 0 1 85 34 C85 50 70 62 50 80Z" fill="#FFB6C1" opacity="0.5"/>
33
+ </svg>`),
34
+ },
35
+ {
36
+ id: 'stk-star',
37
+ name: 'Star',
38
+ emoji: '\u2B50',
39
+ scale: 0.22,
40
+ zIndex: 10,
41
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
42
+ <polygon points="50,5 63,35 95,40 72,62 78,95 50,78 22,95 28,62 5,40 37,35" fill="#FFD700"/>
43
+ <polygon points="50,18 59,38 80,42 65,56 69,78 50,67 31,78 35,56 20,42 41,38" fill="#FFF8DC" opacity="0.6"/>
44
+ </svg>`),
45
+ },
46
+ {
47
+ id: 'stk-butterfly',
48
+ name: 'Butterfly',
49
+ emoji: '\u{1F98B}',
50
+ scale: 0.3,
51
+ zIndex: 10,
52
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100">
53
+ <ellipse cx="35" cy="35" rx="30" ry="25" fill="#DDA0DD" transform="rotate(-15 35 35)"/>
54
+ <ellipse cx="85" cy="35" rx="30" ry="25" fill="#DDA0DD" transform="rotate(15 85 35)"/>
55
+ <ellipse cx="30" cy="62" rx="22" ry="18" fill="#E6B8E6" transform="rotate(-10 30 62)"/>
56
+ <ellipse cx="90" cy="62" rx="22" ry="18" fill="#E6B8E6" transform="rotate(10 90 62)"/>
57
+ <ellipse cx="35" cy="35" rx="18" ry="14" fill="#BA55D3" opacity="0.5" transform="rotate(-15 35 35)"/>
58
+ <ellipse cx="85" cy="35" rx="18" ry="14" fill="#BA55D3" opacity="0.5" transform="rotate(15 85 35)"/>
59
+ <rect x="58" y="15" width="4" height="65" rx="2" fill="#4B0082"/>
60
+ <circle cx="55" cy="10" r="4" fill="#4B0082"/><circle cx="65" cy="10" r="4" fill="#4B0082"/>
61
+ </svg>`),
62
+ },
63
+ {
64
+ id: 'stk-flower',
65
+ name: 'Flower',
66
+ emoji: '\u{1F338}',
67
+ scale: 0.22,
68
+ zIndex: 10,
69
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
70
+ <circle cx="50" cy="28" r="18" fill="#FFB7C5"/>
71
+ <circle cx="28" cy="45" r="18" fill="#FFB7C5"/>
72
+ <circle cx="72" cy="45" r="18" fill="#FFB7C5"/>
73
+ <circle cx="35" cy="68" r="18" fill="#FFB7C5"/>
74
+ <circle cx="65" cy="68" r="18" fill="#FFB7C5"/>
75
+ <circle cx="50" cy="50" r="14" fill="#FFD700"/>
76
+ <circle cx="46" cy="46" r="4" fill="#FFA500" opacity="0.5"/>
77
+ <circle cx="54" cy="52" r="3" fill="#FFA500" opacity="0.4"/>
78
+ </svg>`),
79
+ },
80
+ {
81
+ id: 'stk-rainbow',
82
+ name: 'Rainbow',
83
+ emoji: '\u{1F308}',
84
+ scale: 0.4,
85
+ zIndex: 9,
86
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 80">
87
+ <path d="M10 75 A60 60 0 0 1 130 75" fill="none" stroke="#FF0000" stroke-width="8" opacity="0.7"/>
88
+ <path d="M18 75 A52 52 0 0 1 122 75" fill="none" stroke="#FF8C00" stroke-width="8" opacity="0.7"/>
89
+ <path d="M26 75 A44 44 0 0 1 114 75" fill="none" stroke="#FFD700" stroke-width="8" opacity="0.7"/>
90
+ <path d="M34 75 A36 36 0 0 1 106 75" fill="none" stroke="#32CD32" stroke-width="8" opacity="0.7"/>
91
+ <path d="M42 75 A28 28 0 0 1 98 75" fill="none" stroke="#4169E1" stroke-width="8" opacity="0.7"/>
92
+ <path d="M50 75 A20 20 0 0 1 90 75" fill="none" stroke="#8A2BE2" stroke-width="8" opacity="0.7"/>
93
+ </svg>`),
94
+ },
95
+ {
96
+ id: 'stk-gem',
97
+ name: 'Gem',
98
+ emoji: '\u{1F48E}',
99
+ scale: 0.18,
100
+ zIndex: 10,
101
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
102
+ <polygon points="50,8 85,35 70,92 30,92 15,35" fill="#87CEEB"/>
103
+ <polygon points="50,8 65,35 50,92 35,35" fill="#ADD8E6" opacity="0.7"/>
104
+ <polygon points="50,8 85,35 65,35" fill="#B0E0E6" opacity="0.5"/>
105
+ <line x1="50" y1="8" x2="50" y2="92" stroke="white" stroke-width="1" opacity="0.3"/>
106
+ <polygon points="15,35 35,35 30,92" fill="#5F9EA0" opacity="0.3"/>
107
+ </svg>`),
108
+ },
109
+ {
110
+ id: 'stk-bow',
111
+ name: 'Bow',
112
+ emoji: '\u{1F380}',
113
+ scale: 0.25,
114
+ zIndex: 10,
115
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
116
+ <path d="M60 40 Q20 5 8 35 Q5 50 60 40Z" fill="#FF69B4"/>
117
+ <path d="M60 40 Q100 5 112 35 Q115 50 60 40Z" fill="#FF69B4"/>
118
+ <path d="M60 40 Q40 55 35 72 Q50 60 60 40Z" fill="#FF69B4"/>
119
+ <path d="M60 40 Q80 55 85 72 Q70 60 60 40Z" fill="#FF69B4"/>
120
+ <circle cx="60" cy="40" r="8" fill="#FF1493"/>
121
+ <path d="M60 40 Q25 10 12 32" fill="none" stroke="#FFB6C1" stroke-width="2" opacity="0.5"/>
122
+ <path d="M60 40 Q95 10 108 32" fill="none" stroke="#FFB6C1" stroke-width="2" opacity="0.5"/>
123
+ </svg>`),
124
+ },
125
+ {
126
+ id: 'stk-moon',
127
+ name: 'Moon',
128
+ emoji: '\u{1F319}',
129
+ scale: 0.2,
130
+ zIndex: 9,
131
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
132
+ <circle cx="45" cy="50" r="38" fill="#FFD700"/>
133
+ <circle cx="62" cy="42" r="32" fill="#FDF6E3"/>
134
+ <circle cx="30" cy="35" r="3" fill="#FFF8DC" opacity="0.6"/>
135
+ <circle cx="25" cy="55" r="2" fill="#FFF8DC" opacity="0.5"/>
136
+ </svg>`),
137
+ },
138
+ {
139
+ id: 'stk-wand',
140
+ name: 'Magic wand',
141
+ emoji: '\u{1FA84}',
142
+ scale: 0.35,
143
+ zIndex: 10,
144
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120">
145
+ <line x1="50" y1="45" x2="50" y2="115" stroke="#DDA0DD" stroke-width="6" stroke-linecap="round"/>
146
+ <line x1="50" y1="45" x2="50" y2="115" stroke="#BA55D3" stroke-width="3" stroke-linecap="round"/>
147
+ <polygon points="50,2 57,18 74,20 62,32 65,50 50,42 35,50 38,32 26,20 43,18" fill="#FFD700"/>
148
+ <polygon points="50,10 54,22 66,23 58,31 60,43 50,37 40,43 42,31 34,23 46,22" fill="#FFF8DC" opacity="0.7"/>
149
+ <circle cx="30" cy="12" r="2" fill="#FFD700" opacity="0.7"/>
150
+ <circle cx="72" cy="8" r="2.5" fill="#FFD700" opacity="0.6"/>
151
+ <circle cx="22" cy="35" r="2" fill="#FFD700" opacity="0.5"/>
152
+ <circle cx="78" cy="32" r="1.5" fill="#FFD700" opacity="0.6"/>
153
+ </svg>`),
154
+ },
155
+ {
156
+ id: 'stk-crown',
157
+ name: 'Tiara',
158
+ emoji: '\u{1F451}',
159
+ scale: 0.3,
160
+ zIndex: 11,
161
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 70">
162
+ <path d="M10 60 L20 20 L40 40 L60 8 L80 40 L100 20 L110 60Z" fill="#FFD700"/>
163
+ <path d="M15 60 L23 25 L40 42 L60 15 L80 42 L97 25 L105 60Z" fill="#FFF8DC" opacity="0.4"/>
164
+ <circle cx="60" cy="18" r="6" fill="#FF69B4"/>
165
+ <circle cx="22" cy="28" r="5" fill="#87CEEB"/>
166
+ <circle cx="98" cy="28" r="5" fill="#87CEEB"/>
167
+ <rect x="10" y="55" width="100" height="10" rx="3" fill="#FFD700"/>
168
+ <rect x="12" y="57" width="96" height="6" rx="2" fill="#DAA520" opacity="0.3"/>
169
+ </svg>`),
170
+ },
171
+ {
172
+ id: 'stk-cloud',
173
+ name: 'Cloud',
174
+ emoji: '\u2601\uFE0F',
175
+ scale: 0.3,
176
+ zIndex: 8,
177
+ url: svgURL(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 70">
178
+ <ellipse cx="60" cy="45" rx="50" ry="22" fill="white" opacity="0.85"/>
179
+ <circle cx="40" cy="32" r="22" fill="white" opacity="0.85"/>
180
+ <circle cx="65" cy="28" r="26" fill="white" opacity="0.85"/>
181
+ <circle cx="85" cy="36" r="18" fill="white" opacity="0.85"/>
182
+ </svg>`),
183
+ },
184
+ ];
js/store.js CHANGED
@@ -1,5 +1,5 @@
1
  const DB_NAME = 'princess-game';
2
- const DB_VERSION = 1;
3
 
4
  let db = null;
5
 
@@ -24,6 +24,9 @@ function openDB() {
24
  if (!d.objectStoreNames.contains('clothes')) {
25
  d.createObjectStore('clothes', { keyPath: 'id' });
26
  }
 
 
 
27
  };
28
  req.onsuccess = () => { db = req.result; resolve(db); };
29
  req.onerror = () => reject(req.error);
@@ -73,12 +76,21 @@ export async function updatePrincessAnchors(id, anchors) {
73
  }
74
  }
75
 
 
 
 
 
 
 
 
 
 
76
  // --- Clothes ---
77
 
78
  export async function saveClothing({ name, category, imageBlob, targetAnchor, scale, zIndex }) {
79
  const id = uuid();
80
  const store = await tx('clothes', 'readwrite');
81
- const record = { id, name, category, imageBlob, targetAnchor, scale: scale || 0.4, zIndex, createdAt: Date.now() };
82
  await reqToPromise(store.put(record));
83
  return record;
84
  }
@@ -98,6 +110,26 @@ export async function deleteClothing(id) {
98
  await reqToPromise(store.delete(id));
99
  }
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  // --- Helpers ---
102
 
103
  export function blobToURL(blob) {
 
1
  const DB_NAME = 'princess-game';
2
+ const DB_VERSION = 2;
3
 
4
  let db = null;
5
 
 
24
  if (!d.objectStoreNames.contains('clothes')) {
25
  d.createObjectStore('clothes', { keyPath: 'id' });
26
  }
27
+ if (!d.objectStoreNames.contains('photos')) {
28
+ d.createObjectStore('photos', { keyPath: 'id' });
29
+ }
30
  };
31
  req.onsuccess = () => { db = req.result; resolve(db); };
32
  req.onerror = () => reject(req.error);
 
76
  }
77
  }
78
 
79
+ export async function savePrincessOutfit(id, outfit) {
80
+ const store = await tx('princesses', 'readwrite');
81
+ const p = await reqToPromise(store.get(id));
82
+ if (p) {
83
+ p.outfit = outfit;
84
+ await reqToPromise(store.put(p));
85
+ }
86
+ }
87
+
88
  // --- Clothes ---
89
 
90
  export async function saveClothing({ name, category, imageBlob, targetAnchor, scale, zIndex }) {
91
  const id = uuid();
92
  const store = await tx('clothes', 'readwrite');
93
+ const record = { id, name, category, imageBlob, targetAnchor, scale: scale || 0.8, zIndex, createdAt: Date.now() };
94
  await reqToPromise(store.put(record));
95
  return record;
96
  }
 
110
  await reqToPromise(store.delete(id));
111
  }
112
 
113
+ // --- Photos (captured source images) ---
114
+
115
+ export async function savePhoto(imageBlob) {
116
+ const id = uuid();
117
+ const store = await tx('photos', 'readwrite');
118
+ const record = { id, imageBlob, createdAt: Date.now() };
119
+ await reqToPromise(store.put(record));
120
+ return record;
121
+ }
122
+
123
+ export async function loadPhotos() {
124
+ const store = await tx('photos');
125
+ return reqToPromise(store.getAll());
126
+ }
127
+
128
+ export async function deletePhoto(id) {
129
+ const store = await tx('photos', 'readwrite');
130
+ await reqToPromise(store.delete(id));
131
+ }
132
+
133
  // --- Helpers ---
134
 
135
  export function blobToURL(blob) {
js/upload.js CHANGED
@@ -1,6 +1,6 @@
1
  import { CATEGORIES, getCategoryInfo, DEFAULT_ANCHORS } from './models.js';
2
- import { savePrincess, saveClothing } from './store.js';
3
- import { removeBackground, isModelLoaded } from './segmentation.js';
4
  import { showPolygonSelector } from './polygon-select.js';
5
  import { navigate } from './app.js';
6
 
@@ -13,8 +13,16 @@ let segmentedBlob = null; // after background removal
13
  let previewURL = null;
14
  let croppedURL = null;
15
 
16
- export function initUpload() {
 
 
17
  const screen = document.getElementById('upload-screen');
 
 
 
 
 
 
18
  screen.innerHTML = `
19
  <div class="upload-header">
20
  <a href="#/" class="back-btn">\u2190 Back</a>
@@ -34,6 +42,24 @@ export function initUpload() {
34
  </label>
35
  </div>
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  <button id="select-area-btn" class="pill select-area-btn" style="display:none">
38
  \u2702\uFE0F Select area
39
  </button>
@@ -56,7 +82,7 @@ export function initUpload() {
56
  `;
57
 
58
  buildCategoryGrid();
59
- bindEvents();
60
  resetState();
61
  }
62
 
@@ -70,7 +96,24 @@ function buildCategoryGrid() {
70
  `).join('');
71
  }
72
 
73
- function bindEvents() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  // Type toggle
75
  document.querySelectorAll('.type-toggle .pill').forEach(btn => {
76
  btn.addEventListener('click', () => {
@@ -92,27 +135,39 @@ function bindEvents() {
92
  btn.classList.add('selected');
93
  });
94
 
95
- // Camera input
96
- document.getElementById('camera-input').addEventListener('change', (e) => {
97
  const file = e.target.files[0];
98
  if (!file) return;
99
 
100
- imageBlob = file;
101
- croppedBlob = null;
102
- polygon = null;
103
- segmentedBlob = null;
104
- if (previewURL) URL.revokeObjectURL(previewURL);
105
- if (croppedURL) URL.revokeObjectURL(croppedURL);
106
- previewURL = URL.createObjectURL(file);
107
- croppedURL = null;
108
-
109
- showPhotoPreview(previewURL);
110
- document.getElementById('select-area-btn').style.display = '';
111
- document.getElementById('upload-status').innerHTML = '';
112
- document.getElementById('submit-btn').style.display = '';
113
- updateSubmitState();
114
  });
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  // Select area
117
  document.getElementById('select-area-btn').addEventListener('click', handleSelectArea);
118
 
@@ -143,7 +198,7 @@ async function handleSelectArea() {
143
  if (croppedURL) URL.revokeObjectURL(croppedURL);
144
  croppedURL = URL.createObjectURL(croppedBlob);
145
 
146
- showPhotoPreview(croppedURL, 'Area selected \u2014 tap to retake photo');
147
  document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Reselect area';
148
  updateSubmitState();
149
  }
@@ -167,9 +222,7 @@ async function handleMagicTime() {
167
  btn.disabled = true;
168
  btn.style.display = 'none';
169
 
170
- const modelHint = isModelLoaded()
171
- ? ''
172
- : '<p class="progress-hint">First time takes a moment to download the AI model.</p>';
173
 
174
  status.innerHTML = `
175
  <div class="processing">
@@ -200,7 +253,7 @@ async function handleMagicTime() {
200
 
201
  // Show preview with approve/retry
202
  const segURL = URL.createObjectURL(segmentedBlob);
203
- const beforeURL = croppedURL || previewURL;
204
  status.innerHTML = `
205
  <div class="seg-preview">
206
  <p class="seg-preview-label">How does it look?</p>
@@ -248,25 +301,8 @@ async function handleMagicTime() {
248
  }
249
 
250
  function resetToCamera() {
251
- const status = document.getElementById('upload-status');
252
- const btn = document.getElementById('submit-btn');
253
- status.innerHTML = '';
254
- btn.style.display = '';
255
- btn.disabled = false;
256
- document.getElementById('camera-input').value = '';
257
- const label = document.getElementById('camera-label');
258
- label.innerHTML = `
259
- <span class="camera-icon">\u{1F4F8}</span>
260
- <span class="camera-text">Tap to photograph!</span>
261
- `;
262
- label.className = 'camera-label';
263
- document.getElementById('select-area-btn').style.display = 'none';
264
- document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Select area';
265
- imageBlob = null;
266
- croppedBlob = null;
267
- polygon = null;
268
- segmentedBlob = null;
269
- updateSubmitState();
270
  }
271
 
272
  /**
@@ -338,7 +374,7 @@ async function saveAsset(name, blob) {
338
  category: selectedCategory,
339
  imageBlob: blob,
340
  targetAnchor: catInfo.targetAnchor,
341
- scale: 0.4,
342
  zIndex: catInfo.zIndex,
343
  });
344
  }
 
1
  import { CATEGORIES, getCategoryInfo, DEFAULT_ANCHORS } from './models.js';
2
+ import { savePrincess, saveClothing, savePhoto, loadPhotos, deletePhoto, blobToURL } from './store.js';
3
+ import { removeBackground } from './segmentation.js';
4
  import { showPolygonSelector } from './polygon-select.js';
5
  import { navigate } from './app.js';
6
 
 
13
  let previewURL = null;
14
  let croppedURL = null;
15
 
16
+ let photoURLs = []; // object URLs for gallery thumbnails
17
+
18
+ export async function initUpload() {
19
  const screen = document.getElementById('upload-screen');
20
+
21
+ photoURLs.forEach(u => URL.revokeObjectURL(u));
22
+ photoURLs = [];
23
+
24
+ const photos = await loadPhotos();
25
+
26
  screen.innerHTML = `
27
  <div class="upload-header">
28
  <a href="#/" class="back-btn">\u2190 Back</a>
 
42
  </label>
43
  </div>
44
 
45
+ ${photos.length > 0 ? `
46
+ <div class="photo-gallery">
47
+ <p class="picker-label">Or use a previous photo</p>
48
+ <div class="photo-gallery-scroll" id="photo-gallery">
49
+ ${photos.map(p => {
50
+ const url = blobToURL(p.imageBlob);
51
+ photoURLs.push(url);
52
+ return `
53
+ <div class="photo-gallery-item" data-photo-id="${p.id}">
54
+ <img src="${url}" class="photo-gallery-thumb" draggable="false"/>
55
+ <button class="photo-gallery-delete" data-photo-id="${p.id}">\u00D7</button>
56
+ </div>
57
+ `;
58
+ }).join('')}
59
+ </div>
60
+ </div>
61
+ ` : ''}
62
+
63
  <button id="select-area-btn" class="pill select-area-btn" style="display:none">
64
  \u2702\uFE0F Select area
65
  </button>
 
82
  `;
83
 
84
  buildCategoryGrid();
85
+ bindEvents(photos);
86
  resetState();
87
  }
88
 
 
96
  `).join('');
97
  }
98
 
99
+ function selectImage(blob) {
100
+ imageBlob = blob;
101
+ croppedBlob = null;
102
+ polygon = null;
103
+ segmentedBlob = null;
104
+ if (previewURL) URL.revokeObjectURL(previewURL);
105
+ if (croppedURL) URL.revokeObjectURL(croppedURL);
106
+ previewURL = URL.createObjectURL(blob);
107
+ croppedURL = null;
108
+
109
+ showPhotoPreview(previewURL);
110
+ document.getElementById('select-area-btn').style.display = '';
111
+ document.getElementById('upload-status').innerHTML = '';
112
+ document.getElementById('submit-btn').style.display = '';
113
+ updateSubmitState();
114
+ }
115
+
116
+ function bindEvents(photos) {
117
  // Type toggle
118
  document.querySelectorAll('.type-toggle .pill').forEach(btn => {
119
  btn.addEventListener('click', () => {
 
135
  btn.classList.add('selected');
136
  });
137
 
138
+ // Camera input — save to gallery automatically
139
+ document.getElementById('camera-input').addEventListener('change', async (e) => {
140
  const file = e.target.files[0];
141
  if (!file) return;
142
 
143
+ // Save to photo gallery for reuse
144
+ await savePhoto(file);
145
+
146
+ selectImage(file);
 
 
 
 
 
 
 
 
 
 
147
  });
148
 
149
+ // Photo gallery — tap to reuse, X to delete
150
+ const gallery = document.getElementById('photo-gallery');
151
+ if (gallery) {
152
+ gallery.addEventListener('click', async (e) => {
153
+ // Delete button
154
+ const delBtn = e.target.closest('.photo-gallery-delete');
155
+ if (delBtn) {
156
+ e.stopPropagation();
157
+ const id = delBtn.dataset.photoId;
158
+ await deletePhoto(id);
159
+ initUpload(); // re-render
160
+ return;
161
+ }
162
+
163
+ // Select photo
164
+ const item = e.target.closest('.photo-gallery-item');
165
+ if (!item) return;
166
+ const photo = photos.find(p => p.id === item.dataset.photoId);
167
+ if (photo) selectImage(photo.imageBlob);
168
+ });
169
+ }
170
+
171
  // Select area
172
  document.getElementById('select-area-btn').addEventListener('click', handleSelectArea);
173
 
 
198
  if (croppedURL) URL.revokeObjectURL(croppedURL);
199
  croppedURL = URL.createObjectURL(croppedBlob);
200
 
201
+ showPhotoPreview(previewURL, '\u2702\uFE0F Area selected \u2014 tap to retake photo');
202
  document.getElementById('select-area-btn').textContent = '\u2702\uFE0F Reselect area';
203
  updateSubmitState();
204
  }
 
222
  btn.disabled = true;
223
  btn.style.display = 'none';
224
 
225
+ const modelHint = '<p class="progress-hint">This might take a moment.</p>';
 
 
226
 
227
  status.innerHTML = `
228
  <div class="processing">
 
253
 
254
  // Show preview with approve/retry
255
  const segURL = URL.createObjectURL(segmentedBlob);
256
+ const beforeURL = previewURL;
257
  status.innerHTML = `
258
  <div class="seg-preview">
259
  <p class="seg-preview-label">How does it look?</p>
 
301
  }
302
 
303
  function resetToCamera() {
304
+ resetState();
305
+ initUpload();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  }
307
 
308
  /**
 
374
  category: selectedCategory,
375
  imageBlob: blob,
376
  targetAnchor: catInfo.targetAnchor,
377
+ scale: 0.8,
378
  zIndex: catInfo.zIndex,
379
  });
380
  }
style.css CHANGED
@@ -161,6 +161,35 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
161
 
162
  .switch-label { font-size: 1.1rem; font-weight: 700; }
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  .clothing-tray {
165
  display: flex;
166
  gap: 0.5rem;
@@ -227,6 +256,23 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
227
  flex-shrink: 0;
228
  }
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  /* ---- Manage screen ---- */
231
 
232
  #manage-screen {
@@ -376,6 +422,64 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
376
  box-shadow: 0 2px 0 var(--ink);
377
  }
378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  .camera-zone { position: relative; flex-shrink: 0; }
380
  .camera-input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
381
 
@@ -551,10 +655,11 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
551
 
552
  .placed-item.poof {
553
  animation: poof 0.3s ease-out forwards;
 
554
  }
555
 
556
  @keyframes poof {
557
- to { transform: translate(-50%, -50%) scale(0); opacity: 0; }
558
  }
559
 
560
  /* ---- Sparkle burst ---- */
 
161
 
162
  .switch-label { font-size: 1.1rem; font-weight: 700; }
163
 
164
+ /* ---- Tray tabs ---- */
165
+
166
+ .tray-tabs {
167
+ display: flex;
168
+ justify-content: center;
169
+ gap: 0.4rem;
170
+ flex-shrink: 0;
171
+ }
172
+
173
+ .tray-tab {
174
+ font-family: var(--font);
175
+ font-size: 0.9rem;
176
+ font-weight: 700;
177
+ padding: 0.4rem 1rem;
178
+ border: 2px solid var(--lavender);
179
+ border-radius: 1.5rem;
180
+ background: var(--panel);
181
+ color: var(--lavender-dk);
182
+ cursor: pointer;
183
+ transition: all 0.15s var(--spring);
184
+ touch-action: manipulation;
185
+ }
186
+
187
+ .tray-tab.active {
188
+ background: var(--lavender);
189
+ color: white;
190
+ border-color: var(--lavender-dk);
191
+ }
192
+
193
  .clothing-tray {
194
  display: flex;
195
  gap: 0.5rem;
 
256
  flex-shrink: 0;
257
  }
258
 
259
+ /* ---- Screenshot flash ---- */
260
+
261
+ .screenshot-flash {
262
+ animation: flash 0.4s ease-out;
263
+ }
264
+
265
+ @keyframes flash {
266
+ 0% { filter: brightness(2); }
267
+ 100% { filter: brightness(1); }
268
+ }
269
+
270
+ /* ---- Sticker tray items ---- */
271
+
272
+ .tray-sticker .tray-thumb {
273
+ background: none;
274
+ }
275
+
276
  /* ---- Manage screen ---- */
277
 
278
  #manage-screen {
 
422
  box-shadow: 0 2px 0 var(--ink);
423
  }
424
 
425
+ /* ---- Photo gallery (reuse previous captures) ---- */
426
+
427
+ .photo-gallery {
428
+ display: flex;
429
+ flex-direction: column;
430
+ gap: 0.4rem;
431
+ }
432
+
433
+ .photo-gallery-scroll {
434
+ display: flex;
435
+ gap: 0.5rem;
436
+ overflow-x: auto;
437
+ padding: 0.25rem;
438
+ -webkit-overflow-scrolling: touch;
439
+ scroll-snap-type: x mandatory;
440
+ }
441
+
442
+ .photo-gallery-item {
443
+ position: relative;
444
+ flex-shrink: 0;
445
+ scroll-snap-align: start;
446
+ cursor: pointer;
447
+ }
448
+
449
+ .photo-gallery-thumb {
450
+ width: 80px;
451
+ height: 80px;
452
+ object-fit: cover;
453
+ border-radius: 0.75rem;
454
+ border: 3px solid var(--lavender);
455
+ transition: border-color 0.15s;
456
+ }
457
+
458
+ .photo-gallery-item:active .photo-gallery-thumb {
459
+ border-color: var(--gold);
460
+ }
461
+
462
+ .photo-gallery-delete {
463
+ position: absolute;
464
+ top: -6px;
465
+ right: -6px;
466
+ width: 24px;
467
+ height: 24px;
468
+ border: 2px solid var(--ink);
469
+ border-radius: 50%;
470
+ background: var(--rose);
471
+ color: white;
472
+ font-size: 0.9rem;
473
+ font-weight: 700;
474
+ line-height: 1;
475
+ cursor: pointer;
476
+ display: flex;
477
+ align-items: center;
478
+ justify-content: center;
479
+ padding: 0;
480
+ touch-action: manipulation;
481
+ }
482
+
483
  .camera-zone { position: relative; flex-shrink: 0; }
484
  .camera-input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
485
 
 
655
 
656
  .placed-item.poof {
657
  animation: poof 0.3s ease-out forwards;
658
+ pointer-events: none;
659
  }
660
 
661
  @keyframes poof {
662
+ to { scale: 0; opacity: 0; }
663
  }
664
 
665
  /* ---- Sparkle burst ---- */