@mevdragon/vidfarm-devcli 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +166 -0
  2. package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
  3. package/.agents/skills/vidfarm-media/SKILL.md +2 -0
  4. package/SKILL.director.md +174 -16
  5. package/SKILL.platform.md +7 -3
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +79 -75
  8. package/dist/src/account-pages-legacy.js +1 -1
  9. package/dist/src/app.js +1694 -217
  10. package/dist/src/cli.js +1365 -105
  11. package/dist/src/config.js +8 -0
  12. package/dist/src/devcli/clips.js +3 -0
  13. package/dist/src/devcli/composition-edit.js +401 -10
  14. package/dist/src/devcli/local-backend.js +123 -0
  15. package/dist/src/devcli/migrate-local.js +140 -0
  16. package/dist/src/devcli/sync.js +311 -0
  17. package/dist/src/devcli/timeline-edit.js +208 -1
  18. package/dist/src/editor-chat.js +37 -18
  19. package/dist/src/frontend/file-directory.js +271 -20
  20. package/dist/src/frontend/homepage-view.js +3 -3
  21. package/dist/src/frontend/template-editor-chat.js +6 -3
  22. package/dist/src/page-shell.js +1 -1
  23. package/dist/src/primitive-context.js +31 -2
  24. package/dist/src/primitive-registry.js +409 -4
  25. package/dist/src/reskin/chat-page.js +103 -15
  26. package/dist/src/reskin/discover-page.js +247 -10
  27. package/dist/src/reskin/document.js +147 -10
  28. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  29. package/dist/src/reskin/inpaint-page.js +2459 -452
  30. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  31. package/dist/src/reskin/library-page.js +324 -82
  32. package/dist/src/reskin/portfolio-page.js +687 -0
  33. package/dist/src/reskin/theme.js +36 -11
  34. package/dist/src/services/billing.js +4 -0
  35. package/dist/src/services/clip-curation/hunt.js +2 -0
  36. package/dist/src/services/clip-records.js +28 -0
  37. package/dist/src/services/file-directory.js +6 -3
  38. package/dist/src/services/hyperframes.js +535 -86
  39. package/dist/src/services/serverless-jobs.js +3 -1
  40. package/dist/src/services/serverless-records.js +43 -0
  41. package/dist/src/services/storage.js +24 -2
  42. package/dist/src/template-editor-pages.js +1 -1
  43. package/package.json +1 -1
  44. package/public/assets/file-directory-app.js +2 -2
  45. package/public/assets/homepage-client-app.js +1 -1
  46. package/public/assets/page-runtime-client-app.js +2 -2
  47. package/public/assets/placeholders/scene-placeholder.png +0 -0
@@ -145,6 +145,30 @@ function viewerHtml() {
145
145
  </div>
146
146
  </div>`;
147
147
  }
148
+ // The fullscreen TikTok-style viewer for the SWIPE deck — a sibling of the feed
149
+ // viewer above but purpose-built for swiping: slides carry live composition
150
+ // iframes (not video/img), and a bottom action bar reject/approves the ACTIVE
151
+ // card, driving the same POST /discover/swipe/:id/{approve,reject} the deck uses.
152
+ // The client script (swipe block) fills [data-sv-track] with slides on open.
153
+ function swipeViewerHtml() {
154
+ return `
155
+ <div class="rk-discover-viewer rk-swipe-viewer" data-rk-swipe-viewer hidden role="dialog" aria-modal="true" aria-label="Swipe preview">
156
+ <div class="rk-discover-viewer-scrim" data-sv-close></div>
157
+ <div class="rk-discover-viewer-track rk-swipe-viewer-track" data-sv-track></div>
158
+ <div class="rk-discover-viewer-ui">
159
+ <button type="button" class="rk-discover-viewer-btn" data-sv-mute aria-label="Toggle sound"><span class="rk-dvm-on">${SOUND_ON_ICON}</span><span class="rk-dvm-off">${SOUND_OFF_ICON}</span></button>
160
+ <button type="button" class="rk-discover-viewer-btn" data-sv-close aria-label="Close viewer">${CROSS_ICON}</button>
161
+ </div>
162
+ <div class="rk-discover-viewer-nav">
163
+ <button type="button" class="rk-discover-viewer-btn" data-sv-prev aria-label="Previous">${CHEV_UP_ICON}</button>
164
+ <button type="button" class="rk-discover-viewer-btn" data-sv-next aria-label="Next">${CHEV_DOWN_ICON}</button>
165
+ </div>
166
+ <div class="rk-swipe-viewer-actions" data-sv-actions>
167
+ <button type="button" class="rk-discover-act rk-discover-act-skip" data-sv-reject aria-label="Reject">${CROSS_ICON}</button>
168
+ <button type="button" class="rk-discover-act rk-discover-act-fork" data-sv-approve aria-label="Approve">${CHECK_ICON}</button>
169
+ </div>
170
+ </div>`;
171
+ }
148
172
  // The "Add New" modal (feed mode only) — paste a social video URL or upload a
149
173
  // video, adding it to the caller's private Discover feed via the same real
150
174
  // endpoints the old React SPA used (POST /discover/templates + the upload
@@ -237,8 +261,8 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
237
261
  </div>
238
262
  <button type="button" class="rk-discover-act rk-discover-act-fork" data-swipe-fork data-swipe-act hidden aria-label="Fork">${CHECK_ICON}</button>
239
263
  </div>
240
- <p class="rk-discover-act-hint" data-actions-hint hidden>Swipe to customize into an ad for your brand.</p>
241
- </div>`;
264
+ <p class="rk-discover-act-hint" data-actions-hint hidden>Tap the card to preview full-screen. Swipe to customize into an ad for your brand.</p>
265
+ </div>${swipeViewerHtml()}`;
242
266
  }
243
267
  // ── CATEGORIES ──────────────────────────────────────────────────────────
244
268
  if (mode === "categories") {
@@ -537,6 +561,19 @@ video.rk-discover-viewer-media{cursor:pointer}
537
561
  .rk-discover-act-fork:hover{background:var(--rk-gold-700);box-shadow:0 8px 22px -6px rgba(252,185,0,.6)}
538
562
  .rk-discover-act-hint{font-size:12.5px;font-weight:600;color:var(--rk-text-faint);text-align:center;letter-spacing:.01em}
539
563
  .rk-discover-act-hint[hidden]{display:none}
564
+ /* tapping the front card opens the fullscreen swipe viewer */
565
+ .rk-discover-deck .rk-discover-swipe{cursor:pointer}
566
+ /* ── swipe: fullscreen TikTok viewer — reuses .rk-discover-viewer* shell; slides
567
+ hold live composition iframes (.rk-discover-swipe-frame) and a bottom bar
568
+ reject/approves the active card. */
569
+ .rk-swipe-viewer-stage{background:#000}
570
+ .rk-swipe-viewer-slide .rk-discover-swipe-frame{border-radius:inherit}
571
+ .rk-swipe-viewer .rk-discover-viewer-info{padding-bottom:112px}
572
+ .rk-swipe-viewer-actions{position:absolute;left:0;right:0;bottom:max(24px,env(safe-area-inset-bottom,0px));z-index:6;
573
+ display:flex;gap:40px;justify-content:center;align-items:center;pointer-events:none}
574
+ .rk-swipe-viewer-actions .rk-discover-act{pointer-events:auto;width:64px;height:64px}
575
+ .rk-swipe-viewer-actions .rk-discover-act[hidden]{display:none}
576
+ @media(max-width:560px){.rk-swipe-viewer-actions{gap:30px}}
540
577
 
541
578
  /* ── categories: browse-by-format cards ── */
542
579
  .rk-discover-cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(268px,1fr));gap:20px}
@@ -759,7 +796,7 @@ video.rk-discover-viewer-media{cursor:pointer}
759
796
  ? '<span class="rk-btn rk-btn-ghost rk-btn-sm rk-btn-block rk-discover-fork rk-discover-fork-disabled" aria-disabled="true">'+(t.status==='failed'?'Download failed':'Preparing&hellip;')+'</span>'
760
797
  : remix
761
798
  ? '<button type="button" class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" data-remix="'+attr(inspirationIdOf(t))+'">'+EDIT_ICON+' Remix</button>'
762
- : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" href="'+attr(editorUrl(tid))+'">'+EDIT_ICON+' Open Editor</a>';
799
+ : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" href="'+attr(editorUrl(tid))+'" target="_blank" rel="noopener">'+EDIT_ICON+' Open Editor</a>';
763
800
  return '<article class="rk-discover-card rk-card-hover">'
764
801
  + poster
765
802
  + '<div class="rk-discover-body">'
@@ -846,7 +883,7 @@ video.rk-discover-viewer-media{cursor:pointer}
846
883
  +(desc?'<div class="rk-discover-viewer-desc">'+esc(desc)+'</div>':'')
847
884
  +(remix
848
885
  ? '<button type="button" class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" data-remix="'+attr(inspirationIdOf(t))+'">'+EDIT_ICON+' Remix</button>'
849
- : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" href="'+attr(editorUrl(tid))+'">'+EDIT_ICON+' Open Editor</a>')
886
+ : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" href="'+attr(editorUrl(tid))+'" target="_blank" rel="noopener">'+EDIT_ICON+' Open Editor</a>')
850
887
  +'</div>'
851
888
  +'</figure>'
852
889
  +'</section>';
@@ -1236,6 +1273,7 @@ video.rk-discover-viewer-media{cursor:pointer}
1236
1273
  var soundBtn=swipeRoot.querySelector('[data-swipe-sound]');
1237
1274
  var sw={target:5,customizing:0,productSet:false,busy:false,refilling:false,loaded:false,soundOn:false,known:{}};
1238
1275
  var nodes=[]; // live card DOM nodes, top-of-deck first
1276
+ var deckExpanded=false; // true while the fullscreen swipe viewer owns the previews
1239
1277
 
1240
1278
  // ── audio: the same-origin preview iframes poll these window flags (see
1241
1279
  // injectSwipePreviewFit). Only the TOP card's id is named audible so the
@@ -1243,6 +1281,9 @@ video.rk-discover-viewer-media{cursor:pointer}
1243
1281
  // always start muted (the only autoplay browsers permit); the toggle is the
1244
1282
  // user gesture that unlocks sound.
1245
1283
  function syncSound(){
1284
+ // While the fullscreen viewer is open it owns the audio flags (it names the
1285
+ // active slide's card audible); the deck must not clobber them.
1286
+ if(deckExpanded) return;
1246
1287
  try{
1247
1288
  window.__VF_SWIPE_SOUND__ = sw.soundOn===true;
1248
1289
  window.__VF_SWIPE_SOUND_TOP__ = nodes.length? String(nodes[0]._card.id): null;
@@ -1308,12 +1349,16 @@ video.rk-discover-viewer-media{cursor:pointer}
1308
1349
  el.style.zIndex=String(20-i);
1309
1350
  el.style.transform='translateY('+(i*12)+'px) scale('+(1-i*0.045).toFixed(3)+')';
1310
1351
  el.style.opacity=i===0?'1':(i===1?'0.92':'0.82');
1311
- if(i<=1) upgradeToIframe(el);
1352
+ // The viewer owns the previews while expanded — don't build deck iframes
1353
+ // (that would double the audio + CPU); resume rebuilds them on close.
1354
+ if(i<=1 && !deckExpanded) upgradeToIframe(el);
1312
1355
  });
1313
1356
  var has=nodes.length>0;
1314
1357
  actionBtns.forEach(function(b){ b.hidden=!has; });
1315
1358
  if(hint) hint.hidden=!has;
1316
1359
  syncSound(); updateSoundBtn();
1360
+ // Keep the open viewer's slide list in step with refills/removals.
1361
+ if(deckExpanded && swViewer) swViewer.sync();
1317
1362
  if(!has) swCaughtUp();
1318
1363
  }
1319
1364
  function swCaughtUp(){
@@ -1323,6 +1368,25 @@ video.rk-discover-viewer-media{cursor:pointer}
1323
1368
  swShowState('<div class="rk-discover-swipe-state-title">You\\u2019re all caught up</div><div>No more templates to customize right now. Check back as new ones are added.</div>');
1324
1369
  }
1325
1370
  }
1371
+ // POST approve/reject for a card (shared by the deck buttons + the viewer).
1372
+ function postDecision(card,dir){
1373
+ var path=swipeBase+'/'+encodeURIComponent(String(card.id))+'/'+(dir==='fork'?'approve':'reject');
1374
+ return fetch(withAccount(path),{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json','accept':'application/json'},body:'{}'})
1375
+ .then(function(r){ return r.json().catch(function(){ return {}; }); })
1376
+ .catch(function(){ return {}; });
1377
+ }
1378
+ // Drop a card from the live deck (array + its DOM node) by id. The viewer can
1379
+ // consume a card that isn't the top one, so removal is id-based, not shift().
1380
+ function removeNodeByCard(card){
1381
+ var id=card&&card.id!=null?String(card.id):null; if(id==null) return;
1382
+ for(var i=0;i<nodes.length;i++){
1383
+ if(String(nodes[i]._card.id)===id){
1384
+ var el=nodes[i]; nodes.splice(i,1);
1385
+ if(el&&el.parentNode) el.parentNode.removeChild(el);
1386
+ return;
1387
+ }
1388
+ }
1389
+ }
1326
1390
  function decide(dir){
1327
1391
  if(sw.busy||!nodes.length) return;
1328
1392
  sw.busy=true;
@@ -1333,11 +1397,7 @@ video.rk-discover-viewer-media{cursor:pointer}
1333
1397
  el.style.transform='translateX('+(dir==='fork'?460:-460)+'px) rotate('+(dir==='fork'?15:-15)+'deg)';
1334
1398
  el.style.opacity='0';
1335
1399
  window.setTimeout(function(){ if(el&&el.parentNode) el.parentNode.removeChild(el); },380);
1336
- var path=swipeBase+'/'+encodeURIComponent(String(card.id))+'/'+(dir==='fork'?'approve':'reject');
1337
- fetch(withAccount(path),{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json','accept':'application/json'},body:'{}'})
1338
- .then(function(r){ return r.json().catch(function(){ return {}; }); })
1339
- .catch(function(){ /* swallowed — the card already left the deck */ })
1340
- .then(function(){ sw.busy=false; restack(); refill(); });
1400
+ postDecision(card,dir).then(function(){ sw.busy=false; restack(); refill(); });
1341
1401
  }
1342
1402
  function refill(){
1343
1403
  if(sw.refilling||!sw.productSet||!sw.loaded) return;
@@ -1420,6 +1480,183 @@ video.rk-discover-viewer-media{cursor:pointer}
1420
1480
  swShowState('<div class="rk-discover-swipe-state-title">Couldn\\u2019t load your deck</div><div>'+esc(friendlyError(err))+'</div>','error');
1421
1481
  });
1422
1482
  }
1483
+ // ── fullscreen swipe viewer — a TikTok-style vertical scroll over the CURRENT
1484
+ // deck. Its bottom bar reject/approves the ACTIVE slide (same postDecision +
1485
+ // refill the deck buttons use). While open it OWNS the previews: the deck's
1486
+ // iframes are torn down (parkDeck) so audio/CPU never double, and it drives
1487
+ // __VF_SWIPE_SOUND_* for the active slide's card. Closing rebuilds the deck.
1488
+ var swViewer=(function(){
1489
+ var el=root.querySelector('[data-rk-swipe-viewer]');
1490
+ if(!el) return null;
1491
+ var track=el.querySelector('[data-sv-track]');
1492
+ var actionsEl=el.querySelector('[data-sv-actions]');
1493
+ var rejectBtn=el.querySelector('[data-sv-reject]');
1494
+ var approveBtn=el.querySelector('[data-sv-approve]');
1495
+ var open=false, active=-1, sound=false, io=null, busy=false, activeCardId=null;
1496
+
1497
+ function updateMuteUi(){ el.classList.toggle('is-muted',!sound); }
1498
+ function syncSVSound(){
1499
+ try{ window.__VF_SWIPE_SOUND__ = sound===true; window.__VF_SWIPE_SOUND_TOP__ = activeCardId || null; }catch(e){}
1500
+ updateMuteUi();
1501
+ }
1502
+ function updateActionState(){ if(actionsEl) actionsEl.style.display=track.children.length?'':'none'; }
1503
+ // Detach every deck iframe so only the viewer plays audio/video.
1504
+ function parkDeck(){
1505
+ nodes.forEach(function(n){
1506
+ var f=n.querySelector('.rk-discover-swipe-frame');
1507
+ if(f&&f.parentNode) f.parentNode.removeChild(f);
1508
+ n._iframed=false;
1509
+ var fb=n.querySelector('.rk-discover-swipe-fallback');
1510
+ if(fb){ fb.style.opacity=''; fb.style.pointerEvents=''; }
1511
+ });
1512
+ }
1513
+ function hydrate(slide){
1514
+ if(!slide||slide._iframed) return;
1515
+ var url=slide.getAttribute('data-preview-url'); if(!url) return;
1516
+ var fb=slide.querySelector('.rk-discover-swipe-fallback');
1517
+ var frame=document.createElement('iframe');
1518
+ frame.className='rk-discover-swipe-frame';
1519
+ frame.setAttribute('src',url);
1520
+ frame.setAttribute('title', slide.getAttribute('data-title')||'Preview');
1521
+ frame.setAttribute('loading','lazy');
1522
+ frame.setAttribute('scrolling','no');
1523
+ frame.setAttribute('allow','autoplay');
1524
+ function reveal(){ if(fb){ fb.style.opacity='0'; fb.style.pointerEvents='none'; } }
1525
+ frame.addEventListener('load',function(){ window.setTimeout(reveal,120); });
1526
+ window.setTimeout(reveal,2600);
1527
+ var stage=slide.querySelector('.rk-swipe-viewer-stage');
1528
+ if(stage) stage.insertBefore(frame, stage.firstChild); else slide.insertBefore(frame, slide.firstChild);
1529
+ slide._iframed=true;
1530
+ }
1531
+ function setActive(idx){
1532
+ active=idx;
1533
+ var kids=track.children;
1534
+ for(var i=0;i<kids.length;i++){ if(Math.abs(i-idx)<=1) hydrate(kids[i]); }
1535
+ var s=kids[idx];
1536
+ activeCardId=s? s.getAttribute('data-card-id'): null;
1537
+ syncSVSound();
1538
+ }
1539
+ function appendSlide(card){
1540
+ var slide=document.createElement('section');
1541
+ slide.className='rk-discover-viewer-slide rk-swipe-viewer-slide';
1542
+ slide.setAttribute('data-card-id', String(card.id));
1543
+ var url=card.preview_url||card.previewUrl||'';
1544
+ if(url) slide.setAttribute('data-preview-url', url);
1545
+ var titleTxt=String(card.title||card.source_title||card.template_id||'Template');
1546
+ slide.setAttribute('data-title', titleTxt);
1547
+ var title=esc(titleTxt);
1548
+ var caption=card.caption?('<div class="rk-discover-viewer-desc">'+esc(card.caption)+'</div>'):'';
1549
+ slide.innerHTML='<figure class="rk-discover-viewer-stage rk-swipe-viewer-stage">'
1550
+ +'<div class="rk-discover-swipe-fallback"><span>'+title+'</span></div>'
1551
+ +'<div class="rk-discover-viewer-shade"></div>'
1552
+ +'<div class="rk-discover-viewer-info"><div class="rk-discover-viewer-title">'+title+'</div>'+caption+'</div>'
1553
+ +'</figure>';
1554
+ track.appendChild(slide);
1555
+ if(open&&io) io.observe(slide);
1556
+ return slide;
1557
+ }
1558
+ // Keep the open track in step with the live deck: append a slide for any
1559
+ // node that doesn't have one yet (refills). Removals go through decide().
1560
+ function sync(){
1561
+ if(!open) return;
1562
+ var have={};
1563
+ for(var i=0;i<track.children.length;i++){ have[track.children[i].getAttribute('data-card-id')]=true; }
1564
+ nodes.forEach(function(n){ if(!have[String(n._card.id)]) appendSlide(n._card); });
1565
+ updateActionState();
1566
+ }
1567
+ function step(d){
1568
+ if(!open) return;
1569
+ var next=Math.max(0,Math.min(track.children.length-1,active+d));
1570
+ if(next===active) return;
1571
+ track.scrollTo({top:next*track.clientHeight,behavior:'smooth'});
1572
+ }
1573
+ function decideActive(dir){
1574
+ if(busy||active<0) return;
1575
+ var slide=track.children[active]; if(!slide) return;
1576
+ var id=slide.getAttribute('data-card-id'); var card=null;
1577
+ for(var i=0;i<nodes.length;i++){ if(String(nodes[i]._card.id)===String(id)){ card=nodes[i]._card; break; } }
1578
+ if(!card) return;
1579
+ busy=true; var wasIdx=active;
1580
+ var stage=slide.querySelector('.rk-swipe-viewer-stage');
1581
+ if(stage){
1582
+ stage.style.transition='transform .34s ease, opacity .34s ease';
1583
+ stage.style.transform='translateX('+(dir==='fork'?'62%':'-62%')+') rotate('+(dir==='fork'?'9deg':'-9deg')+')';
1584
+ stage.style.opacity='0';
1585
+ }
1586
+ postDecision(card,dir).then(function(){ refill(); });
1587
+ removeNodeByCard(card);
1588
+ window.setTimeout(function(){
1589
+ if(slide&&slide.parentNode) slide.parentNode.removeChild(slide);
1590
+ busy=false; updateActionState();
1591
+ if(!track.children.length){ close(); return; }
1592
+ var nextIdx=Math.min(wasIdx, track.children.length-1);
1593
+ track.scrollTop=nextIdx*track.clientHeight;
1594
+ setActive(nextIdx);
1595
+ },340);
1596
+ }
1597
+ function openAt(startId){
1598
+ if(!nodes.length||open) return;
1599
+ deckExpanded=true; parkDeck();
1600
+ track.innerHTML=''; sound=sw.soundOn===true; open=true;
1601
+ el.hidden=false; document.documentElement.classList.add('rk-noscroll');
1602
+ if(io) io.disconnect();
1603
+ io=('IntersectionObserver' in window)?new IntersectionObserver(function(entries){
1604
+ entries.forEach(function(en){
1605
+ if(!en.isIntersecting) return;
1606
+ var i=[].indexOf.call(track.children,en.target);
1607
+ if(i>=0&&i!==active) setActive(i);
1608
+ });
1609
+ },{root:track,threshold:0.6}):null;
1610
+ nodes.forEach(function(n){ appendSlide(n._card); });
1611
+ updateActionState();
1612
+ var startIdx=0;
1613
+ for(var i=0;i<nodes.length;i++){ if(String(nodes[i]._card.id)===String(startId)){ startIdx=i; break; } }
1614
+ track.scrollTop=startIdx*track.clientHeight;
1615
+ setActive(startIdx);
1616
+ }
1617
+ function close(){
1618
+ if(!open) return;
1619
+ open=false; active=-1; activeCardId=null; busy=false;
1620
+ if(io){ io.disconnect(); io=null; }
1621
+ track.innerHTML=''; el.hidden=true;
1622
+ document.documentElement.classList.remove('rk-noscroll');
1623
+ deckExpanded=false; sw.soundOn=sound; // carry the sound pref back to the deck
1624
+ restack(); updateSoundBtn();
1625
+ }
1626
+
1627
+ el.querySelectorAll('[data-sv-close]').forEach(function(b){ b.addEventListener('click',close); });
1628
+ var muteBtn=el.querySelector('[data-sv-mute]');
1629
+ if(muteBtn) muteBtn.addEventListener('click',function(){ sound=!sound; syncSVSound(); });
1630
+ var prevBtn=el.querySelector('[data-sv-prev]'); if(prevBtn) prevBtn.addEventListener('click',function(){ step(-1); });
1631
+ var nextBtn=el.querySelector('[data-sv-next]'); if(nextBtn) nextBtn.addEventListener('click',function(){ step(1); });
1632
+ if(rejectBtn) rejectBtn.addEventListener('click',function(){ decideActive('skip'); });
1633
+ if(approveBtn) approveBtn.addEventListener('click',function(){ decideActive('fork'); });
1634
+ // tap the empty gutter (not the phone frame) closes the viewer
1635
+ track.addEventListener('click',function(e){
1636
+ var t=e.target;
1637
+ if(t&&t.classList&&(t.classList.contains('rk-discover-viewer-slide')||t===track)) close();
1638
+ });
1639
+ document.addEventListener('keydown',function(e){
1640
+ if(!open) return;
1641
+ if(e.key==='Escape'){ e.preventDefault(); close(); }
1642
+ else if(e.key==='ArrowDown'||e.key==='PageDown'){ e.preventDefault(); step(1); }
1643
+ else if(e.key==='ArrowUp'||e.key==='PageUp'){ e.preventDefault(); step(-1); }
1644
+ else if(e.key==='ArrowRight'){ e.preventDefault(); decideActive('fork'); }
1645
+ else if(e.key==='ArrowLeft'){ e.preventDefault(); decideActive('skip'); }
1646
+ });
1647
+ window.addEventListener('resize',function(){ if(open&&active>=0) track.scrollTop=active*track.clientHeight; });
1648
+
1649
+ return { openAt:openAt, close:close, sync:sync, isOpen:function(){ return open; } };
1650
+ })();
1651
+
1652
+ // Tap the front card → open the fullscreen viewer at that card.
1653
+ if(deck) deck.addEventListener('click',function(e){
1654
+ if(sw.busy||(swViewer&&swViewer.isOpen())||!nodes.length) return;
1655
+ var el=(e.target&&e.target.closest)?e.target.closest('.rk-discover-swipe'):null;
1656
+ if(!el||!el._card||el!==nodes[0]) return; // only the front card is interactive
1657
+ if(swViewer) swViewer.openAt(el._card.id);
1658
+ });
1659
+
1423
1660
  if(skipBtn) skipBtn.addEventListener('click',function(){ decide('skip'); });
1424
1661
  if(forkBtn) forkBtn.addEventListener('click',function(){ decide('fork'); });
1425
1662
  loadStack();
@@ -37,7 +37,7 @@ export function rkBrandTitle(raw) {
37
37
  * Other pages stay reachable from the /reskin gallery (brand link). */
38
38
  const SIDEBAR_NAV = [
39
39
  { slug: "discover", label: "Discover" },
40
- { slug: "chat", label: "Chat" },
40
+ { slug: "chat", label: "Create" },
41
41
  { slug: "library", label: "Library" },
42
42
  { slug: "calendar", label: "Calendar" },
43
43
  { slug: "help", label: "Guides" },
@@ -115,7 +115,7 @@ export function renderChatDock(activeSlug) {
115
115
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
116
116
  </button>
117
117
  </div>
118
- <div class="rk-aichat-files-mount" id="rkAichatFilesMount" data-rk-directory data-mode="attach"></div>
118
+ <div class="rk-aichat-files-mount" id="rkAichatFilesMount" data-rk-directory data-mode="attach" data-primary-click="preview"></div>
119
119
  </div>
120
120
  <div class="rk-aichat-history" id="rkAichatHistory" aria-label="Chat history">
121
121
  <div class="rk-aichat-hist-head">Chat history</div>
@@ -127,6 +127,9 @@ export function renderChatDock(activeSlug) {
127
127
  <button class="rk-aichat-tool" id="rkHistoryToggle" type="button" title="Chat history" aria-label="Toggle chat history" aria-pressed="false">
128
128
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg>
129
129
  </button>
130
+ <button class="rk-aichat-tool" id="rkCopyThread" type="button" title="Copy chat ID" aria-label="Copy chat ID">
131
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
132
+ </button>
130
133
  <button class="rk-aichat-tool" id="rkNewChat" type="button" title="New chat" aria-label="Start a new chat">
131
134
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
132
135
  </button>
@@ -306,7 +309,35 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
306
309
  var ENDPOINT = '/api/v1/editor-chat', API_KEY = null, TEMPLATE = null, CACHED = null;
307
310
  var THREADS_URL = '/api/v1/editor-chat/threads', TEMPLATE_ID = 'chat-brainstorm';
308
311
  var convo = []; // [{role,text}] running conversation
309
- var threadId = null; // set on first send / when opening a saved thread
312
+ // Minted up-front so the "Copy chat ID" tool always has something to copy
313
+ // (even before the first message) and so the id we copy is the SAME one the
314
+ // first send persists under. Reset to a fresh id on New chat, replaced by the
315
+ // real id when a saved thread is opened.
316
+ var threadId = genId('thread');
317
+
318
+ // Cross-page hand-off: /chat (or a future AI-driven page nav) can pass
319
+ // ?rk_chat=<threadId> to continue that EXACT conversation in this dock. Read it
320
+ // once, strip it from the URL (so reload / back-button doesn't re-trigger), and
321
+ // replay the thread as soon as boot is ready (see consumeHandoff, called from
322
+ // loadBoot's ready path). Also opens the panel below so the jump is seamless.
323
+ var handoffThread = null;
324
+ try {
325
+ var _hq = new URLSearchParams(window.location.search);
326
+ handoffThread = _hq.get('rk_chat') || null;
327
+ if (handoffThread) {
328
+ _hq.delete('rk_chat');
329
+ var _hqs = _hq.toString();
330
+ window.history.replaceState(window.history.state, '', window.location.pathname + (_hqs ? '?' + _hqs : '') + window.location.hash);
331
+ }
332
+ } catch (e) { handoffThread = null; }
333
+ // Replay a handed-off thread once the dock is authed + booted. openThread needs
334
+ // API_KEY (set by loadBoot), so this only fires from the boot-ready path.
335
+ function consumeHandoff() {
336
+ if (!handoffThread || BOOT_STATE !== 'ready') return;
337
+ var id = handoffThread; handoffThread = null;
338
+ setLeftMode(''); // show the conversation, not the Files/History drawer
339
+ openThread(id);
340
+ }
310
341
  var busy = false;
311
342
  var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
312
343
 
@@ -385,6 +416,7 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
385
416
  THREADS_URL = b.threadsUrl || THREADS_URL;
386
417
  BOOT_STATE = 'ready';
387
418
  loadThreads();
419
+ consumeHandoff();
388
420
  })
389
421
  .catch(function () { BOOT_STATE = 'error'; });
390
422
  }
@@ -759,10 +791,27 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
759
791
  return pump();
760
792
  }
761
793
 
794
+ // Turn this turn's attachments into a text line the model can quote back as
795
+ // REST payload URLs (prompt_attachments / source_image_url / media_url), in
796
+ // addition to the binary file content parts. Mirrors the /editor React dock
797
+ // (buildAttachmentTransportText). Double-backslash-n = a browser-side newline
798
+ // (we live inside a script template literal, so a bare newline escape breaks it).
799
+ function buildAttachmentUrlText(atts) {
800
+ if (!atts || !atts.length) return '';
801
+ var lines = atts.map(function (a) { return '- ' + (a.fileName || 'file') + ' (' + (a.contentType || '') + '): ' + a.viewUrl; });
802
+ return '\\n\\nAttached file URLs for REST payloads:\\n' + lines.join('\\n') + '\\nUse these exact URLs as prompt_attachments for image generation, source_image_url for image edits, media_url for video slides, or reference attachment URLs when calling template routes.';
803
+ }
804
+
762
805
  function sendMessage(text) {
763
806
  if (busy) return;
764
807
  text = (text || '').trim();
765
808
  var picked = (typeof selected !== 'undefined' ? selected : []).slice();
809
+ // A pasted file may still be uploading to /temp — wait for it rather than
810
+ // silently dropping the attachment. Flash the in-flight chips as a hint.
811
+ if (picked.some(function (f) { return f && f.pending; })) {
812
+ picked.forEach(function (f) { if (f.pending) flashChip(f.id); });
813
+ return;
814
+ }
766
815
  var atts = picked.filter(function (f) { return f && f.viewUrl; }).map(function (f) {
767
816
  return { id: String(f.id), fileName: f.name || 'file', contentType: f.contentType || 'application/octet-stream', viewUrl: f.viewUrl };
768
817
  });
@@ -788,10 +837,22 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
788
837
  // Attach a fresh <editor_context> to the current (last) user turn only, so the
789
838
  // model sees the composition state without bloating persisted history.
790
839
  var ctxBlock = editorContextBlock();
840
+ // Attachments (pasted files OR files picked from the directory explorer) must
841
+ // ride in the model messages as file content parts + a URL text line — the
842
+ // backend only feeds the model messages[].content, NOT user_message.attachments
843
+ // (which is persistence-only). Without this the AI never saw attached files.
844
+ var attUrlText = atts.length ? buildAttachmentUrlText(atts) : '';
791
845
  var outMessages = convo.map(function (m, i) {
846
+ var isLast = i === convo.length - 1;
847
+ var isLastUser = isLast && m.role === 'user';
792
848
  var t = m.text;
793
- if (ctxBlock && m.role === 'user' && i === convo.length - 1) t = t + ctxBlock;
794
- return { role: m.role, content: [{ type: 'text', text: t }] };
849
+ if (isLastUser && attUrlText) t = t + attUrlText;
850
+ if (ctxBlock && isLastUser) t = t + ctxBlock;
851
+ var content = [{ type: 'text', text: t }];
852
+ if (isLastUser && atts.length) {
853
+ atts.forEach(function (a) { content.push({ type: 'file', data: a.viewUrl, mediaType: a.contentType || 'application/octet-stream' }); });
854
+ }
855
+ return { role: m.role, content: content };
795
856
  });
796
857
  var body = {
797
858
  messages: outMessages,
@@ -859,7 +920,7 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
859
920
  }
860
921
 
861
922
  function resetConversation() {
862
- convo = []; threadId = null;
923
+ convo = []; threadId = genId('thread');
863
924
  if (log) { log.innerHTML = ''; bubble('is-ai', GREETING); }
864
925
  if (typeof clearChips === 'function') clearChips();
865
926
  }
@@ -1047,6 +1108,16 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
1047
1108
  var historyToggle = document.getElementById('rkHistoryToggle');
1048
1109
  var newChatBtn = document.getElementById('rkNewChat');
1049
1110
  if (newChatBtn) newChatBtn.addEventListener('click', function () { if (!busy) newChat(); });
1111
+ // Copy the current chat thread id (handy for support / linking a conversation).
1112
+ // threadId is minted up-front, so this always has a real id to copy.
1113
+ var copyThreadBtn = document.getElementById('rkCopyThread');
1114
+ if (copyThreadBtn) copyThreadBtn.addEventListener('click', function () {
1115
+ var prevTitle = copyThreadBtn.title;
1116
+ copyText(String(threadId));
1117
+ copyThreadBtn.title = 'Copied chat ID';
1118
+ copyThreadBtn.classList.add('is-on');
1119
+ setTimeout(function () { copyThreadBtn.title = prevTitle; copyThreadBtn.classList.remove('is-on'); }, 1400);
1120
+ });
1050
1121
  var histBody = document.getElementById('rkAichatHistBody');
1051
1122
  var selected = [];
1052
1123
  window.__rkDirectoryHost = {
@@ -1087,14 +1158,15 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
1087
1158
  chipsEl.innerHTML = '';
1088
1159
  selected.forEach(function (f, i) {
1089
1160
  var chip = document.createElement('span');
1090
- chip.className = 'rk-aichat-chip';
1161
+ chip.className = 'rk-aichat-chip' + (f.pending ? ' is-pending' : '');
1091
1162
  chip.setAttribute('data-id', f.id);
1163
+ if (f.pending) { var sp = document.createElement('span'); sp.className = 'rk-aichat-chip-spin'; sp.setAttribute('aria-hidden', 'true'); chip.appendChild(sp); }
1092
1164
  var label = document.createElement('button');
1093
1165
  label.type = 'button';
1094
1166
  label.className = 'rk-aichat-chip-label';
1095
- label.title = 'Preview ' + f.name;
1167
+ label.title = f.pending ? 'Uploading ' + f.name + '…' : 'Preview ' + f.name;
1096
1168
  label.textContent = f.name;
1097
- if (f.viewUrl) label.addEventListener('click', function () { openFilePreview(selected, i); });
1169
+ if (f.viewUrl && !f.pending) label.addEventListener('click', function () { openFilePreview(selected.filter(function (s) { return !s.pending; }), selected.filter(function (s) { return !s.pending; }).indexOf(f)); });
1098
1170
  var x = document.createElement('button');
1099
1171
  x.type = 'button';
1100
1172
  x.setAttribute('aria-label', 'Remove ' + f.name);
@@ -1275,6 +1347,66 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
1275
1347
  if (composerAttach) {
1276
1348
  composerAttach.addEventListener('click', function () { setLeftMode(leftMode() === 'files' ? '' : 'files'); });
1277
1349
  }
1350
+
1351
+ // ─── paste-to-attach: drop a copied image/file straight into the composer ───
1352
+ // Clipboard files (screenshot, copied image, "Copy image") get auto-uploaded
1353
+ // to the user's /temp folder, then attached as a chip just like a picked file.
1354
+ // We show a spinner chip immediately and swap it for the real file on success.
1355
+ function extNameFor(blob, idx) {
1356
+ var ct = (blob && blob.type) || '';
1357
+ var base = ct.indexOf('image/') === 0 ? 'pasted-image' : ct.indexOf('video/') === 0 ? 'pasted-video' : ct.indexOf('audio/') === 0 ? 'pasted-audio' : 'pasted-file';
1358
+ var ext = ct && ct.indexOf('/') > -1 ? ct.split('/')[1].split('+')[0].split(';')[0] : 'bin';
1359
+ if (ext === 'jpeg') ext = 'jpg';
1360
+ var stamp = Date.now().toString(36) + (idx ? '-' + idx : '');
1361
+ return base + '-' + stamp + '.' + ext;
1362
+ }
1363
+ function uploadPastedBlob(blob, fileName, pendingId) {
1364
+ var fd = new FormData();
1365
+ fd.append('file', blob, fileName);
1366
+ var headers = {}; if (API_KEY) headers['vidfarm-api-key'] = API_KEY;
1367
+ fetch(window.location.origin + '/api/v1/user/me/temporary-files/upload', {
1368
+ method: 'POST', credentials: 'same-origin', headers: headers, body: fd
1369
+ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { if (!r.ok) throw new Error((j && j.error) || ('upload failed (' + r.status + ')')); return j; }); })
1370
+ .then(function (j) {
1371
+ var f = j && j.file;
1372
+ if (!f || !f.viewUrl) throw new Error('upload returned no file');
1373
+ var idx = selected.map(function (s) { return s.id; }).indexOf(pendingId);
1374
+ var item = { id: String(f.id), name: f.fileName || fileName, contentType: f.contentType || (blob && blob.type) || 'application/octet-stream', viewUrl: f.viewUrl };
1375
+ if (idx > -1) selected[idx] = item; else selected.push(item);
1376
+ renderChips();
1377
+ })
1378
+ .catch(function () {
1379
+ // A failed paste-upload just drops its chip — keep the chat log clean.
1380
+ selected = selected.filter(function (s) { return s.id !== pendingId; });
1381
+ renderChips();
1382
+ });
1383
+ }
1384
+ function handlePasteFiles(e) {
1385
+ // Files-only drawer (on /chat) has no composer of its own — let that page's
1386
+ // composer own paste; here we only handle the dock's real chat composer.
1387
+ if (panel.classList.contains('is-files-only')) return;
1388
+ var dt = e.clipboardData || (window.clipboardData);
1389
+ if (!dt) return;
1390
+ var blobs = [];
1391
+ var items = dt.items;
1392
+ if (items && items.length) {
1393
+ for (var i = 0; i < items.length; i++) {
1394
+ if (items[i] && items[i].kind === 'file') { var b = items[i].getAsFile(); if (b) blobs.push(b); }
1395
+ }
1396
+ }
1397
+ if (!blobs.length && dt.files && dt.files.length) { for (var k = 0; k < dt.files.length; k++) blobs.push(dt.files[k]); }
1398
+ if (!blobs.length) return; // plain-text paste → let the textarea handle it
1399
+ e.preventDefault();
1400
+ blobs.forEach(function (blob, idx) {
1401
+ var name = (blob && blob.name) || extNameFor(blob, idx);
1402
+ var pendingId = 'paste-' + Date.now().toString(36) + '-' + idx + '-' + Math.random().toString(36).slice(2, 6);
1403
+ selected.push({ id: pendingId, name: name, contentType: (blob && blob.type) || 'application/octet-stream', pending: true });
1404
+ renderChips();
1405
+ uploadPastedBlob(blob, name, pendingId);
1406
+ });
1407
+ if (input) input.focus();
1408
+ }
1409
+ if (input) input.addEventListener('paste', handlePasteFiles);
1278
1410
  if (historyToggle) {
1279
1411
  historyToggle.addEventListener('click', function () { setLeftMode(leftMode() === 'history' ? '' : 'history'); });
1280
1412
  }
@@ -1307,6 +1439,11 @@ export const RESKIN_CHROME_SCRIPT = `(() => {
1307
1439
  else if (reopen === '1') openPanel();
1308
1440
  } catch (e) {}
1309
1441
 
1442
+ // A handed-off thread (?rk_chat=…) always opens the dock so the conversation
1443
+ // reappears immediately (openPanel → loadBoot → consumeHandoff replays it).
1444
+ // The wide editor dock force-opens itself just below, so skip it here.
1445
+ if (handoffThread && !isChatPage && !editorDockWide) openPanel();
1446
+
1310
1447
  // Editor dock: force the panel open and pinned as a left column. Reuses the
1311
1448
  // normal openPanel path so the live /chat-dock/boot fetch + threads + files
1312
1449
  // all wire up exactly as on the reskin pages.
@@ -1442,7 +1579,7 @@ ${renderChatDock(active)}
1442
1579
  ${RESKIN_FONT_LINKS}
1443
1580
  <style>${RESKIN_CSS}${pageCss}</style>
1444
1581
  </head>
1445
- <body class="rk-root${chrome === "full" ? " rk-has-sidebar" : ""}">
1582
+ <body class="rk-root${chrome === "full" ? " rk-has-sidebar" : ""}${input.account?.email?.trim() ? " rk-signed-in" : ""}">
1446
1583
  ${shell}${script}
1447
1584
  </body>
1448
1585
  </html>`;