PageSourceSearch

https://www.ecoflix.com/assets/site.js

js ecoflix.com collected 2026-09-24 19:52:58 UTC 84,851 bytes, 2,000 lines download raw bytes

1/* ============================================================
2   Header navigation — single source of truth.
3   Every page ships with a fallback flat <nav class="nav">…</nav>
4   for no-JS users. This block re-renders that nav with grouped
5   dropdowns (About / Projects / Give) plus two flat links
6   (Watch, News). Currently-active route is marked
7   aria-current="page". Dropdowns open on click + keyboard, close
8   on outside click / Escape.
9   ============================================================ */
10/* Header navigation now lives in /assets/nav.js (loaded on every page). */
11
12
13/* ============================================================
14   Header utility link — small "NGO sign-in ↗" prepended to the
15   .header-cta cluster on every page. Discreet, muted, sits to
16   the left of the Channel / Support our work buttons so it's
17   discoverable for partner NGOs without dominating the header.
18   Also surfaces inside the mobile drawer so it's reachable on
19   touch devices where .header-cta is hidden.
20   ============================================================ */
21(function ngoSignInLink(){
22  const NGO_LABEL = 'NGO sign-in';
23  const NGO_HREF  = '/ngo-portal/';
24
25  // Desktop / tablet — slot it into the header-cta cluster.
26  const cta = document.querySelector('header.header .header-cta');
27  if (cta && !cta.querySelector('.header-ngo-link')){
28    const a = document.createElement('a');
29    a.href = NGO_HREF;
30    a.className = 'header-ngo-link';
31    a.setAttribute('aria-label', 'NGO partner sign-in');
32    a.innerHTML = NGO_LABEL + ' <span class="hnl-arr" aria-hidden="true">↗</span>';
33    cta.insertBefore(a, cta.firstChild);
34  }
35
36  // Mobile drawer — append as a secondary action so partners can
37  // reach the portal from the burger menu too.
38  const drawer = document.querySelector('.drawer');
39  if (drawer && !drawer.querySelector('.drawer-ngo-link')){
40    const a = document.createElement('a');
41    a.href = NGO_HREF;
42    a.className = 'drawer-ngo-link';
43    a.innerHTML = '<span>NGO partner sign-in</span><span aria-hidden="true">↗</span>';
44    drawer.appendChild(a);
45  }
46})();
47
48/* ============================================================
49   Cartograph auto-enricher.
50   Many sub-pages ship a `<svg>` inside `.bg-cartograph` with
51   only one or two decorative paths — looks thin compared to the
52   homepage's richer setups. This IIFE finds those sparse SVGs
53   and injects additional layers (a secondary trail, a compass
54   rose, animal tracks, coordinate markers, ruler ticks) so
55   every page has the same cartographic depth.
56
57   It varies the injection by section index so adjacent sections
58   on the same page don't look identical, and skips SVGs that
59   already have rich content (3+ children of relevant types).
60   ============================================================ */
61(function enrichCartographs(){
62  const NS = 'http://www.w3.org/2000/svg';
63  const ready = cb => (document.readyState === 'loading')
64    ? document.addEventListener('DOMContentLoaded', cb, { once:true })
65    : cb();
66
67  // Five mini-templates that get rotated through sections. Each
68  // is an array of SVG elements built lazily so we can vary
69  // transforms / coords per instance.
70  function buildVariant(i, w, h){
71    const els = [];
72    const make = (tag, attrs) => {
73      const el = document.createElementNS(NS, tag);
74      Object.entries(attrs).forEach(([k,v]) => el.setAttribute(k, String(v)));
75      return el;
76    };
77    const v = i % 5;
78
79    // Latitude lines — always 2-3 to anchor the horizon.
80    const latPositions = [h*0.22, h*0.58, h*0.85].slice(0, v === 4 ? 3 : 2);
81    latPositions.forEach(y => {
82      els.push(make('line', {
83        class:'bg-lat', x1:-50, y1:y, x2:w+50, y2:y,
84        'stroke-dasharray': v % 2 === 0 ? '2 18' : '2 12'
85      }));
86    });
87
88    // Primary trail — varied shape per index.
89    const trails = [
90      `M-50,${h*0.42} C${w*0.25},${h*0.30} ${w*0.42},${h*0.62} ${w*0.62},${h*0.42} C${w*0.82},${h*0.22} ${w},${h*0.55} ${w+60},${h*0.40}`,
91      `M-50,${h*0.65} C${w*0.20},${h*0.50} ${w*0.45},${h*0.75} ${w*0.70},${h*0.55} C${w*0.88},${h*0.40} ${w*1.05},${h*0.65} ${w+60},${h*0.55}`,
92      `M-50,${h*0.35} C${w*0.18},${h*0.55} ${w*0.40},${h*0.25} ${w*0.65},${h*0.45} C${w*0.85},${h*0.65} ${w*1.05},${h*0.32} ${w+60},${h*0.45}`,
93      `M-50,${h*0.78} C${w*0.30},${h*0.45} ${w*0.55},${h*0.70} ${w*0.80},${h*0.40} C${w*0.95},${h*0.25} ${w*1.05},${h*0.50} ${w+60},${h*0.38}`,
94      `M-50,${h*0.50} C${w*0.22},${h*0.62} ${w*0.48},${h*0.32} ${w*0.72},${h*0.55} C${w*0.90},${h*0.72} ${w*1.05},${h*0.42} ${w+60},${h*0.50}`,
95    ];
96    els.push(make('path', { class:'bg-trail', d: trails[v] }));
97
98    // Soft secondary trail for layered depth.
99    els.push(make('path', {
100      class:'bg-trail-soft',
101      d:`M-50,${h*0.20 + v*20} C${w*0.30},${h*0.42} ${w*0.55},${h*0.18} ${w*0.78},${h*0.32} C${w*0.95},${h*0.42} ${w*1.05},${h*0.22} ${w+60},${h*0.28}`
102    }));
103
104    // Animal tracks along the primary trail (small dots).
105    const trackY = h * (0.40 + (v - 2) * 0.06);
106    for (let k = 0; k < 6; k++){
107      els.push(make('circle', {
108        class:'bg-track',
109        cx: w*0.18 + k * w*0.10,
110        cy: trackY + Math.sin(k + v) * 14,
111        r: 2.8 + (k % 2) * 0.4
112      }));
113    }
114
115    // Compass rose in a corner (alternates corners).
116    const corners = [
117      { x: w*0.88, y: h*0.18, label:'N' },
118      { x: w*0.10, y: h*0.82, label:'S' },
119      { x: w*0.90, y: h*0.78, label:'E' },
120      { x: w*0.12, y: h*0.22, label:'W' },
121      { x: w*0.86, y: h*0.20, label:'N' },
122    ];
123    const c = corners[v];
124    const compass = make('g', { class:'bg-compass', transform:`translate(${c.x},${c.y})` });
125    compass.appendChild(make('circle', { class:'bg-compass-ring', r:42 }));
126    compass.appendChild(make('circle', { class:'bg-compass-ring', r:30 }));
127    compass.appendChild(make('line',   { class:'bg-compass-tick', x1:0, y1:-46, x2:0, y2:46 }));
128    compass.appendChild(make('line',   { class:'bg-compass-tick', x1:-46, y1:0, x2:46, y2:0 }));
129    compass.appendChild(make('polygon',{ class:'bg-compass-arrow', points:'0,-42 5,0 0,42 -5,0' }));
130    compass.appendChild(make('polygon',{ class:'bg-compass-arrow', points:'-42,0 0,5 42,0 0,-5' }));
131    const label = make('text', { class:'bg-compass-label', x:0, y:-52, 'text-anchor':'middle' });
132    label.textContent = c.label;
133    compass.appendChild(label);
134    els.push(compass);
135
136    // Two coordinate markers (cross-bearings) scattered.
137    const markers = [
138      { x: w*0.32, y: h*0.30, r: 14 },
139      { x: w*0.68, y: h*0.72, r: 10 },
140    ];
141    markers.forEach((m, idx) => {
142      const g = make('g', { class:'bg-marker', transform:`translate(${m.x},${m.y})` });
143      g.appendChild(make('circle', { r: m.r }));
144      g.appendChild(make('circle', { r: 3, class:'bg-marker-dot' }));
145      if (idx === 0){
146        g.appendChild(make('line', { class:'bg-compass-tick', x1:-m.r-8, y1:0, x2:-m.r, y2:0 }));
147        g.appendChild(make('line', { class:'bg-compass-tick', x1: m.r,   y1:0, x2: m.r+8, y2:0 }));
148      }
149      els.push(g);
150    });
151
152    // Coordinate stamp text — gives a "log entry" feel.
153    const stampLabels = ['02 · CROSSING','03 · WAYPOINT','04 · CACHE','05 · MARKER','06 · TRAIL'];
154    const stamp = make('text', {
155      class:'bg-coord-text',
156      x: w*0.08, y: h*0.92
157    });
158    stamp.textContent = stampLabels[v];
159    els.push(stamp);
160
161    return els;
162  }
163
164  ready(() => {
165    let idx = 0;
166    document.querySelectorAll('.bg-cartograph > svg').forEach(svg => {
167      // Only enrich SVGs that look sparse.
168      const children = svg.children.length;
169      if (children >= 5) return;
170
171      // Parse viewBox to size the new content correctly.
172      const vb = (svg.getAttribute('viewBox') || '0 0 1600 900').split(/\s+/).map(Number);
173      const w = vb[2] || 1600;
174      const h = vb[3] || 900;
175
176      const newEls = buildVariant(idx++, w, h);
177
178      // Insert before any existing children so they sit on top.
179      const first = svg.firstChild;
180      newEls.forEach(el => svg.insertBefore(el, first));
181
182      svg.setAttribute('data-enriched','1');
183    });
184  });
185})();
186
187/* ====== Year ====== */
188(function setYear(){
189  const yr = document.getElementById('yr');
190  if (yr) yr.textContent = new Date().getFullYear();
191})();
192
193/* ====== Ambient mouse-follow green glow ====== */
194(() => {
195  const glow = document.getElementById('ambientGlow');
196  if (!glow) return;
197  const finePointer = window.matchMedia('(hover:hover) and (pointer:fine)').matches;
198  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
199  if (!finePointer || reduceMotion) return;
200
201  let tx = window.innerWidth / 2, ty = window.innerHeight / 3;
202  let gx = tx, gy = ty;
203  let running = false;
204
205  document.addEventListener('mousemove', (e) => {
206    tx = e.clientX; ty = e.clientY;
207    if (!glow.classList.contains('is-on')) glow.classList.add('is-on');
208    if (!running) { running = true; requestAnimationFrame(frame); }
209  });
210
211  function frame(){
212    // slow easing for an ambient drift, not a snappy cursor
213    gx += (tx - gx) * 0.05;
214    gy += (ty - gy) * 0.05;
215    glow.style.transform = `translate(${gx}px, ${gy}px) translate(-50%, -50%)`;
216    if (Math.abs(tx - gx) > 0.5 || Math.abs(ty - gy) > 0.5) {
217      requestAnimationFrame(frame);
218    } else {
219      running = false;
220    }
221  }
222})();
223
224/* ====== Header scroll state + parallax CSS variable for cartograph art ====== */
225const header = document.getElementById('header');
226const docEl = document.documentElement;
227const onScroll = () => {
228  header.classList.toggle('scrolled', window.scrollY > 24);
229  // Expose scroll position to CSS so the .bg-cartograph illustrations
230  // parallax up slightly as the user scrolls (subtle depth-of-field).
231  docEl.style.setProperty('--scroll-y', String(window.scrollY));
232};
233onScroll();
234window.addEventListener('scroll', onScroll, { passive:true });
235
236/* ====== Mobile drawer ====== */
237const menuBtn = document.getElementById('menuBtn');
238const drawer = document.getElementById('drawer');
239function setDrawer(open){
240  menuBtn.classList.toggle('open', open);
241  drawer.classList.toggle('open', open);
242  drawer.setAttribute('aria-hidden', String(!open));
243  menuBtn.setAttribute('aria-expanded', String(open));
244  menuBtn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
245  document.body.style.overflow = open ? 'hidden' : '';
246}
247menuBtn?.addEventListener('click', () => setDrawer(!menuBtn.classList.contains('open')));
248drawer?.querySelectorAll('a').forEach(a => a.addEventListener('click', () => setDrawer(false)));
249document.addEventListener('keydown', (e) => {
250  if (e.key === 'Escape' && menuBtn.classList.contains('open')) setDrawer(false);
251});
252
253/* ====== Hero (single-viewport static, no scroll-scrub) ====== */
254const heroCard = document.getElementById('heroCard');
255const scrollCue = document.getElementById('scrollCue');
256const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
257
258if (heroCard) heroCard.style.setProperty('--card-copy-op', 1);
259if (scrollCue) scrollCue.style.setProperty('--cue-op', 1);
260
261// Honour reduced-motion: pause every autoplay video, leave the poster in view.
262if (reducedMotion) {
263  document.querySelectorAll('video[autoplay]').forEach(v => {
264    v.removeAttribute('autoplay');
265    v.pause();
266  });
267}
268
269/* ====== IntersectionObserver reveals ====== */
270const io = new IntersectionObserver((entries) => {
271  entries.forEach(e => {
272    if (e.isIntersecting) {
273      e.target.classList.add('in');
274      io.unobserve(e.target);
275    }
276  });
277}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
278
279document.querySelectorAll('.reveal, .reveal-stagger').forEach(el => io.observe(el));
280
281/* Section card-lift trigger — adds .is-revealed to each .section-lift
282   when its top edge enters the viewport, animating the rise + scale */
283const liftIO = new IntersectionObserver((entries) => {
284  entries.forEach(e => {
285    if (e.isIntersecting) {
286      e.target.classList.add('is-revealed');
287      liftIO.unobserve(e.target);
288    }
289  });
290}, { threshold: 0.04, rootMargin: '0px 0px -2% 0px' });
291document.querySelectorAll('.section-lift').forEach(el => liftIO.observe(el));
292
293/* ====== Hero single clip — kick off playback, no carousel rotation ====== */
294(() => {
295  const carousel = document.getElementById('heroCarousel');
296  if (!carousel) return;
297  const video = carousel.querySelector('.hcv');
298  if (!video) return;
299  video.play().catch(() => {/* autoplay policy may block — fine, will play on first interaction */});
300})();
301
302/* Project cards now render as a static grid — horizontal-scroll driver removed. */
303
304/* ====== Word-by-word reveals on big cinematic headlines ====== */
305(() => {
306  function wrapWords(el){
307    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
308    const nodes = [];
309    while (walker.nextNode()) nodes.push(walker.currentNode);
310    nodes.forEach(node => {
311      if (!node.textContent.trim()) return;
312      const parts = node.textContent.split(/(\s+)/);
313      const frag = document.createDocumentFragment();
314      parts.forEach(p => {
315        if (/\S/.test(p)) {
316          const wrap = document.createElement('span');
317          wrap.className = 'word';
318          const inner = document.createElement('span');
319          inner.className = 'word-inner';
320          inner.textContent = p;
321          wrap.appendChild(inner);
322          frag.appendChild(wrap);
323        } else if (p.length) {
324          frag.appendChild(document.createTextNode(p));
325        }
326      });
327      node.parentNode.replaceChild(frag, node);
328    });
329  }
330  document.querySelectorAll('.reveal-words').forEach(wrapWords);
331
332  if (reducedMotion) {
333    document.querySelectorAll('.reveal-words').forEach(el => el.classList.add('in'));
334  } else {
335    const wIO = new IntersectionObserver((entries) => {
336      entries.forEach(e => {
337        if (e.isIntersecting) {
338          e.target.classList.add('in');
339          wIO.unobserve(e.target);
340        }
341      });
342    }, { threshold: 0.3 });
343    document.querySelectorAll('.reveal-words').forEach(el => wIO.observe(el));
344  }
345})();
346
347/* ====== Timeline + pillar icon reveals (per-item, once only) ====== */
348const tlIO = new IntersectionObserver((entries) => {
349  entries.forEach(e => {
350    if (e.isIntersecting) {
351      e.target.classList.add('in');
352      tlIO.unobserve(e.target);
353    }
354  });
355}, { threshold: 0.25, rootMargin: '0px 0px -10% 0px' });
356document.querySelectorAll('.tl-item, .pillar, .proj-card').forEach(el => tlIO.observe(el));
357
358/* ====== World map popups ====== */
359(() => {
360  const wrap = document.querySelector('.globe-wrap');
361  const popup = document.getElementById('mapPopup');
362  if (!wrap || !popup) return;
363  const mpImg = document.getElementById('mpImg');
364  const mpStatus = document.getElementById('mpStatus');
365  const mpWhere = document.getElementById('mpWhere');
366  const mpCoords = document.getElementById('mpCoords');
367  const mpName = document.getElementById('mpName');
368  const mpNgo = document.getElementById('mpNgo');
369  const mpDesc = document.getElementById('mpDesc');
370  const mpStat = document.getElementById('mpStat');
371  const mpCta = document.getElementById('mpCta');
372  const mpClose = document.getElementById('mpClose');
373
374  function close(){
375    popup.classList.remove('is-open');
376    popup.setAttribute('aria-hidden', 'true');
377    wrap.classList.remove('popup-open');
378  }
379  function openFor(pin){
380    const d = pin.dataset;
381    // Populate
382    mpImg.style.backgroundImage = d.img ? `url('${d.img}')` : 'none';
383    mpImg.style.display = d.img ? 'block' : 'none';
384    mpStatus.textContent = d.status === 'urgent' ? 'Urgent' : d.status === 'featured' ? 'Featured' : 'Active';
385    mpWhere.textContent = d.where || '—';
386    mpCoords.textContent = d.coords || '';
387    mpName.textContent = d.name || '—';
388    mpNgo.textContent = d.ngo || '';
389    mpDesc.textContent = d.desc || '';
390    mpStat.textContent = d.stat || '';
391    mpCta.setAttribute('href', d.href || '#');
392    popup.classList.remove('is-urgent', 'is-featured');
393    if (d.status === 'urgent') popup.classList.add('is-urgent');
394    if (d.status === 'featured') popup.classList.add('is-featured');
395
396    // Centre the popup in the middle of the map (size/position via CSS class)
397    const wrapRect = wrap.getBoundingClientRect();
398    const popupW = Math.min(380, wrapRect.width - 32);
399    popup.style.width = popupW + 'px';
400    popup.style.left = '50%';
401    popup.style.top = '50%';
402    // Clear any inline transform so CSS .is-open transition takes over
403    popup.style.transform = '';
404    wrap.classList.add('popup-open');
405
406    popup.classList.add('is-open');
407    popup.setAttribute('aria-hidden', 'false');
408  }
409
410  wrap.querySelectorAll('.pin').forEach(pin => {
411    pin.addEventListener('click', (e) => {
412      e.stopPropagation();
413      openFor(pin);
414    });
415  });
416  mpClose.addEventListener('click', close);
417  document.addEventListener('click', (e) => {
418    if (!popup.classList.contains('is-open')) return;
419    if (popup.contains(e.target)) return;
420    if (e.target.closest('.pin')) return;
421    close();
422  });
423  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
424})();
425
426/* ====== Number counters ====== */
427const easeOut = t => 1 - Math.pow(1 - t, 3);
428function animateCount(el) {
429  const to = parseInt(el.dataset.to, 10);
430  // Guard: only animate elements that opted in with a valid numeric data-to.
431  // Without this, plain .count elements (which carry their own markup) get
432  // overwritten with "NaN" and lose their inner label.
433  if (!Number.isFinite(to)) return;
434  const prefix = el.dataset.prefix || '';
435  const suffix = el.dataset.suffix || '';
436  const dur = 1800;
437  const start = performance.now();
438  const tick = (now) => {
439    const t = Math.min(1, (now - start) / dur);
440    const v = Math.floor(easeOut(t) * to);
441    el.textContent = prefix + v.toLocaleString() + suffix;
442    if (t < 1) requestAnimationFrame(tick);
443    else el.textContent = prefix + to.toLocaleString() + suffix;
444  };
445  requestAnimationFrame(tick);
446}
447const countIO = new IntersectionObserver((entries) => {
448  entries.forEach(e => {
449    if (e.isIntersecting) {
450      animateCount(e.target);
451      countIO.unobserve(e.target);
452    }
453  });
454}, { threshold: 0.4 });
455document.querySelectorAll('.count[data-to]').forEach(el => countIO.observe(el));
456
457/* ====== Pillars pinned-side slide swap ====== */
458const pillars = [...document.querySelectorAll('.pillar')];
459const slides = [...document.querySelectorAll('.pillars-media .slide')];
460const badgeText = document.getElementById('pillarBadgeText');
461const pillarLabels = [
462  'Pillar 01 · The Channel',
463  'Pillar 02 · NGO Pages',
464  'Pillar 03 · The Audience',
465  'Pillar 04 · Direct Action'
466];
467const pillarIO = new IntersectionObserver((entries) => {
468  entries.forEach(e => {
469    if (e.isIntersecting) {
470      const idx = parseInt(e.target.dataset.i, 10);
471      slides.forEach(s => s.classList.toggle('active', parseInt(s.dataset.i,10) === idx));
472      badgeText.textContent = pillarLabels[idx];
473    }
474  });
475}, { threshold: 0.5, rootMargin: '-25% 0px -25% 0px' });
476pillars.forEach(p => pillarIO.observe(p));
477
478/* ====== Project card spotlight + tilt ====== */
479document.querySelectorAll('.proj-card.js-tilt').forEach(card => {
480  card.addEventListener('mousemove', (e) => {
481    const r = card.getBoundingClientRect();
482    const x = ((e.clientX - r.left) / r.width) * 100;
483    const y = ((e.clientY - r.top) / r.height) * 100;
484    card.style.setProperty('--mx', x + '%');
485    card.style.setProperty('--my', y + '%');
486    const tiltX = (50 - y) * 0.05;
487    const tiltY = (x - 50) * 0.05;
488    card.style.transform = `perspective(1000px) rotateX(${tiltX}deg) rotateY(${tiltY}deg) translateZ(0)`;
489  });
490  card.addEventListener('mouseleave', () => {
491    card.style.transform = '';
492    card.style.setProperty('--mx', '50%');
493    card.style.setProperty('--my', '50%');
494  });
495});
496
497/* ====== Biome scrollytelling cross-fade ====== */
498(() => {
499  const section = document.querySelector('.biomes');
500  if (!section) return;
501  const layers = [...section.querySelectorAll('.biome-layer')];
502  const texts = [...section.querySelectorAll('.biome-text')];
503  const chips = [...section.querySelectorAll('.bchip')];
504  const n = layers.length;
505  let currentIdx = -1;
506
507  function setActive(idx){
508    if (idx === currentIdx) return;
509    currentIdx = idx;
510    layers.forEach((l, i) => {
511      const active = i === idx;
512      l.classList.toggle('is-active', active);
513      if (active) l.play().catch(() => {});
514      else l.pause();
515    });
516    texts.forEach((t, i) => t.classList.toggle('is-active', i === idx));
517    chips.forEach((c, i) => c.classList.toggle('is-active', i === idx));
518  }
519
520  function onScroll(){
521    const r = section.getBoundingClientRect();
522    const total = section.offsetHeight - window.innerHeight;
523    const progress = Math.max(0, Math.min(1, -r.top / total));
524    // Map progress to biome index. Each biome owns 1/n of the scroll.
525    const idx = Math.min(n - 1, Math.floor(progress * n));
526    setActive(idx);
527  }
528
529  if (reducedMotion) {
530    setActive(0);
531  } else {
532    onScroll();
533    window.addEventListener('scroll', onScroll, { passive:true });
534    window.addEventListener('resize', onScroll);
535  }
536})();
537
538/* ====== Pause off-screen videos (saves CPU / bandwidth) + custom
539   playback rates ============================================
540   Any video with `data-rate="<n>"` plays at that rate. The rate
541   setup runs UNGATED by reducedMotion (rate is unrelated to motion
542   preference) and attaches a `ratechange` listener that re-asserts
543   our target whenever the rate drifts — handles browsers that
544   reset to 1x after pause/resume, autoplay-from-attribute fires
545   before our handlers attach, etc.
546   ============================================================ */
547function applyVideoRate(v){
548  const r = parseFloat(v.dataset.rate || '');
549  if (!isFinite(r) || r <= 0) return;
550  // Guard against re-entry: when WE set playbackRate, the assignment
551  // triggers a `ratechange` event which would call this again. The
552  // flag breaks that loop.
553  if (v._ecoflixSettingRate) return;
554  if (Math.abs(v.playbackRate - r) > 0.001){
555    try {
556      v._ecoflixSettingRate = true;
557      v.playbackRate = r;
558    } catch(_){}
559    setTimeout(() => { v._ecoflixSettingRate = false; }, 0);
560  }
561}
562// Wire rate-control to EVERY tagged video, regardless of
563// reducedMotion / IntersectionObserver support. This block runs
564// at script parse time AND on DOMContentLoaded so it catches both
565// early autoplay and any videos injected later.
566function wireVideoRate(v){
567  if (v._ecoflixRateWired) return;
568  v._ecoflixRateWired = true;
569  applyVideoRate(v);
570  ['play', 'playing', 'loadedmetadata', 'loadeddata',
571   'canplay', 'seeked', 'ratechange'].forEach(ev => {
572    v.addEventListener(ev, () => applyVideoRate(v));
573  });
574  // Belt-and-braces: re-assert at 150ms, 500ms, 1500ms in case some
575  // upstream code (a library, the browser itself) resets the rate
576  // after first play.
577  setTimeout(() => applyVideoRate(v), 150);
578  setTimeout(() => applyVideoRate(v), 500);
579  setTimeout(() => applyVideoRate(v), 1500);
580}
581function wireAllVideoRates(){
582  document.querySelectorAll('video[data-rate]').forEach(wireVideoRate);
583}
584wireAllVideoRates();
585if (document.readyState === 'loading'){
586  document.addEventListener('DOMContentLoaded', wireAllVideoRates, { once:true });
587}
588
589// Off-screen pause/play — kept inside the reducedMotion + IO guard
590// because that's a perf optimisation (motion-related). The rate
591// system above runs independently of this.
592if (!reducedMotion && 'IntersectionObserver' in window) {
593  const videoIO = new IntersectionObserver((entries) => {
594    entries.forEach(e => {
595      const v = e.target;
596      if (e.isIntersecting) {
597        applyVideoRate(v);
598        v.play().catch(() => {/* autoplay policy may block — ignore */});
599      } else {
600        v.pause();
601      }
602    });
603  }, { threshold: 0.1 });
604  document.querySelectorAll('.btile video, .hero-video, .pvideo, .wvideo, .fq-video, .img-video, .trailer-video, .hgb-video, .cinema-band-video').forEach(v => {
605    videoIO.observe(v);
606  });
607}
608
609/* FAQ accordion is now native <details>/<summary> — no JS needed. */
610
611/* ====== World map activation ======
612   Adds .is-visible to the globe when it enters the viewport (triggers
613   the staggered draw-in for continents, arcs and pins). Then runs an
614   ambient donation-ping loop: every ~1.6s a green expanding ring
615   appears at a random pin location, like a real-time donation feed. */
616(function activateGlobe(){
617  const wrap = document.getElementById('globeWrap');
618  if (!wrap) return;
619
620  let activated = false;
621  const io = new IntersectionObserver((entries) => {
622    entries.forEach(e => {
623      if (e.isIntersecting && !activated){
624        activated = true;
625        wrap.classList.add('is-visible');
626        // Wait for pins to land, then start the ambient pings
627        setTimeout(startPings, 2800);
628      }
629    });
630  }, { threshold: 0.25 });
631  io.observe(wrap);
632
633  function startPings(){
634    if (window.matchMedia('(prefers-reduced-motion:reduce)').matches) return;
635    const pins = Array.from(wrap.querySelectorAll('.pin'));
636    if (!pins.length) return;
637    let lastIdx = -1;
638
639    function spawn(){
640      // Don't spawn while the popup is open — the page is in focus mode
641      if (wrap.classList.contains('popup-open')) {
642        setTimeout(spawn, 1800);
643        return;
644      }
645      // Pick a pin distinct from the last one
646      let idx;
647      do {
648        idx = Math.floor(Math.random() * pins.length);
649      } while (idx === lastIdx && pins.length > 1);
650      lastIdx = idx;
651
652      const pin = pins[idx];
653      const ping = document.createElement('div');
654      ping.className = 'donation-ping';
655      // Featured/urgent pins get a hotter ring to match their dot
656      if (pin.classList.contains('is-urgent')){
657        ping.style.borderColor = 'rgba(226,106,91,.85)';
658        ping.style.boxShadow = '0 0 14px rgba(226,106,91,.55)';
659      } else if (pin.classList.contains('is-featured')){
660        ping.style.borderColor = 'rgba(240,194,95,.85)';
661        ping.style.boxShadow = '0 0 14px rgba(240,194,95,.55)';
662      }
663      ping.style.left = pin.style.left;
664      ping.style.top = pin.style.top;
665      wrap.appendChild(ping);
666      setTimeout(() => ping.remove(), 1900);
667
668      // Stagger 1.2–2.4s between pings for natural rhythm
669      const next = 1200 + Math.random() * 1200;
670      setTimeout(spawn, next);
671    }
672    spawn();
673  }
674})();
675
676/* ====== Smooth anchor (header offset) ====== */
677document.querySelectorAll('a[href^="#"]').forEach(a => {
678  a.addEventListener('click', (e) => {
679    const id = a.getAttribute('href');
680    if (id.length > 1) {
681      const target = document.querySelector(id);
682      if (target) {
683        e.preventDefault();
684        const top = target.getBoundingClientRect().top + window.scrollY - 70;
685        window.scrollTo({ top, behavior: 'smooth' });
686      }
687    }
688  });
689});
690
691/* ===== Geolocate: find the nearest project ===== */
692(function(){
693  const strip = document.getElementById('nearMe');
694  if (!strip) return;
695  const btn = document.getElementById('nearMeBtn');
696  const btnLabel = strip.querySelector('.nm-btn-label');
697  const userLatEl = strip.querySelector('.nm-user-lat');
698  const userLonEl = strip.querySelector('.nm-user-lon');
699  const nearestNameEl = strip.querySelector('.nm-nearest-name');
700  const nearestDistEl = strip.querySelector('.nm-dist');
701  const headlineEl = strip.querySelector('.nm-headline');
702  const idleHeadline = headlineEl.textContent;
703
704  function setState(s){ strip.setAttribute('data-state', s); }
705
706  function fmtCoord(v, posLabel, negLabel){
707    const dir = v >= 0 ? posLabel : negLabel;
708    return Math.abs(v).toFixed(2) + '°' + dir;
709  }
710  function haversine(lat1, lon1, lat2, lon2){
711    const R = 6371; // km
712    const toRad = x => x * Math.PI / 180;
713    const dLat = toRad(lat2 - lat1);
714    const dLon = toRad(lon2 - lon1);
715    const a = Math.sin(dLat/2)**2 +
716              Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
717              Math.sin(dLon/2)**2;
718    return 2 * R * Math.asin(Math.sqrt(a));
719  }
720
721  function findNearest(userLat, userLon){
722    const cards = Array.from(document.querySelectorAll('.proj-card[data-lat][data-lon]'));
723    let best = null;
724    cards.forEach(c => {
725      const lat = parseFloat(c.dataset.lat);
726      const lon = parseFloat(c.dataset.lon);
727      if (Number.isNaN(lat) || Number.isNaN(lon)) return;
728      const d = haversine(userLat, userLon, lat, lon);
729      if (!best || d < best.dist) best = { card: c, dist: d, name: c.dataset.name || 'Project' };
730    });
731    return best;
732  }
733
734  function applyResult(userLat, userLon){
735    userLatEl.textContent = fmtCoord(userLat, 'N', 'S');
736    userLonEl.textContent = fmtCoord(userLon, 'E', 'W');
737    const nearest = findNearest(userLat, userLon);
738    if (nearest){
739      nearestNameEl.textContent = nearest.name;
740      const km = nearest.dist;
741      const niceDist = km < 1
742        ? '< 1 km away'
743        : km < 10
744          ? km.toFixed(1) + ' km away'
745          : Math.round(km).toLocaleString() + ' km away';
746      nearestDistEl.textContent = niceDist;
747      // mark the card
748      document.querySelectorAll('.proj-card.is-nearest').forEach(el => el.classList.remove('is-nearest'));
749      nearest.card.classList.add('is-nearest');
750      headlineEl.textContent = 'Your nearest project';
751    }
752    setState('granted');
753  }
754
755  function handleError(err){
756    setState('error');
757    btnLabel.textContent = 'Try again';
758    headlineEl.textContent = idleHeadline;
759    const status = strip.querySelector('.nearme-status');
760    if (status){
761      if (err && err.code === 1) status.textContent = 'Location blocked. You can re-enable it in your browser settings.';
762      else if (err && err.code === 3) status.textContent = 'Took too long — try again?';
763      else status.textContent = 'Couldn’t pin your location. Try again?';
764    }
765  }
766
767  btn.addEventListener('click', () => {
768    if (!('geolocation' in navigator)){
769      handleError({ code: 0 });
770      return;
771    }
772    setState('loading');
773    btnLabel.textContent = 'Locating…';
774    navigator.geolocation.getCurrentPosition(
775      pos => applyResult(pos.coords.latitude, pos.coords.longitude),
776      err => handleError(err),
777      { enableHighAccuracy:false, timeout:10000, maximumAge:300000 }
778    );
779  });
780})();
781
782/* ================= CLIENT DEMO MODULES ================= */
783
784/* === 1. Live giving feed (in-page section) === */
785(function(){
786  const list = document.getElementById('lfList');
787  const countEl = document.getElementById('lfCount');
788  if (!list) return;
789
790  // Pool of names + cities + projects. Each tick picks one combo + a plausible amount.
791  const names = ['Sarah','Marcus','Aisha','Tom','Lena','Ravi','Camille','James','Mei','Ben','Olivia','Jonas','Priya','Lucas','Nia','Felix','Yuki','Hannah','Diego','Amara','Ines','Oscar','Sofia','Theo'];
792  const cities = ['Berlin','Brooklyn','London','Melbourne','Stockholm','Bangalore','Paris','San Francisco','Singapore','Dublin','Lisbon','Oslo','Cape Town','Toronto','Montréal','Madrid','Edinburgh','Auckland','Buenos Aires','Reykjavík','Nairobi','Bristol'];
793  const projects = ['Wolf Connection','The Last Stand','Elephant Nature Park','Going Wild','Free Billy','Wildlife on the Edge'];
794  const amounts = [10,15,20,25,35,40,50,50,75,100,100,150,200,250,500,1000];
795
796  const MAX_ROWS = 6;
797  const TICK_MS = 4200; // new donation lands roughly every 4s
798  let counter = 47; // matches initial "47 donations this hour"
799  let secondsSinceFirst = 0; // for "when" labels
800
801  function fmtWhen(seconds){
802    if (seconds < 10) return 'just now';
803    if (seconds < 60) return seconds + 's ago';
804    const m = Math.floor(seconds / 60);
805    if (m < 60) return m + 'm ago';
806    const h = Math.floor(m / 60);
807    return h + 'h ago';
808  }
809
810  function pickActivity(){
811    const n = names[Math.floor(Math.random() * names.length)];
812    const c = cities[Math.floor(Math.random() * cities.length)];
813    const p = projects[Math.floor(Math.random() * projects.length)];
814    const a = amounts[Math.floor(Math.random() * amounts.length)];
815    return { who: n + ' · ' + c, amt: a, project: p };
816  }
817
818  function buildRow(a, whenLabel){
819    const li = document.createElement('li');
820    li.className = 'lf-row is-new';
821    li.innerHTML = `
822      <span class="lf-mini-dot" aria-hidden="true"></span>
823      <div class="lf-body">
824        <span class="lf-line"><b>${a.who}</b> just gave <span class="lf-amt">$${a.amt.toLocaleString()}</span> to <b>${a.project}</b></span>
825        <span class="lf-meta"><span class="lf-when">${whenLabel}</span></span>
826      </div>
827    `;
828    return li;
829  }
830
831  // Re-label all rows' "when" relative to their age
832  function rerollTimes(){
833    const rows = list.querySelectorAll('.lf-row');
834    rows.forEach((row, idx) => {
835      const whenEl = row.querySelector('.lf-when');
836      if (!whenEl) return;
837      const age = parseInt(row.dataset.age || '0', 10);
838      whenEl.textContent = fmtWhen(age);
839    });
840  }
841
842  // Age every visible row by `delta` seconds
843  function ageRows(delta){
844    list.querySelectorAll('.lf-row').forEach(row => {
845      const age = parseInt(row.dataset.age || '0', 10) + delta;
846      row.dataset.age = age;
847    });
848  }
849
850  function addRow(){
851    const a = pickActivity();
852    const li = buildRow(a, 'just now');
853    li.dataset.age = '0';
854    list.insertBefore(li, list.firstChild);
855    // remove .is-new after animation so subsequent rerolls don't re-trigger
856    setTimeout(() => li.classList.remove('is-new'), 700);
857
858    // trim list — only remove rows that haven't already been marked for removal
859    const overflow = list.querySelectorAll('.lf-row:not(.is-leaving)');
860    for (let k = MAX_ROWS; k < overflow.length; k++){
861      const old = overflow[k];
862      old.classList.add('is-leaving');
863      setTimeout(() => old.remove(), 480);
864    }
865
866    counter++;
867    countEl.textContent = counter;
868  }
869
870  // Seed with 5 historical rows
871  function seed(){
872    const seedAges = [12, 38, 75, 142, 240]; // seconds ago
873    seedAges.forEach((age, idx) => {
874      const a = pickActivity();
875      const li = buildRow(a, fmtWhen(age));
876      li.classList.remove('is-new');
877      li.dataset.age = age;
878      list.appendChild(li);
879    });
880  }
881
882  seed();
883
884  // Tick: age rows + maybe add new
885  setInterval(() => {
886    ageRows(1);
887    rerollTimes();
888  }, 1000);
889
890  setInterval(addRow, TICK_MS);
891})();
892
893/* === 2. Impact calculator === */
894(function(){
895  const slider = document.getElementById('calcSlider');
896  if (!slider) return;
897  const valEl = document.getElementById('calcVal');
898  const headlineEl = document.getElementById('calcHeadline');
899  const subEl = document.getElementById('calcSub');
900  const iconEl = document.getElementById('calcImpactIcon');
901  const impactWrap = document.getElementById('calcImpact');
902  const ctaAmtEl = document.getElementById('calcCtaAmt');
903  const ctaEl = document.getElementById('calcCta');
904  const presets = document.querySelectorAll('#calcPresets .calc-preset');
905
906  // Tiers: each defines a threshold (amount) and the impact copy + icon
907  const tiers = [
908    { up:9,   icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M4 8c4 0 4 6 8 6s4-6 8-6"/></svg>',
909      h:'A ranger meal in Kenya',
910      s:'A hot meal at the end of a 12-hour patrol. Small but it keeps people in the field.' },
911    { up:24,  icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="4"/><path d="M3 21c0-5 4-9 9-9s9 4 9 9"/></svg>',
912      h:'Two days of school workshops',
913      s:'Curriculum and teacher packs for a class learning what conservation actually does.' },
914    { up:74,  icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l8-8 8 8M6 10v10h12V10"/><path d="M9 20v-6h6v6"/></svg>',
915      h:'A day of drone surveillance over Sumatra',
916      s:'Forest Watch Indonesia maps illegal logging from the air daily — this funds one flight.' },
917    { up:199, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6"/></svg>',
918      h:'A week of sanctuary care for one elephant',
919      s:'Food, vet checks, mahout salary. Save Elephant Foundation runs the largest sanctuary in Thailand.' },
920    { up:499, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 21c0-3 2.5-6 6-6s6 3 6 6"/><path d="M14 21c0-2 2-4 4-4s4 2 4 4"/></svg>',
921      h:'A herder fence in Nepal',
922      s:'Solar-powered livestock fences stop snow leopards being killed in retaliation by herders.' },
923    { up:999, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c-3 5-3 9 0 12s3 6 0 8"/><path d="M5 11c2 1 3 3 3 5M19 11c-2 1-3 3-3 5"/></svg>',
924      h:'A month of ranger pay, kit and fuel',
925      s:'Boots, salary, vehicle, radio. A full ranger paid in the field for 30 days.' },
926    { up:Infinity, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L3 7v6c0 5 4 9 9 9s9-4 9-9V7l-9-5z"/><path d="M9 12l2 2 4-4"/></svg>',
927      h:'An acre of old-growth forest protected',
928      s:'Legal challenge, ranger patrol, drone mapping. The full stack to defend a chunk of forest.' },
929  ];
930
931  function pick(v){
932    for (const t of tiers){ if (v <= t.up) return t; }
933    return tiers[tiers.length-1];
934  }
935  function update(v, fromPreset){
936    const n = parseInt(v,10);
937    valEl.textContent = n.toLocaleString();
938    ctaAmtEl.textContent = '$' + n.toLocaleString();
939    const t = pick(n);
940    headlineEl.textContent = t.h;
941    subEl.textContent = t.s;
942    iconEl.innerHTML = t.icon;
943    // slider fill %
944    const pct = ((n - parseInt(slider.min,10)) / (parseInt(slider.max,10) - parseInt(slider.min,10))) * 100;
945    slider.style.setProperty('--p', pct.toFixed(1) + '%');
946    // bump animation
947    impactWrap.classList.remove('bump'); void impactWrap.offsetWidth; impactWrap.classList.add('bump');
948    // preset active state
949    presets.forEach(b => b.classList.toggle('active', parseInt(b.dataset.v,10) === n));
950  }
951  slider.addEventListener('input', e => update(e.target.value));
952  presets.forEach(b => b.addEventListener('click', () => { slider.value = b.dataset.v; update(b.dataset.v, true); }));
953  update(slider.value);
954})();
955
956/* === 3. Species-lost counter === */
957(function(){
958  const el = document.getElementById('speciesNum');
959  if (!el) return;
960  // Wilson estimate: ~27,000 species lost per year. As of mid-May (~day 133),
961  // we'd be at ~27,000 * (133/365) ≈ 9,830. Bump to a punchier baseline + live tick.
962  // Tick: 27,000/year = 1 every ~19 min ≈ 0.0009 per sec.
963  // We tick the counter visibly by ~1 every 18-20 seconds for visual life.
964  const start = Date.now();
965  const baseline = 14237;
966  let visibleCount = baseline;
967  function tick(){
968    visibleCount += 1;
969    el.textContent = visibleCount.toLocaleString();
970  }
971  // Trigger when section becomes visible
972  const io = new IntersectionObserver(entries => {
973    entries.forEach(e => {
974      if (e.isIntersecting){
975        // count-up reveal: animate baseline up over 1.4s
976        const target = baseline;
977        const dur = 1400; const t0 = performance.now();
978        function step(now){
979          const k = Math.min(1, (now - t0) / dur);
980          const eased = 1 - Math.pow(1 - k, 3);
981          el.textContent = Math.round(target * eased).toLocaleString();
982          if (k < 1) requestAnimationFrame(step);
983          else {
984            // live tick every ~19s
985            setInterval(tick, 19000);
986          }
987        }
988        requestAnimationFrame(step);
989        io.unobserve(e.target);
990      }
991    });
992  }, { threshold: .3 });
993  io.observe(el);
994})();
995
996/* === 5. Press strip reveal (no extra JS needed; reveal handled by IO) === */
997
998/* === 6. Hero sound toggle === */
999(function(){
1000  const btn = document.getElementById('heroSoundBtn');
1001  if (!btn) return;
1002  const vid = document.querySelector('.hero-carousel .hcv');
1003  if (!vid) return;
1004  btn.addEventListener('click', () => {
1005    const on = btn.getAttribute('data-on') === 'true';
1006    if (on){
1007      vid.muted = true;
1008      btn.setAttribute('data-on','false');
1009      btn.setAttribute('aria-label','Turn sound on');
1010    } else {
1011      vid.muted = false;
1012      // some browsers require volume + a fresh play call after unmute
1013      try { vid.volume = 0.6; vid.play(); } catch(e){}
1014      btn.setAttribute('data-on','true');
1015      btn.setAttribute('aria-label','Turn sound off');
1016    }
1017  });
1018})();
1019
1020/* === 7. Donate-button leaf burst === */
1021(function(){
1022  document.querySelectorAll('.btn-primary').forEach(btn => {
1023    if (btn.querySelector('.leafburst')) return;
1024    const wrap = document.createElement('span');
1025    wrap.className = 'leafburst';
1026    wrap.setAttribute('aria-hidden','true');
1027    const N = 8;
1028    for (let k = 0; k < N; k++){
1029      const leaf = document.createElement('span');
1030      leaf.className = 'leaf-p';
1031      const angle = (Math.PI * 2 * k) / N + (Math.random() * 0.6);
1032      const dist = 50 + Math.random() * 38;
1033      const dx = Math.cos(angle) * dist;
1034      const dy = Math.sin(angle) * dist - 30;
1035      leaf.style.setProperty('--ldx', dx.toFixed(1) + 'px');
1036      leaf.style.setProperty('--ldy', dy.toFixed(1) + 'px');
1037      leaf.style.setProperty('--lr', (Math.random()*120 - 60).toFixed(0) + 'deg');
1038      leaf.style.animationDelay = (k * 0.04).toFixed(2) + 's';
1039      wrap.appendChild(leaf);
1040    }
1041    btn.appendChild(wrap);
1042  });
1043})();
1044
1045/* === 8. Picks for tonight — purely declarative, no JS needed === */
1046
1047/* === 9. Cursor compass on dark sections === */
1048(function(){
1049  if (matchMedia('(pointer:coarse)').matches) return; // skip touch devices
1050  const cc = document.createElement('div');
1051  cc.className = 'cursor-compass';
1052  cc.innerHTML = '<span class="cc-n">N</span><span class="cc-needle"></span>';
1053  document.body.appendChild(cc);
1054  const needle = cc.querySelector('.cc-needle');
1055
1056  let mx = 0, my = 0, rx = 0, ry = 0;
1057  let active = false;
1058  let lastMove = 0;
1059  document.addEventListener('mousemove', e => {
1060    mx = e.clientX; my = e.clientY;
1061    lastMove = Date.now();
1062    // Determine if we're over a dark section. Use elementFromPoint and walk up
1063    const node = document.elementFromPoint(mx, my);
1064    if (!node) return;
1065    const dark = node.closest('.broll-section, .proof, .convert, .hp-promise, .anchor, .watch-change, .founder-quote, .hscroll-stage, footer');
1066    const hover = node.closest('a, button, [role="button"], input, textarea, select');
1067    active = !!dark && !hover;
1068    cc.classList.toggle('show', active);
1069  });
1070  function raf(){
1071    rx += (mx - rx) * 0.22;
1072    ry += (my - ry) * 0.22;
1073    cc.style.transform = `translate(${rx}px, ${ry}px) translate(-50%,-50%)`;
1074    // rotate needle based on cursor velocity direction (subtle)
1075    const angle = (Math.sin(Date.now() / 1800) * 18); // slow drift like real compass
1076    needle.style.transform = `rotate(${angle.toFixed(2)}deg)`;
1077    requestAnimationFrame(raf);
1078  }
1079  requestAnimationFrame(raf);
1080})();
1081
1082/* === 10. Live viewers count === REMOVED.
1083   The simulated "people on this page right now" counter was taken down
1084   sitewide (owner decision, 2026-06) — its markup is gone and this driver
1085   is intentionally disabled. */
1086
1087/* ================= UX ENHANCEMENT MODULES ================= */
1088
1089/* === Back-to-top + sticky donate bar + chapter nav (scroll-driven) === */
1090(function(){
1091  const bttop = document.getElementById('backToTop');
1092  const sdb = document.getElementById('stickyDonate');
1093  const chapNav = document.getElementById('chapNav');
1094  const chapDots = chapNav ? Array.from(chapNav.querySelectorAll('.chap-dot')) : [];
1095
1096  function onScroll(){
1097    const y = window.scrollY;
1098    const vh = window.innerHeight;
1099    const docH = document.documentElement.scrollHeight;
1100
1101    // Back-to-top
1102    if (bttop) bttop.classList.toggle('show', y > vh * 0.8);
1103
1104    // Sticky donate bar — show after hero
1105    if (sdb){
1106      const past = y > vh * 0.65;
1107      const nearFoot = (y + vh) > (docH - 200);
1108      sdb.classList.toggle('show', past && !nearFoot);
1109      sdb.classList.toggle('atfoot', nearFoot);
1110    }
1111
1112    // Chapter nav — show after intro
1113    if (chapNav) chapNav.classList.toggle('show', y > vh * 0.4);
1114
1115    // Highlight current chapter
1116    chapDots.forEach(dot => {
1117      const target = document.querySelector(dot.dataset.target);
1118      if (!target) return;
1119      const r = target.getBoundingClientRect();
1120      const isInView = r.top < vh * 0.4 && r.bottom > vh * 0.2;
1121      dot.classList.toggle('is-active', isInView);
1122    });
1123  }
1124
1125  if (bttop) bttop.addEventListener('click', () => window.scrollTo({ top:0, behavior:'smooth' }));
1126
1127  if (chapNav){
1128    chapDots.forEach(dot => {
1129      dot.addEventListener('click', () => {
1130        const target = document.querySelector(dot.dataset.target);
1131        if (!target) return;
1132        const top = target.getBoundingClientRect().top + window.scrollY - 60;
1133        window.scrollTo({ top, behavior:'smooth' });
1134      });
1135    });
1136  }
1137
1138  window.addEventListener('scroll', onScroll, { passive:true });
1139  onScroll();
1140})();
1141
1142/* === Sticky donate-bar buttons === */
1143(function(){
1144  const calcBtn = document.getElementById('sdbCalcBtn');
1145  const donateBtn = document.getElementById('sdbDonateBtn');
1146  if (calcBtn){
1147    calcBtn.addEventListener('click', () => {
1148      const calc = document.getElementById('impactCalc');
1149      if (!calc) return;
1150      const top = calc.getBoundingClientRect().top + window.scrollY - 80;
1151      window.scrollTo({ top, behavior:'smooth' });
1152    });
1153  }
1154  if (donateBtn){
1155    donateBtn.addEventListener('click', () => {
1156      // Open donation preview modal pre-filled to $50
1157      window.dispatchEvent(new CustomEvent('open-donate-modal', { detail:{ amount:50 } }));
1158    });
1159  }
1160})();
1161
1162/* === Cookie / privacy bar === */
1163(function(){
1164  const bar = document.getElementById('cookBar');
1165  if (!bar) return;
1166  const KEY = 'ecoflix.cookies';
1167  if (localStorage.getItem(KEY)) return; // already chosen
1168  setTimeout(() => bar.classList.add('show'), 1800);
1169  function close(choice){
1170    bar.classList.remove('show');
1171    localStorage.setItem(KEY, choice);
1172    setTimeout(() => bar.remove(), 500);
1173  }
1174  document.getElementById('cbAccept').addEventListener('click', () => close('accepted'));
1175  document.getElementById('cbDecline').addEventListener('click', () => close('declined'));
1176})();
1177
1178/* === Image shimmer === */
1179(function(){
1180  document.querySelectorAll('.proj-card .img').forEach(img => {
1181    // Try to detect when bg image is loaded
1182    const url = img.style.backgroundImage.match(/url\(['"]?([^'"]+)['"]?\)/);
1183    if (!url) { img.classList.add('img-loaded'); return; }
1184    const tmp = new Image();
1185    tmp.onload = () => img.classList.add('img-loaded');
1186    tmp.onerror = () => img.classList.add('img-loaded');
1187    tmp.src = url[1];
1188  });
1189})();
1190
1191/* === Save projects (heart) + filter + view toggle + recall ribbon === */
1192(function(){
1193  const KEY = 'ecoflix.savedProjects';
1194  function load(){
1195    try { return JSON.parse(localStorage.getItem(KEY) || '[]'); }
1196    catch(e){ return []; }
1197  }
1198  function save(list){ localStorage.setItem(KEY, JSON.stringify(list)); }
1199
1200  let saved = load();
1201  const cards = Array.from(document.querySelectorAll('.proj-card[data-id]'));
1202  if (!cards.length) return;
1203
1204  // Inject heart button into each card (top-right; respects existing coord-stamp)
1205  cards.forEach(card => {
1206    const id = card.dataset.id;
1207    const btn = document.createElement('button');
1208    btn.className = 'save-btn';
1209    btn.type = 'button';
1210    btn.setAttribute('aria-label', 'Save project');
1211    btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-4.5-9.5-9C1 9 3 5 7 5c2 0 4 1 5 3 1-2 3-3 5-3 4 0 6 4 4.5 7-2.5 4.5-9.5 9-9.5 9z"/></svg>';
1212    if (saved.includes(id)) btn.classList.add('is-saved');
1213    btn.addEventListener('click', (e) => {
1214      e.preventDefault(); e.stopPropagation();
1215      saved = load();
1216      if (saved.includes(id)){
1217        saved = saved.filter(s => s !== id);
1218        btn.classList.remove('is-saved');
1219        btn.setAttribute('aria-label', 'Save project');
1220      } else {
1221        saved.push(id);
1222        btn.classList.add('is-saved');
1223        btn.setAttribute('aria-label', 'Unsave project');
1224      }
1225      save(saved);
1226      updateSavedCount();
1227      refilter();
1228    });
1229    // Coord-stamp lives at bottom-right; heart at top-right. No collision.
1230    card.appendChild(btn);
1231  });
1232
1233  // Saved count chip + recall ribbon
1234  const savedCountEl = document.getElementById('savedCount');
1235  const ribbon = document.getElementById('savedRibbon');
1236  function updateSavedCount(){
1237    if (savedCountEl) savedCountEl.textContent = '(' + saved.length + ')';
1238    if (ribbon){
1239      if (saved.length > 0){
1240        ribbon.hidden = false;
1241        ribbon.querySelector('.sr-count').textContent = saved.length;
1242        requestAnimationFrame(() => ribbon.classList.add('show'));
1243      } else {
1244        ribbon.classList.remove('show');
1245        setTimeout(() => { ribbon.hidden = true; }, 400);
1246      }
1247    }
1248  }
1249  updateSavedCount();
1250
1251  document.getElementById('srClear')?.addEventListener('click', () => {
1252    saved = [];
1253    save(saved);
1254    document.querySelectorAll('.save-btn.is-saved').forEach(b => b.classList.remove('is-saved'));
1255    updateSavedCount();
1256    refilter();
1257  });
1258
1259  // Filter chips
1260  const chips = document.querySelectorAll('.proj-chip');
1261  let activeFilter = 'all';
1262  let ecoSet = null;             // project ids for the active ?ecosystem= deep-link, else null
1263  function refilter(){
1264    cards.forEach(card => {
1265      let show = true;
1266      if (activeFilter === 'urgent'){
1267        show = card.classList.contains('is-urgent');
1268      } else if (activeFilter === 'saved'){
1269        show = saved.includes(card.dataset.id);
1270      } else if (activeFilter === 'featured'){
1271        show = card.dataset.featured === 'true';
1272      } else if (activeFilter === 'completed'){
1273        show = card.dataset.status === 'completed';
1274      } else if (activeFilter !== 'all'){
1275        show = card.dataset.region === activeFilter;
1276      }
1277      if (ecoSet) show = show && ecoSet.has(card.dataset.id);
1278      card.classList.toggle('is-filtered-out', !show);
1279    });
1280  }
1281  function clearEco(){ ecoSet = null; document.getElementById('ecoChip')?.remove(); }
1282  chips.forEach(chip => {
1283    chip.addEventListener('click', () => {
1284      clearEco();               // a built-in chip supersedes the ecosystem deep-link
1285      chips.forEach(c => c.classList.remove('active'));
1286      chip.classList.add('active');
1287      activeFilter = chip.dataset.filter;
1288      refilter();
1289    });
1290  });
1291
1292  // Ecosystem deep-link (?ecosystem=oceans). The homepage "Find the wild you
1293  // care about" tiles land here; map the ecosystem to its projects via the
1294  // curated /assets/ecosystems.json and filter the carousel to match, with a
1295  // dismissible chip so it's clear what's showing and how to clear it. Async,
1296  // so nudge panels.js to re-collect the visible cards once it applies.
1297  const ecoParam = new URLSearchParams(location.search).get('ecosystem');
1298  if (ecoParam){
1299    fetch('/assets/ecosystems.json').then(r => r.json()).then(map => {
1300      const ids = map && map[ecoParam];
1301      if (!Array.isArray(ids) || !ids.length) return;   // unknown/empty → leave everything showing
1302      ecoSet = new Set(ids);
1303      const label = ecoParam.charAt(0).toUpperCase() + ecoParam.slice(1);
1304      const chip = document.createElement('button');
1305      chip.id = 'ecoChip';
1306      chip.type = 'button';
1307      chip.className = 'proj-chip active';
1308      chip.innerHTML = label + ' <span class="pc-count">(' + ids.length + ')</span> <span aria-hidden="true" style="margin-left:3px">&times;</span>';
1309      chip.setAttribute('aria-label', label + ' ecosystem — clear filter');
1310      chip.addEventListener('click', () => {
1311        clearEco();
1312        const all = document.querySelector('.proj-chip[data-filter="all"]');
1313        chips.forEach(c => c.classList.remove('active'));
1314        if (all) all.classList.add('active');
1315        activeFilter = 'all';
1316        refilter();
1317        document.getElementById('hscrollTrack')?.dispatchEvent(new Event('pcs:refilter'));
1318      });
1319      chips.forEach(c => c.classList.remove('active'));   // no built-in chip active while an ecosystem is
1320      const row = (chips[0] && chips[0].parentElement) || document.body;
1321      row.insertBefore(chip, chips[0] || null);
1322      refilter();
1323      document.getElementById('hscrollTrack')?.dispatchEvent(new Event('pcs:refilter'));
1324    }).catch(() => {});
1325  }
1326
1327  // View toggle
1328  const track = document.getElementById('hscrollTrack');
1329  document.querySelectorAll('.view-toggle button').forEach(b => {
1330    b.addEventListener('click', () => {
1331      document.querySelectorAll('.view-toggle button').forEach(x => x.classList.remove('active'));
1332      b.classList.add('active');
1333      if (track){
1334        track.classList.toggle('view-list', b.dataset.view === 'list');
1335      }
1336    });
1337  });
1338})();
1339
1340/* === Press detail expander === */
1341(function(){
1342  const grid = document.getElementById('pressGrid');
1343  const detail = document.getElementById('pressDetail');
1344  if (!grid || !detail) return;
1345  let activeEl = null;
1346  grid.querySelectorAll('span').forEach(span => {
1347    span.setAttribute('role','button');
1348    span.setAttribute('tabindex','0');
1349    function open(){
1350      if (activeEl === span){
1351        detail.classList.remove('show');
1352        setTimeout(() => { detail.hidden = true; }, 250);
1353        activeEl = null; return;
1354      }
1355      activeEl = span;
1356      detail.innerHTML = `
1357        <div class="pd-source">${span.dataset.src || 'Press'}</div>
1358        <blockquote>&ldquo;${span.dataset.q}&rdquo;</blockquote>
1359        <div class="pd-meta">${span.dataset.meta || ''}</div>
1360      `;
1361      detail.hidden = false;
1362      requestAnimationFrame(() => detail.classList.add('show'));
1363    }
1364    span.addEventListener('click', open);
1365    span.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); open(); } });
1366  });
1367})();
1368
1369/* === Compare bars — animate on reveal === */
1370(function(){
1371  const compare = document.querySelector('.compare');
1372  if (!compare) return;
1373  const io = new IntersectionObserver(entries => {
1374    entries.forEach(e => {
1375      if (e.isIntersecting){
1376        compare.querySelectorAll('.cc-fill').forEach(f => {
1377          const target = f.parentElement.parentElement.classList.contains('is-us') ? '100%' : '61%';
1378          // force the transition to play by going from 0 to target
1379          f.style.width = '0';
1380          requestAnimationFrame(() => { setTimeout(() => f.style.width = target, 120); });
1381        });
1382        io.unobserve(compare);
1383      }
1384    });
1385  }, { threshold: .3 });
1386  io.observe(compare);
1387})();
1388
1389/* === Newsletter signup === */
1390(function(){
1391  const form = document.getElementById('nlForm');
1392  if (!form) return;
1393  const wrap = document.getElementById('newsletter');
1394  const input = document.getElementById('nlEmail');
1395  form.addEventListener('submit', (e) => {
1396    e.preventDefault();
1397    wrap.classList.remove('is-error');
1398    const v = (input.value || '').trim();
1399    const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);
1400    if (!ok){ wrap.classList.add('is-error'); input.focus(); return; }
1401    wrap.classList.add('is-success');
1402    try { localStorage.setItem('ecoflix.newsletter', v); } catch(e){}
1403    // Send to the CMS subscriber list (+ welcome sequence). Fire-and-forget.
1404    fetch('/api/email/subscribe', {
1405      method:'POST', headers:{'Content-Type':'application/json'},
1406      body: JSON.stringify({ email: v, source: 'newsletter' })
1407    }).catch(function(){});
1408  });
1409})();
1410
1411/* === Donation preview modal === */
1412(function(){
1413  const modal = document.getElementById('donateModal');
1414  if (!modal) return;
1415  const close = document.getElementById('dpmClose');
1416  const amtEl = document.getElementById('dpmAmt');
1417  const headlineEl = document.getElementById('dpmHeadline');
1418  const subEl = document.getElementById('dpmSub');
1419  const iconEl = document.getElementById('dpmIcon');
1420  const cta = document.getElementById('dpmCta');
1421  const giftCk = document.getElementById('dpmGiftCk');
1422  const giftFields = document.getElementById('dpmGiftFields');
1423
1424  const tiers = [
1425    { up:9,   icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M4 8c4 0 4 6 8 6s4-6 8-6"/></svg>',  h:'A ranger meal in Kenya', s:'A hot meal at the end of a 12-hour patrol.' },
1426    { up:24,  icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="4"/><path d="M3 21c0-5 4-9 9-9s9 4 9 9"/></svg>', h:'Two days of school workshops', s:'Curriculum and teacher packs for a class.' },
1427    { up:74,  icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l8-8 8 8M6 10v10h12V10"/><path d="M9 21v-6h6v6"/></svg>', h:'A day of drone surveillance over Sumatra', s:'Forest Watch Indonesia maps illegal logging from the air daily — this funds one flight.' },
1428    { up:199, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V8l7-5 7 5v13"/></svg>
1428', h:'A week of sanctuary care for one elephant', s:'Food, vet checks, mahout salary. Save Elephant Foundation in Thailand.' },
1429    { up:499, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 21c0-3 2.5-6 6-6s6 3 6 6"/></svg>', h:'A herder fence in Nepal', s:'Stops snow leopards being killed in retaliation by herders.' },
1430    { up:999, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c-3 5-3 9 0 12s3 6 0 8"/></svg>', h:'A month of ranger pay, kit and fuel', s:'A full ranger paid in the field for 30 days.' },
1431    { up:Infinity, icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L3 7v6c0 5 4 9 9 9s9-4 9-9V7l-9-5z"/><path d="M9 12l2 2 4-4"/></svg>', h:'An acre of old-growth forest protected', s:'Legal challenge, ranger patrol, drone mapping.' },
1432  ];
1433
1434  function setAmount(n){
1435    amtEl.textContent = n.toLocaleString();
1436    const t = tiers.find(x => n <= x.up) || tiers[tiers.length-1];
1437    headlineEl.textContent = t.h;
1438    subEl.textContent = t.s;
1439    iconEl.innerHTML = t.icon;
1440  }
1441
1442  function open(amount){
1443    setAmount(amount || 50);
1444    modal.classList.add('show');
1445    modal.setAttribute('aria-hidden','false');
1446    document.body.style.overflow = 'hidden';
1447  }
1448  function closeModal(){
1449    modal.classList.remove('show');
1450    modal.setAttribute('aria-hidden','true');
1451    document.body.style.overflow = '';
1452  }
1453
1454  close.addEventListener('click', closeModal);
1455  modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
1456  document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.classList.contains('show')) closeModal(); });
1457  giftCk.addEventListener('change', () => giftFields.classList.toggle('show', giftCk.checked));
1458
1459  // Legacy: the open-donate-modal event used to pre-flight donors in a
1460  // preview modal before sending them off-site to Givebutter. We now
1461  // have a full Stripe checkout at /donate/, so this handler instead
1462  // routes straight there, passing the amount through as ?amount=. The
1463  // donate page's picker reads that and seeds the selection.
1464  window.addEventListener('open-donate-modal', e => {
1465    const amount = (e.detail && e.detail.amount) || 50;
1466    window.location.href = '/donate/?amount=' + encodeURIComponent(amount) + '#pick';
1467  });
1468
1469  // The "intercept .btn-primary → open modal" pattern has been removed.
1470  // Every Donate button now navigates directly to /donate/ where the
1471  // Stripe Payment Element + Apple Pay / Google Pay / Link Quick Donate
1472  // lane live. No more modal pre-flight.
1473})();
1474
1475/* ============================================================
1476   POLISH PASS
1477   Site-wide micro-experiences. Each block is a self-contained
1478   IIFE that's safe to remove independently. Reduced-motion
1479   preferences are honoured throughout.
1480   ============================================================ */
1481(function siteWidePolish(){
1482  const ready = cb => (document.readyState === 'loading')
1483    ? document.addEventListener('DOMContentLoaded', cb, { once:true })
1484    : cb();
1485  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
1486  const finePointer  = matchMedia('(hover:hover) and (pointer:fine)').matches;
1487
1488  /* ── Scroll progress bar (top of viewport) ─────────────── */
1489  (function scrollProgress(){
1490    const bar = document.createElement('div');
1491    bar.id = 'scrollProgress';
1492    document.documentElement.appendChild(bar);
1493    let raf = 0;
1494    function update(){
1495      const doc = document.documentElement;
1496      const max = (doc.scrollHeight - window.innerHeight) || 1;
1497      const p   = Math.min(1, Math.max(0, window.scrollY / max));
1498      bar.style.width = (p * 100).toFixed(2) + '%';
1499    }
1500    window.addEventListener('scroll', () => {
1501      if (raf) return;
1502      raf = requestAnimationFrame(() => { raf = 0; update(); });
1503    }, { passive:true });
1504    window.addEventListener('resize', update);
1505    update();
1506  })();
1507
1508  /* ── Image blur-up loader ──────────────────────────────── */
1509  ready(() => {
1510    document.querySelectorAll('img[loading="lazy"]').forEach(img => {
1511      if (img.complete && img.naturalWidth) {
1512        img.classList.add('img-ready');
1513        return;
1514      }
1515      img.addEventListener('load',  () => img.classList.add('img-ready'), { once:true });
1516      img.addEventListener('error', () => img.classList.add('img-ready'), { once:true });
1517    });
1518    // Catch images added later (NGO portal preview, dynamic carousels).
1519    new MutationObserver(records => {
1520      records.forEach(r => r.addedNodes.forEach(n => {
1521        if (!(n instanceof Element)) return;
1522        n.querySelectorAll?.('img[loading="lazy"]').forEach(img => {
1523          if (img.complete && img.naturalWidth) img.classList.add('img-ready');
1524          else img.addEventListener('load', () => img.classList.add('img-ready'), { once:true });
1525        });
1526      }));
1527    }).observe(document.body, { childList:true, subtree:true });
1528  });
1529
1530  /* ── Number counter animation (in-view) ────────────────── */
1531  ready(() => {
1532    if (reduceMotion) return;
1533    // Selectors that contain headline numbers across the site.
1534    // Each match should have its terminal numeric text — we extract it,
1535    // animate from 0 to that value, and preserve any suffix/prefix.
1536    const sel = [
1537      '.hnum',                  // homepage hero stats (.h-num bold text)
1538      '.calc-amount #calcVal',  // donation slider readout
1539      '.sc-number',             // species counter
1540      '.footer-stats b',        // footer mini-stats
1541      '.stat-pill b',           // various stat strips
1542      '.fs-num',                // misc num pills
1543      '.np-stat b',             // ngo portal dashboard stats
1544    ].join(',');
1545    const seen = new WeakSet();
1546    function parseNumber(text){
1547      // Pull the first number (with K/M suffix + commas + decimals).
1548      const m = text.match(/(\d[\d,]*\.?\d*)\s*([KkMm]?)([+%]?)/);
1549      if (!m) return null;
1550      const raw    = parseFloat(m[1].replace(/,/g, ''));
1551      const suffix = m[2] ? m[2].toUpperCase() : '';
1552      const sign   = m[3] || '';
1553      const mult   = suffix === 'K' ? 1e3 : suffix === 'M' ? 1e6 : 1;
1554      return { value: raw * mult, suffix, sign, raw, originalSuffix:m[2] };
1555    }
1556    function format(n, parsed){
1557      let v = n;
1558      if (parsed.suffix === 'M') return (v/1e6).toFixed(1).replace(/\.0$/,'') + parsed.originalSuffix + parsed.sign;
1559      if (parsed.suffix === 'K') return (v/1e3).toFixed(1).replace(/\.0$/,'') + parsed.originalSuffix + parsed.sign;
1560      return Math.round(v).toLocaleString() + parsed.sign;
1561    }
1562    function animate(el){
1563      if (seen.has(el)) return;
1564      seen.add(el);
1565      const original = el.textContent.trim();
1566      const parsed = parseNumber(original);
1567      if (!parsed || parsed.value < 4) return;
1568      // Preserve any wrapping <em>/<span> by working on the leading text node.
1569      const targetText = original;
1570      const dollarPrefix = /^\$/.test(targetText) ? '$' : '';
1571      const duration = Math.min(1400, 400 + parsed.value * 0.5);
1572      const startTime = performance.now();
1573      function tick(now){
1574        const t = Math.min(1, (now - startTime) / duration);
1575        const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
1576        const current = parsed.value * eased;
1577        el.textContent = dollarPrefix + format(current, parsed);
1578        if (t < 1) requestAnimationFrame(tick);
1579        else el.textContent = targetText; // restore exact original so any styling pseudo-content stays right
1580      }
1581      requestAnimationFrame(tick);
1582    }
1583    const io = new IntersectionObserver(entries => {
1584      entries.forEach(e => { if (e.isIntersecting) animate(e.target); });
1585    }, { threshold: 0.4 });
1586    document.querySelectorAll(sel).forEach(el => io.observe(el));
1587  });
1588
1589  /* ── Magnetic primary CTAs ─────────────────────────────── */
1590  (function magneticButtons(){
1591    if (!finePointer || reduceMotion) return;
1592    function bind(btn){
1593      const r = 18; // px of pull radius
1594      let raf = 0;
1595      let target = { x:0, y:0 };
1596      function apply(){
1597        raf = 0;
1598        btn.style.transform = `translate(${target.x.toFixed(2)}px, ${target.y.toFixed(2)}px)`;
1599      }
1600      btn.addEventListener('pointermove', e => {
1601        const b = btn.getBoundingClientRect();
1602        const dx = (e.clientX - (b.left + b.width/2)) / (b.width/2);
1603        const dy = (e.clientY - (b.top  + b.height/2)) / (b.height/2);
1604        target.x = Math.max(-1, Math.min(1, dx)) * r;
1605        target.y = Math.max(-1, Math.min(1, dy)) * (r * 0.6);
1606        if (!raf) raf = requestAnimationFrame(apply);
1607      });
1608      btn.addEventListener('pointerleave', () => {
1609        target.x = 0; target.y = 0;
1610        if (!raf) raf = requestAnimationFrame(apply);
1611      });
1612    }
1613    ready(() => {
1614      document.querySelectorAll('a.btn-primary, button.btn-primary').forEach(bind);
1615    });
1616  })();
1617
1618  /* ── Coord-stamp ripple ────────────────────────────────── */
1619  ready(() => {
1620    const sel = '.coord-stamp, .pd-tag, .ngo-coord, .fc-eyebrow, .ebngo-coord, .ebp-count, .nf-coord, .redir-coord';
1621    document.addEventListener('click', e => {
1622      const el = e.target.closest(sel);
1623      if (!el) return;
1624      const r = el.getBoundingClientRect();
1625      el.style.setProperty('--rx', (e.clientX - r.left) + 'px');
1626      el.style.setProperty('--ry', (e.clientY - r.top)  + 'px');
1627      el.classList.remove('ripple-go');
1628      void el.offsetWidth;
1629      el.classList.add('ripple-go');
1630      setTimeout(() => el.classList.remove('ripple-go'), 850);
1631    }, { passive:true });
1632  });
1633
1634  /* ── Logo easter egg (7 clicks within 3s) ──────────────── */
1635  ready(() => {
1636    const brand = document.querySelector('header.header a.brand');
1637    if (!brand) return;
1638    let taps = 0;
1639    let lastTap = 0;
1640    brand.addEventListener('click', e => {
1641      const now = performance.now();
1642      if (now - lastTap > 3000) taps = 0;
1643      lastTap = now;
1644      taps++;
1645      if (taps >= 7){
1646        taps = 0;
1647        e.preventDefault();
1648        showToast('⊕ Hello, careful reader');
1649      }
1650    });
1651    function showToast(text){
1652      const t = document.createElement('div');
1653      t.className = 'ee-toast';
1654      t.textContent = text;
1655      document.body.appendChild(t);
1656      requestAnimationFrame(() => t.classList.add('show'));
1657      setTimeout(() => {
1658        t.classList.remove('show');
1659        setTimeout(() => t.remove(), 400);
1660      }, 3800);
1661    }
1662  });
1663
1664  /* ── Save-for-later bookmark (project pages) ───────────── */
1665  ready(() => {
1666    const m = location.pathname.match(/^\/projects\/([^/]+)\/?$/);
1667    if (!m || m[1] === 'index.html' || m[1] === '') return;
1668    const slug = m[1];
1669    if (slug === 'projects') return;
1670
1671    // Try to derive the project name from the page title or hero h1.
1672    const titleEl = document.querySelector('main h1, .pd-hero h1, h1');
1673    const projectName = (titleEl?.textContent.trim() || slug).slice(0, 80);
1674
1675    const KEY = 'ecoflix:saved-projects';
1676    function read(){
1677      try{ return JSON.parse(localStorage.getItem(KEY) || '[]'); }
1678      catch{ return []; }
1679    }
1680    function write(list){
1681      try{ localStorage.setItem(KEY, JSON.stringify(list)); }catch{}
1682    }
1683    function isSaved(){
1684      return read().some(p => p.slug === slug);
1685    }
1686    function toggle(){
1687      const list = read();
1688      const idx  = list.findIndex(p => p.slug === slug);
1689      if (idx >= 0) list.splice(idx, 1);
1690      else list.unshift({ slug, name: projectName, savedAt: Date.now() });
1691      write(list);
1692      return idx < 0; // true if newly saved
1693    }
1694
1695    const btn = document.createElement('button');
1696    btn.type = 'button';
1697    btn.className = 'bk-fab';
1698    btn.setAttribute('aria-label', 'Save this project for later');
1699    btn.innerHTML = `
1700      <span class="bk-pulse" aria-hidden="true"></span>
1701      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1702        <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
1703      </svg>
1704      <span class="bk-tip">Save for later</span>
1705    `;
1706    if (isSaved()) btn.classList.add('bk-on');
1707    btn.addEventListener('click', () => {
1708      const justSaved = toggle();
1709      btn.classList.toggle('bk-on', justSaved);
1710      if (justSaved){
1711        btn.classList.add('bk-just-saved');
1712        setTimeout(() => btn.classList.remove('bk-just-saved'), 1000);
1713        // Update tooltip
1714        btn.querySelector('.bk-tip').textContent = 'Saved';
1715      } else {
1716        btn.querySelector('.bk-tip').textContent = 'Removed';
1717      }
1718      setTimeout(() => {
1719        btn.querySelector('.bk-tip').textContent = isSaved() ? 'Saved · click to remove' : 'Save for later';
1720      }, 1500);
1721    });
1722    document.body.appendChild(btn);
1723  });
1724
1725  /* Saved-count badge — appears next to the "Projects" link
1726     in the main nav across every page when the visitor has any
1727     saved projects. Subtle, only when there's a real number. */
1728  ready(() => {
1729    const KEY = 'ecoflix:saved-projects';
1730    let list;
1731    try { list = JSON.parse(localStorage.getItem(KEY) || '[]'); } catch { list = []; }
1732    if (!list.length) return;
1733    // Update if it changes elsewhere on the page.
1734    function paint(){
1735      const count = (function(){
1736        try{ return JSON.parse(localStorage.getItem(KEY) || '[]').length; }
1737        catch{ return 0; }
1738      })();
1739      // Look through every nav menu link for "All projects" or "Projects".
1740      document.querySelectorAll('.nav-menu a, .nav a, .drawer-nav a').forEach(a => {
1741        if (!/projects/i.test(a.textContent.trim())) return;
1742        if (a.querySelector('.saved-count-badge')) {
1743          const b = a.querySelector('.saved-count-badge');
1744          if (count > 0) b.textContent = count;
1745          else b.remove();
1746          return;
1747        }
1748        if (count > 0){
1749          const badge = document.createElement('span');
1750          badge.className = 'saved-count-badge';
1751          badge.textContent = count;
1752          badge.setAttribute('aria-label', count + ' saved');
1753          a.appendChild(badge);
1754        }
1755      });
1756    }
1757    paint();
1758    window.addEventListener('storage', e => { if (e.key === KEY) paint(); });
1759  });
1760
1761  /* ── Command palette (⌘K / Ctrl+K) ─────────────────────── */
1762  (function commandPalette(){
1763    const ENTRIES = [
1764      // About
1765      { g:'About',    label:'Mission',           href:'/mission/',          sub:'Why Ecoflix exists',          gl:'M' },
1766      { g:'About',    label:'How it works',      href:'/how-it-works/',     sub:'Sofa to sanctuary',           gl:'H' },
1767      { g:'About',    label:'How we help',       href:'/how-we-help/',      sub:'The three levers',            gl:'H' },
1768      { g:'About',    label:'Our story',         href:'/history/',          sub:'2019 → today',                gl:'S' },
1769      { g:'About',    label:'Team',              href:'/team/',             sub:'Executive · Board · Advisors',gl:'T' },
1770      { g:'About',    label:'Connect',           href:'/connect/',          sub:'Social channels',             gl:'C' },
1771      { g:'About',    label:'FAQ',               href:'/faq/',              sub:'26 questions answered',       gl:'?' },
1772      // Projects
1773      { g:'Projects', label:'All projects',      href:'/projects/',         sub:'Featured Originals carousel', gl:'P' },
1774      { g:'Projects', label:'NGO partners',      href:'/ngo-partners/',     sub:'64 partners · the globe',     gl:'N' },
1775      { g:'Projects', label:'Impact report',     href:'/impact-report/',    sub:'Where the money landed',      gl:'I' },
1776      { g:'Projects', label:"A Free Life for Thailand's Elephants", href:'/projects/a-free-life-for-thailands-rescued-elephants/', sub:'Save Elephant Foundation · Thailand', gl:'EL' },
1777      { g:'Projects', label:'Saving the Cross River Gorilla', href:'/projects/saving-the-cross-river-gorilla/', sub:'African Conservation Foundation · Cameroon', gl:'CR' },
1778      { g:'Projects', label:"NA's First Whale Sanctuary", href:'/projects/building-north-americas-first-whale-sanctuary/', sub:'The Whale Sanctuary Project · Canada', gl:'WS' },
1779      { g:'Projects', label:"Frontline Support for Africa", href:'/projects/frontline-support-for-africas-wildlife/', sub:'Over and Above Africa', gl:'OA' },
1780      { g:'Projects', label:"Freedom for India's Bears & Elephants", href:'/projects/freedom-for-indias-bears-and-elephants/', sub:'Wildlife SOS 
1780· India', gl:'WS' },
1781      { g:'Projects', label:'A Forest Home for Rescued Wolves', href:'/projects/a-forest-home-for-rescued-wolves/', sub:'Wolf Connection · California, USA', gl:'WC' },
1782      // Give
1783      { g:'Give',     label:'Donate',            href:'/donate/',           sub:'Pick an amount · 100% goes through', gl:'$' },
1784      { g:'Give',     label:'Circle of Giving',  href:'/circle-of-giving/', sub:'Monthly · Field / Sanctuary / Founders', gl:'O' },
1785      { g:'Give',     label:'Gift memberships',  href:'/gift-memberships/', sub:'1 · 5 · 10 · 50 · 100',       gl:'G' },
1786      { g:'Give',     label:'Corporate partners',href:'/partners/',         sub:'Founding · Programme · Ally', gl:'B' },
1787      { g:'Give',     label:'For teachers',      href:'/teachers/',         sub:'Free classroom access',       gl:'T' },
1788      // Watch
1789      { g:'Watch',    label:'The channel',       href:'/watch/',            sub:'Staff picks · this weekend',  gl:'W' },
1790      { g:'Watch',    label:'Podcasts',          href:'/podcasts/',         sub:'Long-form audio from the field', gl:'P' },
1791      { g:'Watch',    label:'Open watch.ecoflix.com', href:'https://watch.ecoflix.com/', sub:'Go to the channel directly', gl:'↗' },
1792      // Business / BD
1793      { g:'Business', label:'Distribution & ads',href:'/distribution/',     sub:'Carry the channel · advertise · syndicate', gl:'D' },
1794      // News
1795      { g:'News',     label:'News',              href:'/news/',             sub:'Live news from our partners', gl:'N' },
1796      // Utility
1797      { g:'Utility',  label:'Contact',           href:'/contact/',          sub:'Press · partnerships · general', gl:'@' },
1798      { g:'Utility',  label:'Privacy',           href:'/privacy/',          sub:'GDPR · CCPA',                 gl:'§' },
1799      { g:'Utility',  label:'Terms',             href:'/terms/',            sub:'Of use',                      gl:'§' },
1800      { g:'Utility',  label:'Cookies',           href:'/cookies/',          sub:'Preferences',                 gl:'§' },
1801      // NGO portal
1802      { g:'NGO',      label:'NGO portal sign-in',href:'/ngo-portal/',       sub:'Partner login',               gl:'⤴' },
1803    ];
1804
1805    let backdrop, input, list, items = [], current = 0, lastFocus = null;
1806
1807    function build(){
1808      backdrop = document.createElement('div');
1809      backdrop.className = 'cp-backdrop';
1810      backdrop.setAttribute('role','dialog');
1811      backdrop.setAttribute('aria-modal','true');
1812      backdrop.setAttribute('aria-label','Go to');
1813      backdrop.innerHTML = `
1814        <div class="cp-panel" role="document">
1815          <div class="cp-search">
1816            <svg class="cp-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/></svg>
1817            <input id="cpInput" type="search" placeholder="Go to… (try 'donate', 'team', 'wolves')" autocomplete="off" spellcheck="false" />
1818            <span class="cp-search-kbd">esc</span>
1819          </div>
1820          <ul class="cp-list" id="cpList" role="listbox"></ul>
1821          <div class="cp-foot">
1822            <span><span class="kbd">↑</span><span class="kbd">↓</span> navigate</span>
1823            <span><span class="kbd">↵</span> open · <span class="kbd">esc</span> close</span>
1824          </div>
1825        </div>
1826      `;
1827      document.body.appendChild(backdrop);
1828      input = backdrop.querySelector('#cpInput');
1829      list  = backdrop.querySelector('#cpList');
1830
1831      input.addEventListener('input', () => render(input.value.trim()));
1832      input.addEventListener('keydown', onKeydown);
1833      backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
1834      list.addEventListener('click', e => {
1835        const it = e.target.closest('.cp-item');
1836        if (it){ navigate(it.dataset.href); }
1837      });
1838    }
1839
1840    function score(query, label, sub){
1841      // Simple fuzzy: case-insensitive substring + token boost.
1842      const q = query.toLowerCase();
1843      const l = label.toLowerCase();
1844      const s = (sub || '').toLowerCase();
1845      if (!q) return 1; // show all
1846      if (l.startsWith(q)) return 100;
1847      if (l.includes(q))   return 70;
1848      if (s.includes(q))   return 40;
1849      // Letter-by-letter loose match
1850      let qi = 0;
1851      for (let i = 0; i < l.length && qi < q.length; i++){
1852        if (l[i] === q[qi]) qi++;
1853      }
1854      return qi === q.length ? 15 : 0;
1855    }
1856
1857    function render(query){
1858      const ranked = ENTRIES
1859        .map(e => ({ e, sc: score(query, e.label, e.sub) }))
1860        .filter(x => x.sc > 0)
1861        .sort((a,b) => b.sc - a.sc);
1862
1863      list.innerHTML = '';
1864      if (!ranked.length){
1865        list.innerHTML = '<li class="cp-empty">Nothing matches — try a different word</li>';
1866        items = []; current = 0;
1867        return;
1868      }
1869      let currentGroup = null;
1870      ranked.forEach(({ e }, idx) => {
1871        if (!query && e.g !== currentGroup){
1872          currentGroup = e.g;
1873          const g = document.createElement('li');
1874          g.className = 'cp-group';
1875          g.textContent = e.g;
1876          list.appendChild(g);
1877        }
1878        const li = document.createElement('li');
1879        li.className = 'cp-item' + (idx === 0 ? ' is-active' : '');
1880        li.dataset.href = e.href;
1881        li.setAttribute('role','option');
1882        li.innerHTML = `
1883          <span class="cp-glyph">${e.gl || '·'}</span>
1884          <span class="cp-text"><b>${e.label}</b><span>${e.sub || ''}</span></span>
1885          <span class="cp-arrow">→</span>
1886        `;
1887        list.appendChild(li);
1888      });
1889      items = Array.from(list.querySelectorAll('.cp-item'));
1890      current = 0;
1891    }
1892
1893    function setActive(i){
1894      if (!items.length) return;
1895      items[current]?.classList.remove('is-active');
1896      current = (i + items.length) % items.length;
1897      const next = items[current];
1898      next?.classList.add('is-active');
1899      next?.scrollIntoView({ block:'nearest' });
1900    }
1901
1902    function onKeydown(e){
1903      if (e.key === 'Escape')        { e.preventDefault(); close(); }
1904      else if (e.key === 'ArrowDown'){ e.preventDefault(); setActive(current + 1); }
1905      else if (e.key === 'ArrowUp')  { e.preventDefault(); setActive(current - 1); }
1906      else if (e.key === 'Enter')    { e.preventDefault(); if (items[current]) navigate(items[current].dataset.href); }
1907    }
1908
1909    function navigate(href){
1910      if (!href) return;
1911      if (/^https?:/.test(href)) window.open(href, '_blank', 'noopener');
1912      else window.location.href = href;
1913      close();
1914    }
1915
1916    function open(){
1917      if (!backdrop) build();
1918      lastFocus = document.activeElement;
1919      backdrop.classList.add('is-open');
1920      input.value = '';
1921      render('');
1922      // Defer to next frame so focus lands after the animation start.
1923      requestAnimationFrame(() => input.focus());
1924      document.documentElement.style.overflow = 'hidden';
1925    }
1926    function close(){
1927      if (!backdrop) return;
1928      backdrop.classList.remove('is-open');
1929      document.documentElement.style.overflow = '';
1930      lastFocus?.focus?.();
1931    }
1932
1933    window.addEventListener('keydown', e => {
1934      // ⌘K or Ctrl+K opens the palette from anywhere.
1935      const isShortcut = (e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey);
1936      if (isShortcut){
1937        e.preventDefault();
1938        if (backdrop && backdrop.classList.contains('is-open')) close();
1939        else open();
1940      }
1941      // "/" key opens the palette when not focused in an input.
1942      if (e.key === '/' && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '')){
1943        e.preventDefault();
1944        open();
1945      }
1946    });
1947
1948    // Public hook so other UI (e.g. a future header search icon) can launch it.
1949    window.openCommandPalette = open;
1950  })();
1951
1952  /* ── Cartograph trail stroke-draw ──────────────────────────
1953     Every `.bg-cartograph .bg-trail` path on every page gets a
1954     stroke-draw entrance: the path measures itself, stores the
1955     length as a CSS variable, starts invisible (offset = length),
1956     and animates to offset=0 (fully drawn) once it enters the
1957     viewport. Brings the cartograph signature to life across the
1958     site without touching any per-page markup. */
1959  ready(() => {
1960    if (reduceMotion) return;
1961    const trails = document.querySelectorAll('.bg-cartograph .bg-trail');
1962    if (!trails.length) return;
1963    // Measure first. Skip 0-length paths defensively.
1964    trails.forEach(t => {
1965      let len = 0;
1966      try { len = t.getTotalLength(); } catch {}
1967      if (!len || !isFinite(len)) return;
1968      t.style.setProperty('--tl', len.toFixed(0));
1969      t.classList.add('tr-pending');
1970    });
1971    const io = new IntersectionObserver(entries => {
1972      entries.forEach(e => {
1973        if (!e.isIntersecting) return;
1974        // Stagger entry so adjacent trails don't draw at the same instant.
1975        const t = e.target;
1976        const delay = 80 + Math.random() * 220;
1977        setTimeout(() => t.classList.add('tr-drawn'), delay);
1978        io.unobserve(t);
1979      });
1980    }, { threshold: 0.18 });
1981    trails.forEach(t => io.observe(t));
1982  });
1983
1984})();
1985
1986/* ============================================================
1987   Vercel Web Analytics — privacy-friendly page views & visitors.
1988   Loaded on every page that ships site.js. Multi-page site, so each
1989   navigation is a full page load and the collector auto-fires one
1990   pageview per view. Pages without site.js carry the tag inline.
1991   ============================================================ */
1992(function () {
1993  if (window.__ecoflixVA) return;
1994  window.__ecoflixVA = true;
1995  window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
1996  var s = document.createElement('script');
1997  s.defer = true;
1998  s.src = '/_vercel/insights/script.js';
1999  (document.head || document.documentElement).appendChild(s);
2000})();

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.