@mevdragon/vidfarm-devcli 0.17.0 → 0.18.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 (55) hide show
  1. package/.agents/skills/vidfarm-media/SKILL.md +43 -6
  2. package/SKILL.director.md +37 -23
  3. package/SKILL.platform.md +6 -6
  4. package/demo/dist/app.js +69 -69
  5. package/demo/dist/favicon.ico +0 -0
  6. package/dist/src/app.js +1832 -213
  7. package/dist/src/cli.js +238 -17
  8. package/dist/src/devcli/clip-store.js +29 -2
  9. package/dist/src/devcli/clips.js +116 -73
  10. package/dist/src/devcli/composition-edit.js +262 -0
  11. package/dist/src/devcli/timeline-edit.js +283 -0
  12. package/dist/src/editor-chat.js +29 -6
  13. package/dist/src/frontend/discover-client.js +130 -0
  14. package/dist/src/frontend/discover-store.js +23 -0
  15. package/dist/src/frontend/file-directory.js +744 -0
  16. package/dist/src/frontend/template-editor-chat.js +22 -19
  17. package/dist/src/landing-page.js +24 -7
  18. package/dist/src/page-shell.js +25 -1
  19. package/dist/src/reskin/agency-page.js +214 -170
  20. package/dist/src/reskin/calendar-page.js +503 -187
  21. package/dist/src/reskin/chat-page.js +739 -299
  22. package/dist/src/reskin/discover-page.js +1450 -279
  23. package/dist/src/reskin/document.js +1392 -16
  24. package/dist/src/reskin/help-page.js +139 -100
  25. package/dist/src/reskin/index-page.js +1 -1
  26. package/dist/src/reskin/inpaint-page.js +547 -0
  27. package/dist/src/reskin/job-runs-page.js +355 -127
  28. package/dist/src/reskin/library-page.js +1188 -317
  29. package/dist/src/reskin/login-page.js +124 -87
  30. package/dist/src/reskin/pricing-page.js +197 -166
  31. package/dist/src/reskin/settings-page.js +566 -187
  32. package/dist/src/reskin/theme.js +434 -17
  33. package/dist/src/services/clip-curation/gemini.js +5 -0
  34. package/dist/src/services/clip-curation/hunt.js +79 -1
  35. package/dist/src/services/clip-curation/index.js +2 -1
  36. package/dist/src/services/clip-curation/local-agent.js +4 -3
  37. package/dist/src/services/clip-curation/media-select.js +85 -0
  38. package/dist/src/services/clip-curation/query.js +5 -1
  39. package/dist/src/services/clip-curation/refine.js +50 -20
  40. package/dist/src/services/clip-curation/scan.js +10 -3
  41. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  42. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  43. package/dist/src/services/clip-records.js +14 -1
  44. package/dist/src/services/clip-search.js +43 -13
  45. package/dist/src/services/file-directory.js +114 -0
  46. package/dist/src/services/storage.js +24 -1
  47. package/dist/src/services/upstream.js +5 -5
  48. package/dist/src/template-editor-shell.js +16 -2
  49. package/package.json +1 -1
  50. package/public/assets/discover-client-app.js +1 -0
  51. package/public/assets/file-directory-app.js +2 -0
  52. package/public/assets/homepage-client-app.js +12 -12
  53. package/public/assets/page-runtime-client-app.js +24 -24
  54. package/src/assets/favicon.ico +0 -0
  55. package/src/assets/logo-vidfarm.png +0 -0
@@ -1,17 +1,58 @@
1
- // /reskin/discover — the template discovery surface, in the farmville design system.
2
- // SIMPLIFIED & RESTRUCTURED into THREE server-routed modes, each its own URL:
3
- // feed /reskin/discover/feed — clean 9:16 poster grid
4
- // swipe → /reskin/discover/swipe — Tinder-style single-card deck
5
- // • categories → /reskin/discover/categories — browse-by-format cards
1
+ // /reskin/discover — the template discovery surface, MIGRATED to the REAL
2
+ // production feed (part of the /reskin/* UX migration; reference:
3
+ // job-runs-page.ts for the "render a shell client-fetch a real endpoint"
4
+ // pattern, settings-page.ts for the real-data render pattern).
6
5
  //
7
- // The mode is chosen server-side (the `mode` arg) and surfaced as a Feed / Swipe /
8
- // Categories segmented toggle rendered as THREE real <a> links. Rendered with baked
9
- // SAMPLE data so every mode views without auth or a live catalog. Uses ONLY shared
10
- // rk-* primitives + a page-scoped rk-discover-* CSS block + a tiny self-contained
11
- // module script (deck advance in swipe). Pill density is kept deliberately low: at
12
- // most a duration overlay + a plain meta line + the single gold Fork CTA per card,
13
- // so the surface reads calm rather than repetitive.
14
- import { reskinDocument } from "./document.js";
6
+ // Three server-routed modes, each its own URL, surfaced as a Feed / Swipe /
7
+ // Categories segmented toggle (three real <a> links):
8
+ // feed → /reskin/discover/feed — real /discover/feed poster grid
9
+ // swipe → /reskin/discover/swipe — real /discover/swipe/* Tinder deck
10
+ // categories /reskin/discover/categories — real /discover/popular groups
11
+ //
12
+ // Every mode renders the farmville shell + a self-contained module script that
13
+ // fetches the SAME real, same-origin endpoints the live React SPA hits, with
14
+ // credentials, and renders real poster cards (real title, real thumbnail/video
15
+ // from the real url, real source + duration) that fork/open into the real
16
+ // editor at /editor/<template_id>. Loading / empty / error states are all
17
+ // guarded so an offline fetch degrades to a friendly state instead of throwing.
18
+ //
19
+ // Poster-grid extras (feed + categories reveal, which share posterCardHtml):
20
+ // • hover audio — mousing over a card's (muted, autoplaying) video unmutes
21
+ // it while the pointer stays, with an autoplay-policy fallback that
22
+ // reverts to muted playback if the browser blocks unmuted play.
23
+ // • TikTok-style viewer — clicking a card opens a fullscreen vertical
24
+ // scroll-snap deck (swipe / wheel / arrow keys between templates, sound
25
+ // on, tap video to pause, mute toggle) with an "Open Editor" CTA per
26
+ // slide. The card's "Open Editor" button still deep-links the editor.
27
+ // The viewer is shared: feed swipes the (filtered) feed, categories
28
+ // swipes the revealed group.
29
+ // • bookmarks — one-tap bookmark on each viewer slide + on cards on hover,
30
+ // persisted client-side in localStorage ('rk-discover-bookmarks'; there
31
+ // is no backend favorite endpoint), plus a "bookmarked only" toolbar
32
+ // filter chip on the feed so saved templates are findable.
33
+ // • no platform pill on posters (user feedback) — source only shows as the
34
+ // small meta line inside the viewer slide.
35
+ //
36
+ // The renderer keeps its `mode` signature (so the existing zero-arg
37
+ // `/reskin/discover/:mode` route keeps working — the client fetches the live
38
+ // endpoints same-origin using the browser session cookie) and adds an optional
39
+ // 2nd `input` arg (defaulted) so the parent route can, if it wants, pass the
40
+ // signed-in account id (→ ?account= on fetches + editor links, matching the
41
+ // live editorHref) and override any endpoint.
42
+ import { reskinDocument, rkEscape } from "./document.js";
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). */
46
+ const DEFAULT_INPUT = {
47
+ accountId: "",
48
+ isLoggedIn: false,
49
+ feedEndpoint: "/discover/feed",
50
+ popularEndpoint: "/discover/popular",
51
+ swipeStackEndpoint: "/discover/swipe/stack",
52
+ swipeRefillEndpoint: "/discover/swipe/refill",
53
+ swipeBase: "/discover/swipe",
54
+ editorBase: "/editor"
55
+ };
15
56
  const MODES = [
16
57
  { mode: "feed", label: "Feed" },
17
58
  { mode: "swipe", label: "Swipe" },
@@ -19,227 +60,278 @@ const MODES = [
19
60
  ];
20
61
  const MODE_COPY = {
21
62
  feed: {
22
- badge: "Template feed",
23
- sub: "Browse proven short-form formats, then fork one into a live editor and make it yours in an afternoon."
63
+ sub: "Browse proven short-form formats, then open one in the editor and make it yours in an afternoon."
24
64
  },
25
65
  swipe: {
26
- badge: "Swipe to customize",
27
66
  sub: "One template at a time — keep the ones that fit and we'll recut them into ads for your brand."
28
67
  },
29
68
  categories: {
30
- badge: "Browse by format",
31
69
  sub: "Jump straight to the style you need — every short-form format, neatly grouped."
32
70
  }
33
71
  };
34
- const CARDS = [
35
- { emoji: "💄", grad: "linear-gradient(150deg,#ffd76a,#f97316)", hook: "POV: you finally found the serum", title: "Skincare UGC hook", handle: "@glow.recipes", difficulty: "easy", duration: "0:18", cat: "ugc", forks: "2.4k" },
36
- { emoji: "🎙️", grad: "linear-gradient(160deg,#6cc0f5,#3f9fe0)", hook: "Nobody tells you this about growth", title: "Talking-head monologue", handle: "@founder.notes", difficulty: "easy", duration: "0:32", cat: "talking", forks: "1.9k" },
37
- { emoji: "🧠", grad: "linear-gradient(155deg,#c4a0ee,#7c3aed)", hook: "How it actually works", title: "Kinetic explainer", handle: "@explain.daily", difficulty: "medium", duration: "0:44", cat: "explainer", forks: "3.1k" },
38
- { emoji: "🔟", grad: "linear-gradient(160deg,#7de0a3,#16a34a)", hook: "5 tools I can't live without", title: "Listicle countdown", handle: "@stack.picks", difficulty: "easy", duration: "0:27", cat: "listicle", forks: "1.2k" },
39
- { emoji: "😂", grad: "linear-gradient(150deg,#ffb27a,#f43f5e)", hook: "me explaining this to my boss", title: "Meme recaption", handle: "@office.brainrot", difficulty: "easy", duration: "0:11", cat: "meme", forks: "5.8k" },
40
- { emoji: "", grad: "linear-gradient(155deg,#ffc9d4,#f472b6)", hook: "get ready with me", title: "GRWM voiceover", handle: "@morning.rituals", difficulty: "medium", duration: "0:38", cat: "talking", forks: "980" },
41
- { emoji: "📱", grad: "linear-gradient(155deg,#4b4b4b,#171717)", hook: "This app saved me 4 hours a day", title: "Software product demo", handle: "@shipfast.co", difficulty: "hard", duration: "0:41", cat: "product", forks: "2.0k" },
42
- { emoji: "📖", grad: "linear-gradient(160deg,#8fa3ff,#4f46e5)", hook: "AITA for quitting on day one?", title: "Reddit story + gameplay", handle: "@story.loops", difficulty: "medium", duration: "0:52", cat: "story", forks: "4.3k" },
43
- { emoji: "🏆", grad: "linear-gradient(150deg,#ffd76a,#fb7185)", hook: "Top 10 hooks that actually convert", title: "Ranking / tier list", handle: "@viral.teardown", difficulty: "medium", duration: "0:35", cat: "listicle", forks: "2.7k" },
44
- { emoji: "💬", grad: "linear-gradient(160deg,#5eead4,#0d9488)", hook: "I doubled my revenue in 60 days", title: "Testimonial UGC", handle: "@case.studies", difficulty: "easy", duration: "0:24", cat: "ugc", forks: "1.5k" },
45
- { emoji: "📊", grad: "linear-gradient(150deg,#ffe08a,#38bdf8)", hook: "The chart that broke the internet", title: "Data-viz explainer", handle: "@number.go.up", difficulty: "hard", duration: "0:29", cat: "explainer", forks: "890" },
46
- { emoji: "🎬", grad: "linear-gradient(160deg,#c084fc,#1e1b4b)", hook: "One shot. No cuts.", title: "Cinematic short", handle: "@one.take", difficulty: "expert", duration: "0:47", cat: "story", forks: "670" }
47
- ];
48
- // The swipe deck a curated front-to-back run through the strongest formats.
49
- const SWIPE_CARDS = [
50
- CARDS[0], // Skincare UGC
51
- CARDS[4], // Meme recaption
52
- CARDS[2], // Kinetic explainer
53
- CARDS[7], // Reddit story
54
- CARDS[1] // Talking-head monologue
55
- ];
56
- const CATEGORIES = [
57
- {
58
- slug: "ugc", label: "UGC ad", emoji: "💄", tile: "rk-tile-coral", count: 128,
59
- blurb: "Creator-style ads that feel like a recommendation, not an ad.",
60
- thumbs: [
61
- { emoji: "💄", grad: "linear-gradient(150deg,#ffd76a,#f97316)" },
62
- { emoji: "💬", grad: "linear-gradient(160deg,#5eead4,#0d9488)" },
63
- { emoji: "🧴", grad: "linear-gradient(150deg,#ffc9d4,#f472b6)" },
64
- { emoji: "🛍️", grad: "linear-gradient(155deg,#ffb27a,#f43f5e)" }
65
- ]
66
- },
67
- {
68
- slug: "talking", label: "Talking head", emoji: "🎙️", tile: "rk-tile-sky", count: 96,
69
- blurb: "Straight-to-camera monologues with punchy captioned hooks.",
70
- thumbs: [
71
- { emoji: "🎙️", grad: "linear-gradient(160deg,#6cc0f5,#3f9fe0)" },
72
- { emoji: "✨", grad: "linear-gradient(155deg,#ffc9d4,#f472b6)" },
73
- { emoji: "🗣️", grad: "linear-gradient(160deg,#8fa3ff,#4f46e5)" }
74
- ]
75
- },
76
- {
77
- slug: "explainer", label: "Explainer", emoji: "🧠", tile: "rk-tile-violet", count: 74,
78
- blurb: "Kinetic typography and diagrams that make an idea click.",
79
- thumbs: [
80
- { emoji: "🧠", grad: "linear-gradient(155deg,#c4a0ee,#7c3aed)" },
81
- { emoji: "📊", grad: "linear-gradient(150deg,#ffe08a,#38bdf8)" },
82
- { emoji: "🔗", grad: "linear-gradient(160deg,#a5b4fc,#6366f1)" },
83
- { emoji: "💡", grad: "linear-gradient(150deg,#ffe08a,#f59e0b)" }
84
- ]
85
- },
86
- {
87
- slug: "listicle", label: "Listicle", emoji: "🔟", tile: "rk-tile-green", count: 63,
88
- blurb: "Countdowns, tier lists and ranked picks that keep watch time high.",
89
- thumbs: [
90
- { emoji: "🔟", grad: "linear-gradient(160deg,#7de0a3,#16a34a)" },
91
- { emoji: "🏆", grad: "linear-gradient(150deg,#ffd76a,#fb7185)" },
92
- { emoji: "⭐", grad: "linear-gradient(150deg,#ffe08a,#f97316)" }
93
- ]
94
- },
95
- {
96
- slug: "meme", label: "Meme", emoji: "😂", tile: "rk-tile-gold", count: 141,
97
- blurb: "Recaptioned reactions and relatable brainrot for reach.",
98
- thumbs: [
99
- { emoji: "😂", grad: "linear-gradient(150deg,#ffb27a,#f43f5e)" },
100
- { emoji: "🤡", grad: "linear-gradient(150deg,#ffd76a,#f97316)" },
101
- { emoji: "🔥", grad: "linear-gradient(150deg,#fb923c,#ef4444)" },
102
- { emoji: "💀", grad: "linear-gradient(160deg,#94a3b8,#334155)" }
103
- ]
104
- },
105
- {
106
- slug: "product", label: "Product demo", emoji: "📱", tile: "rk-tile-ink", count: 52,
107
- blurb: "Screen-first walkthroughs that sell the feature in 30 seconds.",
108
- thumbs: [
109
- { emoji: "📱", grad: "linear-gradient(155deg,#4b4b4b,#171717)" },
110
- { emoji: "🖥️", grad: "linear-gradient(160deg,#64748b,#0f172a)" },
111
- { emoji: "⚡", grad: "linear-gradient(150deg,#ffe08a,#38bdf8)" }
112
- ]
113
- },
114
- {
115
- slug: "story", label: "Story", emoji: "📖", tile: "rk-tile-violet", count: 88,
116
- blurb: "Narrated stories over satisfying gameplay or B-roll.",
117
- thumbs: [
118
- { emoji: "📖", grad: "linear-gradient(160deg,#8fa3ff,#4f46e5)" },
119
- { emoji: "🎬", grad: "linear-gradient(160deg,#c084fc,#1e1b4b)" },
120
- { emoji: "🎮", grad: "linear-gradient(160deg,#5eead4,#0d9488)" },
121
- { emoji: "📜", grad: "linear-gradient(150deg,#ffd76a,#f59e0b)" }
122
- ]
123
- }
124
- ];
125
- const FORK_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"><circle cx="6" cy="6" r="2.4"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="7" r="2.4"/><path d="M6 8.4v7.2M18 9.4c0 3-3 3.6-6 4.2"/></svg>`;
126
- // ── feed: poster card ──────────────────────────────────────────────────────
127
- // Deliberately calm: just the duration overlay on the poster, ONE plain muted
128
- // meta line (handle · forks — not pills), and the single gold Fork CTA.
129
- function posterCard(c) {
130
- return `<article class="rk-discover-card rk-card-hover">
131
- <div class="rk-discover-poster" style="background:${c.grad}">
132
- <span class="rk-discover-poster-glyph" aria-hidden="true">${c.emoji}</span>
133
- <span class="rk-discover-dur">${c.duration}</span>
134
- <div class="rk-discover-scrim"></div>
135
- <p class="rk-discover-hook">${c.hook}</p>
136
- </div>
137
- <div class="rk-discover-body">
138
- <div class="rk-discover-titlewrap">
139
- <h3 class="rk-discover-title">${c.title}</h3>
140
- <p class="rk-discover-metaline">${c.handle} · ${c.forks} forks</p>
141
- </div>
142
- <button type="button" class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork">${FORK_ICON} Fork template</button>
143
- </div>
144
- </article>`;
72
+ // Functional (non-decorative) SVG glyphs.
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>`;
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>`;
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>`;
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>`;
79
+ const SOUND_ON_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 6a9 9 0 0 1 0 12"/></svg>`;
80
+ const SOUND_OFF_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9.5 5 5M21 9.5l-5 5"/></svg>`;
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>`;
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>`;
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>`;
86
+ /** Safe JSON embedding inside a <script> tag (mirrors page-shell.escapeJsonForHtml). */
87
+ function jsonForScript(value) {
88
+ return JSON.stringify(value ?? null)
89
+ .replace(/</g, "\\u003c")
90
+ .replace(/>/g, "\\u003e")
91
+ .replace(/&/g, "\\u0026")
92
+ .replace(/\u2028/g, "\\u2028")
93
+ .replace(/\u2029/g, "\\u2029");
145
94
  }
146
- // ── swipe: single deck card (full-bleed poster with overlaid info) ──────────
147
- function swipeCard(c, i) {
148
- const z = 20 - i;
149
- const scale = (1 - i * 0.045).toFixed(3);
150
- const ty = i * 12;
151
- const op = i === 0 ? 1 : i === 1 ? 0.92 : 0.82;
152
- const hidden = i > 2 ? "display:none;" : "";
153
- const style = `z-index:${z};transform:translateY(${ty}px) scale(${scale});opacity:${op};${hidden}`;
154
- return `<article class="rk-discover-swipe${i === 0 ? " is-front" : ""}" data-swipe style="${style}">
155
- <div class="rk-discover-swipe-art" style="background:${c.grad}"></div>
156
- <span class="rk-discover-poster-glyph" aria-hidden="true">${c.emoji}</span>
157
- <span class="rk-discover-dur">${c.duration}</span>
158
- <div class="rk-discover-swipe-scrim"></div>
159
- <div class="rk-discover-swipe-info">
160
- <p class="rk-discover-hook">${c.hook}</p>
161
- <div class="rk-discover-swipe-foot">
162
- <div class="rk-discover-swipe-title">${c.title}</div>
163
- <div class="rk-discover-swipe-handle">${c.handle} · ${c.forks} forks</div>
164
- </div>
165
- </div>
166
- </article>`;
95
+ // The segmented Feed / Swipe / Categories toggle (three real links).
96
+ function modeToggle(active) {
97
+ const links = MODES.map((m) => {
98
+ const on = m.mode === active ? " is-active" : "";
99
+ return `<a class="rk-discover-mode${on}" href="/discover/${m.mode}"${m.mode === active ? ' aria-current="page"' : ""}>${m.label}</a>`;
100
+ }).join("");
101
+ return `<nav class="rk-discover-modes" aria-label="Discover mode">${links}</nav>`;
167
102
  }
168
- // ── categories: one browse card ─────────────────────────────────────────────
169
- function categoryCard(g) {
170
- const thumbs = g.thumbs
171
- .map((t) => `<span class="rk-discover-mini" style="background:${t.grad}">${t.emoji}</span>`)
172
- .join("");
173
- return `<a class="rk-discover-cat rk-card-hover" href="/reskin/discover/feed" data-cat="${g.slug}">
103
+ // Server-rendered category skeleton from the real POPULAR_INSPIRATION_GROUPS
104
+ // manifest — always renders (real group labels + real format counts) even
105
+ // before/without the /discover/popular fetch, so categories mode is never blank
106
+ // and degrades gracefully offline. The client enriches each card with real
107
+ // thumbnails + wires an in-page reveal of that group's real templates.
108
+ function categorySkeleton() {
109
+ return POPULAR_INSPIRATION_GROUPS.map((g) => {
110
+ const count = g.items.length;
111
+ const blurb = g.items.slice(0, 4).map((i) => i.title).join(" · ");
112
+ const fallbackQuery = encodeURIComponent(g.label);
113
+ // Placeholder thumb tiles (no media yet) keep the card height stable; the
114
+ // client swaps in real poster media once /discover/popular resolves.
115
+ const thumbs = Array.from({ length: 4 })
116
+ .map(() => `<span class="rk-discover-mini rk-discover-mini-ph"></span>`)
117
+ .join("");
118
+ return `<a class="rk-discover-cat rk-card-hover" href="/discover?q=${fallbackQuery}" data-rk-cat="${rkEscape(g.label)}">
174
119
  <div class="rk-discover-cat-head">
175
120
  <div class="rk-discover-cat-titlewrap">
176
- <h3 class="rk-discover-cat-title">${g.label}</h3>
177
- <p class="rk-discover-cat-count">${g.count} templates</p>
121
+ <h3 class="rk-discover-cat-title">${rkEscape(g.label)}</h3>
122
+ <p class="rk-discover-cat-count" data-rk-cat-count>${count} format${count === 1 ? "" : "s"}</p>
178
123
  </div>
179
- <span class="rk-discover-cat-arrow" aria-hidden="true">→</span>
124
+ <span class="rk-discover-cat-arrow" aria-hidden="true">&rarr;</span>
180
125
  </div>
181
- <p class="rk-discover-cat-blurb">${g.blurb}</p>
182
- <div class="rk-discover-cat-thumbs">${thumbs}</div>
126
+ <div class="rk-discover-cat-thumbs" data-rk-cat-thumbs>${thumbs}</div>
127
+ <p class="rk-discover-cat-blurb">${rkEscape(blurb)}</p>
183
128
  </a>`;
184
- }
185
- // ── the segmented Feed / Swipe / Categories toggle (three real links) ───────
186
- function modeToggle(active) {
187
- const links = MODES.map((m) => {
188
- const on = m.mode === active ? " is-active" : "";
189
- return `<a class="rk-discover-mode${on}" href="/reskin/discover/${m.mode}"${m.mode === active ? ' aria-current="page"' : ""}>${m.label}</a>`;
190
129
  }).join("");
191
- return `<nav class="rk-discover-modes" aria-label="Discover mode">${links}</nav>`;
192
130
  }
193
- export function renderReskinDiscover(mode = "feed") {
131
+ // The fullscreen TikTok-style viewer shell — shared by feed and categories
132
+ // (the client script fills [data-viewer-track] with slides on open).
133
+ function viewerHtml() {
134
+ return `
135
+ <div class="rk-discover-viewer" data-rk-viewer hidden role="dialog" aria-modal="true" aria-label="Template viewer">
136
+ <div class="rk-discover-viewer-scrim" data-viewer-close></div>
137
+ <div class="rk-discover-viewer-track" data-viewer-track></div>
138
+ <div class="rk-discover-viewer-ui">
139
+ <button type="button" class="rk-discover-viewer-btn" data-viewer-mute aria-label="Toggle sound"><span class="rk-dvm-on">${SOUND_ON_ICON}</span><span class="rk-dvm-off">${SOUND_OFF_ICON}</span></button>
140
+ <button type="button" class="rk-discover-viewer-btn" data-viewer-close aria-label="Close viewer">${CROSS_ICON}</button>
141
+ </div>
142
+ <div class="rk-discover-viewer-nav">
143
+ <button type="button" class="rk-discover-viewer-btn" data-viewer-prev aria-label="Previous template">${CHEV_UP_ICON}</button>
144
+ <button type="button" class="rk-discover-viewer-btn" data-viewer-next aria-label="Next template">${CHEV_DOWN_ICON}</button>
145
+ </div>
146
+ </div>`;
147
+ }
148
+ // The "Add New" modal (feed mode only) — paste a social video URL or upload a
149
+ // video, adding it to the caller's private Discover feed via the same real
150
+ // endpoints the old React SPA used (POST /discover/templates + the upload
151
+ // presign pair). Mirrors the old UI's Add Template modal, farmville-styled.
152
+ function addModalHtml() {
153
+ return `
154
+ <div class="rk-add-modal" data-rk-add-modal hidden role="dialog" aria-modal="true" aria-labelledby="rk-add-title">
155
+ <div class="rk-add-scrim" data-rk-add-close></div>
156
+ <div class="rk-add-card">
157
+ <div class="rk-add-head">
158
+ <h2 class="rk-add-title" id="rk-add-title">Add to your feed</h2>
159
+ <button type="button" class="rk-add-x" data-rk-add-close aria-label="Close">${CROSS_ICON}</button>
160
+ </div>
161
+ <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>
162
+ <form class="rk-add-form" data-rk-add-form>
163
+ <div class="rk-add-field">
164
+ <label class="rk-add-label" for="rk-add-url">Video URL</label>
165
+ <input class="rk-add-input" id="rk-add-url" type="url" placeholder="https://www.tiktok.com/@&hellip;" autocomplete="off" data-rk-add-url />
166
+ </div>
167
+ <div class="rk-add-or"><span>or</span></div>
168
+ <label class="rk-add-file" data-rk-add-filelabel>
169
+ <input type="file" accept="video/*,.mp4,.mov,.webm,.m4v" data-rk-add-file />
170
+ <span class="rk-add-file-ic">${UPLOAD_ICON}</span>
171
+ <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>
172
+ </label>
173
+ <div class="rk-add-field">
174
+ <label class="rk-add-label" for="rk-add-tagline">Tagline <span class="rk-add-optional">optional</span></label>
175
+ <input class="rk-add-input" id="rk-add-tagline" type="text" placeholder="What makes it work" autocomplete="off" data-rk-add-tagline />
176
+ </div>
177
+ <div class="rk-add-field">
178
+ <label class="rk-add-label" for="rk-add-notes">Notes <span class="rk-add-optional">optional</span></label>
179
+ <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>
180
+ </div>
181
+ <p class="rk-add-error" data-rk-add-error hidden></p>
182
+ <div class="rk-add-actions">
183
+ <button type="button" class="rk-btn rk-btn-ghost" data-rk-add-close>Cancel</button>
184
+ <button type="submit" class="rk-btn rk-btn-gold" data-rk-add-submit>Add to feed</button>
185
+ </div>
186
+ </form>
187
+ </div>
188
+ </div>`;
189
+ }
190
+ export function renderReskinDiscover(mode = "feed", input = {}) {
194
191
  const copy = MODE_COPY[mode];
195
- // ── FEED ──────────────────────────────────────────────────────────────────
192
+ // feedInitial/feedFetchedAt only make sense in feed mode; drop them elsewhere
193
+ // so swipe/categories boots stay lean.
194
+ const { feedInitial, feedFetchedAt, ...cfg } = input;
195
+ const boot = {
196
+ ...DEFAULT_INPUT,
197
+ ...cfg,
198
+ feedInitial: mode === "feed" ? feedInitial ?? null : null,
199
+ feedFetchedAt: mode === "feed" ? feedFetchedAt ?? null : null
200
+ };
196
201
  let modeBody = "";
202
+ // ── FEED ────────────────────────────────────────────────────────────────
197
203
  if (mode === "feed") {
198
- const cards = CARDS.map(posterCard).join("");
199
- modeBody = `<div class="rk-discover-grid">${cards}</div>`;
204
+ modeBody = `
205
+ <div class="rk-discover-feed" data-rk-feed>
206
+ <div class="rk-discover-grid" data-rk-grid></div>
207
+ <div class="rk-discover-state" data-rk-state>Loading templates&hellip;</div>
208
+ </div>${viewerHtml()}`;
200
209
  }
201
- // ── SWIPE ─────────────────────────────────────────────────────────────────
210
+ // Feed's quick search + bookmarked-only chip share the toolbar row with the
211
+ // mode toggle.
212
+ const toolbarSearch = mode === "feed"
213
+ ? `<label class="rk-discover-search">
214
+ <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>
215
+ <input type="search" data-rk-search placeholder="Search templates" autocomplete="off" aria-label="Search templates" />
216
+ </label>
217
+ <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>`
218
+ : "";
219
+ // Categories' in-page group reveal header ("← All categories" + the group
220
+ // title) lives IN the toolbar row, next to the mode toggle (user feedback);
221
+ // the client unhides it while a group is revealed.
222
+ const toolbarReveal = mode === "categories"
223
+ ? `<div class="rk-discover-reveal-head" data-rk-reveal-head hidden>
224
+ <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm" data-rk-cat-back>&larr; All categories</button>
225
+ <h2 class="rk-discover-reveal-title" data-rk-reveal-title></h2>
226
+ </div>`
227
+ : "";
228
+ // ── SWIPE ───────────────────────────────────────────────────────────────
202
229
  if (mode === "swipe") {
203
- const deck = SWIPE_CARDS.map((c, i) => swipeCard(c, i)).join("");
204
230
  modeBody = `
205
- <div class="rk-discover-swipe-wrap">
206
- <p class="rk-discover-helper">Tap <b>✓ Fork</b> to recut a template into an ad for your brand, or <b>✕ Skip</b> to see the next one.</p>
207
- <div class="rk-discover-deck" data-deck>
208
- ${deck}
209
- <div class="rk-discover-empty" data-empty hidden>
210
- <h3>You're all caught up</h3>
211
- <p class="rk-muted">We're lining up fresh templates that match your brand.</p>
212
- <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm" data-restart>↻ Reshuffle the deck</button>
231
+ <div class="rk-discover-swipe-wrap" data-rk-swipe>
232
+ <div class="rk-discover-stage">
233
+ <button type="button" class="rk-discover-act rk-discover-act-skip" data-swipe-skip data-swipe-act hidden aria-label="Skip">${CROSS_ICON}</button>
234
+ <div class="rk-discover-deck" data-deck>
235
+ <button type="button" class="rk-discover-sound-toggle" data-swipe-sound hidden aria-label="Unmute" aria-pressed="false">${SOUND_OFF_ICON}</button>
236
+ <div class="rk-discover-swipe-state" data-rk-swipe-state>Loading your deck&hellip;</div>
213
237
  </div>
238
+ <button type="button" class="rk-discover-act rk-discover-act-fork" data-swipe-fork data-swipe-act hidden aria-label="Fork">${CHECK_ICON}</button>
214
239
  </div>
215
- <div class="rk-discover-actions" data-actions>
216
- <button type="button" class="rk-discover-act rk-discover-act-skip" data-swipe-skip aria-label="Skip">✕</button>
217
- <button type="button" class="rk-discover-act rk-discover-act-fork" data-swipe-fork aria-label="Fork">✓</button>
218
- </div>
219
- <p class="rk-discover-act-hint" data-actions-hint>Swipe to customize into an ad for your brand.</p>
240
+ <p class="rk-discover-act-hint" data-actions-hint hidden>Swipe to customize into an ad for your brand.</p>
220
241
  </div>`;
221
242
  }
222
- // ── CATEGORIES ────────────────────────────────────────────────────────────
243
+ // ── CATEGORIES ──────────────────────────────────────────────────────────
223
244
  if (mode === "categories") {
224
- const cats = CATEGORIES.map(categoryCard).join("");
225
- modeBody = `<div class="rk-discover-cats">${cats}</div>`;
245
+ modeBody = `
246
+ <div class="rk-discover-catwrap" data-rk-cats>
247
+ <div class="rk-discover-cats" data-rk-cats-grid>${categorySkeleton()}</div>
248
+ <section class="rk-discover-cat-reveal" data-rk-cat-reveal hidden>
249
+ <div class="rk-discover-grid" data-rk-reveal-grid></div>
250
+ </section>
251
+ <div class="rk-discover-state rk-discover-cats-state" data-rk-cats-state hidden></div>
252
+ </div>${viewerHtml()}`;
226
253
  }
254
+ // "Add New" lives top-right of the page head (feed mode only) — opens the
255
+ // Add modal when signed in, else routes to /login (mirrors the old UI).
256
+ const addNewBtn = mode === "feed"
257
+ ? (boot.isLoggedIn
258
+ ? `<button type="button" class="rk-btn rk-btn-gold rk-discover-add" data-rk-add>${PLUS_ICON}<span>Add New</span></button>`
259
+ : `<a class="rk-btn rk-btn-gold rk-discover-add" href="/login">${PLUS_ICON}<span>Add New</span></a>`)
260
+ : "";
227
261
  const body = `
228
262
  <main class="rk-container rk-page">
229
263
  <div class="rk-page-head rk-discover-head">
230
- <span class="rk-badge">${copy.badge}</span>
231
- <h1 class="rk-h1">Discover</h1>
232
- <p class="rk-sub">${copy.sub}</p>
264
+ <div class="rk-discover-headtext">
265
+ <h1 class="rk-h1">Discover</h1>
266
+ <p class="rk-sub">${copy.sub}</p>
267
+ </div>
268
+ ${addNewBtn}
269
+ </div>
270
+ <div class="rk-discover-toolbar">
271
+ ${modeToggle(mode)}
272
+ ${toolbarReveal}
273
+ ${toolbarSearch}
233
274
  </div>
234
- ${modeToggle(mode)}
235
275
  ${modeBody}
276
+ ${mode === "feed" ? addModalHtml() : ""}
277
+ <script type="application/json" data-rk-discover-boot>${jsonForScript(boot)}</script>
236
278
  </main>`;
237
279
  const pageCss = `
238
- .rk-discover-head{margin-bottom:22px}
280
+ /* head row: title block on the left, "Add New" CTA pinned top-right */
281
+ .rk-discover-head{margin-bottom:22px;display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
282
+ .rk-discover-headtext{display:grid;gap:4px;min-width:0}
283
+ .rk-discover-add{flex:none;align-self:flex-start}
284
+ .rk-discover-add svg{margin-top:-1px}
285
+ @media(max-width:520px){
286
+ .rk-discover-add span{display:none}
287
+ .rk-discover-add{padding:11px}
288
+ }
289
+
290
+ /* ── "Add New" modal ── */
291
+ .rk-add-modal{position:fixed;inset:0;z-index:140;display:grid;place-items:center;padding:20px}
292
+ .rk-add-modal[hidden]{display:none}
293
+ .rk-add-scrim{position:absolute;inset:0;background:rgba(15,12,5,.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
294
+ .rk-add-card{position:relative;z-index:1;width:min(480px,100%);max-height:calc(100vh - 40px);overflow:auto;
295
+ 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}
296
+ .rk-add-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}
297
+ .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}
298
+ .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);
299
+ display:grid;place-items:center;cursor:pointer;transition:color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
300
+ .rk-add-x:hover{color:var(--rk-ink);border-color:var(--rk-border-strong)}
301
+ .rk-add-x svg{width:16px;height:16px}
302
+ .rk-add-sub{font-size:13px;color:var(--rk-text-muted);line-height:1.5;margin-bottom:18px}
303
+ .rk-add-form{display:grid;gap:14px}
304
+ .rk-add-field{display:grid;gap:6px}
305
+ .rk-add-label{font-size:12.5px;font-weight:700;color:var(--rk-n-700)}
306
+ .rk-add-optional{font-weight:500;color:var(--rk-text-faint)}
307
+ .rk-add-input{width:100%;box-sizing:border-box;border:1px solid var(--rk-border-strong);border-radius:var(--rk-r-lg);
308
+ background:#fff;padding:11px 14px;font-family:var(--rk-font-body);font-size:14px;color:var(--rk-ink);outline:none;
309
+ transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
310
+ .rk-add-input::placeholder{color:var(--rk-n-400)}
311
+ .rk-add-input:focus{border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
312
+ .rk-add-textarea{resize:vertical;min-height:52px;line-height:1.45}
313
+ .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}
314
+ .rk-add-or::before,.rk-add-or::after{content:"";height:1px;flex:1;background:var(--rk-border)}
315
+ .rk-add-file{display:flex;align-items:center;gap:12px;border:1px dashed var(--rk-border-strong);border-radius:var(--rk-r-lg);
316
+ padding:13px 14px;cursor:pointer;color:var(--rk-n-700);
317
+ transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
318
+ .rk-add-file:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
319
+ .rk-add-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
320
+ .rk-add-file-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
321
+ .rk-add-file-text{font-size:13.5px;font-weight:600;display:grid;gap:2px;min-width:0}
322
+ .rk-add-file-text .rk-add-optional{font-size:11.5px;font-weight:500}
323
+ .rk-add-file.is-picked{border-style:solid;border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
324
+ .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}
325
+ .rk-add-error[hidden]{display:none}
326
+ .rk-add-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
327
+ .rk-add-actions .rk-btn[disabled]{opacity:.6;cursor:default;transform:none}
328
+
329
+ /* toolbar row: mode toggle + (feed) quick search share one line, wrap on mobile */
330
+ .rk-discover-toolbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:26px}
239
331
 
240
332
  /* segmented Feed / Swipe / Categories toggle — three real links, gold active */
241
- .rk-discover-modes{display:inline-flex;gap:4px;background:var(--rk-n-100);border:1px solid var(--rk-border);
242
- border-radius:var(--rk-r-full);padding:4px;box-shadow:var(--rk-shadow-xs);margin-bottom:28px;max-width:100%;overflow:auto;scrollbar-width:none}
333
+ .rk-discover-modes{display:inline-flex;flex:none;gap:4px;background:var(--rk-n-100);border:1px solid var(--rk-border);
334
+ border-radius:var(--rk-r-full);padding:4px;box-shadow:var(--rk-shadow-xs);max-width:100%;overflow:auto;scrollbar-width:none}
243
335
  .rk-discover-modes::-webkit-scrollbar{display:none}
244
336
  .rk-discover-mode{display:inline-flex;align-items:center;gap:7px;padding:9px 18px;border-radius:var(--rk-r-full);
245
337
  font-size:14px;font-weight:600;color:var(--rk-n-600);white-space:nowrap;
@@ -247,62 +339,209 @@ export function renderReskinDiscover(mode = "feed") {
247
339
  .rk-discover-mode:hover{color:var(--rk-ink)}
248
340
  .rk-discover-mode.is-active{background:var(--rk-gold-500);color:var(--rk-ink);font-weight:700;box-shadow:var(--rk-shadow-sm)}
249
341
 
250
- /* ── feed: poster grid ── */
342
+ /* ── feed: quick search (lives in the toolbar row) + poster grid ── */
343
+ .rk-discover-search{flex:1;min-width:220px;display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--rk-border-strong);
344
+ border-radius:var(--rk-r-full);padding:10px 16px;box-shadow:var(--rk-shadow-xs);
345
+ transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
346
+ .rk-discover-search:focus-within{border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
347
+ .rk-discover-search-ic{color:var(--rk-text-faint);display:grid;place-items:center;flex:none}
348
+ .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)}
349
+ .rk-discover-search input::placeholder{color:var(--rk-n-400)}
350
+
251
351
  .rk-discover-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(212px,1fr));gap:22px}
252
352
  @media(max-width:520px){.rk-discover-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}}
253
- .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}
254
- .rk-discover-poster{position:relative;aspect-ratio:9/16;display:flex;align-items:flex-end;overflow:hidden}
255
- .rk-discover-poster-glyph{position:absolute;inset:0;z-index:0;display:grid;place-items:center;font-size:3.2rem;line-height:1;
256
- opacity:.92;filter:drop-shadow(0 2px 10px rgba(0,0,0,.2))}
257
- .rk-discover-dur{position:absolute;top:14px;right:12px;z-index:3;font-family:var(--rk-font-body);font-size:11.5px;font-weight:700;color:#fff;
258
- background:rgba(0,0,0,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);padding:4px 9px;border-radius:var(--rk-r-full)}
259
- .rk-discover-scrim{position:absolute;inset:0;z-index:1;background:linear-gradient(to top,rgba(0,0,0,.5) 0%,rgba(0,0,0,.14) 38%,transparent 62%)}
260
- .rk-discover-hook{position:relative;z-index:2;margin:0;padding:16px 15px 17px;font-family:var(--rk-font-display);font-weight:800;font-size:1.12rem;
261
- line-height:1.12;letter-spacing:-.02em;color:#fff;text-shadow:0 1px 12px rgba(0,0,0,.4),0 1px 2px rgba(0,0,0,.35)}
262
- .rk-discover-body{padding:15px 16px 16px;display:grid;gap:13px}
263
- .rk-discover-titlewrap{display:grid;gap:4px}
264
- .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}
265
- .rk-discover-metaline{font-size:12.5px;font-weight:500;color:var(--rk-text-muted)}
353
+ .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}
354
+ .rk-discover-poster{position:relative;display:block;aspect-ratio:9/16;overflow:hidden;background:var(--rk-n-100);color:inherit}
355
+ .rk-discover-poster img,.rk-discover-poster video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
356
+ .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);
357
+ background:repeating-linear-gradient(135deg,var(--rk-n-100),var(--rk-n-100) 12px,var(--rk-n-50) 12px,var(--rk-n-50) 24px)}
358
+ .rk-discover-dur{position:absolute;top:12px;right:12px;z-index:3;font-family:var(--rk-font-body);font-size:11.5px;font-weight:700;color:#fff;
359
+ background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);padding:4px 9px;border-radius:var(--rk-r-full)}
360
+ .rk-discover-scrim{position:absolute;inset:0;z-index:1;background:linear-gradient(to top,rgba(0,0,0,.28) 0%,transparent 42%)}
361
+ /* wrap = positioning context so the bookmark button can sit NEXT TO (not
362
+ inside) the poster anchor — a button inside an <a> is invalid HTML */
363
+ .rk-discover-posterwrap{position:relative}
364
+ .rk-discover-bm{position:absolute;right:10px;bottom:10px;z-index:4;width:34px;height:34px;border:0;border-radius:var(--rk-r-full);
365
+ display:none;place-items:center;color:#fff;background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);cursor:pointer;
366
+ transition:transform var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
367
+ .rk-discover-posterwrap:hover .rk-discover-bm{display:grid}
368
+ /* touch has no hover, so always show the bookmark button there (else you can
369
+ never save a not-yet-bookmarked card from the grid) */
370
+ @media(hover:none){.rk-discover-bm{display:grid}}
371
+ .rk-discover-bm:hover{transform:scale(1.08);background:rgba(0,0,0,.6)}
372
+ .rk-discover-bm.is-on{display:grid;background:var(--rk-gold-500);color:var(--rk-ink)}
373
+ .rk-discover-bm.is-on svg{fill:currentColor}
374
+ .rk-discover-body{padding:15px 16px 16px;display:grid;gap:13px;min-width:0}
375
+ .rk-discover-titlewrap{display:grid;gap:6px;min-width:0}
376
+ .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;
377
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
378
+ .rk-discover-desc{font-size:12.5px;font-weight:500;color:var(--rk-text-muted);line-height:1.4;
379
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
380
+ .rk-discover-idchip{justify-self:start;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;
381
+ padding:3px 8px;border-radius:999px;background:var(--rk-surface-2,rgba(0,0,0,.05));
382
+ font-family:var(--rk-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:11px;font-weight:600;
383
+ color:var(--rk-text-muted);cursor:pointer;border:0;line-height:1.4;transition:background .15s,color .15s;
384
+ -webkit-user-select:none;user-select:none;box-sizing:border-box}
385
+ .rk-discover-idchip:hover{background:var(--rk-surface-3,rgba(0,0,0,.09));color:var(--rk-ink)}
386
+ .rk-discover-idchip svg{flex:0 0 auto;opacity:.7}
387
+ .rk-discover-idchip .rk-idchip-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
388
+ .rk-discover-idchip.is-copied{background:rgba(52,168,110,.16);color:#1f7a4d}
389
+ .rk-discover-idchip.is-copied svg{opacity:1;color:#1f7a4d}
266
390
  .rk-discover-fork{gap:7px}
267
391
  .rk-discover-fork svg{margin-top:-1px}
392
+ .rk-discover-fork-disabled{opacity:.6;cursor:default;pointer-events:none}
393
+ .rk-discover-badges{display:flex;flex-wrap:wrap;gap:6px}
394
+
395
+ /* hover-to-unmute speaker badge (video posters only): hidden until hover,
396
+ flips to a gold "sound on" chip once the browser actually allows audio */
397
+ .rk-discover-sound{position:absolute;left:12px;bottom:12px;z-index:3;width:32px;height:32px;display:none;place-items:center;color:#fff;
398
+ background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);border-radius:var(--rk-r-full);pointer-events:none}
399
+ .rk-discover-sound span{display:grid;place-items:center;line-height:0}
400
+ .rk-discover-posterwrap:hover .rk-discover-sound{display:grid}
401
+ .rk-discover-sound .rk-ds-on{display:none}
402
+ .rk-discover-posterwrap.is-sound .rk-discover-sound{display:grid;background:var(--rk-gold-500);color:var(--rk-ink)}
403
+ .rk-discover-posterwrap.is-sound .rk-ds-on{display:grid}
404
+ .rk-discover-posterwrap.is-sound .rk-ds-off{display:none}
405
+
406
+ /* bookmarked-only toolbar chip (feed) — count span collapses when empty */
407
+ .rk-discover-bmfilter{flex:none;display:inline-flex;align-items:center;gap:7px;padding:10px 15px;border-radius:var(--rk-r-full);
408
+ border:1px solid var(--rk-border-strong);background:#fff;color:var(--rk-n-600);font-family:var(--rk-font-body);font-size:13px;font-weight:700;
409
+ cursor:pointer;box-shadow:var(--rk-shadow-xs);
410
+ transition:color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
411
+ .rk-discover-bmfilter:hover{color:var(--rk-ink);border-color:var(--rk-gold-600)}
412
+ .rk-discover-bmfilter.is-active{background:var(--rk-gold-500);border-color:transparent;color:var(--rk-ink)}
413
+ .rk-discover-bmfilter.is-active svg{fill:currentColor}
414
+ .rk-discover-bmfilter-count:empty{display:none}
415
+
416
+ /* load / empty / error state (feed + categories) */
417
+ .rk-discover-state{padding:48px 16px;text-align:center;color:var(--rk-text-muted);font-size:14px;
418
+ background:#fff;border:1px solid var(--rk-border);border-radius:var(--rk-r-2xl);box-shadow:var(--rk-shadow-xs)}
419
+ .rk-discover-state[hidden]{display:none}
420
+ .rk-discover-state.is-error{color:#b91c1c;background:var(--rk-red-tint);border-color:#fecaca;font-weight:500}
421
+ .rk-discover-state .rk-btn{margin-top:14px}
422
+
423
+ /* ── feed: TikTok-style fullscreen viewer (click a card → vertical deck) ── */
424
+ html.rk-noscroll,html.rk-noscroll body{overflow:hidden}
425
+ .rk-discover-viewer{position:fixed;inset:0;z-index:120}
426
+ .rk-discover-viewer[hidden]{display:none}
427
+ .rk-discover-viewer-scrim{position:absolute;inset:0;background:rgba(15,12,5,.88);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
428
+ .rk-discover-viewer-track{position:absolute;inset:0;overflow-y:auto;scroll-snap-type:y mandatory;overscroll-behavior:contain;scrollbar-width:none}
429
+ .rk-discover-viewer-track::-webkit-scrollbar{display:none}
430
+ .rk-discover-viewer-slide{height:100%;display:grid;place-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:20px 0}
431
+ .rk-discover-viewer-stage{position:relative;width:min(92vw,calc(93vh * 9 / 16));aspect-ratio:9/16;max-height:100%;margin:0;
432
+ border-radius:var(--rk-r-3xl);overflow:hidden;background:#000;box-shadow:var(--rk-shadow-xl)}
433
+ .rk-discover-viewer-media{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block}
434
+ video.rk-discover-viewer-media{cursor:pointer}
435
+ .rk-discover-viewer-pause{position:absolute;inset:0;z-index:2;display:none;place-items:center;color:rgba(255,255,255,.92);pointer-events:none;
436
+ filter:drop-shadow(0 4px 14px rgba(0,0,0,.55))}
437
+ .rk-discover-viewer-slide.is-paused .rk-discover-viewer-pause{display:grid}
438
+ .rk-discover-viewer-shade{position:absolute;left:0;right:0;bottom:0;height:46%;z-index:1;pointer-events:none;
439
+ background:linear-gradient(to top,rgba(0,0,0,.74) 0%,rgba(0,0,0,.22) 62%,transparent 100%)}
440
+ /* right padding reserves the bookmark rail so text never runs under it */
441
+ .rk-discover-viewer-info{position:absolute;left:0;right:0;bottom:0;z-index:3;padding:20px 78px 20px 20px;display:grid;gap:7px;justify-items:start;pointer-events:none}
442
+ .rk-discover-viewer-bm{position:absolute;right:14px;bottom:22px;z-index:4;width:48px;height:48px;border-radius:var(--rk-r-full);
443
+ border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.14);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
444
+ color:#fff;display:grid;place-items:center;cursor:pointer;
445
+ transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
446
+ .rk-discover-viewer-bm:hover{background:rgba(255,255,255,.26);transform:scale(1.06)}
447
+ .rk-discover-viewer-bm.is-on{background:var(--rk-gold-500);border-color:transparent;color:var(--rk-ink)}
448
+ .rk-discover-viewer-bm.is-on svg{fill:currentColor}
449
+ .rk-discover-viewer-meta{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:rgba(255,255,255,.72)}
450
+ .rk-discover-viewer-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.12rem;line-height:1.2;letter-spacing:-.01em;color:#fff}
451
+ .rk-discover-viewer-desc{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.82);line-height:1.4;
452
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
453
+ .rk-discover-viewer-fork{pointer-events:auto;gap:7px;margin-top:5px}
454
+ .rk-discover-viewer-ui{position:absolute;top:max(16px,env(safe-area-inset-top,0px));right:16px;z-index:5;display:flex;gap:10px}
455
+ .rk-discover-viewer-btn{width:44px;height:44px;border-radius:var(--rk-r-full);border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.12);
456
+ backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;display:grid;place-items:center;cursor:pointer;
457
+ transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
458
+ .rk-discover-viewer-btn:hover{background:rgba(255,255,255,.24);transform:scale(1.06)}
459
+ .rk-discover-viewer-btn span{display:grid;place-items:center;line-height:0}
460
+ .rk-discover-viewer .rk-dvm-off{display:none}
461
+ .rk-discover-viewer.is-muted .rk-dvm-on{display:none}
462
+ .rk-discover-viewer.is-muted .rk-dvm-off{display:grid}
463
+ .rk-discover-viewer-nav{position:absolute;right:18px;top:50%;transform:translateY(-50%);z-index:5;display:grid;gap:12px}
464
+ @media(hover:none){.rk-discover-viewer-nav{display:none}}
465
+ @media(max-width:640px){
466
+ .rk-discover-viewer-nav{display:none}
467
+ .rk-discover-viewer-slide{padding:0}
468
+ .rk-discover-viewer-stage{width:100%;height:100%;max-height:none;aspect-ratio:auto;border-radius:0;box-shadow:none}
469
+ }
268
470
 
269
471
  /* ── swipe: single-card deck ── */
270
472
  .rk-discover-swipe-wrap{display:grid;justify-items:center;gap:18px;padding:4px 0 8px}
271
- .rk-discover-helper{font-size:14px;color:var(--rk-text-muted);text-align:center;max-width:440px}
272
- .rk-discover-helper b{color:var(--rk-ink);font-weight:700}
273
- .rk-discover-deck{position:relative;width:min(322px,84vw);aspect-ratio:9/16;margin:6px 0 44px}
274
- .rk-discover-swipe{position:absolute;inset:0;border-radius:var(--rk-r-3xl);overflow:hidden;border:1px solid var(--rk-border);
473
+ .rk-discover-deck{position:relative;width:min(322px,84vw);aspect-ratio:9/16;margin:6px 0}
474
+ .rk-discover-swipe-state{position:absolute;inset:0;z-index:1;display:grid;place-content:center;justify-items:center;gap:10px;text-align:center;padding:28px;
475
+ background:#fff;border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card);color:var(--rk-text-muted);font-size:14px}
476
+ .rk-discover-swipe-state[hidden]{display:none}
477
+ .rk-discover-swipe-state.is-error{color:#b91c1c}
478
+ .rk-discover-swipe-state-title{font-family:var(--rk-font-display);font-size:var(--rk-text-lg);font-weight:700;color:var(--rk-ink)}
479
+ .rk-discover-swipe-state .rk-btn{margin-top:6px}
480
+ .rk-discover-spinner{width:30px;height:30px;border-radius:50%;border:3px solid var(--rk-gold-tint2);border-top-color:var(--rk-gold-700);animation:rk-discover-spin .8s linear infinite}
481
+ @keyframes rk-discover-spin{to{transform:rotate(360deg)}}
482
+ .rk-discover-swipe{position:absolute;inset:0;border-radius:var(--rk-r-3xl);overflow:hidden;border:1px solid var(--rk-border);background:#0b0b0b;
275
483
  box-shadow:var(--rk-shadow-xl);transform-origin:center bottom;will-change:transform;
276
484
  transition:transform .38s var(--rk-ease),opacity .38s var(--rk-ease)}
277
- .rk-discover-swipe-art{position:absolute;inset:0;z-index:0}
278
- .rk-discover-swipe .rk-discover-poster-glyph{font-size:5rem;opacity:.9}
279
- .rk-discover-swipe-scrim{position:absolute;inset:0;z-index:1;background:linear-gradient(to top,rgba(0,0,0,.62) 0%,rgba(0,0,0,.2) 42%,rgba(0,0,0,.06) 66%,transparent 82%)}
280
- .rk-discover-swipe-info{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:20px 20px 22px;display:grid;gap:14px}
281
- .rk-discover-swipe .rk-discover-hook{padding:0;font-size:1.5rem;line-height:1.08}
282
- .rk-discover-swipe-foot{display:grid;gap:4px;min-width:0}
283
- .rk-discover-swipe-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.02rem;color:#fff;letter-spacing:-.01em;line-height:1.2}
284
- .rk-discover-swipe-handle{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.82)}
285
-
286
- .rk-discover-empty{position:absolute;inset:0;z-index:1;display:grid;place-content:center;justify-items:center;gap:8px;text-align:center;padding:28px;
287
- background:#fff;border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card)}
288
- .rk-discover-empty h3{font-family:var(--rk-font-display);font-size:var(--rk-text-xl);color:var(--rk-ink)}
289
- .rk-discover-empty p{font-size:13.5px;max-width:230px}
290
- .rk-discover-empty .rk-btn{margin-top:8px}
291
-
292
- .rk-discover-actions{display:flex;align-items:center;justify-content:center;gap:20px}
485
+ .rk-discover-swipe-frame{position:absolute;inset:0;width:100%;height:100%;border:0;display:block;pointer-events:none;background:#000}
486
+ .rk-discover-swipe-fallback{position:absolute;inset:0;display:grid;place-items:center;padding:24px;text-align:center;
487
+ 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;
488
+ transition:opacity .4s var(--rk-ease)}
489
+ .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%)}
490
+ .rk-discover-swipe-info{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:20px;display:grid;gap:6px;pointer-events:none}
491
+ .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}
492
+ .rk-discover-swipe-caption{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.84);line-height:1.35;
493
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
494
+ /* audio toggle — overlays the top-of-deck card, top-right; global mute state */
495
+ .rk-discover-sound-toggle{position:absolute;top:12px;right:12px;z-index:40;width:38px;height:38px;border-radius:var(--rk-r-full);
496
+ border:0;display:grid;place-items:center;cursor:pointer;color:#fff;background:rgba(15,15,15,.5);backdrop-filter:blur(6px);
497
+ -webkit-backdrop-filter:blur(6px);box-shadow:0 2px 8px -2px rgba(0,0,0,.5);
498
+ transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
499
+ .rk-discover-sound-toggle[hidden]{display:none}
500
+ .rk-discover-sound-toggle:hover{background:rgba(15,15,15,.72);transform:scale(1.06)}
501
+ .rk-discover-sound-toggle.is-on{background:var(--rk-gold-500);color:var(--rk-ink)}
502
+ .rk-discover-sound-toggle.is-on:hover{background:var(--rk-gold-700)}
503
+
504
+ /* deck flanked by skip/fork — buttons ride the card's vertical center, 1fr gutters absorb spare width.
505
+ Explicit column placement keeps the deck centered when the buttons are display:none (empty/caught-up states). */
506
+ .rk-discover-stage{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:20px;width:100%}
507
+ .rk-discover-deck{grid-column:2}
293
508
  .rk-discover-act{width:60px;height:60px;flex:none;border-radius:var(--rk-r-full);border:1px solid var(--rk-border);background:#fff;
294
- display:grid;place-items:center;font-size:24px;font-weight:700;cursor:pointer;box-shadow:var(--rk-shadow-md);
295
- transition:transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
509
+ display:grid;place-items:center;cursor:pointer;box-shadow:var(--rk-shadow-md);
510
+ transition:transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
511
+ .rk-discover-act[hidden]{display:none}
512
+ .rk-discover-act-skip{grid-column:1;justify-self:end}
513
+ .rk-discover-act-fork{grid-column:3;justify-self:start}
514
+ /* no side room on small phones — card spans, buttons drop under it as a pair */
515
+ @media(max-width:560px){
516
+ .rk-discover-stage{grid-template-columns:auto auto;grid-template-areas:"deck deck" "skip fork";justify-content:center;column-gap:26px;row-gap:16px}
517
+ .rk-discover-deck{grid-area:deck}
518
+ .rk-discover-act-skip{grid-area:skip;justify-self:center}
519
+ .rk-discover-act-fork{grid-area:fork;justify-self:center}
520
+ }
521
+ /* short viewports (landscape phones, small laptops): the deck is sized by width
522
+ with a locked 9/16 ratio → ~570px tall, taller than the screen, so the card +
523
+ its flanking Skip/Fork controls fall off-screen. Cap it by HEIGHT instead and
524
+ let width follow the ratio (mirrors how the fullscreen viewer stage sizes). */
525
+ @media(max-height:720px){
526
+ .rk-discover-deck{height:min(74vh,572px);width:auto}
527
+ }
528
+ @media(max-height:520px){
529
+ .rk-discover-head{margin-bottom:10px}
530
+ .rk-discover-swipe-wrap{gap:10px;padding:0}
531
+ }
296
532
  .rk-discover-act:hover{transform:translateY(-2px);box-shadow:var(--rk-shadow-lg)}
533
+ .rk-discover-act:disabled{opacity:.5;cursor:default;transform:none}
297
534
  .rk-discover-act-skip{color:var(--rk-n-500)}
298
535
  .rk-discover-act-skip:hover{color:var(--rk-red);border-color:var(--rk-red-tint)}
299
536
  .rk-discover-act-fork{background:var(--rk-gold-500);color:var(--rk-ink);border-color:transparent}
300
537
  .rk-discover-act-fork:hover{background:var(--rk-gold-700);box-shadow:0 8px 22px -6px rgba(252,185,0,.6)}
301
538
  .rk-discover-act-hint{font-size:12.5px;font-weight:600;color:var(--rk-text-faint);text-align:center;letter-spacing:.01em}
539
+ .rk-discover-act-hint[hidden]{display:none}
302
540
 
303
541
  /* ── categories: browse-by-format cards ── */
304
542
  .rk-discover-cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(268px,1fr));gap:20px}
305
543
  @media(max-width:560px){.rk-discover-cats{grid-template-columns:1fr}}
544
+ .rk-discover-cats[hidden]{display:none}
306
545
  .rk-discover-cat{display:flex;flex-direction:column;gap:14px;background:var(--rk-surface);border:1px solid var(--rk-border);
307
546
  border-radius:var(--rk-r-3xl);padding:22px;box-shadow:var(--rk-shadow-card);color:inherit}
308
547
  .rk-discover-cat-head{display:flex;align-items:center;gap:14px}
@@ -311,70 +550,1002 @@ export function renderReskinDiscover(mode = "feed") {
311
550
  .rk-discover-cat-count{font-size:12.5px;font-weight:600;color:var(--rk-text-muted)}
312
551
  .rk-discover-cat-arrow{flex:none;font-size:18px;color:var(--rk-n-400);transition:transform var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
313
552
  .rk-discover-cat:hover .rk-discover-cat-arrow{transform:translateX(3px);color:var(--rk-gold-700)}
314
- .rk-discover-cat-blurb{font-size:13px;color:var(--rk-text-muted);line-height:1.45}
553
+ .rk-discover-cat-blurb{font-size:13px;color:var(--rk-text-muted);line-height:1.45;
554
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
315
555
  .rk-discover-cat-thumbs{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:8px}
316
- .rk-discover-mini{aspect-ratio:9/16;border-radius:var(--rk-r-lg);display:grid;place-items:center;font-size:20px;box-shadow:var(--rk-shadow-xs)}
556
+ .rk-discover-mini{position:relative;aspect-ratio:9/16;border-radius:var(--rk-r-lg);overflow:hidden;background:var(--rk-n-100);box-shadow:var(--rk-shadow-xs)}
557
+ .rk-discover-mini img,.rk-discover-mini video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
558
+ .rk-discover-mini-ph{background:linear-gradient(135deg,var(--rk-n-100),var(--rk-n-200))}
559
+
560
+ .rk-discover-cat-reveal[hidden]{display:none}
561
+ /* reveal header shares the toolbar row with the mode toggle (user feedback) */
562
+ .rk-discover-reveal-head{display:flex;align-items:center;gap:12px;min-width:0}
563
+ .rk-discover-reveal-head[hidden]{display:none}
564
+ .rk-discover-reveal-title{font-family:var(--rk-font-display);font-size:var(--rk-text-xl);font-weight:800;color:var(--rk-ink);letter-spacing:-.02em;
565
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
566
+ .rk-discover-cats-state[hidden]{display:none}
317
567
  `;
318
568
  const script = `
319
569
  (function(){
320
570
  var root=document;
321
- // ── swipe: advance the deck ──
322
- var deck=root.querySelector('[data-deck]');
323
- if(deck){
324
- var empty=deck.querySelector('[data-empty]');
325
- var actions=root.querySelector('[data-actions]');
326
- var hint=root.querySelector('[data-actions-hint]');
327
- var all=Array.prototype.slice.call(deck.querySelectorAll('[data-swipe]'));
328
- var live=all.slice();
571
+ var bootEl=root.querySelector('[data-rk-discover-boot]');
572
+ var boot={};
573
+ try{ boot=JSON.parse(bootEl&&bootEl.textContent?bootEl.textContent:'{}')||{}; }catch(e){ boot={}; }
574
+ var feedEndpoint=boot.feedEndpoint||'/discover/feed';
575
+ var popularEndpoint=boot.popularEndpoint||'/discover/popular';
576
+ var swipeStackEndpoint=boot.swipeStackEndpoint||'/discover/swipe/stack';
577
+ var swipeRefillEndpoint=boot.swipeRefillEndpoint||'/discover/swipe/refill';
578
+ var swipeBase=boot.swipeBase||'/discover/swipe';
579
+ var editorBase=boot.editorBase||'/editor';
580
+ var accountId=boot.accountId||'';
581
+
582
+ function esc(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
583
+ function attr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
584
+ function isVideo(u){ return Boolean(u)&&/\\.(mp4|webm|ogg|mov)(\\?|#|$)/i.test(u); }
585
+ // previewUrl falls back to the template's raw videoUrl, which is often a
586
+ // signed/extension-less URL — so treat "not a known image" as video, never
587
+ // the other way round, or those templates render a broken <img> (no preview).
588
+ function isImage(u){ return Boolean(u)&&/\\.(png|jpe?g|gif|webp|avif|svg)(\\?|#|$)/i.test(u); }
589
+ function withAccount(u){
590
+ try{ var url=new URL(u, window.location.href); if(accountId) url.searchParams.set('account', accountId); return url.toString(); }
591
+ catch(e){ return u; }
592
+ }
593
+ function editorUrl(id){
594
+ var u=editorBase+'/'+encodeURIComponent(String(id));
595
+ return accountId? u+'?account='+encodeURIComponent(accountId) : u;
596
+ }
597
+ // Feed cards browse INSPIRATIONS: an entry may have no template yet. The real
598
+ // template id (if decomposed) opens the editor directly; otherwise the card
599
+ // carries an inspirationId and its CTA decomposes on demand (preview → Remix).
600
+ function realTemplateId(t){ var id=t.templateId||t.template_id||''; return /^template_/.test(String(id))? String(id):''; }
601
+ function inspirationIdOf(t){ return String(t.inspirationId||t.inspiration_id||''); }
602
+ function domId(t){ return realTemplateId(t)||inspirationIdOf(t)||''; }
603
+ function needsDecompose(t){ return !realTemplateId(t) && Boolean(inspirationIdOf(t)); }
604
+ // POST the lazy decompose, then hand off to the editor. Shared by card +
605
+ // viewer Remix buttons via a delegated [data-remix] listener.
606
+ function remixInspiration(btn){
607
+ var inspId=btn.getAttribute('data-remix'); if(!inspId||btn.getAttribute('data-busy')) return;
608
+ btn.setAttribute('data-busy','1');
609
+ var label=btn.innerHTML; btn.innerHTML='Preparing&hellip;';
610
+ fetch(withAccount('/discover/inspirations/'+encodeURIComponent(inspId)+'/decompose'),{method:'POST',credentials:'same-origin',headers:{Accept:'application/json'}})
611
+ .then(function(r){ return r.json().then(function(d){ return {ok:r.ok,data:d}; }); })
612
+ .then(function(res){
613
+ if(res.ok&&res.data&&res.data.templateId){ window.location.href=editorUrl(res.data.templateId); return; }
614
+ btn.removeAttribute('data-busy'); btn.innerHTML=label;
615
+ window.alert((res.data&&res.data.error)||'Could not open this inspiration — please try again.');
616
+ })
617
+ .catch(function(){ btn.removeAttribute('data-busy'); btn.innerHTML=label; window.alert('Network error — please try again.'); });
618
+ }
619
+ document.addEventListener('click',function(e){
620
+ var b=e.target&&e.target.closest?e.target.closest('[data-remix]'):null;
621
+ if(!b) return; e.preventDefault(); remixInspiration(b);
622
+ });
623
+ // Click the template-id chip on a feed card to copy the id to the clipboard,
624
+ // with a brief inline "Copied" swap (icon + label) as subtle confirmation.
625
+ function copyText(text){
626
+ if(navigator.clipboard&&navigator.clipboard.writeText){ return navigator.clipboard.writeText(text); }
627
+ return new Promise(function(resolve,reject){
628
+ try{
629
+ var ta=document.createElement('textarea'); ta.value=text;
630
+ ta.style.position='fixed'; ta.style.top='-9999px'; document.body.appendChild(ta);
631
+ ta.focus(); ta.select(); var ok=document.execCommand('copy'); document.body.removeChild(ta);
632
+ ok?resolve():reject(new Error('copy failed'));
633
+ }catch(err){ reject(err); }
634
+ });
635
+ }
636
+ document.addEventListener('click',function(e){
637
+ var chip=e.target&&e.target.closest?e.target.closest('[data-copy-id]'):null;
638
+ if(!chip) return;
639
+ e.preventDefault(); e.stopPropagation();
640
+ var id=chip.getAttribute('data-copy-id')||'';
641
+ if(!id||chip.getAttribute('data-copied')) return;
642
+ copyText(id).then(function(){
643
+ var txt=chip.querySelector('.rk-idchip-text'); var prev=txt?txt.textContent:'';
644
+ chip.setAttribute('data-copied','1'); chip.classList.add('is-copied');
645
+ chip.innerHTML=IDCHECK_ICON+'<span class="rk-idchip-text">Copied</span>';
646
+ window.setTimeout(function(){
647
+ chip.classList.remove('is-copied'); chip.removeAttribute('data-copied');
648
+ chip.innerHTML=COPY_ICON+'<span class="rk-idchip-text">'+esc(prev)+'</span>';
649
+ },1300);
650
+ }).catch(function(){});
651
+ });
652
+ function fmtDuration(s){
653
+ s=Number(s); if(!s||!isFinite(s)||s<=0) return '';
654
+ if(s>=60){ var m=Math.floor(s/60); var r=Math.round(s%60); return m+':'+(r<10?'0':'')+r; }
655
+ return (s>=10? s.toFixed(0) : s.toFixed(1))+'s';
656
+ }
657
+ 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'};
658
+ 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); }
659
+ var EDIT_ICON=${JSON.stringify(EDIT_ICON)};
660
+ var COPY_ICON=${JSON.stringify(COPY_ICON)};
661
+ var IDCHECK_ICON=${JSON.stringify(IDCHECK_ICON)};
662
+ var BOOKMARK_ICON=${JSON.stringify(BOOKMARK_ICON)};
663
+ var SOUND_ON_ICON=${JSON.stringify(SOUND_ON_ICON)};
664
+ var SOUND_OFF_ICON=${JSON.stringify(SOUND_OFF_ICON)};
665
+ var PLAY_ICON=${JSON.stringify(PLAY_ICON)};
666
+
667
+ // ── bookmarks: client-side saves in localStorage (no backend favorite
668
+ // endpoint exists), shared by feed cards, category reveal cards and the
669
+ // viewer slides. One delegated document listener toggles any [data-bm].
670
+ var BM_KEY='rk-discover-bookmarks';
671
+ var bookmarks=(function(){
672
+ try{ var v=JSON.parse(window.localStorage.getItem(BM_KEY)||'[]'); return new Set(Array.isArray(v)?v.map(String):[]); }
673
+ catch(e){ return new Set(); }
674
+ })();
675
+ var bmChangeHandlers=[];
676
+ function isBookmarked(id){ return Boolean(id)&&bookmarks.has(String(id)); }
677
+ function syncBmButtons(){
678
+ root.querySelectorAll('[data-bm]').forEach(function(b){
679
+ var on=isBookmarked(b.getAttribute('data-bm'));
680
+ b.classList.toggle('is-on',on);
681
+ b.setAttribute('aria-pressed',on?'true':'false');
682
+ b.setAttribute('aria-label',on?'Remove bookmark':'Bookmark');
683
+ });
684
+ }
685
+ function toggleBookmark(id){
686
+ id=String(id||''); if(!id) return;
687
+ if(bookmarks.has(id)) bookmarks.delete(id); else bookmarks.add(id);
688
+ try{ window.localStorage.setItem(BM_KEY, JSON.stringify(Array.from(bookmarks))); }catch(e){}
689
+ syncBmButtons();
690
+ bmChangeHandlers.forEach(function(fn){ try{ fn(id); }catch(e){} });
691
+ }
692
+ function bmBtnHtml(id, cls){
693
+ if(!id) return '';
694
+ var on=isBookmarked(id);
695
+ return '<button type="button" class="'+cls+(on?' is-on':'')+'" data-bm="'+attr(id)+'" aria-pressed="'+(on?'true':'false')+'" aria-label="'+(on?'Remove bookmark':'Bookmark')+'">'+BOOKMARK_ICON+'</button>';
696
+ }
697
+ document.addEventListener('click',function(e){
698
+ var b=(e.target&&e.target.closest)?e.target.closest('[data-bm]'):null;
699
+ if(!b) return;
700
+ e.preventDefault();
701
+ toggleBookmark(b.getAttribute('data-bm'));
702
+ });
703
+
704
+ // ── shared: pause/resume a grid's muted autoplay (while the viewer is open) ──
705
+ function parkVideos(container){
706
+ if(!container) return;
707
+ container.querySelectorAll('video').forEach(function(o){ o.muted=true; o.pause(); });
708
+ container.querySelectorAll('.rk-discover-posterwrap.is-sound').forEach(function(p){ p.classList.remove('is-sound'); });
709
+ }
710
+ function resumeVideos(container){
711
+ if(!container) return;
712
+ container.querySelectorAll('video').forEach(function(o){ o.muted=true; var pr=o.play(); if(pr&&pr.catch) pr.catch(function(){}); });
713
+ }
714
+
715
+ function friendlyError(err){
716
+ var msg=(err&&err.message)?String(err.message):'';
717
+ if(!msg||/failed to fetch|networkerror|load failed/i.test(msg)) return "Couldn't reach the server — check your connection, or sign in to keep browsing.";
718
+ return msg;
719
+ }
720
+
721
+ function mediaHtml(t){
722
+ var url=t.previewUrl||t.preview_url||'';
723
+ if(!url) return '<div class="rk-discover-media-empty">'+(t.status==='processing'?'Downloading&hellip;':'No preview')+'</div>';
724
+ if(isImage(url)) return '<img src="'+attr(url)+'" alt="'+attr((t.title||'Template')+' preview')+'" loading="lazy" decoding="async">';
725
+ // #t=0.1 paints the first frame even where autoplay is blocked.
726
+ return '<video src="'+attr(url+'#t=0.1')+'" muted loop autoplay playsinline preload="metadata" tabindex="-1"></video>';
727
+ }
728
+
729
+ // Real feed/category poster card. The "Open Editor" CTA always deep-links
730
+ // into the real editor; the poster itself carries data-rk-view=<index> so a
731
+ // plain click opens the TikTok-style viewer instead (cmd/middle-click still
732
+ // opens the editor via the untouched href). No platform/source pill on the
733
+ // poster (user feedback) — source shows only in the viewer's meta line.
734
+ function posterCardHtml(t, viewIdx){
735
+ var id=domId(t);
736
+ var tid=realTemplateId(t);
737
+ var remix=needsDecompose(t);
738
+ var url=t.previewUrl||t.preview_url||'';
739
+ var hasVideo=Boolean(url)&&!isImage(url);
740
+ var pending=(t.status==='processing'||t.status==='failed');
741
+ var viewable=!pending&&Boolean(url)&&typeof viewIdx==='number'&&viewIdx>=0;
742
+ var dur=fmtDuration(t.durationSeconds);
743
+ var desc=String(t.viralDna||t.summary||'').trim();
744
+ // Real templates deep-link to the editor; inspiration-only cards open the
745
+ // preview viewer instead (a plain anchor href would 404 the editor), and the
746
+ // CTA decomposes on demand.
747
+ var posterHref=remix? '#' : editorUrl(tid);
748
+ var posterInner=mediaHtml(t)
749
+ +(dur?'<span class="rk-discover-dur">'+esc(dur)+'</span>':'')
750
+ +'<div class="rk-discover-scrim"></div>'
751
+ +(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>':'');
752
+ var poster=pending
753
+ ? '<div class="rk-discover-poster">'+posterInner+'</div>'
754
+ : '<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>';
755
+ // bookmark sits NEXT TO the anchor inside the wrap (button-in-anchor is
756
+ // invalid HTML); the wrap is the shared hover context for sound + bookmark
757
+ poster='<div class="rk-discover-posterwrap">'+poster+(pending?'':bmBtnHtml(id,'rk-discover-bm'))+'</div>';
758
+ var cta=pending
759
+ ? '<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
+ : remix
761
+ ? '<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>';
763
+ return '<article class="rk-discover-card rk-card-hover">'
764
+ + poster
765
+ + '<div class="rk-discover-body">'
766
+ + '<div class="rk-discover-titlewrap">'
767
+ + '<h3 class="rk-discover-title">'+esc(t.title||id)+'</h3>'
768
+ + (desc?'<p class="rk-discover-desc">'+esc(desc)+'</p>':'')
769
+ + (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>':'')
770
+ + '</div>'
771
+ + cta
772
+ + '</div>'
773
+ + '</article>';
774
+ }
775
+
776
+ // ── hover audio: unmute a poster's (muted, autoplaying) video while the
777
+ // pointer is over it; only one card is ever audible at a time. Browsers may
778
+ // refuse unmuted playback before the user's first click (autoplay policy) —
779
+ // in that case we verify shortly after and fall back to muted playback
780
+ // instead of leaving a frozen poster. Delegated, so grid re-renders keep it.
781
+ function bindHoverAudio(container){
782
+ if(!container) return;
783
+ function posterOf(e){ var t=e.target; return (t&&t.closest)?t.closest('.rk-discover-posterwrap'):null; }
784
+ function setSound(poster,on){
785
+ var v=poster.querySelector('video'); if(!v) return;
786
+ if(on){
787
+ container.querySelectorAll('video').forEach(function(o){ if(o!==v) o.muted=true; });
788
+ container.querySelectorAll('.rk-discover-posterwrap.is-sound').forEach(function(p){ if(p!==poster) p.classList.remove('is-sound'); });
789
+ v.muted=false;
790
+ if(v.paused){ var pr=v.play(); if(pr&&pr.catch) pr.catch(function(){}); }
791
+ window.setTimeout(function(){
792
+ if(v.muted) return; // already reverted (pointer left)
793
+ if(v.paused){ v.muted=true; poster.classList.remove('is-sound'); var pr2=v.play(); if(pr2&&pr2.catch) pr2.catch(function(){}); }
794
+ else poster.classList.add('is-sound');
795
+ },70);
796
+ } else {
797
+ v.muted=true; poster.classList.remove('is-sound');
798
+ }
799
+ }
800
+ container.addEventListener('mouseover',function(e){
801
+ var p=posterOf(e); if(!p) return;
802
+ if(e.relatedTarget&&p.contains(e.relatedTarget)) return; // still inside the same poster
803
+ setSound(p,true);
804
+ });
805
+ container.addEventListener('mouseout',function(e){
806
+ var p=posterOf(e); if(!p) return;
807
+ if(e.relatedTarget&&p.contains(e.relatedTarget)) return;
808
+ setSound(p,false);
809
+ });
810
+ }
811
+
812
+ // ── TikTok-style viewer modal — SHARED by the feed grid and the categories
813
+ // reveal grid. open(list, clickedIdx, park): list is the template array
814
+ // behind the grid the user clicked, clickedIdx the clicked card index in
815
+ // that list, park the grid container whose muted autoplay is paused while
816
+ // the modal owns audio + CPU. Slides hydrate lazily (active +/-1); the click
817
+ // that opens the modal is a real user gesture, so the active video plays
818
+ // WITH sound (with a muted-playback fallback if the browser still refuses).
819
+ var viewerCtl=(function(){
820
+ var viewer=root.querySelector('[data-rk-viewer]');
821
+ if(!viewer) return null;
822
+ var vTrack=viewer.querySelector('[data-viewer-track]');
823
+ var vItems=[]; var vOpen=false; var vActive=-1; var vSound=true; var vIO=null; var parkEl=null;
824
+
825
+ function viewerSlideHtml(t,i){
826
+ var id=domId(t);
827
+ var tid=realTemplateId(t);
828
+ var remix=needsDecompose(t);
829
+ var url=t.previewUrl||t.preview_url||'';
830
+ var media=isImage(url)
831
+ ? '<img class="rk-discover-viewer-media" src="'+attr(url)+'" alt="'+attr((t.title||'Template')+' preview')+'">'
832
+ : '<video class="rk-discover-viewer-media" data-vsrc="'+attr(url)+'" loop playsinline preload="none"></video>';
833
+ var meta=[];
834
+ var srcL=sourceLabel(t.sourceType); if(srcL) meta.push(esc(srcL));
835
+ var dur=fmtDuration(t.durationSeconds); if(dur) meta.push(esc(dur));
836
+ var desc=String(t.viralDna||t.summary||'').trim();
837
+ return '<section class="rk-discover-viewer-slide" data-vslide="'+i+'">'
838
+ +'<figure class="rk-discover-viewer-stage">'
839
+ + media
840
+ +'<span class="rk-discover-viewer-pause">'+PLAY_ICON+'</span>'
841
+ +'<div class="rk-discover-viewer-shade"></div>'
842
+ + bmBtnHtml(id,'rk-discover-viewer-bm')
843
+ +'<div class="rk-discover-viewer-info">'
844
+ +(meta.length?'<div class="rk-discover-viewer-meta">'+meta.join(' &middot; ')+'</div>':'')
845
+ +'<div class="rk-discover-viewer-title">'+esc(t.title||id)+'</div>'
846
+ +(desc?'<div class="rk-discover-viewer-desc">'+esc(desc)+'</div>':'')
847
+ +(remix
848
+ ? '<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>')
850
+ +'</div>'
851
+ +'</figure>'
852
+ +'</section>';
853
+ }
854
+ function hydrateSlide(slide){
855
+ var v=slide.querySelector('video[data-vsrc]');
856
+ if(!v||v.getAttribute('src')) return;
857
+ v.preload='auto';
858
+ v.setAttribute('src', v.getAttribute('data-vsrc'));
859
+ }
860
+ function updateMuteUi(){ viewer.classList.toggle('is-muted',!vSound); }
861
+ function setViewerActive(idx){
862
+ vActive=idx;
863
+ [].forEach.call(vTrack.children,function(s,i){
864
+ if(Math.abs(i-idx)<=1) hydrateSlide(s);
865
+ s.classList.remove('is-paused');
866
+ var v=s.querySelector('video');
867
+ if(!v) return;
868
+ if(i===idx){
869
+ v.muted=!vSound;
870
+ var pr=v.play();
871
+ if(pr&&pr.catch) pr.catch(function(){
872
+ // sound-on autoplay blocked → fall back to muted playback
873
+ v.muted=true; vSound=false; updateMuteUi();
874
+ var pr2=v.play(); if(pr2&&pr2.catch) pr2.catch(function(){});
875
+ });
876
+ } else { v.pause(); }
877
+ });
878
+ updateMuteUi();
879
+ }
880
+ function stepViewer(d){
881
+ if(!vOpen) return;
882
+ var next=Math.max(0,Math.min(vItems.length-1,vActive+d));
883
+ if(next===vActive) return;
884
+ vTrack.scrollTo({top:next*vTrack.clientHeight,behavior:'smooth'});
885
+ }
886
+ function open(list, clickedIdx, park){
887
+ vItems=[]; var start=0;
888
+ (list||[]).forEach(function(t,i){
889
+ var url=t.previewUrl||t.preview_url||'';
890
+ if(!url||t.status==='processing'||t.status==='failed') return;
891
+ if(i===clickedIdx) start=vItems.length;
892
+ vItems.push(t);
893
+ });
894
+ if(!vItems.length) return;
895
+ parkEl=park||null;
896
+ vTrack.innerHTML=vItems.map(viewerSlideHtml).join('');
897
+ vOpen=true; vSound=true;
898
+ viewer.hidden=false;
899
+ document.documentElement.classList.add('rk-noscroll');
900
+ parkVideos(parkEl);
901
+ if(vIO) vIO.disconnect();
902
+ vIO=('IntersectionObserver' in window)?new IntersectionObserver(function(entries){
903
+ entries.forEach(function(en){
904
+ if(!en.isIntersecting) return;
905
+ var i=Number(en.target.getAttribute('data-vslide'));
906
+ if(i!==vActive) setViewerActive(i);
907
+ });
908
+ },{root:vTrack,threshold:0.6}):null;
909
+ if(vIO) [].forEach.call(vTrack.children,function(s){ vIO.observe(s); });
910
+ vTrack.scrollTop=start*vTrack.clientHeight;
911
+ setViewerActive(start);
912
+ }
913
+ function close(){
914
+ if(!vOpen) return;
915
+ vOpen=false; vActive=-1;
916
+ if(vIO){ vIO.disconnect(); vIO=null; }
917
+ vTrack.querySelectorAll('video').forEach(function(v){ v.pause(); });
918
+ vTrack.innerHTML='';
919
+ viewer.hidden=true;
920
+ document.documentElement.classList.remove('rk-noscroll');
921
+ resumeVideos(parkEl);
922
+ parkEl=null;
923
+ }
924
+
925
+ viewer.querySelectorAll('[data-viewer-close]').forEach(function(b){ b.addEventListener('click',close); });
926
+ var muteBtn=viewer.querySelector('[data-viewer-mute]');
927
+ if(muteBtn) muteBtn.addEventListener('click',function(){
928
+ vSound=!vSound; updateMuteUi();
929
+ var s=vTrack.children[vActive]; var v=s&&s.querySelector('video');
930
+ if(v){ v.muted=!vSound; if(vSound&&v.paused){ var pr=v.play(); if(pr&&pr.catch) pr.catch(function(){}); s.classList.remove('is-paused'); } }
931
+ });
932
+ var prevBtn=viewer.querySelector('[data-viewer-prev]');
933
+ var nextBtn=viewer.querySelector('[data-viewer-next]');
934
+ if(prevBtn) prevBtn.addEventListener('click',function(){ stepViewer(-1); });
935
+ if(nextBtn) nextBtn.addEventListener('click',function(){ stepViewer(1); });
936
+ // tap the video → pause/resume (TikTok); tap the empty gutter → close;
937
+ // bookmarks are handled by the document-level [data-bm] listener
938
+ vTrack.addEventListener('click',function(e){
939
+ var t=e.target;
940
+ if(t&&t.closest&&(t.closest('a')||t.closest('[data-bm]'))) return;
941
+ var media=(t&&t.closest)?t.closest('.rk-discover-viewer-media'):null;
942
+ if(media&&media.tagName==='VIDEO'){
943
+ var slide=media.closest('.rk-discover-viewer-slide');
944
+ if(media.paused){ var pr=media.play(); if(pr&&pr.catch) pr.catch(function(){}); if(slide) slide.classList.remove('is-paused'); }
945
+ else { media.pause(); if(slide) slide.classList.add('is-paused'); }
946
+ return;
947
+ }
948
+ if(t&&t.classList&&(t.classList.contains('rk-discover-viewer-slide')||t===vTrack)) close();
949
+ });
950
+ document.addEventListener('keydown',function(e){
951
+ if(!vOpen) return;
952
+ if(e.key==='Escape'){ e.preventDefault(); close(); }
953
+ else if(e.key==='ArrowDown'||e.key==='PageDown'){ e.preventDefault(); stepViewer(1); }
954
+ else if(e.key==='ArrowUp'||e.key==='PageUp'){ e.preventDefault(); stepViewer(-1); }
955
+ });
956
+ window.addEventListener('resize',function(){
957
+ if(vOpen&&vActive>=0) vTrack.scrollTop=vActive*vTrack.clientHeight;
958
+ });
959
+
960
+ return { open: open, close: close, isOpen: function(){ return vOpen; } };
961
+ })();
962
+
963
+ // Wire a poster grid's plain-clicks into the shared viewer. getList is
964
+ // called at click time so it always reflects the grid's CURRENT contents
965
+ // (search filter, bookmark filter, revealed group).
966
+ function bindViewerOpens(container, getList){
967
+ if(!container||!viewerCtl) return;
968
+ container.addEventListener('click',function(e){
969
+ if(e.defaultPrevented||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0) return;
970
+ var a=(e.target&&e.target.closest)?e.target.closest('[data-rk-view]'):null;
971
+ if(!a||!container.contains(a)) return;
972
+ e.preventDefault();
973
+ viewerCtl.open(getList()||[], Number(a.getAttribute('data-rk-view')), container);
974
+ });
975
+ }
976
+
977
+ // ════════════════════════════════════════════════════════════════════════
978
+ // FEED
979
+ // ════════════════════════════════════════════════════════════════════════
980
+ var feedRoot=root.querySelector('[data-rk-feed]');
981
+ if(feedRoot){
982
+ var gridEl=feedRoot.querySelector('[data-rk-grid]');
983
+ var stateEl=feedRoot.querySelector('[data-rk-state]');
984
+ // search lives in the shared toolbar row (outside [data-rk-feed]); the
985
+ // visible-count is folded into the search placeholder rather than shown as
986
+ // a separate label.
987
+ var searchEl=root.querySelector('[data-rk-search]');
988
+ // caption the placeholder with the count, but only while the box is empty —
989
+ // a typed query keeps its own placeholder-less state.
990
+ function setCountHint(n){
991
+ if(!searchEl) return;
992
+ searchEl.placeholder = (n==null)
993
+ ? 'Search templates'
994
+ : 'Search '+n+' template'+(n===1?'':'s');
995
+ }
996
+ var bmChip=root.querySelector('[data-rk-bmfilter]');
997
+ var bmCountEl=root.querySelector('[data-rk-bmcount]');
998
+ var bmFilter=false;
999
+ var feedState={items:[],visible:[],loading:false};
1000
+ var feedReq=0;
1001
+
1002
+ function feedSetState(msg,isError){
1003
+ if(gridEl) gridEl.innerHTML='';
1004
+ if(stateEl){ stateEl.hidden=false; stateEl.innerHTML=msg; stateEl.classList.toggle('is-error',!!isError); }
1005
+ setCountHint(null);
1006
+ }
1007
+ function updateBmChip(){
1008
+ if(!bmChip) return;
1009
+ var n=0;
1010
+ feedState.items.forEach(function(t){ if(isBookmarked(t.templateId||t.template_id)) n++; });
1011
+ if(bmCountEl) bmCountEl.textContent=n?String(n):'';
1012
+ bmChip.classList.toggle('is-active',bmFilter);
1013
+ bmChip.setAttribute('aria-pressed',bmFilter?'true':'false');
1014
+ }
1015
+ function feedRender(){
1016
+ var vis=bmFilter
1017
+ ? feedState.items.filter(function(t){ return isBookmarked(t.templateId||t.template_id); })
1018
+ : feedState.items;
1019
+ feedState.visible=vis;
1020
+ updateBmChip();
1021
+ if(!vis.length){
1022
+ feedSetState(bmFilter?'No bookmarks yet — tap the bookmark on any template to save it for later.':'No templates match — try a different search.',false);
1023
+ return;
1024
+ }
1025
+ if(stateEl) stateEl.hidden=true;
1026
+ if(gridEl) gridEl.innerHTML=vis.map(function(t,i){ return posterCardHtml(t,i); }).join('');
1027
+ setCountHint(vis.length);
1028
+ }
1029
+ if(bmChip) bmChip.addEventListener('click',function(){ bmFilter=!bmFilter; feedRender(); });
1030
+ bmChangeHandlers.push(function(){
1031
+ updateBmChip();
1032
+ // with the filter on, un-bookmarking drops the card from the grid; the
1033
+ // re-render restarts muted autoplay, so re-park it if the viewer is open
1034
+ if(bmFilter){ feedRender(); if(viewerCtl&&viewerCtl.isOpen()) parkVideos(gridEl); }
1035
+ });
1036
+ function applyFeedItems(items){
1037
+ feedState.items=Array.isArray(items)?items:[];
1038
+ feedState.loading=false;
1039
+ feedRender();
1040
+ }
1041
+ // Direct same-origin fetch — the fallback for when the Zustand bridge isn't
1042
+ // on the page yet (its deferred module is still loading) or is disabled.
1043
+ function feedFetchDirect(query,my){
1044
+ var url=feedEndpoint;
1045
+ 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; }
1046
+ fetch(url,{credentials:'same-origin',headers:{'accept':'application/json'}})
1047
+ .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(); })
1048
+ .then(function(j){ if(my!==feedReq) return; applyFeedItems((j&&Array.isArray(j.templates))?j.templates:[]); })
1049
+ .catch(function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); });
1050
+ }
1051
+ // Feed loads flow through the Zustand store bridge (SSR-seed + SWR cache);
1052
+ // it may call onResult twice for the default feed (cached snapshot first,
1053
+ // then a background-revalidated fresh copy) — both are honored under the
1054
+ // same request token so a newer search still wins.
1055
+ function feedLoad(query){
1056
+ var my=++feedReq;
1057
+ feedState.loading=true;
1058
+ // Only flash the loading state when nothing's painted yet — a
1059
+ // revalidate must never blank an already-good grid.
1060
+ if(!feedState.items.length) feedSetState('Loading templates&hellip;',false);
1061
+ var bridge=window.__vidfarmDiscover;
1062
+ if(bridge&&bridge.loadFeed){
1063
+ bridge.loadFeed(query,{
1064
+ onLoading:function(){ if(my===feedReq && !feedState.items.length) feedSetState('Loading templates&hellip;',false); },
1065
+ onResult:function(items){ if(my!==feedReq) return; applyFeedItems(items); },
1066
+ onError:function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); }
1067
+ });
1068
+ } else {
1069
+ feedFetchDirect(query,my);
1070
+ }
1071
+ }
1072
+ // Honor a ?q= (e.g. from a category link) in the page URL.
1073
+ var initialQuery='';
1074
+ try{ initialQuery=(new URL(window.location.href)).searchParams.get('q')||''; }catch(e){ initialQuery=''; }
1075
+ if(searchEl){
1076
+ searchEl.value=initialQuery;
1077
+ var debounce=null;
1078
+ searchEl.addEventListener('input',function(){
1079
+ if(debounce) window.clearTimeout(debounce);
1080
+ var v=searchEl.value.trim();
1081
+ debounce=window.setTimeout(function(){ feedLoad(v); },260);
1082
+ });
1083
+ searchEl.addEventListener('keydown',function(e){ if(e.key==='Enter'){ if(debounce) window.clearTimeout(debounce); feedLoad(searchEl.value.trim()); } });
1084
+ }
1085
+ bindHoverAudio(gridEl);
1086
+ // plain click on a card → shared viewer over the CURRENT visible feed
1087
+ // (search + bookmark filter applied)
1088
+ bindViewerOpens(gridEl, function(){ return feedState.visible; });
1089
+ // SSR-seed: if the server inlined the first page of the default feed AND
1090
+ // there's no ?q= search, paint it immediately with NO initial fetch — this
1091
+ // is what kills the redundant SSR-render → client-fetch round trip.
1092
+ var seeded=false;
1093
+ if(!initialQuery && boot.feedInitial && Array.isArray(boot.feedInitial) && boot.feedInitial.length){
1094
+ applyFeedItems(boot.feedInitial);
1095
+ seeded=true;
1096
+ }
1097
+ if(!seeded){
1098
+ feedLoad(initialQuery.trim());
1099
+ }
1100
+ // A background revalidation (stale snapshot / BFCache restore) from the
1101
+ // store bridge re-paints the grid — but only while not mid-search.
1102
+ window.addEventListener('vidfarm:discover-feed-updated',function(ev){
1103
+ var q=searchEl?searchEl.value.trim():'';
1104
+ if(q) return;
1105
+ var items=ev&&ev.detail&&ev.detail.items;
1106
+ if(Array.isArray(items)) applyFeedItems(items);
1107
+ });
1108
+
1109
+ // ── "Add New" modal — paste a social URL or upload a video, add it to the
1110
+ // caller's private feed (real POST /discover/templates + upload presign
1111
+ // pair), then reload the feed so the new pending card appears. In feed
1112
+ // scope so it can reach feedLoad + the current search value.
1113
+ var addModal=root.querySelector('[data-rk-add-modal]');
1114
+ var addBtn=root.querySelector('[data-rk-add]');
1115
+ if(addModal&&addBtn){
1116
+ var addForm=addModal.querySelector('[data-rk-add-form]');
1117
+ var addUrl=addModal.querySelector('[data-rk-add-url]');
1118
+ var addFile=addModal.querySelector('[data-rk-add-file]');
1119
+ var addFileLabel=addModal.querySelector('[data-rk-add-filelabel]');
1120
+ var addFileText=addModal.querySelector('[data-rk-add-filetext]');
1121
+ var addTagline=addModal.querySelector('[data-rk-add-tagline]');
1122
+ var addNotes=addModal.querySelector('[data-rk-add-notes]');
1123
+ var addErr=addModal.querySelector('[data-rk-add-error]');
1124
+ var addSubmit=addModal.querySelector('[data-rk-add-submit]');
1125
+ var MAX_UPLOAD=200*1024*1024;
1126
+ var defaultFileText=addFileText?addFileText.innerHTML:'';
1127
+ var addBusy=false;
1128
+
1129
+ function showAddErr(msg){ if(addErr){ addErr.textContent=msg||''; addErr.hidden=!msg; } }
1130
+ function openAdd(){
1131
+ addModal.hidden=false;
1132
+ document.documentElement.classList.add('rk-noscroll');
1133
+ if(addUrl){ try{ addUrl.focus(); }catch(e){} }
1134
+ }
1135
+ function closeAdd(){
1136
+ if(addBusy) return;
1137
+ addModal.hidden=true;
1138
+ document.documentElement.classList.remove('rk-noscroll');
1139
+ }
1140
+ function resetAdd(){
1141
+ if(addForm) addForm.reset();
1142
+ if(addFileLabel) addFileLabel.classList.remove('is-picked');
1143
+ if(addFileText) addFileText.innerHTML=defaultFileText;
1144
+ showAddErr('');
1145
+ }
1146
+ function setAddBusy(on,label){
1147
+ addBusy=on;
1148
+ if(addSubmit){ addSubmit.disabled=on; addSubmit.textContent=on?(label||'Adding\\u2026'):'Add to feed'; }
1149
+ }
1150
+
1151
+ addBtn.addEventListener('click',openAdd);
1152
+ addModal.querySelectorAll('[data-rk-add-close]').forEach(function(b){ b.addEventListener('click',closeAdd); });
1153
+ document.addEventListener('keydown',function(e){ if(e.key==='Escape'&&!addModal.hidden) closeAdd(); });
1154
+
1155
+ if(addFile){
1156
+ addFile.addEventListener('change',function(){
1157
+ var f=addFile.files&&addFile.files[0];
1158
+ if(addFileLabel) addFileLabel.classList.toggle('is-picked',!!f);
1159
+ if(addFileText) addFileText.innerHTML=f?esc(f.name):defaultFileText;
1160
+ showAddErr('');
1161
+ });
1162
+ }
1163
+
1164
+ function ingest(uploadRef){
1165
+ var tagline=addTagline?addTagline.value.trim():'';
1166
+ var notes=addNotes?addNotes.value.trim():'';
1167
+ var payload=uploadRef
1168
+ ? { upload:uploadRef, tagline:tagline||null, notes:notes||null }
1169
+ : { source_url:(addUrl?addUrl.value.trim():''), tagline:tagline||null, notes:notes||null };
1170
+ return fetch(withAccount('/discover/templates'),{
1171
+ method:'POST',credentials:'same-origin',
1172
+ headers:{'content-type':'application/json',Accept:'application/json'},
1173
+ body:JSON.stringify(payload)
1174
+ }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,status:r.status,data:d}; }); });
1175
+ }
1176
+ function uploadFile(file){
1177
+ return fetch(withAccount('/discover/templates/upload/presign'),{
1178
+ method:'POST',credentials:'same-origin',
1179
+ headers:{'content-type':'application/json',Accept:'application/json'},
1180
+ body:JSON.stringify({ file_name:file.name, content_type:file.type||null, size_bytes:file.size })
1181
+ }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,data:d}; }); })
1182
+ .then(function(res){
1183
+ if(!res.ok) throw new Error((res.data&&res.data.error)||'Unable to start the video upload.');
1184
+ var p=res.data||{};
1185
+ if(p.transport==='presigned'&&p.upload&&p.upload.url&&p.storage_key){
1186
+ return fetch(p.upload.url,{ method:(p.upload.method||'PUT'), headers:(p.upload.headers||{}), body:file })
1187
+ .then(function(put){ if(!put.ok) throw new Error('Video upload failed ('+put.status+'). Try again.');
1188
+ return { storage_key:p.storage_key, file_name:p.file_name||file.name }; });
1189
+ }
1190
+ var form=new FormData(); form.append('file',file,p.file_name||file.name);
1191
+ return fetch((p.upload&&p.upload.url)||'/discover/templates/upload',{ method:'POST',credentials:'same-origin',body:form })
1192
+ .then(function(direct){ return direct.json().catch(function(){ return {}; }).then(function(dd){
1193
+ if(!direct.ok||!dd.storage_key) throw new Error(dd.error||'Video upload failed. Try again.');
1194
+ return { storage_key:dd.storage_key, file_name:dd.file_name||file.name }; }); });
1195
+ });
1196
+ }
1197
+
1198
+ if(addForm){
1199
+ addForm.addEventListener('submit',function(e){
1200
+ e.preventDefault();
1201
+ if(addBusy) return;
1202
+ showAddErr('');
1203
+ var file=addFile&&addFile.files?addFile.files[0]:null;
1204
+ var url=addUrl?addUrl.value.trim():'';
1205
+ if(!file&&!url){ showAddErr('Paste a TikTok, YouTube, Twitter/X, or Instagram video URL \\u2014 or upload a video file.'); return; }
1206
+ if(file&&file.size>MAX_UPLOAD){ showAddErr('Video uploads can be at most 200 MB.'); return; }
1207
+ setAddBusy(true,file?'Uploading\\u2026':'Adding\\u2026');
1208
+ var chain=file?uploadFile(file):Promise.resolve(null);
1209
+ chain.then(function(uploadRef){ return ingest(uploadRef); })
1210
+ .then(function(res){
1211
+ setAddBusy(false);
1212
+ if(!res.ok){ showAddErr((res.data&&res.data.error)||'Unable to add that video.'); return; }
1213
+ resetAdd(); closeAdd();
1214
+ // A new inspiration just landed — drop the cached snapshot so the
1215
+ // reload fetches fresh (no stale-cache flash of the old feed).
1216
+ if(window.__vidfarmDiscover&&window.__vidfarmDiscover.invalidate) window.__vidfarmDiscover.invalidate();
1217
+ feedLoad(searchEl?searchEl.value.trim():'');
1218
+ })
1219
+ .catch(function(err){ setAddBusy(false); showAddErr((err&&err.message)?err.message:'Unable to add that video.'); });
1220
+ });
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ // ════════════════════════════════════════════════════════════════════════
1226
+ // SWIPE
1227
+ // ════════════════════════════════════════════════════════════════════════
1228
+ var swipeRoot=root.querySelector('[data-rk-swipe]');
1229
+ if(swipeRoot){
1230
+ var deck=swipeRoot.querySelector('[data-deck]');
1231
+ var swStateEl=swipeRoot.querySelector('[data-rk-swipe-state]');
1232
+ var actionBtns=[].slice.call(swipeRoot.querySelectorAll('[data-swipe-act]'));
1233
+ var hint=swipeRoot.querySelector('[data-actions-hint]');
1234
+ var skipBtn=swipeRoot.querySelector('[data-swipe-skip]');
1235
+ var forkBtn=swipeRoot.querySelector('[data-swipe-fork]');
1236
+ var soundBtn=swipeRoot.querySelector('[data-swipe-sound]');
1237
+ var sw={target:5,customizing:0,productSet:false,busy:false,refilling:false,loaded:false,soundOn:false,known:{}};
1238
+ var nodes=[]; // live card DOM nodes, top-of-deck first
1239
+
1240
+ // ── audio: the same-origin preview iframes poll these window flags (see
1241
+ // injectSwipePreviewFit). Only the TOP card's id is named audible so the
1242
+ // preloaded 2nd iframe stays silent (audio never doubles). Deck previews
1243
+ // always start muted (the only autoplay browsers permit); the toggle is the
1244
+ // user gesture that unlocks sound.
1245
+ function syncSound(){
1246
+ try{
1247
+ window.__VF_SWIPE_SOUND__ = sw.soundOn===true;
1248
+ window.__VF_SWIPE_SOUND_TOP__ = nodes.length? String(nodes[0]._card.id): null;
1249
+ }catch(e){}
1250
+ }
1251
+ function updateSoundBtn(){
1252
+ if(!soundBtn) return;
1253
+ var show=nodes.length>0;
1254
+ soundBtn.hidden=!show;
1255
+ soundBtn.classList.toggle('is-on', sw.soundOn);
1256
+ soundBtn.setAttribute('aria-pressed', sw.soundOn?'true':'false');
1257
+ soundBtn.setAttribute('aria-label', sw.soundOn?'Mute':'Unmute');
1258
+ soundBtn.innerHTML = sw.soundOn? SOUND_ON_ICON : SOUND_OFF_ICON;
1259
+ }
1260
+ if(soundBtn) soundBtn.addEventListener('click',function(){ sw.soundOn=!sw.soundOn; syncSound(); updateSoundBtn(); });
1261
+
1262
+ function swShowState(html,kind){
1263
+ if(swStateEl){ swStateEl.hidden=false; swStateEl.innerHTML=html; swStateEl.classList.toggle('is-error',kind==='error'); }
1264
+ actionBtns.forEach(function(b){ b.hidden=true; });
1265
+ if(hint) hint.hidden=true;
1266
+ }
1267
+ function swHideState(){ if(swStateEl) swStateEl.hidden=true; }
1268
+
1269
+ function makeCard(card){
1270
+ if(card&&card.id) sw.known[String(card.id)]=true;
1271
+ var el=document.createElement('article');
1272
+ el.className='rk-discover-swipe';
1273
+ el._card=card; el._iframed=false;
1274
+ var title=esc(card.title||card.source_title||card.template_id||'Template');
1275
+ var caption=card.caption?('<div class="rk-discover-swipe-caption">'+esc(card.caption)+'</div>'):'';
1276
+ el.innerHTML='<div class="rk-discover-swipe-fallback"><span>'+title+'</span></div>'
1277
+ +'<div class="rk-discover-swipe-scrim"></div>'
1278
+ +'<div class="rk-discover-swipe-info"><div class="rk-discover-swipe-title">'+title+'</div>'+caption+'</div>';
1279
+ deck.appendChild(el);
1280
+ return el;
1281
+ }
1282
+ function upgradeToIframe(el){
1283
+ if(el._iframed) return;
1284
+ var card=el._card; var url=card&&(card.preview_url||card.previewUrl);
1285
+ if(!url) return;
1286
+ var fb=el.querySelector('.rk-discover-swipe-fallback');
1287
+ var frame=document.createElement('iframe');
1288
+ frame.className='rk-discover-swipe-frame';
1289
+ frame.setAttribute('src', url);
1290
+ frame.setAttribute('title', String(card.title||'Preview'));
1291
+ frame.setAttribute('loading','lazy');
1292
+ frame.setAttribute('scrolling','no');
1293
+ frame.setAttribute('allow','autoplay');
1294
+ // The fallback is an OPAQUE dark placeholder that sits later in the DOM,
1295
+ // so it paints over the iframe. Reveal the live preview once it loads by
1296
+ // fading the placeholder out (kept as a load state until then). A timed
1297
+ // fallback covers the case where 'load' never fires.
1298
+ function revealFrame(){ if(fb){ fb.style.opacity='0'; fb.style.pointerEvents='none'; } }
1299
+ frame.addEventListener('load', function(){ window.setTimeout(revealFrame, 120); });
1300
+ window.setTimeout(revealFrame, 2600);
1301
+ if(fb&&fb.parentNode) fb.parentNode.insertBefore(frame, fb); else el.insertBefore(frame, el.firstChild);
1302
+ el._iframed=true;
1303
+ }
329
1304
  function restack(){
330
- live.forEach(function(c,i){
331
- if(i>2){c.style.display='none';return;}
332
- c.style.display='';
333
- c.style.zIndex=String(20-i);
334
- c.style.transform='translateY('+(i*12)+'px) scale('+(1-i*0.045).toFixed(3)+')';
335
- c.style.opacity=i===0?'1':(i===1?'0.92':'0.82');
336
- c.classList.toggle('is-front',i===0);
1305
+ nodes.forEach(function(el,i){
1306
+ if(i>2){ el.style.display='none'; return; }
1307
+ el.style.display='';
1308
+ el.style.zIndex=String(20-i);
1309
+ el.style.transform='translateY('+(i*12)+'px) scale('+(1-i*0.045).toFixed(3)+')';
1310
+ el.style.opacity=i===0?'1':(i===1?'0.92':'0.82');
1311
+ if(i<=1) upgradeToIframe(el);
337
1312
  });
338
- if(live.length===0){
339
- if(empty)empty.hidden=false;
340
- if(actions)actions.style.visibility='hidden';
341
- if(hint)hint.style.visibility='hidden';
1313
+ var has=nodes.length>0;
1314
+ actionBtns.forEach(function(b){ b.hidden=!has; });
1315
+ if(hint) hint.hidden=!has;
1316
+ syncSound(); updateSoundBtn();
1317
+ if(!has) swCaughtUp();
1318
+ }
1319
+ function swCaughtUp(){
1320
+ if(sw.customizing>0||sw.refilling){
1321
+ swShowState('<div class="rk-discover-spinner" aria-hidden="true"></div><div class="rk-discover-swipe-state-title">Customizing fresh clips&hellip;</div><div>Recutting viral templates into ads for your product.</div>');
1322
+ } else {
1323
+ 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>');
342
1324
  }
343
1325
  }
344
- function advance(dir){
345
- var front=live.shift();
346
- if(!front)return;
347
- front.classList.remove('is-front');
348
- front.style.transform='translateX('+(dir==='fork'?460:-460)+'px) rotate('+(dir==='fork'?15:-15)+'deg)';
349
- front.style.opacity='0';
350
- window.setTimeout(function(){front.style.display='none';},360);
351
- restack();
352
- }
353
- var skip=root.querySelector('[data-swipe-skip]');
354
- var fork=root.querySelector('[data-swipe-fork]');
355
- if(skip)skip.addEventListener('click',function(){advance('skip')});
356
- if(fork)fork.addEventListener('click',function(){advance('fork')});
357
- var restart=deck.querySelector('[data-restart]');
358
- if(restart)restart.addEventListener('click',function(){
359
- live=all.slice();
360
- all.forEach(function(c){c.style.transition='none';c.style.opacity='';c.style.transform='';});
361
- // force reflow so the reset transforms take without animating
362
- void deck.offsetWidth;
363
- all.forEach(function(c){c.style.transition='';});
364
- if(empty)empty.hidden=true;
365
- if(actions)actions.style.visibility='';
366
- if(hint)hint.style.visibility='';
367
- restack();
1326
+ function decide(dir){
1327
+ if(sw.busy||!nodes.length) return;
1328
+ sw.busy=true;
1329
+ var el=nodes.shift();
1330
+ var card=el._card;
1331
+ el.classList.remove('is-front');
1332
+ el.style.zIndex='30';
1333
+ el.style.transform='translateX('+(dir==='fork'?460:-460)+'px) rotate('+(dir==='fork'?15:-15)+'deg)';
1334
+ el.style.opacity='0';
1335
+ 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(); });
1341
+ }
1342
+ function refill(){
1343
+ if(sw.refilling||!sw.productSet||!sw.loaded) return;
1344
+ if(nodes.length>=sw.target) return;
1345
+ sw.refilling=true;
1346
+ fetch(withAccount(swipeRefillEndpoint),{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json','accept':'application/json'},body:'{}'})
1347
+ .then(function(r){ return r.json().catch(function(){ return {}; }); })
1348
+ .then(function(j){
1349
+ sw.refilling=false;
1350
+ if(!j||j.ok===false) return; // e.g. need_provider_key — stop quietly
1351
+ if(j.filled&&j.record){ makeCard(j.record); restack(); }
1352
+ var more=j.filled&&nodes.length<sw.target&&j.reason!=='no_eligible'&&j.reason!=='at_target';
1353
+ if(more) refill();
1354
+ // Server said it's still working (no card yet, not "done") — the
1355
+ // customization keeps running Lambda-side even if THIS request replies
1356
+ // late; poll the stack so a finished card appears without a refresh.
1357
+ else if(!j.filled&&j.reason!=='no_eligible'&&j.reason!=='at_target') schedulePoll();
1358
+ })
1359
+ .catch(function(){
1360
+ // Timeout / 504 / network drop: customizeOneMore often runs past the
1361
+ // gateway's reply window, so the card lands server-side after we gave
1362
+ // up here. Poll the stack to pick it up (this is the "504 on first
1363
+ // load, works after a manual refresh" case — now it self-heals).
1364
+ sw.refilling=false; schedulePoll();
1365
+ });
1366
+ }
1367
+ // ── stack re-poll: recover cards the refill Lambda produced after its HTTP
1368
+ // reply timed out. Backs off after a fixed number of empty tries so we never
1369
+ // poll forever.
1370
+ var pollTimer=null, pollTries=0;
1371
+ function schedulePoll(){
1372
+ if(pollTimer||pollTries>=12) return;
1373
+ if(nodes.length>=sw.target) return;
1374
+ pollTimer=window.setTimeout(function(){ pollTimer=null; pollStack(); }, 5000);
1375
+ }
1376
+ function pollStack(){
1377
+ if(nodes.length>=sw.target) return;
1378
+ fetch(withAccount(swipeStackEndpoint),{credentials:'same-origin',headers:{'accept':'application/json'}})
1379
+ .then(function(r){ return r.ok? r.json(): null; })
1380
+ .then(function(j){
1381
+ if(!j){ pollTries++; schedulePoll(); return; }
1382
+ sw.customizing=Number(j&&j.customizing)||0;
1383
+ var stack=(j&&Array.isArray(j.stack))?j.stack:[];
1384
+ var added=0;
1385
+ stack.forEach(function(card){ if(card&&card.id&&!sw.known[String(card.id)]){ nodes.push(makeCard(card)); added++; } });
1386
+ if(added){ pollTries=0; swHideState(); restack(); }
1387
+ else { pollTries++; if(!nodes.length) swCaughtUp(); }
1388
+ if((sw.customizing>0||nodes.length<sw.target)&&nodes.length<sw.target) schedulePoll();
1389
+ })
1390
+ .catch(function(){ pollTries++; schedulePoll(); });
1391
+ }
1392
+ function loadStack(){
1393
+ swShowState('<div class="rk-discover-spinner" aria-hidden="true"></div><div>Loading your deck&hellip;</div>');
1394
+ fetch(withAccount(swipeStackEndpoint),{credentials:'same-origin',headers:{'accept':'application/json'}})
1395
+ .then(function(r){
1396
+ if(r.status===401){ var e=new Error('unauth'); e.code=401; throw e; }
1397
+ if(!r.ok){ throw new Error("Couldn't load your deck ("+r.status+")."); }
1398
+ return r.json();
1399
+ })
1400
+ .then(function(j){
1401
+ sw.loaded=true;
1402
+ sw.target=Number(j&&j.target_depth)||5;
1403
+ sw.customizing=Number(j&&j.customizing)||0;
1404
+ sw.productSet=Boolean(j&&j.product_description_set);
1405
+ var stack=(j&&Array.isArray(j.stack))?j.stack:[];
1406
+ if(!sw.productSet){
1407
+ 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>');
1408
+ return;
1409
+ }
1410
+ nodes=stack.map(makeCard);
1411
+ if(nodes.length){ swHideState(); restack(); }
1412
+ else { swCaughtUp(); }
1413
+ refill();
1414
+ })
1415
+ .catch(function(err){
1416
+ if(err&&err.code===401){
1417
+ swShowState('<div class="rk-discover-swipe-state-title">Swipe is for your product</div><div>Log in to auto-generate ads from viral templates and swipe to approve.</div><a class="rk-btn rk-btn-gold rk-btn-sm" href="/login">Log in</a>');
1418
+ return;
1419
+ }
1420
+ swShowState('<div class="rk-discover-swipe-state-title">Couldn\\u2019t load your deck</div><div>'+esc(friendlyError(err))+'</div>','error');
1421
+ });
1422
+ }
1423
+ if(skipBtn) skipBtn.addEventListener('click',function(){ decide('skip'); });
1424
+ if(forkBtn) forkBtn.addEventListener('click',function(){ decide('fork'); });
1425
+ loadStack();
1426
+ }
1427
+
1428
+ // ════════════════════════════════════════════════════════════════════════
1429
+ // CATEGORIES
1430
+ // ════════════════════════════════════════════════════════════════════════
1431
+ var catsRoot=root.querySelector('[data-rk-cats]');
1432
+ if(catsRoot){
1433
+ var catsGrid=catsRoot.querySelector('[data-rk-cats-grid]');
1434
+ var reveal=catsRoot.querySelector('[data-rk-cat-reveal]');
1435
+ var revealGrid=catsRoot.querySelector('[data-rk-reveal-grid]');
1436
+ // reveal header ("← All categories" + group title) lives in the toolbar row
1437
+ var revealHead=root.querySelector('[data-rk-reveal-head]');
1438
+ var revealTitle=root.querySelector('[data-rk-reveal-title]');
1439
+ var backBtn=root.querySelector('[data-rk-cat-back]');
1440
+ var catsState=catsRoot.querySelector('[data-rk-cats-state]');
1441
+ var groups={}; // label -> [templates]
1442
+ var revealItems=[]; // the currently-revealed group's templates (viewer list)
1443
+
1444
+ function miniHtml(t){
1445
+ var url=t.previewUrl||t.preview_url||'';
1446
+ if(!url) return '<span class="rk-discover-mini rk-discover-mini-ph"></span>';
1447
+ if(isImage(url)) return '<span class="rk-discover-mini"><img src="'+attr(url)+'" alt="" loading="lazy" decoding="async"></span>';
1448
+ // First-frame still, NOT autoplay: 4 tiles x 16 category cards would be
1449
+ // ~64 playing videos. preload=metadata + #t=0.1 paints frame one only.
1450
+ return '<span class="rk-discover-mini"><video src="'+attr(url+'#t=0.1')+'" muted playsinline preload="metadata" tabindex="-1" aria-hidden="true"></video></span>';
1451
+ }
1452
+ function showReveal(label){
1453
+ var items=groups[label]||[];
1454
+ if(!items.length) return false;
1455
+ revealItems=items;
1456
+ if(revealTitle) revealTitle.textContent=label;
1457
+ // cards carry data-rk-view=<i> so a plain click opens the shared viewer
1458
+ if(revealGrid) revealGrid.innerHTML=items.map(function(t,i){ return posterCardHtml(t,i); }).join('');
1459
+ if(catsGrid) catsGrid.hidden=true;
1460
+ if(reveal) reveal.hidden=false;
1461
+ if(revealHead) revealHead.hidden=false;
1462
+ window.scrollTo(0,0);
1463
+ return true;
1464
+ }
1465
+ function hideReveal(){
1466
+ if(viewerCtl&&viewerCtl.isOpen()) viewerCtl.close();
1467
+ revealItems=[];
1468
+ if(reveal) reveal.hidden=true;
1469
+ if(revealHead) revealHead.hidden=true;
1470
+ if(catsGrid) catsGrid.hidden=false;
1471
+ }
1472
+ if(backBtn) backBtn.addEventListener('click',hideReveal);
1473
+ bindHoverAudio(revealGrid); // reveal cards share posterCardHtml → same hover audio
1474
+ bindViewerOpens(revealGrid, function(){ return revealItems; }); // click → shared TikTok viewer
1475
+
1476
+ // Intercept category-card clicks: if we have live templates for that group,
1477
+ // reveal them in-page; otherwise let the href fall through to a filtered
1478
+ // feed (graceful offline / not-yet-seeded fallback).
1479
+ catsRoot.querySelectorAll('[data-rk-cat]').forEach(function(a){
1480
+ a.addEventListener('click',function(e){
1481
+ var label=a.getAttribute('data-rk-cat');
1482
+ if(groups[label]&&groups[label].length){ e.preventDefault(); showReveal(label); }
1483
+ });
368
1484
  });
1485
+
1486
+ function enrichCards(){
1487
+ catsRoot.querySelectorAll('[data-rk-cat]').forEach(function(a){
1488
+ var label=a.getAttribute('data-rk-cat');
1489
+ var items=groups[label]||[];
1490
+ if(!items.length) return;
1491
+ var thumbs=a.querySelector('[data-rk-cat-thumbs]');
1492
+ if(thumbs) thumbs.innerHTML=items.slice(0,4).map(miniHtml).join('');
1493
+ var count=a.querySelector('[data-rk-cat-count]');
1494
+ if(count) count.textContent=items.length+' template'+(items.length===1?'':'s');
1495
+ });
1496
+ }
1497
+ function ingestPopular(list){
1498
+ groups={};
1499
+ (list||[]).forEach(function(t){ var g=(t.popularGroup||'Popular').trim()||'Popular'; (groups[g]=groups[g]||[]).push(t); });
1500
+ enrichCards();
1501
+ }
1502
+ function loadPopular(){
1503
+ var url=popularEndpoint;
1504
+ try{ var u=new URL(popularEndpoint, window.location.href); if(accountId) u.searchParams.set('account', accountId); url=u.toString(); }catch(e){ url=popularEndpoint; }
1505
+ fetch(url,{credentials:'same-origin',headers:{'accept':'application/json'}})
1506
+ .then(function(r){ if(!r.ok){ throw new Error("Couldn't load categories ("+r.status+")."); } return r.json(); })
1507
+ .then(function(j){
1508
+ var list=(j&&Array.isArray(j.templates))?j.templates:[];
1509
+ if(list.length){ ingestPopular(list); return; }
1510
+ // Local serve without the popular catalog seeded (and no upstream
1511
+ // passthrough configured): fall back to the public prod endpoint —
1512
+ // it's CORS-open and unauthenticated — so the sandbox still shows
1513
+ // real first-frame previews on a fully-local box.
1514
+ loadPopularFallback();
1515
+ // The static skeleton already shows every real group; the fetch just
1516
+ // enriches it, so a fetch that returns nothing is not an error state.
1517
+ })
1518
+ .catch(function(){
1519
+ // Offline / unreachable: try the public prod catalog before settling
1520
+ // for the static skeleton grid (real group labels + counts, clicks
1521
+ // fall through to the filtered feed).
1522
+ loadPopularFallback();
1523
+ });
1524
+ }
1525
+ function loadPopularFallback(){
1526
+ if(window.location.hostname==='vidfarm.cc') return; // already prod
1527
+ fetch('https://vidfarm.cc/discover/popular',{headers:{'accept':'application/json'}})
1528
+ .then(function(r){ return r.ok?r.json():null; })
1529
+ .then(function(j){
1530
+ var list=(j&&Array.isArray(j.templates))?j.templates:[];
1531
+ if(list.length) ingestPopular(list);
1532
+ })
1533
+ .catch(function(){ /* keep the static skeleton */ });
1534
+ }
1535
+ loadPopular();
369
1536
  }
370
1537
  })();`;
371
1538
  return reskinDocument({
372
1539
  title: `vidfarm reskin — Discover (${mode})`,
373
- description: "Reskinned vidfarm Discover — Feed / Swipe / Categories modes, poster cards, warm sunny SaaS.",
1540
+ description: "Reskinned vidfarm Discover — real Feed / Swipe / Categories, live template posters, warm sunny SaaS.",
374
1541
  activeSlug: "discover",
1542
+ account: { name: input.name, email: input.email },
375
1543
  pageCss,
376
1544
  body,
377
- script
1545
+ script,
1546
+ // Feed mode hydrates a Zustand store (SSR-seed + stale-while-revalidate);
1547
+ // the other modes don't need it.
1548
+ moduleScripts: mode === "feed" ? ["/assets/discover-client-app.js"] : undefined
378
1549
  });
379
1550
  }
380
1551
  //# sourceMappingURL=discover-page.js.map