PageSourceSearch

https://www.demandcurve.com/frontier.js

js demandcurve.com collected 2026-10-02 03:39:14 UTC 58,374 bytes, 998 lines download raw bytes

1/* The Frontier — shared interactions: starfield, mobile sidebar, subscribe modal, filters */
2(function () {
3  /* ── Starfield (ported from StarField.tsx: 180 drifting stars) ── */
4  var canvas = document.querySelector('.fr-stars');
5  if (canvas && canvas.getContext) {
6    var ctx = canvas.getContext('2d');
7    var stars = [];
8    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
9    var DPR = Math.min(window.devicePixelRatio || 1, 2);
10    function resize() {
11      canvas.width = window.innerWidth * DPR;
12      canvas.height = window.innerHeight * DPR;
13      ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
14    }
15    function init() {
16      stars = [];
17      var w = window.innerWidth, h = window.innerHeight;
18      for (var i = 0; i < 180; i++) {
19        stars.push({
20          x: Math.random() * w, y: Math.random() * h,
21          size: 0.4 + Math.random() * 1.2,
22          opacity: 0.05 + Math.random() * 0.7,
23          target: 0.05 + Math.random() * 0.7,
24          vx: (Math.random() - 0.5) * 0.08,
25          vy: (Math.random() - 0.5) * 0.08
26        });
27      }
28    }
29    function frame() {
30      var w = window.innerWidth, h = window.innerHeight;
31      ctx.clearRect(0, 0, w, h);
32      for (var i = 0; i < stars.length; i++) {
33        var s = stars[i];
34        s.x += s.vx; s.y += s.vy;
35        if (s.x < 0) s.x = w; if (s.x > w) s.x = 0;
36        if (s.y < 0) s.y = h; if (s.y > h) s.y = 0;
37        s.opacity += (s.target - s.opacity) * 0.02;
38        if (Math.abs(s.target - s.opacity) < 0.02) s.target = 0.05 + Math.random() * 0.7;
39        ctx.fillStyle = 'rgba(210,220,255,' + s.opacity.toFixed(3) + ')';
40        ctx.fillRect(s.x, s.y, s.size, s.size);
41      }
42      if (!reduce) requestAnimationFrame(frame);   // reduce-motion: draw one static frame, no loop
43    }
44    resize(); init(); frame();
45    window.addEventListener('resize', function () { resize(); init(); if (reduce) frame(); });
46  }
47
48  /* ── Mobile sidebar ── */
49  var shell = document.querySelector('.fr-shell');
50  var menuBtn = document.querySelector('.fr-menu-btn');
51  var scrim = document.querySelector('.fr-scrim');
52  function closeNav() { if (shell) shell.classList.remove('nav-open'); }
53  if (menuBtn && shell) menuBtn.addEventListener('click', function () { shell.classList.toggle('nav-open'); });
54  if (scrim) scrim.addEventListener('click', closeNav);
55
56  /* ── Desktop sidebar collapse — GSAP-animated + persisted ── */
57  var panelToggle = document.querySelector('.fr-panel-toggle');
58  var sidebar = document.querySelector('.fr-sidebar');
59  var SIDEBAR_W = 256;
60  if (panelToggle && shell && sidebar) {
61    // restore state instantly on load (no animation)
62    try {
63      /* LOCAL FIX (RE-APPLY on spec sync, Justin 2026-07-17): Growth Guide
64         chapters carry .fr-article for reader styling but behave like MAIN
65         views - sidebar stays open (guide has its own chapter nav). */
66      var isArticle = !!document.querySelector('.fr-wrap.fr-article') && !document.querySelector('[data-format="guide"]');
67      // Funnel pages (/welcome) lock the sidebar closed regardless of
68      // the saved toggle preference (Joey 2026-07-29) - focused conversion screens.
69      var lockCollapsed = isArticle || document.body.hasAttribute('data-collapse-lock');
70      var stored = localStorage.getItem('fr-collapsed');
71      // Article / funnel pages: collapsed by default, regardless of the saved global
72      // preference. Other Frontier pages honor the saved '1'/'0' toggle state.
73      if (lockCollapsed) shell.classList.add('collapsed');
74      else if (stored === '1') shell.classList.add('collapsed');
75      else if (stored === '0') shell.classList.remove('collapsed');
76      // desktop only: collapse the rail to 0 width. On mobile the sidebar is an off-canvas
77      // drawer (hidden via transform), so zeroing its width would make it impossible to open.
78      if (shell.classList.contains('collapsed') && window.innerWidth > 900) {
79        sidebar.style.width = '0px'; sidebar.style.minWidth = '0px'; sidebar.style.flexBasis = '0px'; sidebar.style.overflow = 'hidden';
80      }
81    } catch (e) {}
82
83    panelToggle.addEventListener('click', function () {
84      if (window.innerWidth <= 900) { shell.classList.toggle('nav-open'); return; }
85      var collapsing = !shell.classList.contains('collapsed');
86      shell.classList.toggle('collapsed', collapsing);
87      try { localStorage.setItem('fr-collapsed', collapsing ? '1' : '0'); } catch (e) {}
88
89      if (window.gsap) {
90        gsap.killTweensOf(sidebar);
91        if (collapsing) {
92          sidebar.style.overflow = 'hidden';
93          gsap.fromTo(sidebar, { width: SIDEBAR_W, minWidth: SIDEBAR_W, flexBasis: SIDEBAR_W },
94            { width: 0, minWidth: 0, flexBasis: 0, duration: 0.45, ease: 'power3.inOut' });
95        } else {
96          gsap.fromTo(sidebar, { width: 0, minWidth: 0, flexBasis: 0 },
97            { width: SIDEBAR_W, minWidth: SIDEBAR_W, flexBasis: SIDEBAR_W, duration: 0.45, ease: 'power3.inOut',
98              onComplete: function () { sidebar.style.cssText = ''; } });
99        }
100      } else {
101        // fallback: instant
102        if (collapsing) { sidebar.style.width = '0px'; sidebar.style.minWidth = '0px'; sidebar.style.flexBasis = '0px'; sidebar.style.overflow = 'hidden'; }
103        else { sidebar.style.cssText = ''; }
104      }
105    });
106  }
107
108  /* ── Subscribe modal ── */
109  var modal = document.querySelector('.fr-modal');
110  function openModal() { if (modal) modal.classList.add('open'); }
111  function closeModal() { if (modal) modal.classList.remove('open'); }
112  document.querySelectorAll('[data-subscribe]').forEach(function (b) { b.addEventListener('click', function (e) { e.preventDefault(); openModal(); }); });
113  document.querySelectorAll('[data-modal-close]').forEach(function (b) { b.addEventListener('click', closeModal); });
114  if (modal) modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
115  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });
116
117  /* ── Sponsor block -> Win98 "Services.exe" window: inject the control buttons into the
118        title bar and wrap the body so it gets the sunken-panel bevel (same as .os-window). ── */
119  document.querySelectorAll('.fr-sponsor').forEach(function (box) {
120    if (box.dataset.win) return; box.dataset.win = '1';
121    var tag = box.querySelector('.fr-sponsor-tag');
122    if (tag && !tag.querySelector('.os-window-controls')) {
123      var ctrl = document.createElement('span');
124      ctrl.className = 'os-window-controls';
125      ctrl.innerHTML =
126        '<button type="button" class="win98-btn win-min" aria-label="Minimize sponsor"></button>' +
127        '<button type="button" class="win98-btn win-max" aria-label="Expand sponsor"></button>' +
128        '<button type="button" class="win98-btn win-close" aria-label="Minimize sponsor"></button>';
129      tag.appendChild(ctrl);
130      var collapse = function (e) { e.preventDefault(); e.stopPropagation(); box.classList.add('fr-sponsor--collapsed'); };
131      var expand   = function (e) { e.preventDefault(); e.stopPropagation(); box.classList.remove('fr-sponsor--collapsed'); };
132      ctrl.querySelector('.win-min').addEventListener('click', collapse);
133      ctrl.querySelector('.win-close').addEventListener('click', collapse);
134      ctrl.querySelector('.win-max').addEventListener('click', expand);
135    }
136    var body = document.createElement('div'); body.className = 'fr-sponsor-body';
137    var kids = [];
138    for (var i = 0; i < box.children.length; i++) { if (box.children[i] !== tag) kids.push(box.children[i]); }
139    kids.forEach(function (k) { body.appendChild(k); });
140    box.appendChild(body);
141  });
142
143  /* ── "Services.exe" sidebar window: same minimize/restore as the sponsor block.
144        Swap the ASCII _ □ × for crisp CSS glyphs and wire the controls. ── */
145  document.querySelectorAll('.os-window').forEach(function (win) {
146    if (win.dataset.win) return; win.dataset.win = '1';
147    var ctrl = win.querySelector('.os-window-controls');
148    if (!ctrl) return;
149    ctrl.innerHTML =
150      '<button type="button" class="win98-btn win-min" aria-label="Minimize"></button>' +
151      '<button type="button" class="win98-btn win-max" aria-label="Expand"></button>' +
152      '<button type="button" class="win98-btn win-close" aria-label="Minimize"></button>';
153    var collapse = function (e) { e.preventDefault(); e.stopPropagation(); win.classList.add('os-window--collapsed'); };
154    var expand   = function (e) { e.preventDefault(); e.stopPropagation(); win.classList.remove('os-window--collapsed'); };
155    ctrl.querySelector('.win-min').addEventListener('click', collapse);
156    ctrl.querySelector('.win-close').addEventListener('click', collapse);
157    ctrl.querySelector('.win-max').addEventListener('click', expand);
158  });
159
160  /* Curated blocks marked [data-hide-on-filter] (e.g. Featured Playbooks) collapse while a topic
161     filter or a search is active, so the view becomes just the matching results. */
162  function syncHideOnFilter() {
163    var grp = document.querySelector('[data-filter-group]');
164    var active = grp && grp.querySelector('.chip--active');
165    var filterOn = active && active.getAttribute('data-filter') !== 'all';
166    var s = document.querySelector('[data-search]');
167    var searchOn = s && s.value.trim() !== '';
168    var hide = !!(filterOn || searchOn);
169    document.querySelectorAll('[data-hide-on-filter]').forEach(function (el) { el.style.display = hide ? 'none' : ''; });
170  }
171
172  /* ── Generic chip/grid filtering (Library, Ads, Tactics) ── */
173  document.querySelectorAll('[data-filter-group]').forEach(function (group) {
174    var chips = group.querySelectorAll('.chip[data-filter]');
175    var targetSel = group.getAttribute('data-filter-target');
176    var items = document.querySelectorAll(targetSel + ' [data-tags]');
177    chips.forEach(function (chip) {
178      chip.addEventListener('click', function () {
179        chips.forEach(function (c) { c.classList.remove('chip--active'); });
180        chip.classList.add('chip--active');
181        var f = chip.getAttribute('data-filter');
182        items.forEach(function (it) {
183          var tags = it.getAttribute('data-tags') || '';
184          it.style.display = (f === 'all' || tags.indexOf('|' + f + '|') !== -1) ? '' : 'none';
185        });
186        syncHideOnFilter();
187      });
188    });
189  });
190
191  /* ── Reading content light-mode toggle (flips whole .fr-main; chrome stays dark) ── */
192  var contentMain = document.querySelector('.fr-main');
193  var readerBtns = document.querySelectorAll('[data-reader-toggle]');
194  if (contentMain && readerBtns.length) {
195    try { if (localStorage.getItem('fr-reader-light') === '1') contentMain.classList.add('reader-light'); } catch (e) {}
196    readerBtns.forEach(function (b) {
197      b.addEventListener('click', function () {
198        var on = contentMain.classList.toggle('reader-light');
199        try { localStorage.setItem('fr-reader-light', on ? '1' : '0'); } catch (e) {}
200      });
201    });
202  }
203
204  /* ── TOC scrollspy (article reader) ── */
205  var toc = document.querySelector('.fr-toc');
206  var scroller = document.querySelector('.fr-main');
207  if (toc && scroller && 'IntersectionObserver' in window) {
208    var links = {};
209    toc.querySelectorAll('a').forEach(function (a) { links[a.getAttribute('href').slice(1)] = a; });
210    var heads = document.querySelectorAll('.fr-prose [id]');
211    if (heads.length) {
212      var io = new IntersectionObserver(function (entries) {
213        entries.forEach(function (e) {
214          if (e.isIntersecting) {
215            Object.keys(links).forEach(function (k) { links[k].classList.remove('active'); });
216            if (links[e.target.id]) links[e.target.id].classList.add('active');
217          }
218        });
219      }, { root: scroller, rootMargin: '0px 0px -72% 0px', threshold: 0 });
220      heads.forEach(function (h) { io.observe(h); });
221    }
222  }
223
224  /* ── Text search (unpaginated grids only: newsletter archive, ads, etc.) ──
225     Simple same-page DOM substring filter - valid here because ALL items are
226     already in the DOM. Content Library search uses initLibrarySearch below
227     instead, since Library pages are paginated (only 30 of 375+ items are
228     ever in the DOM at once - this filter would only ever search the current
229     page and look broken). data-search vs data-pfsearch is the marker. ── */
230  var search = document.querySelector('[data-search]');
231  if (search) {
232    var sel = search.getAttribute('data-search');
233    search.addEventListener('input', function () {
234      var q = search.value.trim().toLowerCase();
235      document.querySelectorAll(sel + ' [data-tags]').forEach(function (it) {
236        it.style.display = it.textContent.toLowerCase().indexOf(q) !== -1 ? '' : 'none';
237      });
238      syncHideOnFilter();
239    });
240  }
241
242  /* ── Content Library search (2026-07-28) ── full-corpus search-as-you-type
243     that renders real matching cards, replacing the removed Pagefind
244     "Full-text results" panel (never approved, confusing dual-result UX where
245     the visible grid emptied while a hidden panel had the real matches).
246     Fetches a lightweight card-only index (title/summary/format/topic/image -
247     no body text, ~250KB for all 375 pieces) once per page load, filters
248     title+summary client-side, and swaps the grid's contents to the matching
249     cards - same visual card component as the normal browse grid. Clearing
250     the box restores the original paginated cards exactly as they were. ── */
251  var BADGE_SVG = {
252    deepdive: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3l1.9 5.8L20 11l-6.1 2.2L12 19l-1.9-5.8L4 11l6.1-2.2z"/></svg>',
253    playbook: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>',
254    teardown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>',
255    guide: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>',
256    newsletter: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3l1.9 5.8L20 11l-6.1 2.2L12 19l-1.9-5.8L4 11l6.1-2.2z"/></svg>',
257  };
258  function libEsc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
259  function libCardHtml(p) {
260    return '<a class="floppy" href="' + p.urlpath + '" data-tags="|' + p.topic_slug + '|' + p.format + '|">'
261      + '<div class="floppy-slider"><span class="badge ' + p.badge_class + '">' + (BADGE_SVG[p.format] || '')
262      + '<span class="badge-label">' + libEsc(p.badge_label) + '</span></span>'
263      + (p.edition ? '<span class="topic">#' + libEsc(String(p.edition)) + '</span>' : '')
264      + '</div>'
265      + '<div class="floppy-art"><img loading="lazy" src="' + p.hero_img + '" alt=""><span class="frontier-scanlines"></span></div>'
266      + '<div class="floppy-label"><span class="ft">' + libEsc(p.title) + '</span><span class="fd">' + libEsc(p.summary) + '</span>'
267      + '<span class="fmeta"><span class="topic">' + libEsc(p.topic_label) + '</span><span class="topic">' + p.minutes + ' min</span></span>'
268      + '<span class="floppy-scan"></span></div></a>';
269  }
270  window.initLibrarySearch = function (input, gridEl, opts) {
271    if (!input || !gridEl) return;
272    opts = opts || {};
273    var originalHtml = gridEl.innerHTML;
274    var indexPromise = null;
275    var timer = null;
276    function ensureIndex() {
277      if (!indexPromise) indexPromise = fetch('/search-index.json').then(function (r) { return r.json(); });
278      return indexPromise;
279    }
280    function setActive(active) {
281      document.querySelectorAll('.lib-cnav, .lib-pagecount, [data-hide-on-filter]').forEach(function (el) { el.style.display = active ? 'none' : ''; });
282      // Hub mode (opts.swapEl, 2026-08-11): the curated hub page has no
283      // always-visible grid - while a query is active we hide the whole hub
284      // (swapEl) and show the dedicated results grid instead; on clear, reverse.
285      if (opts.swapEl) {
286        opts.swapEl.hidden = active;
287        (opts.showEl || gridEl).hidden = !active;   // showEl: a wrapper around gridEl, if the grid needs one (e.g. .fr-wrap padding)
288      }
289    }
290    function run(q) {
291      ensureIndex().then(function (all) {
292        var ql = q.toLowerCase();
293        var matches = all.filter(function (p) {
294          if (opts.scopeKind === 'format' && p.format !== opts.scopeValue) return false;
295          if (opts.scopeKind === 'topic' && p.topic_slug !== opts.scopeValue) return false;
296          return p.title.toLowerCase().indexOf(ql) !== -1 || (p.summary || '').toLowerCase().indexOf(ql) !== -1;
297        });
298        gridEl.innerHTML = matches.length ? matches.map(libCardHtml).join('') : '<p class="lib-noresults">No results for "' + libEsc(q) + '".</p>';
299        if (window.DCA) window.DCA.track('Search', { query: q, results: matches.length, page: location.pathname });
300      });
301    }
302    input.addEventListener('input', function () {
303      var q = input.value.trim();
304      clearTimeout(timer);
305      if (q.length < 2) {
306        gridEl.innerHTML = originalHtml;
307        setActive(false);
308        return;
309      }
310      setActive(true);
311      timer = setTimeout(function () { run(q); }, 150);
312    });
313  };
314
315  /* ── Membership — backed by Supabase (window.FA from frontier-auth.js) ──
316     Newsletter signup (email -> Kit) stays lightweight; real ACCOUNTS
317     (password / Google) live on account.html. This wires the topbar chip,
318     sidebar profile, sign-in modal and settings panel to real auth state. ── */
319  (function () {
320    var FA = window.FA;
321    if (!FA) return;   // auth core failed to load — leave the page static
322
323    function subscribeKit(email) {   // fire-and-forget newsletter subscribe
324      try { fetch('/api/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({}, window.DCA ? window.DCA.payload() : {}, { email: email, source: (document.body.hasAttribute('data-nl-onboard') ? 'newsletter' : 'frontier') })) }).catch(function () {}); } catch (e) {}
325    }
326    /* Two-tier signup (2026-07-29, Joey): subscribe with ONLY an email (no account
327       wall, join:false) so the pebble is tiny, then hand off to /welcome to claim
328       the full Frontier account. Redirect happens whether or not Kit responds - the
329       server records the subscribe; the client must not strand them on a spinner. */
330    /* ONE unified join (Justin 2026-08-12, silent accounts): email -> real
331       account with a live session (FA.signUpSilent also fires the Kit
332       subscribe) -> /welcome onboarding. Already-registered emails get a
333       magic link -> /check-email (the only remaining inbox step). */
334    function startJoin(email, btn, restoreLabel) {
335      // Stamp what they signed up FOR, so /welcome can greet them correctly.
336      // localStorage (not just a query param) because the Google OAuth round trip
337      // navigates away and back, losing any params we'd put on the redirect.
338      try { localStorage.setItem('dc_join_src', document.body.hasAttribute('data-nl-onboard') ? 'newsletter' : 'frontier'); } catch (e) {}
339      FA.signUpSilent(email).then(function (res) {
340        // &existing=1 lets /check-email say WHY you are there. Without it that page
341        // greets a months-old account with "Welcome aboard / You just unlocked",
342        // which is what made "save this article" feel broken (Joey 2026-08-26).
343        // The page treats the flag as optional, so this is safe to ship either order.
344        if (res && res.existing) { window.location.href = '/check-email?existing=1&email=' + encodeURIComponent(email); return; }
345        if (res && res.error) {
346          if (btn) { btn.disabled = false; btn.textContent = restoreLabel || 'Join the Frontier'; }
347          alert(res.error.message || 'Something went wrong - please try again.');
348          return;
349        }
350        window.location.href = '/welcome?src=' + encodeURIComponent(document.body.hasAttribute('data-nl-onboard') ? 'newsletter' : 'frontier');
351      });
352    }
353    /* The two-tier subscribe + onboarding flow is OPT-IN per page (Joey 2026-07-29):
354       ONLY the /newsletter page sets <body data-nl-onboard>. Every other Frontier
355       page keeps the original magic-link join untouched. */
356    function nlOnboard() { return document.body.hasAttribute('data-nl-onboard'); }
357    /* dc-site: account = newsletter, ONE step - for ALL join paths. The email flows fire
358       a subscribe pre-auth, but Google OAuth navigates away before any client
359       call can run - so on the first authed page-load per device, sync once.
360       (Kit subscribe is idempotent; the localStorage flag just avoids noise.) */
361    FA.onChange(function () {
362      try {
363        if (FA.isAuthed() && FA.user && FA.user.email && !localStorage.getItem('dc_kit_synced')) {
364          localStorage.setItem('dc_kit_synced', '1');
365          subscribeKit(FA.user.email);
366        }
367        // Flush onboarding answers captured BEFORE the account was confirmed
368        // (frictionless flow: /welcome stashes them when there's no session).
369        var pend = localStorage.getItem('fr-pending-prefs');
370        if (pend && FA.isAuthed() && FA.savePrefs && !(FA.user && FA.user.prefs && FA.user.prefs.topics)) {
371          FA.savePrefs(JSON.parse(pend)).then(function () { try { localStorage.removeItem('fr-pending-prefs'); } catch (e) {} }, function () {});
372        }
373      } catch (e) {}
374    });
375    function accountUrl(email) {
376      return '/account' + (email ? ('?email=' + encodeURIComponent(email)) : '');
377    }
378
379    /* member chip in the topbar (logged-in only); logged-out shows the "Join free" button */
380    var tb = document.querySelector('.tb-actions');
381    var chip = null;
382    if (tb) {
383      chip = document.createElement('button');
384      chip.type = 'button'; chip.className = 'fr-member-chip'; chip.title = 'Account';
385      tb.appendChild(chip);
386      chip.addEventListener('click', function () { openSettings(); });
387    }
388    /* ONE unified entry: every "Join free" / subscribe button opens the join modal.
389       Capture phase overrides the old static-modal opener bound earlier in this file. */
390    document.querySelectorAll('[data-subscribe]').forEach(function (b) {
391      if (b.classList.contains('fr-signin')) return;   // sidebar profile handled separately below
392      b.addEventListener('click', function (e) {
393        e.preventDefault(); e.stopImmediatePropagation();
394        // A typed email is ALWAYS honored in place (Justin 2026-08-12: entering
395        // your email then getting a modal that asks for it again is broken).
396        // /newsletter: two-tier subscribe -> /welcome. Everywhere else: the
397        // original magic-link join -> /check-email. Empty form -> focus the
398        // input; only BARE buttons (topbar Join free etc.) open the modal.
399        var form = b.closest('.fr-capture');
400        var input = form && form.querySelector('input[type="email"]');
401        if (input) {
402          var email = (input.value || '').trim();
403          if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { input.focus(); return; }
404          var lbl = b.textContent; b.disabled = true; b.textContent = 'Joining…';
405          startJoin(email, b, lbl);
406          return;
407        }
408        openJoin();
409      }, true);
410    });
411    /* Google as an up-front subscribe option (on the /newsletter subscribe surfaces):
412       one tap -> full account -> the /welcome funnel (ends on the "joined" page). */
413    document.querySelectorAll('[data-google-subscribe]').forEach(function (b) {
414      b.addEventListener('click', function (e) { e.preventDefault(); FA.signInGoogle('/welcome'); });
415    });
416    var sideBtn = document.querySelector('.fr-userrow .fr-signin');
417    /* sidebar profile: signed-in -> Settings, signed-out -> Join. Capture beats the data-subscribe handler. */
418    if (sideBtn) sideBtn.addEventListener('click', function (e) {
419      e.preventDefault(); e.stopImmediatePropagation();
420      if (FA.isAuthed()) openSettings(); else openJoin();   // sidebar button says "Join for free" -> join framing (Justin 2026-08-12)
421    }, true);
422    /* "Sign in" links (topbar + sidebar) -> auth modal in sign-in framing (hidden once logged in via CSS) */
423    document.querySelectorAll('.fr-signin-link').forEach(function (l) {
424      l.addEventListener('click', function (e) { e.preventDefault(); openSignin(); });
425    });
426
427    function reflect() {
428      var member = FA.isAuthed(), u = FA.user || {};
429      var em = u.email || '';
430      /* FA.onChange fires this once SYNCHRONOUSLY, before bootstrap has asked
431         Supabase anything - at which point FA.user is null for everyone,
432         signed-in people included. Clearing the class on that first call is
433         what undid the pre-paint hint in FrontierShell and put the flicker
434         back. Wait until we actually know. */
435      if (!FA.ready && !member) return;
436      document.body.classList.toggle('fr-is-member', member);
437      if (chip) {
438        if (member) chip.innerHTML = '<span class="fmc-em">' + em + '</span><span class="fmc-tag">Member</span>';
439        else chip.innerHTML = '';
440      }
441      if (sideBtn) {
442        sideBtn.classList.toggle('is-member', member);
443        if (member) {
444          sideBtn.innerHTML = '<span class="fru-avatar">' + (u.avatar ? '<img src="' + u.avatar + '" alt="">' : (em ? em[0].toUpperCase() : 'M')) + '</span>'
445            + '<span class="fru-meta"><span class="fru-name">' + (u.name || 'Frontier Member') + '</span><span class="fru-email">' + em + '</span></span>';
446        } else {
447          sideBtn.textContent = 'Join for free';
448        }
449      }
450    }
451
452    /* Hero email capture -> silent join (every page, Justin 2026-08-12). */
453    var capture = document.querySelector('.fr-capture');
454    if (capture) {
455      capture.addEventListener('submit', function (e) {
456        e.preventDefault();
457        var input = capture.querySelector('input[type="email"]');
458        var email = ((input && input.value) || '').trim();
459        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (input) { input.focus(); } return; }
460        var btn = capture.querySelector('button');
461        var lbl = btn ? btn.textContent : '';
462        if (btn) { btn.disabled = true; btn.textContent = 'Joining…'; }
463        startJoin(email, btn, lbl);
464      });
465    }
466
467    /* ── Unified JOIN modal: Google + passwordless magic link (injected once) ── */
468    var joinModal = null;
469    var GOOGLE_SVG = '<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/><path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/><path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 0 1 3.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.83.96 4.05l3.01-2.33z"/><path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 9 0 9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/></svg>';
470    function buildJoin() {
471      joinModal = document.createElement('div');
472      joinModal.className = 'fr-modal fr-modal--signin';
473      joinModal.setAttribute('role', 'dialog'); joinModal.setAttribute('aria-modal', 'true');
474      joinModal.innerHTML = '<div class="fr-modal-panel">'
475        + '<button class="fr-modal-close" aria-label="Close">×</button>'
476        + '<span class="m-eyebrow">The Frontier · Free</span>'
477        + '<h2 class="mh">Unlock your free membership</h2>'
478        + '<p class="mb">Join 100,000+ operators receiving the growth newsletter and exclusive perks.</p>'
479        + '<form class="fr-modal-form" data-join><input class="fr-input" type="email" placeholder="[email protected]" autocomplete="email" required>'
480        + '<button class="fr-btn glow-btn fr-btn--block" type="submit">Join the Frontier →</button></form>'
481        + '<p class="fr-auth-err" hidden></p>'
482        + '<div class="fr-auth-or"><span>or</span></div>'
483        + '<button class="fr-btn fr-btn-google fr-btn--block" type="button" data-google>' + GOOGLE_SVG + '<span>Subscribe with Google</span></button>'
484        + '<p class="mfine" hidden></p>'
485        + '<p class="fr-auth-switch"></p></div>';
486      document.body.appendChild(joinModal);
487      joinModal.addEventListener('click', function (e) {
488        if (e.target === joinModal) { closeJoin(); return; }
489        if (e.target.closest('[data-auth-switch]')) { setAuthMode(authMode === 'join' ? 'signin' : 'join'); }
490      });
491      joinModal.querySelector('.fr-modal-close').addEventListener('click', closeJoin);
492      var g = joinModal.querySelector('[data-google]');
493      if (g) g.addEventListener('click', function () { FA.signInGoogle(authMode === 'signin' ? '/frontier' : '/welcome'); });
494      var err = joinModal.querySelector('.fr-auth-err');
495      var f = joinModal.querySelector('form');
496      f.addEventListener('submit', function (e) {
497        e.preventDefault();
498        var email = (f.querySelector('input').value || '').trim();
499        var btn = f.querySelector('button[type="submit"]'); var label = btn.textContent;
500        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (err) { err.textContent = 'Please enter a valid email.'; err.hidden = false; } return; }
501        if (err) err.hidden = true; btn.disabled = true;
502        if (authMode !== 'signin') {
503          // join (all pages): silent account -> instant session -> onboarding
504          btn.textContent = 'Joining…';
505          startJoin(email, btn, label);
506        } else {
507          // returning sign-in: magic link -> /check-email
508          btn.textContent = 'Sending…';
509          FA.signInOtp(email).then(function (res) {
510            btn.disabled = false; btn.textContent = label;
511            if (res && res.error) { if (err) { err.textContent = res.error.message; err.hidden = false; } return; }
512            window.location.href = '/check-email?email=' + encodeURIComponent(email);
513          });
514        }
515      });
516    }
517    var authMode = 'join';
518    function setAuthMode(mode) {
519      authMode = mode;
520      if (!joinModal) return;
521      var q = function (s) { return joinModal.querySelector(s); };
522      var subBtn = q('.fr-modal-form button[type="submit"]');
523      var gBtn = q('.fr-btn-google span');
524      if (gBtn) gBtn.textContent = (mode === 'signin') ? 'Sign in with Google' : 'Subscribe with Google';
525      if (mode === 'signin') {
526        if (q('.m-eyebrow')) q('.m-eyebrow').textContent = 'The Frontier · Welcome back';
527        if (q('.mh')) q('.mh').textContent = 'Sign in to your account';
528        if (q('.mb')) q('.mb').textContent = 'Pick up right where you left off — your saved articles and the full archive. No password needed.';
529        if (subBtn) subBtn.textContent = 'Email me a magic link →';
530        if (q('.mfine')) { q('.mfine').textContent = "We'll email a one-click link to sign you in - no password needed."; q('.mfine').hidden = false; }
531        if (q('.fr-auth-switch')) q('.fr-auth-switch').innerHTML = 'New to the Frontier? <button type="button" class="fr-auth-switch-btn" data-auth-switch>Create a free account</button>';
532      } else {
533        // UNIVERSAL join framing (Justin 2026-08-17): same modal on every page
534        if (q('.m-eyebrow')) q('.m-eyebrow').textContent = 'The Frontier · Free';
535        if (q('.mh')) q('.mh').textContent = 'Unlock your free membership';
536        if (q('.mb')) q('.mb').textContent = 'Join 100,000+ operators receiving the growth newsletter and exclusive perks.';
537        if (subBtn) subBtn.textContent = 'Join the Frontier →';
538        if (q('.mfine')) { q('.mfine').textContent = ''; q('.mfine').hidden = true; }
539        if (q('.fr-auth-switch')) q('.fr-auth-switch').innerHTML = 'Already have an account? <button type="button" class="fr-auth-switch-btn" data-auth-switch>Sign in</button>';
540      }
541    }
542    function openAuth(mode) { if (!joinModal) buildJoin(); setAuthMode(mode); requestAnimationFrame(function () { joinModal.classList.add('open'); }); }
543    function openJoin() { openAuth('join'); }
544    function openSignin() { openAuth('signin'); }
545    function closeJoin() { if (joinModal) joinModal.classList.remove('open'); }
546    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeJoin(); closeSettings(); } });
547
548    /* start-a-path: members start/continue; non-members get nudged to sign in */
549    document.querySelectorAll('[data-start-path]').forEach(function (btn) {
550      btn.addEventListener('click', function (e) {
551        e.preventDefault();
552        if (!FA.isAuthed()) { openJoin(); return; }
553        btn.textContent = 'Continue path →'; btn.classList.add('is-started');
554        var first = document.querySelector('.fr-step');
555        if (first) first.scrollIntoView({ behavior: 'smooth', block: 'center' });
556      });
557    });
558
559    /* ── Settings / account panel ── */
560    var settings = null;
561    function buildSettings() {
562      var u = FA.user || {};
563      var em = u.email || '';
564      function esc(s) { return String(s == null ? '' : s).replace(/"/g, '&quot;'); }
565      var avatarInner = u.avatar ? '<img src="' + esc(u.avatar) + '" alt="">' : (em ? em[0].toUpperCase() : 'M');
566      settings = document.createElement('div');
567      settings.className = 'fr-settings';
568      settings.setAttribute('role', 'dialog'); settings.setAttribute('aria-modal', 'true'); settings.setAttribute('aria-label', 'Account settings');
569      settings.innerHTML =
570        '<div class="fr-settings-panel">'
571        + '<button class="fr-modal-close" data-settings-close aria-label="Close">×</button>'
572        + '<div class="fr-settings-rail">'
573        + '<div class="fst-railhead">Settings</div>'
574        + '<button class="fst-tab is-active" data-tab="account">Account</button>'
575        + '<button class="fst-tab" data-tab="security">Security</button>'
576        + '</div>'
577        + '<div class="fr-settings-body">'
578        + '<section class="fst-pane is-active" data-pane="account"><h3 class="fst-h">Account</h3>'
579          + '<div class="fst-id"><div class="fst-avatar" data-avatar>' + avatarInner + '</div>'
580          + '<div><div class="fst-name">' + (u.name || 'Frontier Member') + '</div><div class="fst-sub">Free plan</div></div>'
581          + '<button class="fr-btn fst-photo" type="button" data-avatar-pick>Change photo</button>'
582          + '<input type="file" accept="image/*" data-avatar-input hidden></div>'
583          + '<form class="fst-edit" data-form="name"><span class="fst-fl">Name</span>'
584          + '<div class="fst-editrow"><input class="fr-input" type="text" value="' + esc(u.name) + '" placeholder="Your name"><button class="fr-btn" type="submit">Save</button></div></form>'
585          + '<form class="fst-edit" data-form="email"><span class="fst-fl">Email</span>'
586          + '<div class="fst-editrow"><input class="fr-input" type="email" value="' + esc(em) + '" placeholder="[email protected]" autocomplete="email"><button class="fr-btn" type="submit">Save</button></div></form>'
587          + '<p class="fr-auth-err" data-acct-err hidden></p><p class="fst-note" data-acct-msg hidden></p>'
588          + '<button class="fr-btn fst-signout" data-signout>Sign out</button></section>'
589        + '<section class="fst-pane" data-pane="security"><h3 class="fst-h">Security</h3>'
590          + '<p class="fst-p">Set a new password for your account.</p>'
591          + '<form data-form="pw"><input class="fr-input" type="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="8" required>'
592          + '<button class="fr-btn glow-btn" type="submit">Update password</button></form>'
593          + '<p class="fr-auth-err" hidden></p>'
594          + '<p class="fst-note">Signed up with Google or a magic link? Set a password here to also sign in with email.</p></section>'
595        + '</div></div>';
596      document.body.appendChild(settings);
597      settings.querySelector('[data-settings-close]').addEventListener('click', closeSettings);
598      settings.addEventListener('click', function (e) { if (e.target === settings) closeSettings(); });
599      settings.querySelectorAll('.fst-tab').forEach(function (t) {
600        t.addEventListener('click', function () {
601          settings.querySelectorAll('.fst-tab').forEach(function (x) { x.classList.remove('is-active'); });
602          settings.querySelectorAll('.fst-pane').forEach(function (x) { x.classList.remove('is-active'); });
603          t.classList.add('is-active');
604          var p = settings.querySelector('[data-pane="' + t.getAttribute('data-tab') + '"]');
605          if (p) p.classList.add('is-active');
606        });
607      });
608
609      /* account: editable name / email + avatar upload */
610      var acctErr = settings.querySelector('[data-acct-err]');
611      var acctMsg = settings.querySelector('[data-acct-msg]');
612      function acctNote(msg, isErr) {
613        if (isErr) { if (acctErr) { acctErr.textContent = msg; acctErr.hidden = false; } if (acctMsg) acctMsg.hidden = true; }
614        else { if (acctMsg) { acctMsg.textContent = msg; acctMsg.hidden = false; } if (acctErr) acctErr.hidden = true; }
615      }
616      function wireSave(form, run, okMsg) {
617        if (!form) return;
618        form.addEventListener('submit', function (e) {
619          e.preventDefault();
620          var inp = form.querySelector('input'); var btn = form.querySelector('button');
621          var val = (inp.value || '').trim(); if (!val) return;
622          btn.disabled = true; var lab = btn.textContent; btn.textContent = 'Saving…';
623          run(val).then(function (res) {
624            btn.disabled = false; btn.textContent = lab;
625            if (res && res.error) { acctNote(res.error.message, true); return; }
626            acctNote(okMsg, false);
627            if (form.getAttribute('data-form') === 'name') { var nm = settings.querySelector('.f
627st-name'); if (nm) nm.textContent = val || 'Frontier Member'; }
628          });
629        });
630      }
631      wireSave(settings.querySelector('[data-form="name"]'), function (v) { return FA.updateProfile({ full_name: v }); }, 'Name updated.');
632      wireSave(settings.querySelector('[data-form="email"]'), function (v) { return FA.updateEmail(v); }, 'Check your new inbox to confirm the change.');
633
634      var pick = settings.querySelector('[data-avatar-pick]');
635      var fileInput = settings.querySelector('[data-avatar-input]');
636      if (pick && fileInput) {
637        pick.addEventListener('click', function () { fileInput.click(); });
638        fileInput.addEventListener('change', function () {
639          var file = fileInput.files && fileInput.files[0]; if (!file) return;
640          pick.disabled = true; var lab = pick.textContent; pick.textContent = 'Uploading…';
641          FA.uploadAvatar(file).then(function (res) {
642            pick.disabled = false; pick.textContent = lab;
643            if (res && res.error) { acctNote(res.error.message, true); return; }
644            var av = settings.querySelector('[data-avatar]'); if (av && FA.user && FA.user.avatar) av.innerHTML = '<img src="' + FA.user.avatar + '" alt="">';
645            acctNote('Photo updated.', false);
646          });
647        });
648      }
649
650      var pwForm = settings.querySelector('[data-form="pw"]');
651      if (pwForm) pwForm.addEventListener('submit', function (e) {
652        e.preventDefault();
653        var inp = pwForm.querySelector('input'); var btn = pwForm.querySelector('button');
654        var err = settings.querySelector('[data-pane="security"] .fr-auth-err');
655        btn.disabled = true; var label = btn.textContent; btn.textContent = 'Saving…';
656        FA.updatePassword(inp.value).then(function (res) {
657          btn.disabled = false;
658          if (res && res.error) { btn.textContent = label; if (err) { err.textContent = res.error.message; err.hidden = false; } return; }
659          inp.value = ''; btn.textContent = 'Updated ✓'; setTimeout(function () { btn.textContent = label; }, 1600);
660        });
661      });
662      var so = settings.querySelector('[data-signout]');
663      if (so) so.addEventListener('click', function () { closeSettings(); FA.signOut(); });
664    }
665    function openSettings() {
666      if (!FA.isAuthed()) { openSignin(); return; }
667      if (settings) { settings.parentNode.removeChild(settings); settings = null; }  // rebuild with fresh data
668      buildSettings(); requestAnimationFrame(function () { settings.classList.add('open'); });
669    }
670    function closeSettings() { if (settings) settings.classList.remove('open'); }
671
672    /* expose modal openers so the bookmark + gate code can prompt sign-in */
673    window.FrontierUI = { openSignin: openSignin, openJoin: openJoin, openSettings: openSettings };
674
675    FA.onChange(reflect);   // re-render whenever auth state changes
676  })();
677
678  /* ── Bookmarks (icons on every content card + reader pages) and the
679        NYT-style soft registration gate on full articles ── */
680  (function () {
681    var FA = window.FA;
682    if (!FA || !FA.configured) return;   // dormant until Supabase is connected (no gate, no bookmark icons)
683    var FMT = { blog: 'deepdive', playbooks: 'playbook', teardowns: 'teardown', newsletters: 'newsletter', paths: 'path' };
684
685    /* derive a stable item {slug, format, title, url} from a card anchor or the current URL */
686    function fromHref(href, title) {
687      if (!href) return null;
688      var path = href;
689      try { path = new URL(href, window.location.origin).pathname; } catch (e) {}
690      var parts = path.replace(/\/+$/, '').split('/').filter(Boolean);
691      if (!parts.length) return null;
692      var last = parts[parts.length - 1].replace(/\.html$/, '');
693      var folder = parts.length > 1 ? parts[parts.length - 2] : '';
694      if (!last || last === 'frontier' || last.indexOf('frontier-') === 0) return null;  // skip nav/section pages
695      return { slug: last, format: FMT[folder] || folder || '', title: (title || '').trim(), url: href };
696    }
697
698    var registry = [];   // { btn, item }
699
700    function iconBtn(cls) {
701      var b = document.createElement('button');
702      b.type = 'button'; b.className = 'fr-bm ' + (cls || '');
703      b.setAttribute('aria-label', 'Save');
704      b.innerHTML = '<iconify-icon class="fr-bm-i fr-bm-o" icon="ph:bookmark-simple" aria-hidden="true"></iconify-icon>'
705                  + '<iconify-icon class="fr-bm-i fr-bm-on" icon="ph:bookmark-simple-fill" aria-hidden="true"></iconify-icon>';
706      return b;
707    }
708
709    function wire(btn, item) {
710      registry.push({ btn: btn, item: item });
711      btn.addEventListener('click', function (e) {
712        e.preventDefault(); e.stopPropagation();
713        FA.toggleBookmark(item).then(function (res) {
714          if (res === 'auth-required') {
715            if (window.FrontierUI) window.FrontierUI.openJoin();
716          }
717          paint();
718        });
719      });
720    }
721
722    function paint() {
723      registry.forEach(function (r) {
724        var on = FA.hasBookmark(r.item.slug);
725        r.btn.classList.toggle('is-saved', on);
726        r.btn.setAttribute('aria-label', on ? 'Saved — remove bookmark' : 'Save for later');
727        r.btn.title = on ? 'Saved' : 'Save for later';
728      });
729    }
730
731    /* 1. every content card */
732    document.querySelectorAll('a.floppy').forEach(function (card) {
733      if (card.querySelector('.fr-bm')) return;
734      var titleEl = card.querySelector('.ft');
735      var item = fromHref(card.getAttribute('href'), titleEl ? titleEl.textContent : '');
736      if (!item || !item.slug) return;
737      var btn = iconBtn('fr-bm--card');
738      card.appendChild(btn);
739      card.classList.add('has-bm');
740      wire(btn, item);
741    });
742
743    /* 2. reader page: a "Save for later" button in the top actions bar */
744    var prose = document.querySelector('.fr-prose');
745    var saveSlot = document.querySelector('.fr-save-slot');
746    if (saveSlot && !saveSlot.querySelector('.fr-bm')) {
747      var t = document.querySelector('.fr-article-title');
748      var item = fromHref(window.location.pathname, t ? t.textContent : document.title);
749      if (item && item.slug) {
750        var btn = iconBtn('fr-bm--article fr-bm--top');
751        btn.title = 'Save for later';
752        btn.innerHTML += '<span class="fr-bm-label">Save</span>';
753        saveSlot.appendChild(btn);
754        wire(btn, item);
755      }
756    }
757
758    /* ---- shared: escape + article item for likes/comments ---- */
759    function cesc(s) {
760      return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
761        return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
762      });
763    }
764    var artTitleEl = document.querySelector('.fr-article-title');
765    var artTitle = artTitleEl ? artTitleEl.textContent : document.title;
766    /* An article page declares its own slug via [data-slug]. fromHref() skips anything named
767       frontier-* (nav/section pages) - but the article template IS frontier-piece.html, so
768       without an explicit slug its likes/comments never initialize. */
769    var artEl = document.querySelector('.fr-article[data-slug]');
770    var artItem = artEl
771      ? { slug: artEl.getAttribute('data-slug'), format: artEl.getAttribute('data-format') || '', title: (artTitle || '').trim(), url: window.location.pathname }
772      : fromHref(window.location.pathname, artTitle);
773
774    /* 2b. LIKE button — public count + per-user toggle (sign-in required to like) */
775    var likeBtn = document.querySelector('[data-like]');
776    if (likeBtn && artItem && artItem.slug) {
777      var lslug = artItem.slug;
778      var lnEl = likeBtn.querySelector('.fr-like-n');
779      var lcount = 0;
780      function paintLike() { likeBtn.classList.toggle('is-liked', FA.hasLike(lslug)); if (lnEl) lnEl.textContent = lcount; }
781      function refreshLikeCount() { FA.likeCount(lslug).then(function (c) { lcount = c || 0; paintLike(); }); }
782      refreshLikeCount();
783      likeBtn.addEventListener('click', function (e) {
784        e.preventDefault();
785        FA.toggleLike(artItem).then(function (res) {
786          if (res === 'auth-required') { if (window.FrontierUI) window.FrontierUI.openJoin(); return; }
787          lcount += res ? 1 : -1; if (lcount < 0) lcount = 0; paintLike();
788        });
789      });
790      document.addEventListener('fa:change', function (e) {
791        if (e.detail && (e.detail.authChanged || e.detail.ready)) refreshLikeCount(); else paintLike();
792      });
793    }
794
795    /* 2c. COMMENTS — public read; sign-in required to post; delete own */
796    var cList = document.querySelector('[data-comments-list]');
797    var cCompose = document.querySelector('[data-comment-compose]');
798    var cCountEl = document.querySelector('[data-comments-count]');
799    if (cList && artItem && artItem.slug) {
800      var cslug = artItem.slug;
801      var fmtWhen = function (iso) { try { return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch (e) { return ''; } };
802      var renderComments = function (rows) {
803        if (cCountEl) cCountEl.textContent = rows.length ? '(' + rows.length + ')' : '';
804        if (!rows.length) { cList.innerHTML = '<p class="fr-comments-empty">No comments yet. Be the first.</p>'; return; }
805        var mine = FA.user ? FA.user.id : null;
806        cList.innerHTML = rows.map(function (r) {
807          var del = (mine && r.user_id === mine) ? '<button class="fr-c-del" data-del="' + cesc(r.id) + '">Delete</button>' : '';
808          return '<div class="fr-comment"><div class="fr-c-head"><span class="fr-c-name">' + cesc(r.author_name || 'Member') + '</span><span class="fr-c-when">' + cesc(fmtWhen(r.created_at)) + '</span>' + del + '</div><div class="fr-c-body">' + cesc(r.body).replace(/\n/g, '<br>') + '</div></div>';
809        }).join('');
810        cList.querySelectorAll('[data-del]').forEach(function (b) {
811          b.addEventListener('click', function () { if (window.confirm('Delete this comment?')) FA.deleteComment(b.getAttribute('data-del')).then(loadComments); });
812        });
813      };
814      var loadComments = function () { FA.listComments(cslug).then(renderComments); };
815      var renderCompose = function () {
816        if (!cCompose) return;
817        if (FA.isAuthed()) {
818          cCompose.innerHTML = '<textarea class="fr-c-input" rows="3" placeholder="Add a comment&hellip;" maxlength="4000"></textarea><div class="fr-c-actions"><button class="fr-btn fr-c-post" type="button">Post comment</button></div>';
819          var ta = cCompose.querySelector('.fr-c-input');
820          cCompose.querySelector('.fr-c-post').addEventListener('click', function () {
821            var v = (ta.value || '').trim(); if (!v) return;
822            FA.addComment(cslug, v).then(function (res) { if (!res.error) { ta.value = ''; loadComments(); } });
823          });
824        } else {
825          cCompose.innerHTML = '<button class="fr-btn fr-btn-ghost fr-c-signin" type="button">Sign in to comment</button>';
826          var s = cCompose.querySelector('.fr-c-signin');
827          if (s) s.addEventListener('click', function () { if (window.FrontierUI) window.FrontierUI.openJoin(); });
828        }
829      };
830      renderCompose(); loadComments();
831      document.addEventListener('fa:change', function (e) {
832        if (e.detail && (e.detail.authChanged || e.detail.ready)) { renderCompose(); loadComments(); }
833      });
834    }
835
836    /* 4. sidebar: a "Saved" entry under "More" */
837    (function () {
838      var more = null;
839      document.querySelectorAll('.fr-side-nav .drawer-label').forEach(function (l) {
840        if (l.textContent.trim() === 'More') more = l;
841      });
842      // skip if a Saved tab already exists (frontier-saved.html hardcodes one; match either href form)
843      if (!more || document.querySelector('.fr-side-nav a[href$="frontier-saved.html"], .fr-side-nav a[href$="/frontier-saved"]')) return;
844      var a = document.createElement('a');
845      a.className = 'file-tab' + (/frontier-saved(\.html)?$/.test(location.pathname) ? ' file-tab-active' : '');
846      a.href = '/frontier-saved';
847      a.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
848        + '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>Saved';
849      more.insertAdjacentElement('afterend', a);
850    })();
851
852    /* 3. NYT-style soft gate: full text stays in the DOM (SEO), but visually
853          locked behind a "create free account" CTA until signed in. */
854    /* dc-site: soft gate is OPT-IN via <body data-soft-gate> (launch model =
855       "gate the perks, free the reading" - no reader page sets it). */
856    var softGateOn = document.body.hasAttribute('data-soft-gate');
857    var gateHost = softGateOn ? document.querySelector('.fr-article-main') : null;
858    var gateEl = null;
859    function buildGate() {
860      if (gateEl || !gateHost || !prose) return;
861      gateEl = document.createElement('div');
862      gateEl.className = 'fr-gate';
863      gateEl.innerHTML =
864        '<div class="fr-gate-card">'
865        + '<img class="fr-gate-icon" src="/engravings/gate-key.png" alt="" aria-hidden="true">'
866        + '<span class="fr-gate-eyebrow">Free account required</span>'
867        + '<h3 class="fr-gate-h">Keep reading the full playbook.</h3>'
868        + '<p class="fr-gate-p">Create a free Frontier account to finish this article, save it for later, and unlock the full archive of 100,000+ operators’ favorite growth content.</p>'
869        + '<button class="fr-btn glow-btn fr-gate-cta" type="button" data-gate-join>Create your free account →</button>'
870        + '<p class="fr-gate-fine">Free, no password — sign in with Google or a magic link.</p>'
871        + '</div>';
872      prose.insertAdjacentElement('afterend', gateEl);   // right after the clipped article, before the in-article CTA
873      var jb = gateEl.querySelector('[data-gate-join]');
874      if (jb) jb.addEventListener('click', function (e) { e.preventDefault(); if (window.FrontierUI) window.FrontierUI.openJoin(); });
875    }
876    function setClip() {   // show a consistent ~20% of the FULL article before the gate
877      if (!gateHost || !prose) return;
878      if (!gateHost.classList.contains('fr-gated')) return;
879      prose.style.maxHeight = Math.max(520, Math.round(prose.scrollHeight * 0.2)) + 'px';
880    }
881    function applyGate() {
882      if (!gateHost || !prose) return;
883      if (FA.isAuthed()) {
884        gateHost.classList.remove('fr-gated');
885        prose.style.maxHeight = '';
886        if (gateEl) gateEl.hidden = true;
887      } else {
888        gateHost.classList.add('fr-gated');
889        buildGate();
890        if (gateEl) gateEl.hidden = false;
891        setClip();
892      }
893    }
894    // re-measure once images/fonts settle so the gate lands at a consistent fraction (not earlier)
895    window.addEventListener('load', setClip);
896    if (prose) prose.querySelectorAll('img').forEach(function (im) { if (!im.complete) im.addEventListener('load', setClip); });
897
898    FA.onChange(function () { paint(); applyGate(); });
899  })();
900
901  /* ── Full-page member gate: pages marked <body data-member-gate> (Ads Vault, Deals) ── */
902  (function () {
903    var FA = window.FA;
904    if (!FA || !FA.configured) return;                       // dormant until Supabase connected
905    if (!document.body.hasAttribute('data-member-gate')) return;
906    var main = document.querySelector('.fr-main');
907    if (!main) return;
908
909    // section name from the breadcrumb: "The Frontier / Ads Vault" -> "Ads Vault"
910    var crumb = document.querySelector('.crumb'), section = 'This';
911    if (crumb) { var parts = crumb.textContent.split('/'); section = (parts[parts.length - 1] || '').trim() || 'This'; }
912
913    var gate = document.createElement('div');
914    gate.className = 'fr-pagegate';
915    gate.innerHTML = '<div class="fr-pagegate-spin"></div>';  // loading until we know auth state
916    main.insertBefore(gate, main.firstChild);
917    document.body.classList.add('fr-pagegated');             // hide content immediately (no flash/leak)
918
919    function showCTA() {
920      gate.innerHTML = '<img class="fr-pagegate-emblem" src="engravings/vault-members.png" alt="" aria-hidden="true">'
921        + '<span class="fr-pagegate-eyebrow">Free account required</span>'
922        + '<h2>' + section + ' is for Frontier members.</h2>'
923        + '<p>Create a free account to unlock ' + (section === 'This' ? 'this' : 'the ' + section) + ' plus all other membership perks</p>'
924        + '<button class="fr-btn glow-btn" type="button" data-pagegate-join>Create your free account →</button>';
925      var b = gate.querySelector('[data-pagegate-join]');
926      if (b) b.addEventListener('click', function () { if (window.FrontierUI) window.FrontierUI.openJoin(); });
927    }
928
929    FA.onChange(function () {
930      if (!FA.ready) return;                                 // keep the spinner until we know
931      if (FA.isAuthed()) { document.body.classList.remove('fr-pagegated'); document.body.removeAttribute('data-member-gate'); gate.style.display = 'none'; }
932      else { document.body.classList.add('fr-pagegated'); document.body.setAttribute('data-member-gate', ''); gate.style.display = ''; showCTA(); }
933    });
934  })();
935})();
936
937/* ============================================================
938   Article share buttons (X / LinkedIn / copy link) in the top actions bar.
939   Independent of auth; shares the current page URL at click time.
940   ============================================================ */
941(function () {
942  var btns = document.querySelectorAll('.fr-share-btn[data-share]');
943  if (!btns.length) return;
944  btns.forEach(function (btn) {
945    btn.addEventListener('click', function (e) {
946      e.preventDefault();
947      var url = window.location.href;
948      var titleEl = document.querySelector('.fr-article-title');
949      var title = (titleEl ? titleEl.textContent : document.title).trim();
950      var type = btn.getAttribute('data-share');
951      if (type === 'x') {
952        window.open('https://twitter.com/intent/tweet?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(title), '_blank', 'noopener,noreferrer');
953      } else if (type === 'linkedin') {
954        window.open('https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(url), '_blank', 'noopener,noreferrer');
955      } else if (type === 'copy') {
956        var done = function () { btn.classList.add('copied'); var old = btn.getAttribute('data-t') || 'Copy link'; btn.title = 'Copied!'; setTimeout(function () { btn.classList.remove('copied'); btn.title = old; }, 1600); };
957        if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(done, done); }
958        else { var ta = document.createElement('textarea'); ta.value = url; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e2) {} document.body.removeChild(ta); done(); }
959      }
960    });
961  });
962})();
963
964/* ============================================================
965   CRT header (.crt-text): flicker the RGB-split effect on page load,
966   then randomly flicker it on/off forever (like a dying neon sign).
967   ============================================================ */
968(function () {
969  var el = document.querySelector('.fr-h1.crt-text');
970  if (!el) return;
971  if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
972  var rand = function (a, b) { return a + Math.random() * (b - a); };
973  var on = function () { el.classList.add('crt-glitch'); };
974  var off = function () { el.classList.remove('crt-glitch'); };
975
976  // one glitch burst = a few quick on/off blips
977  function burst(done) {
978    var blips = 2 + Math.floor(Math.random() * 3);   // 2-4 blips
979    var i = 0;
980    (function step() {
981      on();
982      setTimeout(function () {
983        off();
984        i++;
985        if (i < blips) setTimeout(step, rand(40, 130));
986        else if (done) done();
987      }, rand(45, 160));
988    })();
989  }
990
991  // load-in: flicker on almost immediately, hold a beat, settle
992  setTimeout(function () { burst(loop); }, 120);
993
994  // then: sit dark for a random stretch, glitch, repeat forever
995  function loop() {
996    setTimeout(function () { burst(loop); }, rand(2200, 8000));
997  }
998})();

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.