PageSourceSearch

https://influeri.com/home/home-spotlight.js

js influeri.com collected 2026-10-08 02:43:21 UTC 31,039 bytes, 536 lines download raw bytes

1/* ============================================================
2   Influeri — Layout C · "Spotlight" home orchestrator (classic IIFE / var)
3   Composes the shared components into the spotlight landing page:
4     · HERO: the hv3 Spotlight Marquee (cursor wash + dual marquees, driven by
5       hero-variants.js which guards on .hv3) with a heart-mark CENTREPIECE in
6       the `spotlight` interaction — the cursor-following light is the signature
7       of this layout (distinct from Layout B's `pulse`).
8     · bg-fx: the interaction-activated ambient field, armed on load.
9     · SECTIONS (canonical set): logo-marquee, the heart-mark band, the process
10       guided WALKTHROUGH ([data-proc-walkthrough]), the flow-blocks live demo
11       ([data-flow-blocks] + [data-fb-match]/[data-fb-review]/[data-fb-launch]),
12       the features grid, the case-cards CAROUSEL ([data-cc-carousel]), and the
13       testimonials carousel ([data-quotes]) — all of those component scripts
14       self-mount by their data-* hooks; this file only owns the hero heart-mark
15       band + page chrome.
16     · NAV: Pages dropdown + mobile sheet · scroll-reveal · footer year.
17   Reduced-motion: heart-mark + bg-fx receive reduce=true (no cursor follow,
18   no ambient — static crossfade); the hv3 wash/marquees freeze via CSS; the
19   self-mounting components each own their own reduce path; reveal shows
20   everything immediately.
21   Load order (page): gsap → heart-config.js → heart-mark.js → bg-fx.js →
22     studio-deck.js → logo-marquee.js → process.js → flow-blocks.js →
23     case-cards.js → testimonials.js → hero-variants.js →
24     home-spotlight.js (this, last).
25   ============================================================ */
26(function () {
27  "use strict";
28
29  var gsap = window.gsap;
30  var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
31  var doc = document;
32
33  doc.documentElement.classList.add("reveal-ready");
34
35  /* ---- footer year ---- */
36  var yearEl = doc.querySelector("[data-year]");
37  if (yearEl) {
38    var y = new Date().getFullYear();
39    if (y >= 2024) yearEl.textContent = "Copyright © " + y + " Influeri AB";
40  }
41
42  /* ======================= HERO heart-mark (spotlight) ======================= */
43  /* The decorative avatar-heart, mounted in the `spotlight` interaction so a
44     soft pink→gold light follows the cursor and tiles under it brighten/lift —
45     the signature visual of Layout C. Composes heart-mark.js (which composes
46     the heart engine); never forks it. */
47  var markEl = doc.querySelector("[data-mark]");
48  if (gsap && markEl && typeof createHeartMark === "function") {
49    var stage = markEl.querySelector("[data-stage]");
50    var tilesBox = markEl.querySelector("[data-tiles]");
51    var cfg = {};
52    if (markEl.dataset.config) {
53      try { cfg = JSON.parse(markEl.dataset.config); } catch (e) { cfg = {}; }
54    }
55    /* Spotlight-ONLY creator set: authentic Instagram/TikTok-style lifestyle
56       portraits (assets/creators-spotlight/s001..s216.jpg) — full-frame, diverse,
57       real outfits/settings — instead of the shared face-only c01..c60 default.
58       There are MORE images than heart tiles (~203) so every tile gets a distinct
59       photo — zero repetition (paired with featCount:0 in the mark's data-config,
60       which removes the feature tiles that would otherwise reuse an image).
61       Scoped to this page by passing `creators` to createHeartMark; the shared
62       heart-mark.js / heart-config.js and the other home pages are untouched.
63
64       Real creator photos now lead this set (creatorMosaicSet in
65       components/data-creators.js hands back N DISTINCT images — every fetched
66       Instagram avatar first, interim portraits filling the tail), so the mark
67       gains real faces on each fetch while the zero-repetition rule above
68       still holds. Falls back to the plain interim run if the resolver is
69       absent. */
70    var spotCreators = window.creatorMosaicSet
71      ? window.creatorMosaicSet(216)
72      : Array.from({ length: 216 }, function (_, i) {
73          return { img: "assets/creators-spotlight/s" + String(i + 1).padStart(3, "0") + ".jpg" };
74        });
75    var mark = createHeartMark({
76      stage: stage,
77      tilesBox: tilesBox,
78      gsap: gsap,
79      reduce: reduce,
80      interaction: markEl.dataset.mark || "spotlight",
81      config: cfg,
82      creators: spotCreators
83    });
84    mark.enter();
85    markEl._mark = mark;
86  }
87
88  /* ======================= bg-fx — NOT MOUNTED (directed 2026-09-16) =======================
89     The background stays pure white. bg-fx draws its blobs in "11,11,11" and
90     "120,120,120" — near-black and mid-grey, not the pink→gold its own header
91     claims — so once they were actually on screen they read as dark shapes
92     drifting over the white page, which is against white-mode discipline.
93
94     Why they were not on screen before: bg-fx sized its canvas to
95     document.scrollHeight (~20 500px here) while bg-fx.css pins the element
96     position:fixed to one viewport, so the blobs were seeded down a field ~22x
97     taller than the band anyone could see and almost none of them ever showed.
98     That sizing bug is fixed in components/bg-fx.js — which is exactly what
99     made them visible, and why this mount is now off rather than left to a bug
100     to suppress it.
101
102     index.html armed bg-fx TWICE, here and in flow/flow-director.js — two
103     full-viewport canvases, each with its own rAF loop. Both are off.
104
105     To bring it back: restore the two lines below AND give createBgFx a light
106     tint via its `opacity` / a colour pass in the component, or it will be grey
107     shapes on white again.
108         var bg = createBgFx({ root: doc.body, gsap: gsap, reduce: reduce });
109         bg.arm();
110     ========================================================================= */
111
112  /* ======================= NAV: Pages dropdown ======================= */
113  var drop = doc.querySelector("[data-drop]");
114  /* Bound once: some pages load two page scripts (about.html pulls about.js
115     *and* features.js), which would otherwise stack duplicate nav listeners. */
116  if (drop && !drop.hasAttribute("data-drop-bound")) {
117    var dToggle = drop.querySelector("[data-drop-toggle]");
118    drop.setAttribute("data-drop-bound", "");
119    var dropTimer = null;
120    var dropOpen = function () { return drop.classList.contains("is-open"); };
121    var setDrop = function (state) {
122      if (dropTimer) { clearTimeout(dropTimer); dropTimer = null; }
123      drop.classList.toggle("is-open", state);
124      if (dToggle) dToggle.setAttribute("aria-expanded", state ? "true" : "false");
125    };
126    /* Deferred close: a cursor that clips the panel's corner, or crosses the
127       trigger->panel seam, must not snap the menu shut mid-reach. CSS :hover /
128       :focus-within own the visible state; this keeps .is-open and
129       aria-expanded in step with it. */
130    var closeDropSoon = function () {
131      if (dropTimer) clearTimeout(dropTimer);
132      dropTimer = setTimeout(function () { dropTimer = null; setDrop(false); }, 260);
133    };
134    var popLinks = function () { return [].slice.call(drop.querySelectorAll(".nav__pop a")); };
135    /* Escape returns focus to the trigger, which is itself inside .nav__drop --
136       without this latch that programmatic focus fires focusin and reopens the
137       panel the user just dismissed. Cleared as soon as focus or the pointer
138       genuinely re-engages. */
139    var escHold = false;
140    drop.addEventListener("mouseenter", function () { escHold = false; setDrop(true); });
141    drop.addEventListener("mouseleave", closeDropSoon);
142    drop.addEventListener("focusin", function () { if (!escHold) setDrop(true); });
143    drop.addEventListener("focusout", function (e) { if (!drop.contains(e.relatedTarget)) { escHold = false; setDrop(false); } });
144    if (dToggle) dToggle.addEventListener("click", function (e) { e.preventDefault(); escHold = false; setDrop(!dropOpen()); });
145    /* The markup declares role="menu", so honour it: arrows, Home/End, Escape. */
146    drop.addEventListener("keydown", function (e) {
147      var links = popLinks(), i = links.indexOf(doc.activeElement), next = -1;
148      if (e.key === "Escape") {
149        if (!dropOpen()) return;
150        e.stopPropagation();
151        escHold = true;
152        setDrop(false);
153        if (dToggle && drop.contains(doc.activeElement)) dToggle.focus();
154        return;
155      }
156      if (e.key === "ArrowDown") next = i < 0 ? 0 : Math.min(i + 1, links.length - 1);
157      else if (e.key === "ArrowUp") {
158        e.preventDefault(); e.stopPropagation();
159        if (i <= 0) { if (dToggle) dToggle.focus(); return; }
160        next = i - 1;
161      }
162      else if (e.key === "Home" && i >= 0) next = 0;
163      else if (e.key === "End" && i >= 0) next = links.length - 1;
164      else return;
165      e.preventDefault(); e.stopPropagation();
166      escHold = false;
167      setDrop(true);
168      if (links[next]) links[next].focus();
169    });
170    doc.addEventListener("click", function (e) { if (!drop.contains(e.target)) setDrop(false); });
171    /* Esc also closes a panel opened by hover, where focus never entered it. */
172    doc.addEventListener("keydown", function (e) {
173      if (e.key === "Escape" && dropOpen() && !drop.contains(doc.activeElement)) setDrop(false);
174    });
175  }
176
177  /* ======================= NAV: mobile sheet ======================= */
178  var burger = doc.querySelector("[data-burger]");
179  var sheet = doc.querySelector("[data-sheet]");
180  if (burger && sheet) {
181    var sheetClose = sheet.querySelector("[data-sheet-close]");
182    var sheetScrollY = 0;
183    var setSheet = function (state) {
184      sheet.classList.toggle("is-open", state);
185      sheet.setAttribute("aria-hidden", state ? "false" : "true");
186      burger.setAttribute("aria-expanded", state ? "true" : "false");
187      /* iOS Safari still rubber-bands / scrolls the page behind a
188         body{overflow:hidden} sheet -- pin body to its scroll position
189         with position:fixed instead, which iOS actually honors. */
190      if (state) {
191        sheetScrollY = window.scrollY || doc.documentElement.scrollTop;
192        doc.body.style.position = "fixed";
193        doc.body.style.top = -sheetScrollY + "px";
194        doc.body.style.left = "0";
195        doc.body.style.right = "0";
196        doc.body.style.overflow = "hidden";
197      } else {
198        doc.body.style.position = "";
199        doc.body.style.top = "";
200        doc.body.style.left = "";
201        doc.body.style.right = "";
202        doc.body.style.overflow = "";
203        window.scrollTo(0, sheetScrollY);
204      }
205      if (state) { var f = sheet.querySelector("a, button"); if (f) f.focus({ preventScroll: true }); }
206    };
207    burger.addEventListener("click", function () { setSheet(true); });
208    if (sheetClose) sheetClose.addEventListener("click", function () { setSheet(false); burger.focus({ preventScroll: true }); });
209    sheet.querySelectorAll("a").forEach(function (a) { a.addEventListener("click", function () { setSheet(false); }); });
210    window.__influeriCloseSheet = function () {
211      if (sheet.classList.contains("is-open")) { setSheet(false); burger.focus({ preventScroll: true }); return true; }
212      return false;
213    };
214  }
215
216  /* ======================= Global Esc (close the mobile sheet) ======================= */
217  doc.addEventListener("keydown", function (e) {
218    if (e.key !== "Escape") return;
219    if (window.__influeriCloseSheet) window.__influeriCloseSheet();
220  });
221
222  /* ======================= Explore · Block 1 override (spotlight only) =======================
223     Feedback round (PDF #27): Block 1 should show creator PROFILE CARDS appearing
224     from the prompt — NOT the Estimated Results dashboard. flow-blocks.js (shared)
225     still builds Block 2 (review deck) + Block 3 (launch); the page hands Block 1's
226     host the spotlight-only hook [data-spot-match] so flow-blocks skips it and we own
227     it here. We COMPOSE existing CSS only: the .fb-match / .prompt-card chrome and the
228     .ru__creator card (both already on the page) — no shared JS/CSS is forked.
229     Sequence: type brief -> "matching…" beat -> prompt slides up & out -> a single
230     column of creator cards rises in (staggered) -> hold -> loop. Hover pauses;
231     reduced-motion shows the static end-state (prompt hidden, column shown). */
232  (function () {
233    var host = doc.querySelector("[data-spot-match]");
234    if (!host || !gsap) return;
235
236    var BRIEF = "Find fashion creators in the US";
237    // Fashion-focused, diverse mix (styles / genders / backgrounds). Photos
238    // resolve through components/data-creators.js, so each of these five shows
239    // its real Instagram avatar as soon as that handle has been fetched and
240    // keeps an interim portrait until then.
241    var CREATORS = [
242      { h: "@jackiegustafssonn", f: "184k", e: "6.4%" },
243      { h: "@alicejoythomas",    f: "212k", e: "5.1%" },
244      { h: "@erickshots",        f: "98k",  e: "7.8%" },
245      { h: "@chandsgarms",       f: "76k",  e: "8.6%" },
246      { h: "@emmachamberlain",   f: "1.2M", e: "4.9%" }
247    ];
248    CREATORS.forEach(function (c, i) {
249      var u = c.h.replace(/^@/, "");
250      c.img = window.creatorPhoto
251        ? window.creatorPhoto(u, i)
252        : "assets/creators-spotlight/s" + String(((i * 7) % 216) + 1).padStart(3, "0") + ".jpg";
253    });
254
255    var cardsHTML = CREATORS.map(function (c) {
256      return '<article class="ru__creator spot-col__card">' +
257        '<span class="ru__avatar ru__avatar--photo"><img src="' + c.img + '" alt="' + c.h + '" loading="lazy"></span>' +
258        '<div class="ru__cmeta"><p class="ru__cname">' + c.h + "</p>" +
259        '<p class="ru__cstat">Followers: ' + c.f + "</p>" +
260        '<p class="ru__cstat">Engagement: ' + c.e + "</p></div></article>";
261    }).join("");
262
263    host.innerHTML =
264      '<div class="fb-stage fb-match">' +
265        '<div class="fb-match__panel">' +
266          '<div class="prompt-card fb-match__prompt" data-spot-promptcard>' +
267            '<textarea class="prompt-card__input" data-spot-input rows="3" readonly tabindex="-1" ' +
268              'placeholder="Find creators with a single prompt"></textarea>' +
269            '<button class="btn btn--gradient prompt-card__cta" type="button" tabindex="-1" aria-hidden="true">' +
270              'Generate shortlist' +
271              '<svg class="arrow" viewBox="0 0 15 15" width="15" height="15" aria-hidden="true"><path d="M14.7 8.07a1 1 0 0 0 0-1.41L8.34.29A1 1 0 0 0 6.93 1.7l5.66 5.66-5.66 5.66a1 1 0 0 0 1.41 1.41L14.7 8.07ZM0 7.36v1h14v-2H0v1Z" fill="currentColor"/></svg>' +
272            '</button>' +
273            '<span class="fb-match__matching" data-spot-matching aria-hidden="true"><i></i><i></i><i></i> Matching from 300M+ creators…</span>' +
274          '</div>' +
275        '</div>' +
276        '<div class="fb-match__scaler">' +
277          '<div class="spot-col" data-spot-col aria-live="polite">' + cardsHTML + '</div>' +
278        '</div>' +
279      '</div>';
280
281    var panel = host.querySelector(".fb-match__panel");
282    var input = host.querySelector("[data-spot-input]");
283    var caretEl = host.querySelector("[data-spot-caret]");
284    var matchingEl = host.querySelector("[data-spot-matching]");
285    var col = host.querySelector("[data-spot-col]");
286    var cards = Array.prototype.slice.call(col.querySelectorAll(".spot-col__card"));
287
288    var loopId = null, paused = false, killed = false, started = false, tl = null;
289
290    function setMatching(on) { if (matchingEl) matchingEl.classList.toggle("is-on", on); }
291    function setCaret(on) { if (caretEl) caretEl.style.opacity = on ? "" : "0"; }
292
293    function armColHidden() {
294      gsap.set(col, { opacity: 0 });
295      gsap.set(cards, { opacity: 0, y: 18 });
296    }
297
298    function typeBrief(done) {
299      input.value = "";
300      if (reduce) { input.value = BRIEF; if (done) done(); return; }
301      setCaret(true);
302      var i = 0;
303      (function step() {
304        if (killed) return;
305        i++; input.value = BRIEF.slice(0, i);
306        if (i < BRIEF.length) loopId = setTimeout(step, 26);
307        else { setCaret(false);
307 if (done) loopId = setTimeout(done, 420); }
308      })();
309    }
310
311    function closeAndLoop() {
312      if (killed) return;
313      if (paused) { loopId = setTimeout(closeAndLoop, 500); return; }
314      // fade the column out, slide the prompt panel back in, then loop
315      tl = gsap.timeline({ onComplete: function () { loopId = setTimeout(cycle, 700); } });
316      tl.to(col, { opacity: 0, duration: 0.4, ease: "power2.in" }, 0);
317      tl.to(panel, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out" }, 0.1);
318      tl.add(function () { armColHidden(); });
319    }
320
321    function cycle() {
322      if (killed) return;
323      setMatching(false);
324      gsap.set(panel, { opacity: 1, y: 0 });
325      armColHidden();
326      typeBrief(function () {
327        if (killed) return;
328        setMatching(true);
329        loopId = setTimeout(function () {
330          if (killed) return;
331          setMatching(false);
332          // prompt slides up & out; the creator column rises in its place
333          tl = gsap.timeline();
334          tl.to(panel, { opacity: 0, y: -24, duration: 0.4, ease: "power3.out" }, 0);
335          tl.to(col, { opacity: 1, duration: 0.4, ease: "power2.out" }, 0.25);
336          tl.to(cards, { opacity: 1, y: 0, duration: 0.5, stagger: 0.08, ease: "power3.out" }, 0.3);
337          loopId = setTimeout(closeAndLoop, 3600);
338        }, 760);
339      });
340    }
341
342    function start() {
343      if (started) return;
344      started = true;
345      // flow-blocks.css hides .fb-copy/.fb-visual until .fb-block.is-in — the shared
346      // armReveal no longer manages this block (its host was renamed), so reveal it here.
347      if (blockEl) blockEl.classList.add("is-in");
348      if (reduce) {                       // static end-state: hide prompt, show column
349        input.value = BRIEF;
350        setCaret(false);
351        setMatching(false);
352        gsap.set(panel, { opacity: 0 });
353        gsap.set(col, { opacity: 1 });
354        gsap.set(cards, { opacity: 1, y: 0 });
355        return;
356      }
357      armColHidden();
358      loopId = setTimeout(cycle, 700);    // let the block reveal read first
359    }
360
361    host.addEventListener("pointerenter", function () { if (!reduce) { paused = true; if (tl && tl.isActive && tl.isActive()) tl.pause(); } });
362    host.addEventListener("pointerleave", function () { if (!reduce) { paused = false; if (tl && tl.paused && tl.paused()) tl.resume(); } });
363
364    armColHidden();
365
366    // reveal-gated start: begin when the block scrolls into view (or is already in view)
367    var blockEl = host.closest(".fb-block") || host;
368    function inView(el) {
369      var r = el.getBoundingClientRect();
370      return r.top < (window.innerHeight || document.documentElement.clientHeight) && r.bottom > 0;
371    }
372    if (reduce || inView(blockEl) || !("IntersectionObserver" in window)) {
373      start();
374    } else {
375      var io2 = new IntersectionObserver(function (entries) {
376        entries.forEach(function (en) { if (en.isIntersecting) { start(); io2.unobserve(en.target); } });
377      }, { threshold: 0.18, rootMargin: "0px 0px -8% 0px" });
378      io2.observe(blockEl);
379    }
380  })();
381
382  /* ======================= Scroll reveal ======================= */
383  var revealEls = Array.prototype.slice.call(doc.querySelectorAll(".reveal"));
384  if (revealEls.length) {
385    if (reduce || !("IntersectionObserver" in window)) {
386      revealEls.forEach(function (el) { el.classList.add("is-in"); });
387    } else {
388      var io = new IntersectionObserver(function (entries) {
389        entries.forEach(function (en) {
390          if (en.isIntersecting) { en.target.classList.add("is-in"); io.unobserve(en.target); }
391        });
392      }, { threshold: 0.15, rootMargin: "0px 0px -8% 0px" });
393      revealEls.forEach(function (el) { io.observe(el); });
394      // failsafe: never leave anything hidden
395      setTimeout(function () { revealEls.forEach(function (el) { el.classList.add("is-in"); }); }, 2600);
396    }
397  }
398})();
399
400/* ======================= Enterprise Workflow (page-local) =======================
401   Reuses the process.js "connected-flow" CSS classes (.proc-flow, .proc-num,
402   .proc-flow__card) with different content — six org functions instead of the
403   five matching steps — rather than forking process.js's buildFlow(), which
404   only supports one hardcoded STEPS dataset. */
405(function () {
406  "use strict";
407  var el = document.querySelector("[data-workflow-flow]");
408  if (!el) return;
409  var WORKFLOW_ICONS = {
410    marketing: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg>',
411    legal: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
411<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg>',
412    procurement: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/></svg>',
413    finance: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="12" x="2" y="6" rx="2"/><circle cx="12" cy="12" r="2"/><path d="M6 12h.01M18 12h.01"/></svg>',
414    creators: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
415    management: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>'
416  };
417  var WORKFLOW = [
418    { icon: WORKFLOW_ICONS.marketing, title: "Marketing", body: "Builds the brief and owns the creative direction." },
419    { icon: WORKFLOW_ICONS.legal, title: "Legal", body: "Reviews contracts and disclosure compliance automatically." },
420    { icon: WORKFLOW_ICONS.procurement, title: "Procurement", body: "Approves vendor terms and creator rates in one workflow." },
421    { icon: WORKFLOW_ICONS.finance, title: "Finance", body: "Tracks budget, automates payments, and reconciles spend." },
422    { icon: WORKFLOW_ICONS.creators, title: "Creators", body: "Deliver content inside one governed, trackable workspace." },
423    { icon: WORKFLOW_ICONS.management, title: "Data", body: "Sees performance and ROI across every team in real time." }
424  ];
425  el.innerHTML = WORKFLOW.map(function (s, i) {
426    return '<div class="proc-flow__node" style="--i:' + i + '"><span class="proc-num" aria-hidden="true">' + s.icon + '</span>' +
427      '<div class="proc-flow__card"><p class="proc-title">' + s.title + '</p><p class="proc-body">' + s.body + '</p></div></div>';
428  }).join("");
429})();
430
431/* ROI Calculator: replaced by the shared components/savings-calc.js — this
432   used to run its own separate formula, since unified with pricing.html's
433   calculator (see LP-12). */
434
435/* ======================= SPOTLIGHT CREATOR DATA (page-scoped override) =======================
436   Feedback round: replace the placeholder creators in "Creators ready to match"
437   (data-cc-rows) and the Trending marquee (data-track a/b) with the real team
438   list — real names + handles + niches. Photos are INTERIM lifestyle portraits
439   (assets/creators-spotlight/*) until the real profile images are fetched; add a
440   handle→file entry to REAL below to swap any creator's face in (one line each).
441   Match % is intentionally omitted (removed per feedback). We overwrite the shared
442   creator-cards.js / trending-creators.js output page-scoped (those components and
443   the other home pages stay untouched). Runs after they fill the DOM (this file
444   loads last) and before the trending→studio wiring below, so click→popup binds
445   to these cards. */
446(function () {
447  "use strict";
448  var doc = document;
449  // Photo resolution now lives in components/data-creators.js (generated) so a
450  // newly fetched Instagram avatar shows up in every component at once instead
451  // of only here. Run tools/fetch-ig-avatars.mjs, then re-run
452  // tools/build-creator-manifest.mjs, and the faces below swap themselves.
453  // The inline fallback keeps this file working if data-creators.js is absent.
454  function photo(u, i) {
455    if (window.creatorPhoto) return window.creatorPhoto(u, i);
456    return "assets/creators-spotlight/s" + String(((i * 7) % 216) + 1).padStart(3, "0") + ".jpg";
457  }
458  // name = the label the team used (real name for public figures, the niche for
459  // the rest); u = handle without @; niche = shown as the card tagline.
460  var C = [
461    { u:"zaralarsson",         name:"Zara Larsson",        niche:"Music & Fashion",     f:"5.8M", e:"3.1%" },
462    { u:"anitta",              name:"Anitta",              niche:"Music",               f:"3.2M", e:"3.6%" },
463    { u:"pewdiepie",           name:"PewDiePie",           niche:"Gaming",              f:"21M",  e:"2.4%" },
464    { u:"emmachamberlain",     name:"Emma Chamberlain",    niche:"Lifestyle & Style",   f:"1.2M", e:"4.9%" },
465    { u:"mrbeast",             name:"MrBeast",             niche:"Entertainment",       f:"18M",  e:"5.7%" },
466    { u:"jackiegustafssonn",   name:"Streetwear Fashion",  niche:"Fashion",             f:"184k", e:"6.4%" },
467    { u:"alicejoythomas",      name:"Luxury Fashion",      niche:"Fashion",             f:"212k", e:"5.1%" },
468    { u:"erickshots",          name:"Sustainable Fashion", niche:"Fashion",             f:"98k",  e:"7.8%" },
469    { u:"lariearaujoo",        name:"Haircare",            niche:"Beauty",              f:"143k", e:"6.9%" },
470    { u:"juliannahoffman4",    name:"Skincare",            niche:"Beauty",              f:"126k", e:"7.2%" },
471    { u:"aaalmgren",           name:"Running",             niche:"Fitness",             f:"88k",  e:"8.1%" },
472    { u:"danifitnesss_",       name:"Fitness",             niche:"Fitness",             f:"167k", e:"7.5%" },
473    { u:"jennalovisa",         name:"Fitness",             niche:"Fitness",             f:"204k", e:"5.9%" },
474    { u:"karlarcosta",         name:"Yoga & Light Living", niche:"Wellness",            f:"79k",  e:"9.0%" },
475    { u:"huungrysoul",         name:"Restaurants & Food",  niche:"Food",                f:"112k", e:"6.2%" },
476    { u:"foodandcoffeegbg",    name:"Restaurants & Food",  niche:"Food",                f:"64k",  e:"8.8%" },
477    { u:"zeinaskitchen",       name:"Cooking",             niche:"Food",                f:"389k", e:"6.6%" },
478    { u:"sampev2",             name:"Fitness",             niche:"Fitness",             f:"151k", e:"7.0%" },
479    { u:"n.lowry",             name:"Productivity",        niche:"Lifestyle",           f:"93k",  e:"7.7%" },
480    { u:"hormozi",             name:"Entrepreneurship",    niche:"Business",            f:"2.4M", e:"4.2%" },
481    { u:"nannacebner",         name:"Family",              niche:"Family",              f:"71k",  e:"8.3%" },
482    { u:"zoetastetravel",      name:"Travel",              niche:"Travel",              f:"128k", e:"6.1%" },
483    { u:"thia.interior",       name:"Interior",            niche:"Interior",            f:"96k",  e:"7.4%" },
484    { u:"love_this_old_house", name:"Interior",            niche:"Interior",            f:"58k",  e:"9.3%" },
485    { u:"lknsardari",          name:"Tech",                niche:"Tech",                f:"134k", e:"5.8%" },
486    { u:"martimsilvai",        name:"AI",                  niche:"Tech",                f:"87k",  e:"8.0%" },
487    { u:"helenaruddstudio",    name:"DIY",                 niche:"Maker & Craft",       f:"69k",  e:"8.9%" },
488    { u:"chandsgarms",         name:"Vintage Fashion",     niche:"Fashion",             f:"76k",  e:"8.6%" },
489    { u:"sophiasinot",         name:"Beauty",              niche:"Beauty",              f:"158k", e:"6.7%" },
490    { u:"makeupstina",         name:"Beauty",              niche:"Beauty",              f:"119k", e:"7.1%" },
491    { u:"sanaamanda",          name:"Beauty",              niche:"Beauty",              f:"102k", e:"7.9%" }
492  ];
493  C.forEach(function (c, i) { c.i = i; c.img = photo(c.u, i); c.handle = "@" + c.u; });
494
495  /* ---- "Creators ready to match" · 8 rows, NO match % ---- */
496  var pick = ["zaralarsson", "emmachamberlain", "mrbeast", "pewdiepie", "anitta", "jackiegustafssonn", "juliannahoffman4", "danifitnesss_"];
497  var byU = {}; C.forEach(function (c) { byU[c.u] = c; });
498  var rowsHost = doc.querySelector("[data-cc-rows]");
499  if (rowsHost) {
500    rowsHost.innerHTML = pick.map(function (u, i) {
501      var c = byU[u]; if (!c) return "";
502      return '<article class="cc-row" data-cc-row>' +
503        '<span class="cc-row__rank">' + (i + 1) + '</span>' +
504        '<span class="creator__avatar cc-row__avatar"><img src="' + c.img + '" alt="' + c.handle + '" loading="lazy"></span>' +
505        '<span class="cc-row__id"><b class="cc-row__name">' + c.name + '</b>' +
506        '<span class="cc-row__handle">' + c.handle + ' · ' + c.niche + '</span></span>' +
507        '<span class="cc-row__stat"><b>' + c.f + '</b><i>followers</i></span>' +
508        '<span class="cc-row__stat cc-row__stat--hide"><b>' + c.e + '</b><i>eng.</i></span>' +
509      '</article>';
510    }).join("");
511  }
512
513  /* ---- Trending marquee · both rails, set + aria-hidden loop duplicate ---- */
514  function tCard(c, dup) {
515    return '<article class="creator"' + (dup ? ' aria-hidden="true"' : "") + ">" +
516      '<span class="creator__avatar"><img src="' + c.img + '" alt="' + c.handle + '" loading="lazy"></span>' +
517      '<div class="creator__meta">' +
518        '<p class="creator__name">' + c.handle + "</p>" +
519        '<p class="creator__stat">Followers: ' + c.f + "</p>" +
520        '<p class="creator__stat">Engagement: ' + c.e + "</p>" +
521      "</div></article>";
522  }
523  function loopHTML(list) {
524    return list.map(function (c) { return tCard(c, false); }).join("") +
525           list.map(function (c) { return tCard(c, true); }).join("");
526  }
527  var rowA = C.slice(0, 16), rowB = C.slice(16);
528  var tA = doc.querySelector("[data-track='a']"), tB = doc.querySelector("[data-track='b']");
529  if (tA) tA.innerHTML = loopHTML(rowA);
530  if (tB) tB.innerHTML = loopHTML(rowB);
531})();
532
533/* TRENDING → STUDIO POPUP removed (directed 2026-09-02): every creator card
534   is a pure animation now — no click/Enter/Space handoff to a profile
535   popup, on this page or any other. See page/home.js for the matching
536   removal of the duplicate wiring that lived there. */

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.