@mevdragon/vidfarm-devcli 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
  2. package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
  3. package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
  4. package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
  5. package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
  6. package/.agents/skills/vidfarm-media/SKILL.md +4 -3
  7. package/.agents/skills/vidfarm-media/references/tts.md +20 -1
  8. package/SKILL.director.md +20 -20
  9. package/SKILL.platform.md +4 -4
  10. package/dist/src/account-pages-legacy.js +2 -2
  11. package/dist/src/app.js +721 -101
  12. package/dist/src/cli.js +11 -10
  13. package/dist/src/devcli/clips.js +64 -64
  14. package/dist/src/editor-chat.js +2 -2
  15. package/dist/src/frontend/homepage-client.js +162 -2
  16. package/dist/src/frontend/homepage-store.js +30 -1
  17. package/dist/src/frontend/homepage-view.js +111 -4
  18. package/dist/src/homepage.js +184 -1
  19. package/dist/src/landing-page.js +367 -0
  20. package/dist/src/primitive-registry.js +278 -2
  21. package/dist/src/reskin/agency-page.js +299 -0
  22. package/dist/src/reskin/calendar-page.js +567 -0
  23. package/dist/src/reskin/chat-page.js +607 -0
  24. package/dist/src/reskin/discover-page.js +1096 -0
  25. package/dist/src/reskin/document.js +663 -0
  26. package/dist/src/reskin/help-page.js +356 -0
  27. package/dist/src/reskin/index-page.js +62 -0
  28. package/dist/src/reskin/inpaint-page.js +541 -0
  29. package/dist/src/reskin/job-runs-page.js +477 -0
  30. package/dist/src/reskin/library-page.js +688 -0
  31. package/dist/src/reskin/login-page.js +262 -0
  32. package/dist/src/reskin/pricing-page.js +388 -0
  33. package/dist/src/reskin/settings-page.js +687 -0
  34. package/dist/src/reskin/theme.js +362 -0
  35. package/dist/src/services/serverless-records.js +54 -0
  36. package/dist/src/services/swipe-customize.js +434 -0
  37. package/package.json +1 -1
  38. package/public/assets/homepage-client-app.js +22 -22
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import "./sentry.js";
3
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { hydrateRoot } from "react-dom/client";
5
5
  import * as Sentry from "@sentry/react";
6
6
  import { useStore } from "zustand";
@@ -57,6 +57,21 @@ function filterTemplates(input) {
57
57
  return matchesSearch && matchesBookmarks;
58
58
  });
59
59
  }
60
+ // The swipe API serializes snake_case; the store/view use camelCase SwipeCard.
61
+ function mapSwipeCard(raw) {
62
+ return {
63
+ id: String(raw?.id ?? ""),
64
+ templateId: String(raw?.template_id ?? ""),
65
+ forkId: raw?.fork_id ?? null,
66
+ sourceTitle: raw?.source_title ?? null,
67
+ title: String(raw?.title ?? raw?.source_title ?? "Untitled"),
68
+ caption: String(raw?.caption ?? ""),
69
+ pinnedComment: raw?.pinned_comment ?? null,
70
+ previewUrl: raw?.preview_url ?? null,
71
+ durationSeconds: typeof raw?.duration_seconds === "number" ? raw.duration_seconds : null,
72
+ status: String(raw?.status ?? "ready")
73
+ };
74
+ }
60
75
  function HomepageClientApp({ boot }) {
61
76
  const store = useMemo(() => createHomepageStore(boot), [boot]);
62
77
  const templates = useStore(store, (state) => state.templates);
@@ -69,6 +84,15 @@ function HomepageClientApp({ boot }) {
69
84
  const popularTemplates = useStore(store, (state) => state.popularTemplates);
70
85
  const popularLoading = useStore(store, (state) => state.popularLoading);
71
86
  const popularError = useStore(store, (state) => state.popularError);
87
+ const swipeStack = useStore(store, (state) => state.swipeStack);
88
+ const swipeLoading = useStore(store, (state) => state.swipeLoading);
89
+ const swipeLoaded = useStore(store, (state) => state.swipeLoaded);
90
+ const swipeError = useStore(store, (state) => state.swipeError);
91
+ const swipeCustomizing = useStore(store, (state) => state.swipeCustomizing);
92
+ const swipeTargetDepth = useStore(store, (state) => state.swipeTargetDepth);
93
+ const swipeProductSet = useStore(store, (state) => state.swipeProductSet);
94
+ const swipeRefilling = useStore(store, (state) => state.swipeRefilling);
95
+ const swipeFillingRef = useRef(false);
72
96
  const [bookmarksReady, setBookmarksReady] = useState(false);
73
97
  const visibleTemplates = useMemo(() => filterTemplates({ templates, searchQuery, bookmarksOnly, bookmarks }), [templates, searchQuery, bookmarksOnly, bookmarks]);
74
98
  useEffect(() => {
@@ -170,6 +194,142 @@ function HomepageClientApp({ boot }) {
170
194
  }, 8000);
171
195
  return () => window.clearInterval(timer);
172
196
  }, [hasProcessingPopular, refreshPopular]);
197
+ // Swipe deck: the customer's own product, auto-recut from decomposed templates.
198
+ const refreshSwipeStack = useCallback(async (options) => {
199
+ if (options?.showLoading) {
200
+ store.getState().setSwipeLoading(true);
201
+ }
202
+ try {
203
+ const response = await fetch("/discover/swipe/stack", {
204
+ credentials: "same-origin",
205
+ cache: "no-store",
206
+ headers: { Accept: "application/json" }
207
+ });
208
+ const payload = await response.json().catch(() => ({}));
209
+ if (!response.ok) {
210
+ throw new Error(typeof payload.error === "string" && payload.error ? payload.error : "Unable to load your swipe deck.");
211
+ }
212
+ const stack = Array.isArray(payload.stack) ? payload.stack.map(mapSwipeCard) : [];
213
+ store.getState().setSwipeStack(stack, {
214
+ customizing: typeof payload.customizing === "number" ? payload.customizing : 0,
215
+ targetDepth: typeof payload.target_depth === "number" ? payload.target_depth : undefined,
216
+ productSet: typeof payload.product_description_set === "boolean" ? payload.product_description_set : undefined
217
+ });
218
+ }
219
+ catch (fetchError) {
220
+ if (options?.showLoading) {
221
+ store.getState().setSwipeError(fetchError instanceof Error ? fetchError.message : "Unable to load your swipe deck.");
222
+ }
223
+ else {
224
+ debugError("homepage.swipe_refresh_failed", { message: fetchError instanceof Error ? fetchError.message : String(fetchError) });
225
+ }
226
+ }
227
+ }, [store]);
228
+ // Progressively customize until the buffer hits target (server-capped), one
229
+ // per request. Each finished variant streams into the deck as it lands.
230
+ const fillSwipeDeck = useCallback(async () => {
231
+ if (swipeFillingRef.current)
232
+ return;
233
+ swipeFillingRef.current = true;
234
+ store.getState().setSwipeRefilling(true);
235
+ try {
236
+ for (let i = 0; i < 8; i += 1) {
237
+ const response = await fetch("/discover/swipe/refill", {
238
+ method: "POST",
239
+ credentials: "same-origin",
240
+ headers: { Accept: "application/json" }
241
+ });
242
+ const payload = await response.json().catch(() => ({}));
243
+ if (!response.ok) {
244
+ if (payload?.need_product_description) {
245
+ store.getState().setSwipeMeta({ productSet: false });
246
+ }
247
+ else {
248
+ store.getState().setSwipeError(typeof payload.error === "string" && payload.error ? payload.error : "Customization failed.");
249
+ }
250
+ break;
251
+ }
252
+ if (payload.filled && payload.record) {
253
+ store.getState().appendSwipeCard(mapSwipeCard(payload.record));
254
+ }
255
+ if (!payload.filled) {
256
+ break; // at_target or no_eligible
257
+ }
258
+ }
259
+ }
260
+ catch (fillError) {
261
+ debugError("homepage.swipe_refill_failed", { message: fillError instanceof Error ? fillError.message : String(fillError) });
262
+ }
263
+ finally {
264
+ swipeFillingRef.current = false;
265
+ store.getState().setSwipeRefilling(false);
266
+ // Re-sync the customizing/buffered counts after filling settles.
267
+ void refreshSwipeStack();
268
+ }
269
+ }, [store, refreshSwipeStack]);
270
+ // First open of Swipe → load the current deck, then top it up.
271
+ useEffect(() => {
272
+ if (view !== "swipe" || swipeLoaded) {
273
+ return;
274
+ }
275
+ void (async () => {
276
+ await refreshSwipeStack({ showLoading: true });
277
+ const state = store.getState();
278
+ if (state.swipeProductSet && state.swipeStack.length < state.swipeTargetDepth) {
279
+ void fillSwipeDeck();
280
+ }
281
+ })();
282
+ }, [view, swipeLoaded, refreshSwipeStack, fillSwipeDeck, store]);
283
+ const handleSwipeApprove = useCallback(async (id) => {
284
+ try {
285
+ const response = await fetch(`/discover/swipe/${encodeURIComponent(id)}/approve`, {
286
+ method: "POST",
287
+ credentials: "same-origin",
288
+ headers: { Accept: "application/json" }
289
+ });
290
+ const payload = await response.json().catch(() => ({}));
291
+ if (!response.ok) {
292
+ // The server retired this card (built by an older customize pipeline) —
293
+ // drop it from the deck and pull a fresh one instead of jamming.
294
+ if (payload && payload.superseded) {
295
+ store.getState().removeSwipeCard(id);
296
+ void fillSwipeDeck();
297
+ return { ok: true };
298
+ }
299
+ return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to approve." };
300
+ }
301
+ store.getState().removeSwipeCard(id);
302
+ void fillSwipeDeck(); // +1 fresh at the tail
303
+ return { ok: true };
304
+ }
305
+ catch (approveError) {
306
+ return { ok: false, error: approveError instanceof Error ? approveError.message : "Unable to approve." };
307
+ }
308
+ }, [store, fillSwipeDeck]);
309
+ const handleSwipeReject = useCallback(async (id) => {
310
+ try {
311
+ const response = await fetch(`/discover/swipe/${encodeURIComponent(id)}/reject`, {
312
+ method: "POST",
313
+ credentials: "same-origin",
314
+ headers: { Accept: "application/json" }
315
+ });
316
+ const payload = await response.json().catch(() => ({}));
317
+ if (!response.ok) {
318
+ if (payload && payload.superseded) {
319
+ store.getState().removeSwipeCard(id);
320
+ void fillSwipeDeck();
321
+ return { ok: true };
322
+ }
323
+ return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to reject." };
324
+ }
325
+ store.getState().removeSwipeCard(id);
326
+ void fillSwipeDeck(); // +1 fresh at the tail
327
+ return { ok: true };
328
+ }
329
+ catch (rejectError) {
330
+ return { ok: false, error: rejectError instanceof Error ? rejectError.message : "Unable to reject." };
331
+ }
332
+ }, [store, fillSwipeDeck]);
173
333
  const handleDeleteTemplate = useCallback(async (templateId) => {
174
334
  try {
175
335
  const response = await fetch(`/discover/templates/${encodeURIComponent(templateId)}`, {
@@ -268,7 +428,7 @@ function HomepageClientApp({ boot }) {
268
428
  return { ok: false, error: submitError instanceof Error ? submitError.message : "Unable to add that video." };
269
429
  }
270
430
  }, [refreshFeed]);
271
- return (_jsx(HomepageShell, { account: boot.account, templates: templates, visibleTemplates: visibleTemplates, searchQuery: searchQuery, bookmarksOnly: bookmarksOnly, bookmarks: bookmarks, loading: loading, error: error, view: view, popularTemplates: popularTemplates, popularLoading: popularLoading, popularError: popularError, skillEndpointPrefix: boot.skillEndpointPrefix, onSearchQueryChange: (value) => store.getState().setSearchQuery(value), onBookmarksOnlyChange: (value) => store.getState().setBookmarksOnly(value), onViewChange: (value) => store.getState().setView(value), onToggleBookmark: (templateId) => store.getState().toggleBookmark(templateId), onAddTemplate: handleAddTemplate, onDeleteTemplate: handleDeleteTemplate }));
431
+ return (_jsx(HomepageShell, { account: boot.account, templates: templates, visibleTemplates: visibleTemplates, searchQuery: searchQuery, bookmarksOnly: bookmarksOnly, bookmarks: bookmarks, loading: loading, error: error, view: view, popularTemplates: popularTemplates, popularLoading: popularLoading, popularError: popularError, swipeStack: swipeStack, swipeLoading: swipeLoading, swipeLoaded: swipeLoaded, swipeError: swipeError, swipeCustomizing: swipeCustomizing, swipeTargetDepth: swipeTargetDepth, swipeProductSet: swipeProductSet, swipeRefilling: swipeRefilling, skillEndpointPrefix: boot.skillEndpointPrefix, onSearchQueryChange: (value) => store.getState().setSearchQuery(value), onBookmarksOnlyChange: (value) => store.getState().setBookmarksOnly(value), onViewChange: (value) => store.getState().setView(value), onToggleBookmark: (templateId) => store.getState().toggleBookmark(templateId), onAddTemplate: handleAddTemplate, onDeleteTemplate: handleDeleteTemplate, onSwipeApprove: handleSwipeApprove, onSwipeReject: handleSwipeReject }));
272
432
  }
273
433
  const boot = readBoot();
274
434
  const root = document.getElementById("homepage-root");
@@ -12,6 +12,14 @@ export function createHomepageStore(boot) {
12
12
  popularLoading: false,
13
13
  popularLoaded: false,
14
14
  popularError: null,
15
+ swipeStack: [],
16
+ swipeLoading: false,
17
+ swipeLoaded: false,
18
+ swipeError: null,
19
+ swipeCustomizing: 0,
20
+ swipeTargetDepth: 5,
21
+ swipeProductSet: true,
22
+ swipeRefilling: false,
15
23
  setTemplates: (templates) => set({ templates, loading: false, error: null }),
16
24
  setSearchQuery: (searchQuery) => set({ searchQuery }),
17
25
  setBookmarksOnly: (bookmarksOnly) => set({ bookmarksOnly }),
@@ -31,7 +39,28 @@ export function createHomepageStore(boot) {
31
39
  setView: (view) => set({ view }),
32
40
  setPopularTemplates: (popularTemplates) => set({ popularTemplates, popularLoading: false, popularLoaded: true, popularError: null }),
33
41
  setPopularLoading: (popularLoading) => set({ popularLoading }),
34
- setPopularError: (popularError) => set({ popularError, popularLoading: false })
42
+ setPopularError: (popularError) => set({ popularError, popularLoading: false }),
43
+ setSwipeLoading: (swipeLoading) => set({ swipeLoading }),
44
+ setSwipeError: (swipeError) => set({ swipeError, swipeLoading: false }),
45
+ setSwipeStack: (swipeStack, meta) => set({
46
+ swipeStack,
47
+ swipeLoading: false,
48
+ swipeLoaded: true,
49
+ swipeError: null,
50
+ ...(meta?.customizing !== undefined ? { swipeCustomizing: meta.customizing } : {}),
51
+ ...(meta?.targetDepth !== undefined ? { swipeTargetDepth: meta.targetDepth } : {}),
52
+ ...(meta?.productSet !== undefined ? { swipeProductSet: meta.productSet } : {})
53
+ }),
54
+ setSwipeMeta: (meta) => set({
55
+ ...(meta.customizing !== undefined ? { swipeCustomizing: meta.customizing } : {}),
56
+ ...(meta.targetDepth !== undefined ? { swipeTargetDepth: meta.targetDepth } : {}),
57
+ ...(meta.productSet !== undefined ? { swipeProductSet: meta.productSet } : {})
58
+ }),
59
+ appendSwipeCard: (card) => set((state) => (state.swipeStack.some((existing) => existing.id === card.id)
60
+ ? {}
61
+ : { swipeStack: [...state.swipeStack, card] })),
62
+ removeSwipeCard: (id) => set((state) => ({ swipeStack: state.swipeStack.filter((card) => card.id !== id) })),
63
+ setSwipeRefilling: (swipeRefilling) => set({ swipeRefilling })
35
64
  }));
36
65
  }
37
66
  //# sourceMappingURL=homepage-store.js.map
@@ -485,7 +485,7 @@ function AddTemplateModal(input) {
485
485
  // grouped under their editorial category headings and ordered by the server.
486
486
  function PopularTemplates(input) {
487
487
  if (input.loading) {
488
- return _jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: "Loading popular formats\u2026" }) });
488
+ return _jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: "Loading categories\u2026" }) });
489
489
  }
490
490
  if (input.error) {
491
491
  return _jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: input.error }) });
@@ -495,7 +495,7 @@ function PopularTemplates(input) {
495
495
  ? input.templates.filter((template) => `${template.title} ${template.templateId} ${template.popularGroup ?? ""}`.toLowerCase().includes(query))
496
496
  : input.templates;
497
497
  if (filtered.length === 0) {
498
- return (_jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: query ? "No popular formats match your search." : "No popular formats yet." }) }));
498
+ return (_jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: query ? "No categories match your search." : "No categories yet." }) }));
499
499
  }
500
500
  // Group by popularGroup, preserving the server's rank order (already sorted).
501
501
  const groups = [];
@@ -510,6 +510,112 @@ function PopularTemplates(input) {
510
510
  }
511
511
  return (_jsx("div", { className: "discover-popular", children: groups.map((group) => (_jsxs("section", { className: "discover-popular-group", children: [_jsx("h3", { className: "discover-popular-group-label", children: group.label }), _jsx("ul", { className: "discover-list", children: group.items.map((template) => (_jsx(TemplateCard, { template: template, isBookmarked: input.bookmarks.has(template.templateId), onToggleBookmark: input.onToggleBookmark, onOpenPreview: input.onOpenPreview, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId }, template.templateId))) })] }, group.label))) }));
512
512
  }
513
+ // Tinder-style deck of the customer's own product, auto-recut from decomposed
514
+ // templates. Previews are LIVE HyperFrames compositions in an iframe (no baked
515
+ // MP4) — only the top two cards mount an iframe (the concurrent-decoder ceiling);
516
+ // the rest are static placeholders. Drag or tap the buttons to approve/reject.
517
+ function SwipeDeck(input) {
518
+ const [drag, setDrag] = useState(null);
519
+ const [flyOut, setFlyOut] = useState(null);
520
+ const [busy, setBusy] = useState(false);
521
+ const [actionError, setActionError] = useState(null);
522
+ // Deck previews start muted (the only autoplay browsers allow); this toggle
523
+ // is the user gesture that unlocks audio. The same-origin preview iframes
524
+ // poll these window flags (see injectSwipePreviewFit) — the top-card id gate
525
+ // keeps the preloaded second iframe silent so audio never doubles.
526
+ const [soundOn, setSoundOn] = useState(false);
527
+ const topCardId = input.stack.length ? input.stack[0].id : null;
528
+ useEffect(() => {
529
+ window.__VF_SWIPE_SOUND__ = soundOn;
530
+ window.__VF_SWIPE_SOUND_TOP__ = topCardId;
531
+ return () => {
532
+ window.__VF_SWIPE_SOUND__ = false;
533
+ window.__VF_SWIPE_SOUND_TOP__ = null;
534
+ };
535
+ }, [soundOn, topCardId]);
536
+ const startRef = useRef(null);
537
+ const THRESHOLD = 110;
538
+ const decide = (card, dir) => {
539
+ if (!card || busy)
540
+ return;
541
+ setBusy(true);
542
+ setActionError(null);
543
+ setFlyOut({ id: card.id, dir });
544
+ // Let the fly-out animation play, then commit (parent removes the card and
545
+ // refills the deck).
546
+ window.setTimeout(async () => {
547
+ const action = dir === "right" ? input.onApprove : input.onReject;
548
+ try {
549
+ const result = await action?.(card.id);
550
+ if (result && !result.ok)
551
+ setActionError(result.error ?? "Action failed.");
552
+ }
553
+ finally {
554
+ setFlyOut(null);
555
+ setDrag(null);
556
+ startRef.current = null;
557
+ setBusy(false);
558
+ }
559
+ }, 260);
560
+ };
561
+ if (!input.isLoggedIn) {
562
+ return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "Swipe is for your product" }), _jsx("p", { className: "swipe-empty-sub", children: "Log in to auto-generate ads from viral templates and swipe to approve." }), _jsx("a", { className: "swipe-empty-cta", href: "/login", children: "Log in" })] }));
563
+ }
564
+ if (!input.productSet) {
565
+ return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "Tell us about your product" }), _jsx("p", { className: "swipe-empty-sub", children: "Swipe auto-recuts viral templates into ads for your product. Add a product / offer description in Settings to start the deck." }), _jsx("a", { className: "swipe-empty-cta", href: "/settings", children: "Open Settings" })] }));
566
+ }
567
+ if (input.error) {
568
+ return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "Couldn't load your deck" }), _jsx("p", { className: "swipe-empty-sub", children: input.error })] }));
569
+ }
570
+ if (input.stack.length === 0) {
571
+ if (input.loading || input.customizing > 0 || input.refilling) {
572
+ return (_jsxs("div", { className: "swipe-empty", children: [_jsx("div", { className: "swipe-spinner", "aria-hidden": "true" }), _jsx("p", { className: "swipe-empty-title", children: "Customizing fresh clips\u2026" }), _jsx("p", { className: "swipe-empty-sub", children: "Recutting viral templates into ads for your product." })] }));
573
+ }
574
+ return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "You're all caught up" }), _jsx("p", { className: "swipe-empty-sub", children: "No more templates to customize right now. Check back as new ones are added." })] }));
575
+ }
576
+ const visible = input.stack.slice(0, 4);
577
+ const top = visible[0];
578
+ return (_jsxs("div", { className: "swipe-deck-wrap", children: [_jsxs("div", { className: "swipe-deck-row", children: [_jsx("button", { type: "button", className: "swipe-action swipe-action-reject", disabled: busy, onClick: () => decide(top, "left"), "aria-label": "Reject", children: "\u2715" }), _jsxs("div", { className: "swipe-deck", children: [_jsx("button", { type: "button", className: "swipe-sound", onClick: () => setSoundOn((value) => !value), "aria-label": soundOn ? "Mute preview" : "Play preview with sound", "aria-pressed": soundOn, title: soundOn ? "Mute" : "Sound on", children: soundOn ? "🔊" : "🔇" }), visible.map((card, index) => {
579
+ const isTop = index === 0;
580
+ const isFlying = flyOut?.id === card.id;
581
+ const isDragging = isTop && drag?.id === card.id;
582
+ const useIframe = index <= 1 && Boolean(card.previewUrl);
583
+ const dx = isDragging ? drag.dx : 0;
584
+ const dy = isDragging ? drag.dy : 0;
585
+ let transform;
586
+ let transition;
587
+ if (isFlying) {
588
+ transform = `translate(${flyOut.dir === "right" ? 130 : -130}%, ${dy}px) rotate(${flyOut.dir === "right" ? 22 : -22}deg)`;
589
+ transition = "transform 0.26s ease-in";
590
+ }
591
+ else if (isTop) {
592
+ transform = `translate(${dx}px, ${dy * 0.25}px) rotate(${dx / 22}deg)`;
593
+ transition = isDragging ? "none" : "transform 0.2s ease";
594
+ }
595
+ else {
596
+ transform = `translateY(${index * 12}px) scale(${1 - index * 0.04})`;
597
+ transition = "transform 0.2s ease";
598
+ }
599
+ const hint = isDragging ? (drag.dx > 40 ? "approve" : drag.dx < -40 ? "reject" : null) : null;
600
+ return (_jsxs("div", { className: "swipe-card", style: { transform, transition, zIndex: 100 - index }, onPointerDown: isTop && !busy ? (event) => {
601
+ startRef.current = { x: event.clientX, y: event.clientY };
602
+ event.currentTarget.setPointerCapture?.(event.pointerId);
603
+ } : undefined, onPointerMove: isTop && !busy ? (event) => {
604
+ if (!startRef.current)
605
+ return;
606
+ setDrag({ id: card.id, dx: event.clientX - startRef.current.x, dy: event.clientY - startRef.current.y });
607
+ } : undefined, onPointerUp: isTop && !busy ? () => {
608
+ const moved = drag?.id === card.id ? drag.dx : 0;
609
+ startRef.current = null;
610
+ if (moved > THRESHOLD)
611
+ decide(card, "right");
612
+ else if (moved < -THRESHOLD)
613
+ decide(card, "left");
614
+ else
615
+ setDrag(null);
616
+ } : undefined, onPointerCancel: isTop ? () => { startRef.current = null; setDrag(null); } : undefined, children: [useIframe ? (_jsx("iframe", { className: "swipe-card-preview", src: card.previewUrl ?? undefined, title: card.title, loading: "lazy", scrolling: "no", allow: "autoplay", style: { pointerEvents: "none" } })) : (_jsx("div", { className: "swipe-card-fallback", children: _jsx("span", { children: card.title }) })), _jsxs("div", { className: "swipe-card-overlay", children: [_jsx("div", { className: "swipe-card-title", children: card.title }), card.caption ? _jsx("div", { className: "swipe-card-caption", children: card.caption }) : null] }), hint === "approve" ? _jsx("div", { className: "swipe-stamp swipe-stamp-yes", children: "Approve" }) : null, hint === "reject" ? _jsx("div", { className: "swipe-stamp swipe-stamp-no", children: "Reject" }) : null] }, card.id));
617
+ })] }), _jsx("button", { type: "button", className: "swipe-action swipe-action-approve", disabled: busy, onClick: () => decide(top, "right"), "aria-label": "Approve", children: "\u2713" })] }), _jsx("div", { className: "swipe-status", children: input.customizing > 0 || input.refilling ? "Customizing more…" : `${input.stack.length} ready` }), actionError ? _jsx("div", { className: "swipe-action-error", children: actionError }) : null] }));
618
+ }
513
619
  export function HomepageShell(input) {
514
620
  const showEmpty = !input.loading && !input.error && input.templates.length > 0 && input.visibleTemplates.length === 0;
515
621
  const [previewTemplate, setPreviewTemplate] = useState(null);
@@ -518,9 +624,10 @@ export function HomepageShell(input) {
518
624
  // curated static picks fetched from /discover/popular. View state lives in the
519
625
  // store so the client can lazily load the Popular set on first open.
520
626
  const isPopular = input.view === "popular";
627
+ const isSwipe = input.view === "swipe";
521
628
  const setView = (value) => input.onViewChange?.(value);
522
629
  const searchPlaceholder = isPopular
523
- ? "Search popular formats"
630
+ ? "Search categories"
524
631
  : input.loading || input.error
525
632
  ? "Search Templates"
526
633
  : `Search ${input.visibleTemplates.length} Template${input.visibleTemplates.length === 1 ? "" : "s"}`;
@@ -531,7 +638,7 @@ export function HomepageShell(input) {
531
638
  // disabled textarea and a "Log in to chat" CTA in the same slot.
532
639
  const hasChatRail = true;
533
640
  const isChatEnabled = input.account.isLoggedIn;
534
- return (_jsxs("section", { className: `frame discover-frame${hasChatRail ? " has-chat-rail" : ""}`, children: [_jsxs("header", { className: "topbar", children: [_jsx(BrandLockup, { account: input.account }), _jsx("div", { className: "topbar-actions", children: _jsx(PrimaryNav, { account: input.account }) })] }), _jsx("div", { className: "frame-body", children: _jsxs("section", { className: "discover-shell", children: [_jsxs("div", { className: "discover-filter-bar", role: "toolbar", "aria-label": "Composition filters", children: [_jsxs("div", { className: "view-toggle discover-view-toggle", role: "tablist", "aria-label": "Discover view", children: [_jsx("button", { type: "button", className: `view-toggle-tab${!isPopular ? " is-active" : ""}`, role: "tab", "aria-selected": !isPopular, onClick: () => setView("feed"), children: "Feed" }), _jsx("button", { type: "button", className: `view-toggle-tab${isPopular ? " is-active" : ""}`, role: "tab", "aria-selected": isPopular, onClick: () => setView("popular"), children: "Popular" })] }), _jsx(DiscoverMuteToggle, {}), _jsxs("div", { className: "discover-filter-search search-field", children: [_jsx("span", { className: "search-icon", "aria-hidden": "true", children: _jsx(SearchIcon, {}) }), _jsx("input", { id: "search", type: "search", placeholder: searchPlaceholder, autoComplete: "off", value: input.searchQuery, onChange: (event) => input.onSearchQueryChange?.(event.target.value) })] }), !isPopular ? (_jsxs("label", { className: "discover-filter-toggle", htmlFor: "bookmarks-only", "data-active": input.bookmarksOnly ? "true" : "false", children: [_jsx("span", { className: "discover-filter-toggle-label", children: "Saved" }), _jsxs("span", { className: "switch-shell", "data-active": input.bookmarksOnly ? "true" : "false", children: [_jsx("input", { id: "bookmarks-only", className: "switch-input", type: "checkbox", checked: input.bookmarksOnly, onChange: (event) => input.onBookmarksOnlyChange?.(event.target.checked) }), _jsx("span", { className: "switch-knob" })] })] })) : null, input.account.isLoggedIn ? (_jsx("button", { type: "button", className: "discover-add-template-button", onClick: () => setIsAddTemplateOpen(true), children: "Add Template" })) : (_jsx("a", { className: "discover-add-template-button", href: "/login", children: "Add Template" }))] }), _jsx("section", { className: "discover-results", children: _jsx("div", { className: "discover-results-scroll", children: _jsx("div", { className: "discover-list-scroll", children: isPopular ? (_jsx(PopularTemplates, { templates: input.popularTemplates, loading: input.popularLoading, error: input.popularError, searchQuery: input.searchQuery, bookmarks: input.bookmarks, onToggleBookmark: input.onToggleBookmark, onOpenPreview: setPreviewTemplate, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.account.userId })) : (_jsxs(_Fragment, { children: [_jsx("ul", { className: "discover-list", id: "template-list", children: input.loading ? (_jsx("li", { className: "empty-state discover-loading-card", children: "Loading compositions..." })) : input.error ? (_jsx("li", { className: "empty-state discover-loading-card", children: "Unable to load compositions." })) : input.templates.length === 0 ? (_jsx("li", { className: "empty-state discover-loading-card", children: "No compositions available." })) : (input.visibleTemplates.map((template) => (_jsx(TemplateCard, { template: template, isBookmarked: input.bookmarks.has(template.templateId), onToggleBookmark: input.onToggleBookmark, onOpenPreview: setPreviewTemplate, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.account.userId }, template.templateId)))) }), _jsx("div", { className: "empty-state empty", id: "empty-state", "data-visible": showEmpty ? "true" : "false", children: "No compositions match the current search and filter combination." })] })) }) }) })] }) }), hasChatRail ? (_jsx("aside", { className: "editor-right-rail", "aria-label": "Discover copilot", children: isChatEnabled ? (_jsx("div", { className: "editor-chat-panel", "data-template-editor-chat-root": true })) : (_jsxs(_Fragment, { children: [_jsx("a", { className: "vf-editor-chat-mobile-fab", href: "/login", "aria-label": "Log in to chat with Vidfarm", children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M20 11.5a7.5 7.5 0 0 1-9.8 7.1L4 20l1.5-5.3A7.5 7.5 0 1 1 20 11.5Z" }) }) }), _jsxs("div", { className: "editor-chat-panel editor-chat-panel--logged-out", style: {
641
+ return (_jsxs("section", { className: `frame discover-frame${hasChatRail ? " has-chat-rail" : ""}`, children: [_jsxs("header", { className: "topbar", children: [_jsx(BrandLockup, { account: input.account }), _jsx("div", { className: "topbar-actions", children: _jsx(PrimaryNav, { account: input.account }) })] }), _jsx("div", { className: "frame-body", children: _jsxs("section", { className: "discover-shell", children: [_jsxs("div", { className: "discover-filter-bar", role: "toolbar", "aria-label": "Composition filters", children: [_jsxs("div", { className: "view-toggle discover-view-toggle", role: "tablist", "aria-label": "Discover view", children: [_jsx("button", { type: "button", className: `view-toggle-tab${!isPopular && !isSwipe ? " is-active" : ""}`, role: "tab", "aria-selected": !isPopular && !isSwipe, onClick: () => setView("feed"), children: "Feed" }), _jsx("button", { type: "button", className: `view-toggle-tab${isSwipe ? " is-active" : ""}`, role: "tab", "aria-selected": isSwipe, onClick: () => setView("swipe"), children: "Swipe" }), _jsx("button", { type: "button", className: `view-toggle-tab${isPopular ? " is-active" : ""}`, role: "tab", "aria-selected": isPopular, onClick: () => setView("popular"), children: "Category" })] }), _jsx(DiscoverMuteToggle, {}), _jsxs("div", { className: "discover-filter-search search-field", children: [_jsx("span", { className: "search-icon", "aria-hidden": "true", children: _jsx(SearchIcon, {}) }), _jsx("input", { id: "search", type: "search", placeholder: searchPlaceholder, autoComplete: "off", value: input.searchQuery, onChange: (event) => input.onSearchQueryChange?.(event.target.value) })] }), !isPopular && !isSwipe ? (_jsxs("label", { className: "discover-filter-toggle", htmlFor: "bookmarks-only", "data-active": input.bookmarksOnly ? "true" : "false", children: [_jsx("span", { className: "discover-filter-toggle-label", children: "Saved" }), _jsxs("span", { className: "switch-shell", "data-active": input.bookmarksOnly ? "true" : "false", children: [_jsx("input", { id: "bookmarks-only", className: "switch-input", type: "checkbox", checked: input.bookmarksOnly, onChange: (event) => input.onBookmarksOnlyChange?.(event.target.checked) }), _jsx("span", { className: "switch-knob" })] })] })) : null, input.account.isLoggedIn ? (_jsx("button", { type: "button", className: "discover-add-template-button", onClick: () => setIsAddTemplateOpen(true), children: "Add Template" })) : (_jsx("a", { className: "discover-add-template-button", href: "/login", children: "Add Template" }))] }), _jsx("section", { className: "discover-results", children: _jsx("div", { className: "discover-results-scroll", children: _jsx("div", { className: "discover-list-scroll", children: isSwipe ? (_jsx(SwipeDeck, { stack: input.swipeStack, loading: input.swipeLoading, loaded: input.swipeLoaded, error: input.swipeError, customizing: input.swipeCustomizing, targetDepth: input.swipeTargetDepth, productSet: input.swipeProductSet, refilling: input.swipeRefilling, isLoggedIn: input.account.isLoggedIn, onApprove: input.onSwipeApprove, onReject: input.onSwipeReject })) : isPopular ? (_jsx(PopularTemplates, { templates: input.popularTemplates, loading: input.popularLoading, error: input.popularError, searchQuery: input.searchQuery, bookmarks: input.bookmarks, onToggleBookmark: input.onToggleBookmark, onOpenPreview: setPreviewTemplate, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.account.userId })) : (_jsxs(_Fragment, { children: [_jsx("ul", { className: "discover-list", id: "template-list", children: input.loading ? (_jsx("li", { className: "empty-state discover-loading-card", children: "Loading compositions..." })) : input.error ? (_jsx("li", { className: "empty-state discover-loading-card", children: "Unable to load compositions." })) : input.templates.length === 0 ? (_jsx("li", { className: "empty-state discover-loading-card", children: "No compositions available." })) : (input.visibleTemplates.map((template) => (_jsx(TemplateCard, { template: template, isBookmarked: input.bookmarks.has(template.templateId), onToggleBookmark: input.onToggleBookmark, onOpenPreview: setPreviewTemplate, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.account.userId }, template.templateId)))) }), _jsx("div", { className: "empty-state empty", id: "empty-state", "data-visible": showEmpty ? "true" : "false", children: "No compositions match the current search and filter combination." })] })) }) }) })] }) }), hasChatRail ? (_jsx("aside", { className: "editor-right-rail", "aria-label": "Discover copilot", children: isChatEnabled ? (_jsx("div", { className: "editor-chat-panel", "data-template-editor-chat-root": true })) : (_jsxs(_Fragment, { children: [_jsx("a", { className: "vf-editor-chat-mobile-fab", href: "/login", "aria-label": "Log in to chat with Vidfarm", children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M20 11.5a7.5 7.5 0 0 1-9.8 7.1L4 20l1.5-5.3A7.5 7.5 0 1 1 20 11.5Z" }) }) }), _jsxs("div", { className: "editor-chat-panel editor-chat-panel--logged-out", style: {
535
642
  display: "flex",
536
643
  flexDirection: "column",
537
644
  height: "100%",
@@ -25,7 +25,7 @@ export function renderHomepage(input) {
25
25
  }
26
26
  : undefined,
27
27
  body: `
28
- <div id="homepage-root">${renderToString(_jsx(HomepageShell, { account: input.account, templates: input.templates, visibleTemplates: input.templates, searchQuery: "", bookmarksOnly: false, bookmarks: new Set(), loading: input.templates.length === 0, error: null, view: "feed", popularTemplates: [], popularLoading: false, popularError: null, onSearchQueryChange: undefined, onBookmarksOnlyChange: undefined, onViewChange: undefined, onToggleBookmark: undefined, onAddTemplate: undefined, onDeleteTemplate: undefined, skillEndpointPrefix: boot.skillEndpointPrefix }))}</div>
28
+ <div id="homepage-root">${renderToString(_jsx(HomepageShell, { account: input.account, templates: input.templates, visibleTemplates: input.templates, searchQuery: "", bookmarksOnly: false, bookmarks: new Set(), loading: input.templates.length === 0, error: null, view: "feed", popularTemplates: [], popularLoading: false, popularError: null, swipeStack: [], swipeLoading: false, swipeLoaded: false, swipeError: null, swipeCustomizing: 0, swipeTargetDepth: 5, swipeProductSet: true, swipeRefilling: false, onSearchQueryChange: undefined, onBookmarksOnlyChange: undefined, onViewChange: undefined, onToggleBookmark: undefined, onAddTemplate: undefined, onDeleteTemplate: undefined, skillEndpointPrefix: boot.skillEndpointPrefix }))}</div>
29
29
  <script type="application/json" id="homepage-boot">${escapeJsonForHtml(boot)}</script>
30
30
  <script type="module" src="${HOMEPAGE_CLIENT_ASSET}"></script>
31
31
  `,
@@ -527,6 +527,189 @@ export function renderHomepage(input) {
527
527
  padding-right: 6px;
528
528
  }
529
529
 
530
+ /* ---- Swipe deck ---- */
531
+ .swipe-deck-wrap {
532
+ display: flex;
533
+ flex-direction: column;
534
+ align-items: center;
535
+ justify-content: center;
536
+ gap: 18px;
537
+ height: 100%;
538
+ min-height: 0;
539
+ padding: 8px 0 4px;
540
+ }
541
+ .swipe-deck {
542
+ position: relative;
543
+ width: min(320px, 62vw);
544
+ aspect-ratio: 9 / 16;
545
+ max-height: 66vh;
546
+ flex: 0 0 auto;
547
+ }
548
+ .swipe-card {
549
+ position: absolute;
550
+ inset: 0;
551
+ border-radius: 22px;
552
+ overflow: hidden;
553
+ background: #0d0b10;
554
+ box-shadow: 0 18px 44px rgba(31, 20, 8, 0.24), 0 2px 8px rgba(31, 20, 8, 0.12);
555
+ touch-action: none;
556
+ will-change: transform;
557
+ user-select: none;
558
+ }
559
+ .swipe-card:first-child { cursor: grab; }
560
+ .swipe-card:first-child:active { cursor: grabbing; }
561
+ .swipe-sound {
562
+ position: absolute;
563
+ top: 10px;
564
+ right: 10px;
565
+ z-index: 200;
566
+ width: 40px;
567
+ height: 40px;
568
+ border-radius: 50%;
569
+ border: 0;
570
+ background: rgba(0, 0, 0, 0.45);
571
+ color: #fff;
572
+ font-size: 17px;
573
+ line-height: 1;
574
+ cursor: pointer;
575
+ display: flex;
576
+ align-items: center;
577
+ justify-content: center;
578
+ backdrop-filter: blur(4px);
579
+ transition: background 0.15s ease, transform 0.15s ease;
580
+ }
581
+ .swipe-sound:hover { background: rgba(0, 0, 0, 0.68); transform: scale(1.06); }
582
+ .swipe-card-preview,
583
+ .swipe-card-fallback {
584
+ position: absolute;
585
+ inset: 0;
586
+ width: 100%;
587
+ height: 100%;
588
+ border: 0;
589
+ display: block;
590
+ }
591
+ .swipe-card-fallback {
592
+ display: flex;
593
+ align-items: center;
594
+ justify-content: center;
595
+ text-align: center;
596
+ padding: 22px;
597
+ color: rgba(255, 255, 255, 0.72);
598
+ font-size: 15px;
599
+ font-weight: 600;
600
+ background: linear-gradient(160deg, #2a2431, #16121c);
601
+ }
602
+ .swipe-card-overlay {
603
+ position: absolute;
604
+ left: 0;
605
+ right: 0;
606
+ bottom: 0;
607
+ padding: 40px 16px 16px;
608
+ background: linear-gradient(0deg, rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0));
609
+ color: #fff;
610
+ pointer-events: none;
611
+ }
612
+ .swipe-card-title { font-size: 16px; font-weight: 800; line-height: 1.25; }
613
+ .swipe-card-caption {
614
+ margin-top: 4px;
615
+ font-size: 12.5px;
616
+ line-height: 1.35;
617
+ color: rgba(255, 255, 255, 0.86);
618
+ display: -webkit-box;
619
+ -webkit-line-clamp: 2;
620
+ -webkit-box-orient: vertical;
621
+ overflow: hidden;
622
+ }
623
+ .swipe-stamp {
624
+ position: absolute;
625
+ top: 20px;
626
+ padding: 6px 14px;
627
+ border-radius: 10px;
628
+ font-weight: 900;
629
+ font-size: 20px;
630
+ letter-spacing: 0.06em;
631
+ text-transform: uppercase;
632
+ border: 3px solid currentColor;
633
+ pointer-events: none;
634
+ }
635
+ .swipe-stamp-yes { left: 18px; color: #22c55e; transform: rotate(-14deg); }
636
+ .swipe-stamp-no { right: 18px; color: #ef4444; transform: rotate(14deg); }
637
+ /* Buttons flank the deck, filling the empty side space. */
638
+ .swipe-deck-row {
639
+ display: flex;
640
+ align-items: center;
641
+ justify-content: center;
642
+ gap: clamp(10px, 3vw, 56px);
643
+ width: 100%;
644
+ flex: 1 1 auto;
645
+ min-height: 0;
646
+ }
647
+ .swipe-action {
648
+ width: 60px;
649
+ height: 60px;
650
+ border-radius: 50%;
651
+ border: none;
652
+ font-size: 26px;
653
+ font-weight: 800;
654
+ cursor: pointer;
655
+ display: flex;
656
+ align-items: center;
657
+ justify-content: center;
658
+ flex: 0 0 auto;
659
+ box-shadow: 0 8px 20px rgba(31, 20, 8, 0.18);
660
+ transition: transform 0.12s ease, filter 0.12s ease;
661
+ }
662
+ .swipe-action:hover:not(:disabled) { transform: translateY(-3px); }
663
+ .swipe-action:disabled { opacity: 0.5; cursor: default; }
664
+ .swipe-action-reject { background: #fff; color: #ef4444; border: 1.5px solid rgba(239, 68, 68, 0.35); }
665
+ .swipe-action-approve { background: #22c55e; color: #fff; }
666
+ .swipe-status {
667
+ margin-top: 14px;
668
+ text-align: center;
669
+ font-size: 13px;
670
+ font-weight: 700;
671
+ color: #7a6a52;
672
+ flex: 0 0 auto;
673
+ }
674
+ .swipe-action-error {
675
+ color: #b91c1c;
676
+ font-size: 13px;
677
+ font-weight: 600;
678
+ text-align: center;
679
+ }
680
+ .swipe-empty {
681
+ display: flex;
682
+ flex-direction: column;
683
+ align-items: center;
684
+ justify-content: center;
685
+ text-align: center;
686
+ gap: 8px;
687
+ height: 100%;
688
+ padding: 24px;
689
+ color: #6f6047;
690
+ }
691
+ .swipe-empty-title { font-size: 18px; font-weight: 800; color: #3a2f1e; }
692
+ .swipe-empty-sub { font-size: 14px; max-width: 340px; line-height: 1.45; }
693
+ .swipe-empty-cta {
694
+ margin-top: 10px;
695
+ padding: 10px 20px;
696
+ border-radius: 999px;
697
+ background: #f5a524;
698
+ color: #2a1c00;
699
+ font-weight: 800;
700
+ text-decoration: none;
701
+ }
702
+ .swipe-spinner {
703
+ width: 34px;
704
+ height: 34px;
705
+ border-radius: 50%;
706
+ border: 3px solid rgba(245, 165, 36, 0.28);
707
+ border-top-color: #f5a524;
708
+ animation: swipe-spin 0.8s linear infinite;
709
+ margin-bottom: 4px;
710
+ }
711
+ @keyframes swipe-spin { to { transform: rotate(360deg); } }
712
+
530
713
  .discover-count {
531
714
  position: sticky;
532
715
  top: 0;