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, '"'); } 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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…" 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.