@mevdragon/vidfarm-devcli 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) 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 +43 -4
  4. package/SKILL.director.md +190 -18
  5. package/SKILL.platform.md +8 -4
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +77 -75
  8. package/demo/dist/favicon.ico +0 -0
  9. package/dist/src/app.js +3031 -300
  10. package/dist/src/cli.js +1549 -69
  11. package/dist/src/config.js +7 -0
  12. package/dist/src/devcli/clip-store.js +29 -2
  13. package/dist/src/devcli/clips.js +55 -9
  14. package/dist/src/devcli/composition-edit.js +658 -8
  15. package/dist/src/devcli/local-backend.js +123 -0
  16. package/dist/src/devcli/migrate-local.js +140 -0
  17. package/dist/src/devcli/sync.js +311 -0
  18. package/dist/src/devcli/timeline-edit.js +490 -0
  19. package/dist/src/editor-chat.js +56 -17
  20. package/dist/src/frontend/discover-client.js +130 -0
  21. package/dist/src/frontend/discover-store.js +23 -0
  22. package/dist/src/frontend/file-directory.js +995 -0
  23. package/dist/src/frontend/homepage-view.js +3 -3
  24. package/dist/src/frontend/template-editor-chat.js +28 -22
  25. package/dist/src/landing-page.js +24 -7
  26. package/dist/src/page-shell.js +26 -2
  27. package/dist/src/primitive-registry.js +409 -4
  28. package/dist/src/reskin/agency-page.js +1 -1
  29. package/dist/src/reskin/calendar-page.js +2 -1
  30. package/dist/src/reskin/chat-page.js +420 -85
  31. package/dist/src/reskin/discover-page.js +731 -39
  32. package/dist/src/reskin/document.js +1311 -387
  33. package/dist/src/reskin/help-page.js +1 -0
  34. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  35. package/dist/src/reskin/inpaint-page.js +2459 -446
  36. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  37. package/dist/src/reskin/library-page.js +1168 -228
  38. package/dist/src/reskin/login-page.js +6 -6
  39. package/dist/src/reskin/pricing-page.js +2 -0
  40. package/dist/src/reskin/settings-page.js +55 -10
  41. package/dist/src/reskin/theme.js +365 -20
  42. package/dist/src/services/billing.js +4 -0
  43. package/dist/src/services/clip-curation/gemini.js +5 -0
  44. package/dist/src/services/clip-curation/hunt.js +81 -1
  45. package/dist/src/services/clip-curation/index.js +2 -1
  46. package/dist/src/services/clip-curation/local-agent.js +4 -3
  47. package/dist/src/services/clip-curation/media-select.js +85 -0
  48. package/dist/src/services/clip-curation/query.js +5 -1
  49. package/dist/src/services/clip-curation/refine.js +50 -20
  50. package/dist/src/services/clip-curation/scan.js +10 -3
  51. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  52. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  53. package/dist/src/services/clip-records.js +42 -1
  54. package/dist/src/services/clip-search.js +43 -13
  55. package/dist/src/services/file-directory.js +117 -0
  56. package/dist/src/services/hyperframes.js +283 -3
  57. package/dist/src/services/serverless-records.js +43 -0
  58. package/dist/src/services/storage.js +47 -2
  59. package/dist/src/services/upstream.js +5 -5
  60. package/dist/src/template-editor-shell.js +16 -2
  61. package/package.json +1 -1
  62. package/public/assets/discover-client-app.js +1 -0
  63. package/public/assets/file-directory-app.js +2 -0
  64. package/public/assets/homepage-client-app.js +12 -12
  65. package/public/assets/page-runtime-client-app.js +24 -24
  66. package/public/assets/placeholders/scene-placeholder.png +0 -0
  67. package/src/assets/favicon.ico +0 -0
  68. package/src/assets/logo-vidfarm.png +0 -0
@@ -41,6 +41,8 @@
41
41
  // live editorHref) and override any endpoint.
42
42
  import { reskinDocument, rkEscape } from "./document.js";
43
43
  import { POPULAR_INSPIRATION_GROUPS } from "../frontend/homepage-shared.js";
44
+ /** Config-only defaults (the data fields feedInitial/feedFetchedAt are seeded
45
+ * per-request, never defaulted). */
44
46
  const DEFAULT_INPUT = {
45
47
  accountId: "",
46
48
  isLoggedIn: false,
@@ -69,6 +71,8 @@ const MODE_COPY = {
69
71
  };
70
72
  // Functional (non-decorative) SVG glyphs.
71
73
  const EDIT_ICON = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>`;
74
+ const COPY_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg>`;
75
+ const IDCHECK_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5 10-11"/></svg>`;
72
76
  const BOOKMARK_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21l-5-3.4L7 21V5.2A2.2 2.2 0 0 1 9.2 3h5.6A2.2 2.2 0 0 1 17 5.2z"/></svg>`;
73
77
  const CHECK_ICON = `<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5 10-11"/></svg>`;
74
78
  const CROSS_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>`;
@@ -77,6 +81,8 @@ const SOUND_OFF_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="no
77
81
  const CHEV_UP_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 14.5 7-7 7 7"/></svg>`;
78
82
  const CHEV_DOWN_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 9.5 7 7 7-7"/></svg>`;
79
83
  const PLAY_ICON = `<svg width="56" height="56" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.4v13.2L19 12z"/></svg>`;
84
+ const PLUS_ICON = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
85
+ const UPLOAD_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>`;
80
86
  /** Safe JSON embedding inside a <script> tag (mirrors page-shell.escapeJsonForHtml). */
81
87
  function jsonForScript(value) {
82
88
  return JSON.stringify(value ?? null)
@@ -90,7 +96,7 @@ function jsonForScript(value) {
90
96
  function modeToggle(active) {
91
97
  const links = MODES.map((m) => {
92
98
  const on = m.mode === active ? " is-active" : "";
93
- return `<a class="rk-discover-mode${on}" href="/discover${m.mode === "feed" ? "" : "?view=" + m.mode}"${m.mode === active ? ' aria-current="page"' : ""}>${m.label}</a>`;
99
+ return `<a class="rk-discover-mode${on}" href="/discover/${m.mode}"${m.mode === active ? ' aria-current="page"' : ""}>${m.label}</a>`;
94
100
  }).join("");
95
101
  return `<nav class="rk-discover-modes" aria-label="Discover mode">${links}</nav>`;
96
102
  }
@@ -139,9 +145,83 @@ function viewerHtml() {
139
145
  </div>
140
146
  </div>`;
141
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
+ }
172
+ // The "Add New" modal (feed mode only) — paste a social video URL or upload a
173
+ // video, adding it to the caller's private Discover feed via the same real
174
+ // endpoints the old React SPA used (POST /discover/templates + the upload
175
+ // presign pair). Mirrors the old UI's Add Template modal, farmville-styled.
176
+ function addModalHtml() {
177
+ return `
178
+ <div class="rk-add-modal" data-rk-add-modal hidden role="dialog" aria-modal="true" aria-labelledby="rk-add-title">
179
+ <div class="rk-add-scrim" data-rk-add-close></div>
180
+ <div class="rk-add-card">
181
+ <div class="rk-add-head">
182
+ <h2 class="rk-add-title" id="rk-add-title">Add to your feed</h2>
183
+ <button type="button" class="rk-add-x" data-rk-add-close aria-label="Close">${CROSS_ICON}</button>
184
+ </div>
185
+ <p class="rk-add-sub">Paste a TikTok, YouTube, Twitter/X, or Instagram video link &mdash; or upload a video from your computer. It's added to your Discover feed, visible only to your account.</p>
186
+ <form class="rk-add-form" data-rk-add-form>
187
+ <div class="rk-add-field">
188
+ <label class="rk-add-label" for="rk-add-url">Video URL</label>
189
+ <input class="rk-add-input" id="rk-add-url" type="url" placeholder="https://www.tiktok.com/@&hellip;" autocomplete="off" data-rk-add-url />
190
+ </div>
191
+ <div class="rk-add-or"><span>or</span></div>
192
+ <label class="rk-add-file" data-rk-add-filelabel>
193
+ <input type="file" accept="video/*,.mp4,.mov,.webm,.m4v" data-rk-add-file />
194
+ <span class="rk-add-file-ic">${UPLOAD_ICON}</span>
195
+ <span class="rk-add-file-text" data-rk-add-filetext>Upload a video<span class="rk-add-optional">MP4 / MOV / WebM, up to 200 MB</span></span>
196
+ </label>
197
+ <div class="rk-add-field">
198
+ <label class="rk-add-label" for="rk-add-tagline">Tagline <span class="rk-add-optional">optional</span></label>
199
+ <input class="rk-add-input" id="rk-add-tagline" type="text" placeholder="What makes it work" autocomplete="off" data-rk-add-tagline />
200
+ </div>
201
+ <div class="rk-add-field">
202
+ <label class="rk-add-label" for="rk-add-notes">Notes <span class="rk-add-optional">optional</span></label>
203
+ <textarea class="rk-add-input rk-add-textarea" id="rk-add-notes" rows="2" placeholder="Anything to remember about this one" data-rk-add-notes></textarea>
204
+ </div>
205
+ <p class="rk-add-error" data-rk-add-error hidden></p>
206
+ <div class="rk-add-actions">
207
+ <button type="button" class="rk-btn rk-btn-ghost" data-rk-add-close>Cancel</button>
208
+ <button type="submit" class="rk-btn rk-btn-gold" data-rk-add-submit>Add to feed</button>
209
+ </div>
210
+ </form>
211
+ </div>
212
+ </div>`;
213
+ }
142
214
  export function renderReskinDiscover(mode = "feed", input = {}) {
143
215
  const copy = MODE_COPY[mode];
144
- const boot = { ...DEFAULT_INPUT, ...input };
216
+ // feedInitial/feedFetchedAt only make sense in feed mode; drop them elsewhere
217
+ // so swipe/categories boots stay lean.
218
+ const { feedInitial, feedFetchedAt, ...cfg } = input;
219
+ const boot = {
220
+ ...DEFAULT_INPUT,
221
+ ...cfg,
222
+ feedInitial: mode === "feed" ? feedInitial ?? null : null,
223
+ feedFetchedAt: mode === "feed" ? feedFetchedAt ?? null : null
224
+ };
145
225
  let modeBody = "";
146
226
  // ── FEED ────────────────────────────────────────────────────────────────
147
227
  if (mode === "feed") {
@@ -158,8 +238,7 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
158
238
  <span class="rk-discover-search-ic" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8.5" cy="8.5" r="5.8"/><path d="m13 13 4.2 4.2"/></svg></span>
159
239
  <input type="search" data-rk-search placeholder="Search templates" autocomplete="off" aria-label="Search templates" />
160
240
  </label>
161
- <button type="button" class="rk-discover-bmfilter" data-rk-bmfilter aria-pressed="false" title="Show bookmarked templates only">${BOOKMARK_ICON}<span class="rk-discover-bmfilter-count" data-rk-bmcount></span></button>
162
- <span class="rk-hint rk-discover-count" data-rk-count></span>`
241
+ <button type="button" class="rk-discover-bmfilter" data-rk-bmfilter aria-pressed="false" title="Show bookmarked templates only">${BOOKMARK_ICON}<span class="rk-discover-bmfilter-count" data-rk-bmcount></span></button>`
163
242
  : "";
164
243
  // Categories' in-page group reveal header ("← All categories" + the group
165
244
  // title) lives IN the toolbar row, next to the mode toggle (user feedback);
@@ -177,12 +256,13 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
177
256
  <div class="rk-discover-stage">
178
257
  <button type="button" class="rk-discover-act rk-discover-act-skip" data-swipe-skip data-swipe-act hidden aria-label="Skip">${CROSS_ICON}</button>
179
258
  <div class="rk-discover-deck" data-deck>
259
+ <button type="button" class="rk-discover-sound-toggle" data-swipe-sound hidden aria-label="Unmute" aria-pressed="false">${SOUND_OFF_ICON}</button>
180
260
  <div class="rk-discover-swipe-state" data-rk-swipe-state>Loading your deck&hellip;</div>
181
261
  </div>
182
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>
183
263
  </div>
184
- <p class="rk-discover-act-hint" data-actions-hint hidden>Swipe to customize into an ad for your brand.</p>
185
- </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()}`;
186
266
  }
187
267
  // ── CATEGORIES ──────────────────────────────────────────────────────────
188
268
  if (mode === "categories") {
@@ -195,11 +275,21 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
195
275
  <div class="rk-discover-state rk-discover-cats-state" data-rk-cats-state hidden></div>
196
276
  </div>${viewerHtml()}`;
197
277
  }
278
+ // "Add New" lives top-right of the page head (feed mode only) — opens the
279
+ // Add modal when signed in, else routes to /login (mirrors the old UI).
280
+ const addNewBtn = mode === "feed"
281
+ ? (boot.isLoggedIn
282
+ ? `<button type="button" class="rk-btn rk-btn-gold rk-discover-add" data-rk-add>${PLUS_ICON}<span>Add New</span></button>`
283
+ : `<a class="rk-btn rk-btn-gold rk-discover-add" href="/login">${PLUS_ICON}<span>Add New</span></a>`)
284
+ : "";
198
285
  const body = `
199
286
  <main class="rk-container rk-page">
200
287
  <div class="rk-page-head rk-discover-head">
201
- <h1 class="rk-h1">Discover</h1>
202
- <p class="rk-sub">${copy.sub}</p>
288
+ <div class="rk-discover-headtext">
289
+ <h1 class="rk-h1">Discover</h1>
290
+ <p class="rk-sub">${copy.sub}</p>
291
+ </div>
292
+ ${addNewBtn}
203
293
  </div>
204
294
  <div class="rk-discover-toolbar">
205
295
  ${modeToggle(mode)}
@@ -207,10 +297,58 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
207
297
  ${toolbarSearch}
208
298
  </div>
209
299
  ${modeBody}
300
+ ${mode === "feed" ? addModalHtml() : ""}
210
301
  <script type="application/json" data-rk-discover-boot>${jsonForScript(boot)}</script>
211
302
  </main>`;
212
303
  const pageCss = `
213
- .rk-discover-head{margin-bottom:22px}
304
+ /* head row: title block on the left, "Add New" CTA pinned top-right */
305
+ .rk-discover-head{margin-bottom:22px;display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
306
+ .rk-discover-headtext{display:grid;gap:4px;min-width:0}
307
+ .rk-discover-add{flex:none;align-self:flex-start}
308
+ .rk-discover-add svg{margin-top:-1px}
309
+ @media(max-width:520px){
310
+ .rk-discover-add span{display:none}
311
+ .rk-discover-add{padding:11px}
312
+ }
313
+
314
+ /* ── "Add New" modal ── */
315
+ .rk-add-modal{position:fixed;inset:0;z-index:140;display:grid;place-items:center;padding:20px}
316
+ .rk-add-modal[hidden]{display:none}
317
+ .rk-add-scrim{position:absolute;inset:0;background:rgba(15,12,5,.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
318
+ .rk-add-card{position:relative;z-index:1;width:min(480px,100%);max-height:calc(100vh - 40px);overflow:auto;
319
+ background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-xl);padding:24px 24px 22px}
320
+ .rk-add-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}
321
+ .rk-add-title{font-family:var(--rk-font-display);font-size:var(--rk-text-xl);font-weight:800;color:var(--rk-ink);letter-spacing:-.02em}
322
+ .rk-add-x{flex:none;width:34px;height:34px;border-radius:var(--rk-r-full);border:1px solid var(--rk-border);background:#fff;color:var(--rk-n-600);
323
+ display:grid;place-items:center;cursor:pointer;transition:color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
324
+ .rk-add-x:hover{color:var(--rk-ink);border-color:var(--rk-border-strong)}
325
+ .rk-add-x svg{width:16px;height:16px}
326
+ .rk-add-sub{font-size:13px;color:var(--rk-text-muted);line-height:1.5;margin-bottom:18px}
327
+ .rk-add-form{display:grid;gap:14px}
328
+ .rk-add-field{display:grid;gap:6px}
329
+ .rk-add-label{font-size:12.5px;font-weight:700;color:var(--rk-n-700)}
330
+ .rk-add-optional{font-weight:500;color:var(--rk-text-faint)}
331
+ .rk-add-input{width:100%;box-sizing:border-box;border:1px solid var(--rk-border-strong);border-radius:var(--rk-r-lg);
332
+ background:#fff;padding:11px 14px;font-family:var(--rk-font-body);font-size:14px;color:var(--rk-ink);outline:none;
333
+ transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
334
+ .rk-add-input::placeholder{color:var(--rk-n-400)}
335
+ .rk-add-input:focus{border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
336
+ .rk-add-textarea{resize:vertical;min-height:52px;line-height:1.45}
337
+ .rk-add-or{display:flex;align-items:center;gap:12px;color:var(--rk-text-faint);font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
338
+ .rk-add-or::before,.rk-add-or::after{content:"";height:1px;flex:1;background:var(--rk-border)}
339
+ .rk-add-file{display:flex;align-items:center;gap:12px;border:1px dashed var(--rk-border-strong);border-radius:var(--rk-r-lg);
340
+ padding:13px 14px;cursor:pointer;color:var(--rk-n-700);
341
+ transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
342
+ .rk-add-file:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
343
+ .rk-add-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
344
+ .rk-add-file-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
345
+ .rk-add-file-text{font-size:13.5px;font-weight:600;display:grid;gap:2px;min-width:0}
346
+ .rk-add-file-text .rk-add-optional{font-size:11.5px;font-weight:500}
347
+ .rk-add-file.is-picked{border-style:solid;border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
348
+ .rk-add-error{font-size:13px;color:#b91c1c;background:var(--rk-red-tint);border:1px solid #fecaca;border-radius:var(--rk-r-lg);padding:9px 12px;font-weight:500;line-height:1.4}
349
+ .rk-add-error[hidden]{display:none}
350
+ .rk-add-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
351
+ .rk-add-actions .rk-btn[disabled]{opacity:.6;cursor:default;transform:none}
214
352
 
215
353
  /* toolbar row: mode toggle + (feed) quick search share one line, wrap on mobile */
216
354
  .rk-discover-toolbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:26px}
@@ -233,11 +371,10 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
233
371
  .rk-discover-search-ic{color:var(--rk-text-faint);display:grid;place-items:center;flex:none}
234
372
  .rk-discover-search input{flex:1;min-width:0;border:0;outline:none;background:transparent;font-family:var(--rk-font-body);font-size:14px;color:var(--rk-ink)}
235
373
  .rk-discover-search input::placeholder{color:var(--rk-n-400)}
236
- .rk-discover-count{white-space:nowrap}
237
374
 
238
375
  .rk-discover-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(212px,1fr));gap:22px}
239
376
  @media(max-width:520px){.rk-discover-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}}
240
- .rk-discover-card{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);overflow:hidden;box-shadow:var(--rk-shadow-card);display:flex;flex-direction:column}
377
+ .rk-discover-card{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);overflow:hidden;box-shadow:var(--rk-shadow-card);display:flex;flex-direction:column;min-width:0}
241
378
  .rk-discover-poster{position:relative;display:block;aspect-ratio:9/16;overflow:hidden;background:var(--rk-n-100);color:inherit}
242
379
  .rk-discover-poster img,.rk-discover-poster video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
243
380
  .rk-discover-media-empty{position:absolute;inset:0;display:grid;place-items:center;font-size:12.5px;font-weight:600;color:var(--rk-text-faint);
@@ -252,15 +389,28 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
252
389
  display:none;place-items:center;color:#fff;background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);cursor:pointer;
253
390
  transition:transform var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
254
391
  .rk-discover-posterwrap:hover .rk-discover-bm{display:grid}
392
+ /* touch has no hover, so always show the bookmark button there (else you can
393
+ never save a not-yet-bookmarked card from the grid) */
394
+ @media(hover:none){.rk-discover-bm{display:grid}}
255
395
  .rk-discover-bm:hover{transform:scale(1.08);background:rgba(0,0,0,.6)}
256
396
  .rk-discover-bm.is-on{display:grid;background:var(--rk-gold-500);color:var(--rk-ink)}
257
397
  .rk-discover-bm.is-on svg{fill:currentColor}
258
- .rk-discover-body{padding:15px 16px 16px;display:grid;gap:13px}
259
- .rk-discover-titlewrap{display:grid;gap:6px}
398
+ .rk-discover-body{padding:15px 16px 16px;display:grid;gap:13px;min-width:0}
399
+ .rk-discover-titlewrap{display:grid;gap:6px;min-width:0}
260
400
  .rk-discover-title{font-family:var(--rk-font-display);font-size:1.02rem;font-weight:700;letter-spacing:-.01em;color:var(--rk-ink);line-height:1.2;
261
401
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
262
402
  .rk-discover-desc{font-size:12.5px;font-weight:500;color:var(--rk-text-muted);line-height:1.4;
263
403
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
404
+ .rk-discover-idchip{justify-self:start;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;
405
+ padding:3px 8px;border-radius:999px;background:var(--rk-surface-2,rgba(0,0,0,.05));
406
+ font-family:var(--rk-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:11px;font-weight:600;
407
+ color:var(--rk-text-muted);cursor:pointer;border:0;line-height:1.4;transition:background .15s,color .15s;
408
+ -webkit-user-select:none;user-select:none;box-sizing:border-box}
409
+ .rk-discover-idchip:hover{background:var(--rk-surface-3,rgba(0,0,0,.09));color:var(--rk-ink)}
410
+ .rk-discover-idchip svg{flex:0 0 auto;opacity:.7}
411
+ .rk-discover-idchip .rk-idchip-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
412
+ .rk-discover-idchip.is-copied{background:rgba(52,168,110,.16);color:#1f7a4d}
413
+ .rk-discover-idchip.is-copied svg{opacity:1;color:#1f7a4d}
264
414
  .rk-discover-fork{gap:7px}
265
415
  .rk-discover-fork svg{margin-top:-1px}
266
416
  .rk-discover-fork-disabled{opacity:.6;cursor:default;pointer-events:none}
@@ -358,12 +508,22 @@ video.rk-discover-viewer-media{cursor:pointer}
358
508
  transition:transform .38s var(--rk-ease),opacity .38s var(--rk-ease)}
359
509
  .rk-discover-swipe-frame{position:absolute;inset:0;width:100%;height:100%;border:0;display:block;pointer-events:none;background:#000}
360
510
  .rk-discover-swipe-fallback{position:absolute;inset:0;display:grid;place-items:center;padding:24px;text-align:center;
361
- background:linear-gradient(155deg,#3a3a3a,#111);color:#fff;font-family:var(--rk-font-display);font-weight:700;font-size:1.1rem;line-height:1.15}
511
+ background:linear-gradient(155deg,#3a3a3a,#111);color:#fff;font-family:var(--rk-font-display);font-weight:700;font-size:1.1rem;line-height:1.15;
512
+ transition:opacity .4s var(--rk-ease)}
362
513
  .rk-discover-swipe-scrim{position:absolute;inset:0;z-index:1;pointer-events:none;background:linear-gradient(to top,rgba(0,0,0,.62) 0%,rgba(0,0,0,.16) 46%,transparent 74%)}
363
514
  .rk-discover-swipe-info{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:20px;display:grid;gap:6px;pointer-events:none}
364
515
  .rk-discover-swipe-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.05rem;color:#fff;letter-spacing:-.01em;line-height:1.18}
365
516
  .rk-discover-swipe-caption{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.84);line-height:1.35;
366
517
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
518
+ /* audio toggle — overlays the top-of-deck card, top-right; global mute state */
519
+ .rk-discover-sound-toggle{position:absolute;top:12px;right:12px;z-index:40;width:38px;height:38px;border-radius:var(--rk-r-full);
520
+ border:0;display:grid;place-items:center;cursor:pointer;color:#fff;background:rgba(15,15,15,.5);backdrop-filter:blur(6px);
521
+ -webkit-backdrop-filter:blur(6px);box-shadow:0 2px 8px -2px rgba(0,0,0,.5);
522
+ transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
523
+ .rk-discover-sound-toggle[hidden]{display:none}
524
+ .rk-discover-sound-toggle:hover{background:rgba(15,15,15,.72);transform:scale(1.06)}
525
+ .rk-discover-sound-toggle.is-on{background:var(--rk-gold-500);color:var(--rk-ink)}
526
+ .rk-discover-sound-toggle.is-on:hover{background:var(--rk-gold-700)}
367
527
 
368
528
  /* deck flanked by skip/fork — buttons ride the card's vertical center, 1fr gutters absorb spare width.
369
529
  Explicit column placement keeps the deck centered when the buttons are display:none (empty/caught-up states). */
@@ -382,6 +542,17 @@ video.rk-discover-viewer-media{cursor:pointer}
382
542
  .rk-discover-act-skip{grid-area:skip;justify-self:center}
383
543
  .rk-discover-act-fork{grid-area:fork;justify-self:center}
384
544
  }
545
+ /* short viewports (landscape phones, small laptops): the deck is sized by width
546
+ with a locked 9/16 ratio → ~570px tall, taller than the screen, so the card +
547
+ its flanking Skip/Fork controls fall off-screen. Cap it by HEIGHT instead and
548
+ let width follow the ratio (mirrors how the fullscreen viewer stage sizes). */
549
+ @media(max-height:720px){
550
+ .rk-discover-deck{height:min(74vh,572px);width:auto}
551
+ }
552
+ @media(max-height:520px){
553
+ .rk-discover-head{margin-bottom:10px}
554
+ .rk-discover-swipe-wrap{gap:10px;padding:0}
555
+ }
385
556
  .rk-discover-act:hover{transform:translateY(-2px);box-shadow:var(--rk-shadow-lg)}
386
557
  .rk-discover-act:disabled{opacity:.5;cursor:default;transform:none}
387
558
  .rk-discover-act-skip{color:var(--rk-n-500)}
@@ -390,6 +561,19 @@ video.rk-discover-viewer-media{cursor:pointer}
390
561
  .rk-discover-act-fork:hover{background:var(--rk-gold-700);box-shadow:0 8px 22px -6px rgba(252,185,0,.6)}
391
562
  .rk-discover-act-hint{font-size:12.5px;font-weight:600;color:var(--rk-text-faint);text-align:center;letter-spacing:.01em}
392
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}}
393
577
 
394
578
  /* ── categories: browse-by-format cards ── */
395
579
  .rk-discover-cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(268px,1fr));gap:20px}
@@ -447,6 +631,61 @@ video.rk-discover-viewer-media{cursor:pointer}
447
631
  var u=editorBase+'/'+encodeURIComponent(String(id));
448
632
  return accountId? u+'?account='+encodeURIComponent(accountId) : u;
449
633
  }
634
+ // Feed cards browse INSPIRATIONS: an entry may have no template yet. The real
635
+ // template id (if decomposed) opens the editor directly; otherwise the card
636
+ // carries an inspirationId and its CTA decomposes on demand (preview → Remix).
637
+ function realTemplateId(t){ var id=t.templateId||t.template_id||''; return /^template_/.test(String(id))? String(id):''; }
638
+ function inspirationIdOf(t){ return String(t.inspirationId||t.inspiration_id||''); }
639
+ function domId(t){ return realTemplateId(t)||inspirationIdOf(t)||''; }
640
+ function needsDecompose(t){ return !realTemplateId(t) && Boolean(inspirationIdOf(t)); }
641
+ // POST the lazy decompose, then hand off to the editor. Shared by card +
642
+ // viewer Remix buttons via a delegated [data-remix] listener.
643
+ function remixInspiration(btn){
644
+ var inspId=btn.getAttribute('data-remix'); if(!inspId||btn.getAttribute('data-busy')) return;
645
+ btn.setAttribute('data-busy','1');
646
+ var label=btn.innerHTML; btn.innerHTML='Preparing&hellip;';
647
+ fetch(withAccount('/discover/inspirations/'+encodeURIComponent(inspId)+'/decompose'),{method:'POST',credentials:'same-origin',headers:{Accept:'application/json'}})
648
+ .then(function(r){ return r.json().then(function(d){ return {ok:r.ok,data:d}; }); })
649
+ .then(function(res){
650
+ if(res.ok&&res.data&&res.data.templateId){ window.location.href=editorUrl(res.data.templateId); return; }
651
+ btn.removeAttribute('data-busy'); btn.innerHTML=label;
652
+ window.alert((res.data&&res.data.error)||'Could not open this inspiration — please try again.');
653
+ })
654
+ .catch(function(){ btn.removeAttribute('data-busy'); btn.innerHTML=label; window.alert('Network error — please try again.'); });
655
+ }
656
+ document.addEventListener('click',function(e){
657
+ var b=e.target&&e.target.closest?e.target.closest('[data-remix]'):null;
658
+ if(!b) return; e.preventDefault(); remixInspiration(b);
659
+ });
660
+ // Click the template-id chip on a feed card to copy the id to the clipboard,
661
+ // with a brief inline "Copied" swap (icon + label) as subtle confirmation.
662
+ function copyText(text){
663
+ if(navigator.clipboard&&navigator.clipboard.writeText){ return navigator.clipboard.writeText(text); }
664
+ return new Promise(function(resolve,reject){
665
+ try{
666
+ var ta=document.createElement('textarea'); ta.value=text;
667
+ ta.style.position='fixed'; ta.style.top='-9999px'; document.body.appendChild(ta);
668
+ ta.focus(); ta.select(); var ok=document.execCommand('copy'); document.body.removeChild(ta);
669
+ ok?resolve():reject(new Error('copy failed'));
670
+ }catch(err){ reject(err); }
671
+ });
672
+ }
673
+ document.addEventListener('click',function(e){
674
+ var chip=e.target&&e.target.closest?e.target.closest('[data-copy-id]'):null;
675
+ if(!chip) return;
676
+ e.preventDefault(); e.stopPropagation();
677
+ var id=chip.getAttribute('data-copy-id')||'';
678
+ if(!id||chip.getAttribute('data-copied')) return;
679
+ copyText(id).then(function(){
680
+ var txt=chip.querySelector('.rk-idchip-text'); var prev=txt?txt.textContent:'';
681
+ chip.setAttribute('data-copied','1'); chip.classList.add('is-copied');
682
+ chip.innerHTML=IDCHECK_ICON+'<span class="rk-idchip-text">Copied</span>';
683
+ window.setTimeout(function(){
684
+ chip.classList.remove('is-copied'); chip.removeAttribute('data-copied');
685
+ chip.innerHTML=COPY_ICON+'<span class="rk-idchip-text">'+esc(prev)+'</span>';
686
+ },1300);
687
+ }).catch(function(){});
688
+ });
450
689
  function fmtDuration(s){
451
690
  s=Number(s); if(!s||!isFinite(s)||s<=0) return '';
452
691
  if(s>=60){ var m=Math.floor(s/60); var r=Math.round(s%60); return m+':'+(r<10?'0':'')+r; }
@@ -455,6 +694,8 @@ video.rk-discover-viewer-media{cursor:pointer}
455
694
  var SOURCE_MAP={tiktok:'TikTok','tiktok.com':'TikTok',youtube:'YouTube','youtube.com':'YouTube','youtu.be':'YouTube',instagram:'Instagram','instagram.com':'Instagram',twitter:'Twitter/X',x:'Twitter/X','x.com':'Twitter/X'};
456
695
  function sourceLabel(s){ s=String(s||'').toLowerCase(); if(!s) return ''; if(SOURCE_MAP[s]) return SOURCE_MAP[s]; var host=s.replace(/^www\\./,'').split('.')[0]; return host.charAt(0).toUpperCase()+host.slice(1); }
457
696
  var EDIT_ICON=${JSON.stringify(EDIT_ICON)};
697
+ var COPY_ICON=${JSON.stringify(COPY_ICON)};
698
+ var IDCHECK_ICON=${JSON.stringify(IDCHECK_ICON)};
458
699
  var BOOKMARK_ICON=${JSON.stringify(BOOKMARK_ICON)};
459
700
  var SOUND_ON_ICON=${JSON.stringify(SOUND_ON_ICON)};
460
701
  var SOUND_OFF_ICON=${JSON.stringify(SOUND_OFF_ICON)};
@@ -528,32 +769,41 @@ video.rk-discover-viewer-media{cursor:pointer}
528
769
  // opens the editor via the untouched href). No platform/source pill on the
529
770
  // poster (user feedback) — source shows only in the viewer's meta line.
530
771
  function posterCardHtml(t, viewIdx){
531
- var id=t.templateId||t.template_id||'';
772
+ var id=domId(t);
773
+ var tid=realTemplateId(t);
774
+ var remix=needsDecompose(t);
532
775
  var url=t.previewUrl||t.preview_url||'';
533
776
  var hasVideo=Boolean(url)&&!isImage(url);
534
777
  var pending=(t.status==='processing'||t.status==='failed');
535
778
  var viewable=!pending&&Boolean(url)&&typeof viewIdx==='number'&&viewIdx>=0;
536
779
  var dur=fmtDuration(t.durationSeconds);
537
780
  var desc=String(t.viralDna||t.summary||'').trim();
781
+ // Real templates deep-link to the editor; inspiration-only cards open the
782
+ // preview viewer instead (a plain anchor href would 404 the editor), and the
783
+ // CTA decomposes on demand.
784
+ var posterHref=remix? '#' : editorUrl(tid);
538
785
  var posterInner=mediaHtml(t)
539
786
  +(dur?'<span class="rk-discover-dur">'+esc(dur)+'</span>':'')
540
787
  +'<div class="rk-discover-scrim"></div>'
541
788
  +(hasVideo&&!pending?'<span class="rk-discover-sound" aria-hidden="true"><span class="rk-ds-on">'+SOUND_ON_ICON+'</span><span class="rk-ds-off">'+SOUND_OFF_ICON+'</span></span>':'');
542
789
  var poster=pending
543
790
  ? '<div class="rk-discover-poster">'+posterInner+'</div>'
544
- : '<a class="rk-discover-poster" href="'+attr(editorUrl(id))+'"'+(viewable?' data-rk-view="'+viewIdx+'"':'')+' aria-label="'+attr((viewable?'Preview ':'Open ')+(t.title||id)+(viewable?'':' in the editor'))+'">'+posterInner+'</a>';
791
+ : '<a class="rk-discover-poster" href="'+attr(posterHref)+'"'+(viewable?' data-rk-view="'+viewIdx+'"':'')+' aria-label="'+attr((viewable?'Preview ':'Open ')+(t.title||id)+(viewable?'':' in the editor'))+'">'+posterInner+'</a>';
545
792
  // bookmark sits NEXT TO the anchor inside the wrap (button-in-anchor is
546
793
  // invalid HTML); the wrap is the shared hover context for sound + bookmark
547
794
  poster='<div class="rk-discover-posterwrap">'+poster+(pending?'':bmBtnHtml(id,'rk-discover-bm'))+'</div>';
548
795
  var cta=pending
549
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>'
550
- : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" href="'+attr(editorUrl(id))+'">'+EDIT_ICON+' Open Editor</a>';
797
+ : remix
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>'
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>';
551
800
  return '<article class="rk-discover-card rk-card-hover">'
552
801
  + poster
553
802
  + '<div class="rk-discover-body">'
554
803
  + '<div class="rk-discover-titlewrap">'
555
804
  + '<h3 class="rk-discover-title">'+esc(t.title||id)+'</h3>'
556
805
  + (desc?'<p class="rk-discover-desc">'+esc(desc)+'</p>':'')
806
+ + (tid?'<button type="button" class="rk-discover-idchip" data-copy-id="'+attr(tid)+'" title="Copy template ID">'+COPY_ICON+'<span class="rk-idchip-text">'+esc(tid)+'</span></button>':'')
557
807
  + '</div>'
558
808
  + cta
559
809
  + '</div>'
@@ -610,7 +860,9 @@ video.rk-discover-viewer-media{cursor:pointer}
610
860
  var vItems=[]; var vOpen=false; var vActive=-1; var vSound=true; var vIO=null; var parkEl=null;
611
861
 
612
862
  function viewerSlideHtml(t,i){
613
- var id=t.templateId||t.template_id||'';
863
+ var id=domId(t);
864
+ var tid=realTemplateId(t);
865
+ var remix=needsDecompose(t);
614
866
  var url=t.previewUrl||t.preview_url||'';
615
867
  var media=isImage(url)
616
868
  ? '<img class="rk-discover-viewer-media" src="'+attr(url)+'" alt="'+attr((t.title||'Template')+' preview')+'">'
@@ -629,7 +881,9 @@ video.rk-discover-viewer-media{cursor:pointer}
629
881
  +(meta.length?'<div class="rk-discover-viewer-meta">'+meta.join(' &middot; ')+'</div>':'')
630
882
  +'<div class="rk-discover-viewer-title">'+esc(t.title||id)+'</div>'
631
883
  +(desc?'<div class="rk-discover-viewer-desc">'+esc(desc)+'</div>':'')
632
- +'<a class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" href="'+attr(editorUrl(id))+'">'+EDIT_ICON+' Open Editor</a>'
884
+ +(remix
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>'
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>')
633
887
  +'</div>'
634
888
  +'</figure>'
635
889
  +'</section>';
@@ -764,9 +1018,18 @@ video.rk-discover-viewer-media{cursor:pointer}
764
1018
  if(feedRoot){
765
1019
  var gridEl=feedRoot.querySelector('[data-rk-grid]');
766
1020
  var stateEl=feedRoot.querySelector('[data-rk-state]');
767
- // search + count live in the shared toolbar row (outside [data-rk-feed])
768
- var countEl=root.querySelector('[data-rk-count]');
1021
+ // search lives in the shared toolbar row (outside [data-rk-feed]); the
1022
+ // visible-count is folded into the search placeholder rather than shown as
1023
+ // a separate label.
769
1024
  var searchEl=root.querySelector('[data-rk-search]');
1025
+ // caption the placeholder with the count, but only while the box is empty —
1026
+ // a typed query keeps its own placeholder-less state.
1027
+ function setCountHint(n){
1028
+ if(!searchEl) return;
1029
+ searchEl.placeholder = (n==null)
1030
+ ? 'Search templates'
1031
+ : 'Search '+n+' template'+(n===1?'':'s');
1032
+ }
770
1033
  var bmChip=root.querySelector('[data-rk-bmfilter]');
771
1034
  var bmCountEl=root.querySelector('[data-rk-bmcount]');
772
1035
  var bmFilter=false;
@@ -776,7 +1039,7 @@ video.rk-discover-viewer-media{cursor:pointer}
776
1039
  function feedSetState(msg,isError){
777
1040
  if(gridEl) gridEl.innerHTML='';
778
1041
  if(stateEl){ stateEl.hidden=false; stateEl.innerHTML=msg; stateEl.classList.toggle('is-error',!!isError); }
779
- if(countEl) countEl.textContent='';
1042
+ setCountHint(null);
780
1043
  }
781
1044
  function updateBmChip(){
782
1045
  if(!bmChip) return;
@@ -798,7 +1061,7 @@ video.rk-discover-viewer-media{cursor:pointer}
798
1061
  }
799
1062
  if(stateEl) stateEl.hidden=true;
800
1063
  if(gridEl) gridEl.innerHTML=vis.map(function(t,i){ return posterCardHtml(t,i); }).join('');
801
- if(countEl) countEl.textContent=vis.length+' template'+(vis.length===1?'':'s');
1064
+ setCountHint(vis.length);
802
1065
  }
803
1066
  if(bmChip) bmChip.addEventListener('click',function(){ bmFilter=!bmFilter; feedRender(); });
804
1067
  bmChangeHandlers.push(function(){
@@ -807,17 +1070,42 @@ video.rk-discover-viewer-media{cursor:pointer}
807
1070
  // re-render restarts muted autoplay, so re-park it if the viewer is open
808
1071
  if(bmFilter){ feedRender(); if(viewerCtl&&viewerCtl.isOpen()) parkVideos(gridEl); }
809
1072
  });
810
- function feedLoad(query){
811
- var my=++feedReq;
812
- feedState.loading=true;
813
- feedSetState('Loading templates&hellip;',false);
1073
+ function applyFeedItems(items){
1074
+ feedState.items=Array.isArray(items)?items:[];
1075
+ feedState.loading=false;
1076
+ feedRender();
1077
+ }
1078
+ // Direct same-origin fetch — the fallback for when the Zustand bridge isn't
1079
+ // on the page yet (its deferred module is still loading) or is disabled.
1080
+ function feedFetchDirect(query,my){
814
1081
  var url=feedEndpoint;
815
1082
  try{ var u=new URL(feedEndpoint, window.location.href); if(query) u.searchParams.set('q', query); if(accountId) u.searchParams.set('account', accountId); url=u.toString(); }catch(e){ url=feedEndpoint; }
816
1083
  fetch(url,{credentials:'same-origin',headers:{'accept':'application/json'}})
817
1084
  .then(function(r){ if(!r.ok){ throw new Error(r.status===401?'Sign in to browse the template feed.':("Couldn't load templates ("+r.status+").")); } return r.json(); })
818
- .then(function(j){ if(my!==feedReq) return; feedState.items=(j&&Array.isArray(j.templates))?j.templates:[]; feedState.loading=false; feedRender(); })
1085
+ .then(function(j){ if(my!==feedReq) return; applyFeedItems((j&&Array.isArray(j.templates))?j.templates:[]); })
819
1086
  .catch(function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); });
820
1087
  }
1088
+ // Feed loads flow through the Zustand store bridge (SSR-seed + SWR cache);
1089
+ // it may call onResult twice for the default feed (cached snapshot first,
1090
+ // then a background-revalidated fresh copy) — both are honored under the
1091
+ // same request token so a newer search still wins.
1092
+ function feedLoad(query){
1093
+ var my=++feedReq;
1094
+ feedState.loading=true;
1095
+ // Only flash the loading state when nothing's painted yet — a
1096
+ // revalidate must never blank an already-good grid.
1097
+ if(!feedState.items.length) feedSetState('Loading templates&hellip;',false);
1098
+ var bridge=window.__vidfarmDiscover;
1099
+ if(bridge&&bridge.loadFeed){
1100
+ bridge.loadFeed(query,{
1101
+ onLoading:function(){ if(my===feedReq && !feedState.items.length) feedSetState('Loading templates&hellip;',false); },
1102
+ onResult:function(items){ if(my!==feedReq) return; applyFeedItems(items); },
1103
+ onError:function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); }
1104
+ });
1105
+ } else {
1106
+ feedFetchDirect(query,my);
1107
+ }
1108
+ }
821
1109
  // Honor a ?q= (e.g. from a category link) in the page URL.
822
1110
  var initialQuery='';
823
1111
  try{ initialQuery=(new URL(window.location.href)).searchParams.get('q')||''; }catch(e){ initialQuery=''; }
@@ -835,7 +1123,140 @@ video.rk-discover-viewer-media{cursor:pointer}
835
1123
  // plain click on a card → shared viewer over the CURRENT visible feed
836
1124
  // (search + bookmark filter applied)
837
1125
  bindViewerOpens(gridEl, function(){ return feedState.visible; });
838
- feedLoad(initialQuery.trim());
1126
+ // SSR-seed: if the server inlined the first page of the default feed AND
1127
+ // there's no ?q= search, paint it immediately with NO initial fetch — this
1128
+ // is what kills the redundant SSR-render → client-fetch round trip.
1129
+ var seeded=false;
1130
+ if(!initialQuery && boot.feedInitial && Array.isArray(boot.feedInitial) && boot.feedInitial.length){
1131
+ applyFeedItems(boot.feedInitial);
1132
+ seeded=true;
1133
+ }
1134
+ if(!seeded){
1135
+ feedLoad(initialQuery.trim());
1136
+ }
1137
+ // A background revalidation (stale snapshot / BFCache restore) from the
1138
+ // store bridge re-paints the grid — but only while not mid-search.
1139
+ window.addEventListener('vidfarm:discover-feed-updated',function(ev){
1140
+ var q=searchEl?searchEl.value.trim():'';
1141
+ if(q) return;
1142
+ var items=ev&&ev.detail&&ev.detail.items;
1143
+ if(Array.isArray(items)) applyFeedItems(items);
1144
+ });
1145
+
1146
+ // ── "Add New" modal — paste a social URL or upload a video, add it to the
1147
+ // caller's private feed (real POST /discover/templates + upload presign
1148
+ // pair), then reload the feed so the new pending card appears. In feed
1149
+ // scope so it can reach feedLoad + the current search value.
1150
+ var addModal=root.querySelector('[data-rk-add-modal]');
1151
+ var addBtn=root.querySelector('[data-rk-add]');
1152
+ if(addModal&&addBtn){
1153
+ var addForm=addModal.querySelector('[data-rk-add-form]');
1154
+ var addUrl=addModal.querySelector('[data-rk-add-url]');
1155
+ var addFile=addModal.querySelector('[data-rk-add-file]');
1156
+ var addFileLabel=addModal.querySelector('[data-rk-add-filelabel]');
1157
+ var addFileText=addModal.querySelector('[data-rk-add-filetext]');
1158
+ var addTagline=addModal.querySelector('[data-rk-add-tagline]');
1159
+ var addNotes=addModal.querySelector('[data-rk-add-notes]');
1160
+ var addErr=addModal.querySelector('[data-rk-add-error]');
1161
+ var addSubmit=addModal.querySelector('[data-rk-add-submit]');
1162
+ var MAX_UPLOAD=200*1024*1024;
1163
+ var defaultFileText=addFileText?addFileText.innerHTML:'';
1164
+ var addBusy=false;
1165
+
1166
+ function showAddErr(msg){ if(addErr){ addErr.textContent=msg||''; addErr.hidden=!msg; } }
1167
+ function openAdd(){
1168
+ addModal.hidden=false;
1169
+ document.documentElement.classList.add('rk-noscroll');
1170
+ if(addUrl){ try{ addUrl.focus(); }catch(e){} }
1171
+ }
1172
+ function closeAdd(){
1173
+ if(addBusy) return;
1174
+ addModal.hidden=true;
1175
+ document.documentElement.classList.remove('rk-noscroll');
1176
+ }
1177
+ function resetAdd(){
1178
+ if(addForm) addForm.reset();
1179
+ if(addFileLabel) addFileLabel.classList.remove('is-picked');
1180
+ if(addFileText) addFileText.innerHTML=defaultFileText;
1181
+ showAddErr('');
1182
+ }
1183
+ function setAddBusy(on,label){
1184
+ addBusy=on;
1185
+ if(addSubmit){ addSubmit.disabled=on; addSubmit.textContent=on?(label||'Adding\\u2026'):'Add to feed'; }
1186
+ }
1187
+
1188
+ addBtn.addEventListener('click',openAdd);
1189
+ addModal.querySelectorAll('[data-rk-add-close]').forEach(function(b){ b.addEventListener('click',closeAdd); });
1190
+ document.addEventListener('keydown',function(e){ if(e.key==='Escape'&&!addModal.hidden) closeAdd(); });
1191
+
1192
+ if(addFile){
1193
+ addFile.addEventListener('change',function(){
1194
+ var f=addFile.files&&addFile.files[0];
1195
+ if(addFileLabel) addFileLabel.classList.toggle('is-picked',!!f);
1196
+ if(addFileText) addFileText.innerHTML=f?esc(f.name):defaultFileText;
1197
+ showAddErr('');
1198
+ });
1199
+ }
1200
+
1201
+ function ingest(uploadRef){
1202
+ var tagline=addTagline?addTagline.value.trim():'';
1203
+ var notes=addNotes?addNotes.value.trim():'';
1204
+ var payload=uploadRef
1205
+ ? { upload:uploadRef, tagline:tagline||null, notes:notes||null }
1206
+ : { source_url:(addUrl?addUrl.value.trim():''), tagline:tagline||null, notes:notes||null };
1207
+ return fetch(withAccount('/discover/templates'),{
1208
+ method:'POST',credentials:'same-origin',
1209
+ headers:{'content-type':'application/json',Accept:'application/json'},
1210
+ body:JSON.stringify(payload)
1211
+ }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,status:r.status,data:d}; }); });
1212
+ }
1213
+ function uploadFile(file){
1214
+ return fetch(withAccount('/discover/templates/upload/presign'),{
1215
+ method:'POST',credentials:'same-origin',
1216
+ headers:{'content-type':'application/json',Accept:'application/json'},
1217
+ body:JSON.stringify({ file_name:file.name, content_type:file.type||null, size_bytes:file.size })
1218
+ }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,data:d}; }); })
1219
+ .then(function(res){
1220
+ if(!res.ok) throw new Error((res.data&&res.data.error)||'Unable to start the video upload.');
1221
+ var p=res.data||{};
1222
+ if(p.transport==='presigned'&&p.upload&&p.upload.url&&p.storage_key){
1223
+ return fetch(p.upload.url,{ method:(p.upload.method||'PUT'), headers:(p.upload.headers||{}), body:file })
1224
+ .then(function(put){ if(!put.ok) throw new Error('Video upload failed ('+put.status+'). Try again.');
1225
+ return { storage_key:p.storage_key, file_name:p.file_name||file.name }; });
1226
+ }
1227
+ var form=new FormData(); form.append('file',file,p.file_name||file.name);
1228
+ return fetch((p.upload&&p.upload.url)||'/discover/templates/upload',{ method:'POST',credentials:'same-origin',body:form })
1229
+ .then(function(direct){ return direct.json().catch(function(){ return {}; }).then(function(dd){
1230
+ if(!direct.ok||!dd.storage_key) throw new Error(dd.error||'Video upload failed. Try again.');
1231
+ return { storage_key:dd.storage_key, file_name:dd.file_name||file.name }; }); });
1232
+ });
1233
+ }
1234
+
1235
+ if(addForm){
1236
+ addForm.addEventListener('submit',function(e){
1237
+ e.preventDefault();
1238
+ if(addBusy) return;
1239
+ showAddErr('');
1240
+ var file=addFile&&addFile.files?addFile.files[0]:null;
1241
+ var url=addUrl?addUrl.value.trim():'';
1242
+ if(!file&&!url){ showAddErr('Paste a TikTok, YouTube, Twitter/X, or Instagram video URL \\u2014 or upload a video file.'); return; }
1243
+ if(file&&file.size>MAX_UPLOAD){ showAddErr('Video uploads can be at most 200 MB.'); return; }
1244
+ setAddBusy(true,file?'Uploading\\u2026':'Adding\\u2026');
1245
+ var chain=file?uploadFile(file):Promise.resolve(null);
1246
+ chain.then(function(uploadRef){ return ingest(uploadRef); })
1247
+ .then(function(res){
1248
+ setAddBusy(false);
1249
+ if(!res.ok){ showAddErr((res.data&&res.data.error)||'Unable to add that video.'); return; }
1250
+ resetAdd(); closeAdd();
1251
+ // A new inspiration just landed — drop the cached snapshot so the
1252
+ // reload fetches fresh (no stale-cache flash of the old feed).
1253
+ if(window.__vidfarmDiscover&&window.__vidfarmDiscover.invalidate) window.__vidfarmDiscover.invalidate();
1254
+ feedLoad(searchEl?searchEl.value.trim():'');
1255
+ })
1256
+ .catch(function(err){ setAddBusy(false); showAddErr((err&&err.message)?err.message:'Unable to add that video.'); });
1257
+ });
1258
+ }
1259
+ }
839
1260
  }
840
1261
 
841
1262
  // ════════════════════════════════════════════════════════════════════════
@@ -849,8 +1270,35 @@ video.rk-discover-viewer-media{cursor:pointer}
849
1270
  var hint=swipeRoot.querySelector('[data-actions-hint]');
850
1271
  var skipBtn=swipeRoot.querySelector('[data-swipe-skip]');
851
1272
  var forkBtn=swipeRoot.querySelector('[data-swipe-fork]');
852
- var sw={target:5,customizing:0,productSet:false,busy:false,refilling:false,loaded:false};
1273
+ var soundBtn=swipeRoot.querySelector('[data-swipe-sound]');
1274
+ var sw={target:5,customizing:0,productSet:false,busy:false,refilling:false,loaded:false,soundOn:false,known:{}};
853
1275
  var nodes=[]; // live card DOM nodes, top-of-deck first
1276
+ var deckExpanded=false; // true while the fullscreen swipe viewer owns the previews
1277
+
1278
+ // ── audio: the same-origin preview iframes poll these window flags (see
1279
+ // injectSwipePreviewFit). Only the TOP card's id is named audible so the
1280
+ // preloaded 2nd iframe stays silent (audio never doubles). Deck previews
1281
+ // always start muted (the only autoplay browsers permit); the toggle is the
1282
+ // user gesture that unlocks sound.
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;
1287
+ try{
1288
+ window.__VF_SWIPE_SOUND__ = sw.soundOn===true;
1289
+ window.__VF_SWIPE_SOUND_TOP__ = nodes.length? String(nodes[0]._card.id): null;
1290
+ }catch(e){}
1291
+ }
1292
+ function updateSoundBtn(){
1293
+ if(!soundBtn) return;
1294
+ var show=nodes.length>0;
1295
+ soundBtn.hidden=!show;
1296
+ soundBtn.classList.toggle('is-on', sw.soundOn);
1297
+ soundBtn.setAttribute('aria-pressed', sw.soundOn?'true':'false');
1298
+ soundBtn.setAttribute('aria-label', sw.soundOn?'Mute':'Unmute');
1299
+ soundBtn.innerHTML = sw.soundOn? SOUND_ON_ICON : SOUND_OFF_ICON;
1300
+ }
1301
+ if(soundBtn) soundBtn.addEventListener('click',function(){ sw.soundOn=!sw.soundOn; syncSound(); updateSoundBtn(); });
854
1302
 
855
1303
  function swShowState(html,kind){
856
1304
  if(swStateEl){ swStateEl.hidden=false; swStateEl.innerHTML=html; swStateEl.classList.toggle('is-error',kind==='error'); }
@@ -860,6 +1308,7 @@ video.rk-discover-viewer-media{cursor:pointer}
860
1308
  function swHideState(){ if(swStateEl) swStateEl.hidden=true; }
861
1309
 
862
1310
  function makeCard(card){
1311
+ if(card&&card.id) sw.known[String(card.id)]=true;
863
1312
  var el=document.createElement('article');
864
1313
  el.className='rk-discover-swipe';
865
1314
  el._card=card; el._iframed=false;
@@ -883,6 +1332,13 @@ video.rk-discover-viewer-media{cursor:pointer}
883
1332
  frame.setAttribute('loading','lazy');
884
1333
  frame.setAttribute('scrolling','no');
885
1334
  frame.setAttribute('allow','autoplay');
1335
+ // The fallback is an OPAQUE dark placeholder that sits later in the DOM,
1336
+ // so it paints over the iframe. Reveal the live preview once it loads by
1337
+ // fading the placeholder out (kept as a load state until then). A timed
1338
+ // fallback covers the case where 'load' never fires.
1339
+ function revealFrame(){ if(fb){ fb.style.opacity='0'; fb.style.pointerEvents='none'; } }
1340
+ frame.addEventListener('load', function(){ window.setTimeout(revealFrame, 120); });
1341
+ window.setTimeout(revealFrame, 2600);
886
1342
  if(fb&&fb.parentNode) fb.parentNode.insertBefore(frame, fb); else el.insertBefore(frame, el.firstChild);
887
1343
  el._iframed=true;
888
1344
  }
@@ -893,11 +1349,16 @@ video.rk-discover-viewer-media{cursor:pointer}
893
1349
  el.style.zIndex=String(20-i);
894
1350
  el.style.transform='translateY('+(i*12)+'px) scale('+(1-i*0.045).toFixed(3)+')';
895
1351
  el.style.opacity=i===0?'1':(i===1?'0.92':'0.82');
896
- 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);
897
1355
  });
898
1356
  var has=nodes.length>0;
899
1357
  actionBtns.forEach(function(b){ b.hidden=!has; });
900
1358
  if(hint) hint.hidden=!has;
1359
+ syncSound(); updateSoundBtn();
1360
+ // Keep the open viewer's slide list in step with refills/removals.
1361
+ if(deckExpanded && swViewer) swViewer.sync();
901
1362
  if(!has) swCaughtUp();
902
1363
  }
903
1364
  function swCaughtUp(){
@@ -907,6 +1368,25 @@ video.rk-discover-viewer-media{cursor:pointer}
907
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>');
908
1369
  }
909
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
+ }
910
1390
  function decide(dir){
911
1391
  if(sw.busy||!nodes.length) return;
912
1392
  sw.busy=true;
@@ -917,11 +1397,7 @@ video.rk-discover-viewer-media{cursor:pointer}
917
1397
  el.style.transform='translateX('+(dir==='fork'?460:-460)+'px) rotate('+(dir==='fork'?15:-15)+'deg)';
918
1398
  el.style.opacity='0';
919
1399
  window.setTimeout(function(){ if(el&&el.parentNode) el.parentNode.removeChild(el); },380);
920
- var path=swipeBase+'/'+encodeURIComponent(String(card.id))+'/'+(dir==='fork'?'approve':'reject');
921
- fetch(withAccount(path),{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json','accept':'application/json'},body:'{}'})
922
- .then(function(r){ return r.json().catch(function(){ return {}; }); })
923
- .catch(function(){ /* swallowed — the card already left the deck */ })
924
- .then(function(){ sw.busy=false; restack(); refill(); });
1400
+ postDecision(card,dir).then(function(){ sw.busy=false; restack(); refill(); });
925
1401
  }
926
1402
  function refill(){
927
1403
  if(sw.refilling||!sw.productSet||!sw.loaded) return;
@@ -935,8 +1411,43 @@ video.rk-discover-viewer-media{cursor:pointer}
935
1411
  if(j.filled&&j.record){ makeCard(j.record); restack(); }
936
1412
  var more=j.filled&&nodes.length<sw.target&&j.reason!=='no_eligible'&&j.reason!=='at_target';
937
1413
  if(more) refill();
1414
+ // Server said it's still working (no card yet, not "done") — the
1415
+ // customization keeps running Lambda-side even if THIS request replies
1416
+ // late; poll the stack so a finished card appears without a refresh.
1417
+ else if(!j.filled&&j.reason!=='no_eligible'&&j.reason!=='at_target') schedulePoll();
938
1418
  })
939
- .catch(function(){ sw.refilling=false; });
1419
+ .catch(function(){
1420
+ // Timeout / 504 / network drop: customizeOneMore often runs past the
1421
+ // gateway's reply window, so the card lands server-side after we gave
1422
+ // up here. Poll the stack to pick it up (this is the "504 on first
1423
+ // load, works after a manual refresh" case — now it self-heals).
1424
+ sw.refilling=false; schedulePoll();
1425
+ });
1426
+ }
1427
+ // ── stack re-poll: recover cards the refill Lambda produced after its HTTP
1428
+ // reply timed out. Backs off after a fixed number of empty tries so we never
1429
+ // poll forever.
1430
+ var pollTimer=null, pollTries=0;
1431
+ function schedulePoll(){
1432
+ if(pollTimer||pollTries>=12) return;
1433
+ if(nodes.length>=sw.target) return;
1434
+ pollTimer=window.setTimeout(function(){ pollTimer=null; pollStack(); }, 5000);
1435
+ }
1436
+ function pollStack(){
1437
+ if(nodes.length>=sw.target) return;
1438
+ fetch(withAccount(swipeStackEndpoint),{credentials:'same-origin',headers:{'accept':'application/json'}})
1439
+ .then(function(r){ return r.ok? r.json(): null; })
1440
+ .then(function(j){
1441
+ if(!j){ pollTries++; schedulePoll(); return; }
1442
+ sw.customizing=Number(j&&j.customizing)||0;
1443
+ var stack=(j&&Array.isArray(j.stack))?j.stack:[];
1444
+ var added=0;
1445
+ stack.forEach(function(card){ if(card&&card.id&&!sw.known[String(card.id)]){ nodes.push(makeCard(card)); added++; } });
1446
+ if(added){ pollTries=0; swHideState(); restack(); }
1447
+ else { pollTries++; if(!nodes.length) swCaughtUp(); }
1448
+ if((sw.customizing>0||nodes.length<sw.target)&&nodes.length<sw.target) schedulePoll();
1449
+ })
1450
+ .catch(function(){ pollTries++; schedulePoll(); });
940
1451
  }
941
1452
  function loadStack(){
942
1453
  swShowState('<div class="rk-discover-spinner" aria-hidden="true"></div><div>Loading your deck&hellip;</div>');
@@ -953,7 +1464,7 @@ video.rk-discover-viewer-media{cursor:pointer}
953
1464
  sw.productSet=Boolean(j&&j.product_description_set);
954
1465
  var stack=(j&&Array.isArray(j.stack))?j.stack:[];
955
1466
  if(!sw.productSet){
956
- swShowState('<div class="rk-discover-swipe-state-title">Tell us about your product</div><div>Swipe auto-recuts viral templates into ads for your product. Add a product / offer description in Settings to start the deck.</div><a class="rk-btn rk-btn-gold rk-btn-sm" href="/settings#profile">Open Settings</a>');
1467
+ swShowState('<div class="rk-discover-swipe-state-title">Tell us about your product</div><div>Swipe auto-recuts viral templates into ads for your product. Add a product / offer description in Settings to start the deck.</div><a class="rk-btn rk-btn-gold rk-btn-sm" href="/settings/profile">Open Settings</a>');
957
1468
  return;
958
1469
  }
959
1470
  nodes=stack.map(makeCard);
@@ -969,6 +1480,183 @@ video.rk-discover-viewer-media{cursor:pointer}
969
1480
  swShowState('<div class="rk-discover-swipe-state-title">Couldn\\u2019t load your deck</div><div>'+esc(friendlyError(err))+'</div>','error');
970
1481
  });
971
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
+
972
1660
  if(skipBtn) skipBtn.addEventListener('click',function(){ decide('skip'); });
973
1661
  if(forkBtn) forkBtn.addEventListener('click',function(){ decide('fork'); });
974
1662
  loadStack();
@@ -1088,9 +1776,13 @@ video.rk-discover-viewer-media{cursor:pointer}
1088
1776
  title: `vidfarm reskin — Discover (${mode})`,
1089
1777
  description: "Reskinned vidfarm Discover — real Feed / Swipe / Categories, live template posters, warm sunny SaaS.",
1090
1778
  activeSlug: "discover",
1779
+ account: { name: input.name, email: input.email },
1091
1780
  pageCss,
1092
1781
  body,
1093
- script
1782
+ script,
1783
+ // Feed mode hydrates a Zustand store (SSR-seed + stale-while-revalidate);
1784
+ // the other modes don't need it.
1785
+ moduleScripts: mode === "feed" ? ["/assets/discover-client-app.js"] : undefined
1094
1786
  });
1095
1787
  }
1096
1788
  //# sourceMappingURL=discover-page.js.map