/*! trentpower.fr · authored source */
/* app source. edit here, not in app.js. the full file header is
   prepended at build time by generate_site.py, which then inlines
   i18n and LANG_CYCLE from i18n/strings.json.
   cache key: edition-2026-05-09-perf-arch-rev2. */

(function () {
  'use strict';

  // ═══════════════════════════════════════════════════
  // belt-and-braces · ensure html.js is set even if the inline
  // language-bootstrap script was blocked (csp, trusted-types,
  // parse error, anything else). the hero reveal animation is
  // gated by html.js, so if the inline script never runs and only
  // the deferred app.js does, this line still gets the class onto
  // <html> in time for the first paint sequence. cheap, idempotent.
  // ═══════════════════════════════════════════════════
  try { document.documentElement.classList.add('js'); } catch (_) {}

  // phase 37 · once the dom is ready, mark <html> as enhanced so
  // css can trigger the hero reveal cascade. the reveal styles
  // are gated by `html.enhanced .hero-*` selectors in styles.src.css.
  // no-JS users never get this class and see the resting hero state
  // immediately via `html:not(.js) .hero-*` overrides.
  function _markEnhanced() {
    try { document.documentElement.classList.add('enhanced'); } catch (_) {}
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', _markEnhanced, { once: true });
  } else {
    _markEnhanced();
  }

  // ═══════════════════════════════════════════════════
  // trusted types policy , the page's csp enforces
  //   require-trusted-types-for 'script'; trusted-types tp-i18n;
  // so every trustedhtml and trustedscripturl sink must route through
  // a tt policy. the i18n source is build-time inlined from
  // tools/i18n/strings.json (operator-authored); script urls are
  // always same-origin paths to known assets. the policy is therefore
  // a strict-allowlist gate, not a sanitiser. older browsers without
  // tt fall back to bare assignment.
  function _safeScriptURL(s) {
    // allow only same-origin paths under a small allowlist of
    // controlled prefixes, with a known extension. Reject:
    //   - cross-origin urls (http:, https:)
    //   - protocol-relative (//evil.example/)
    //   - data: / blob: / javascript: / filesystem:
    //   - anything outside the allowlist
    if (typeof s !== 'string') {
      throw new TypeError('tp-i18n: script URL must be a string');
    }
    if (s.charAt(0) !== '/' || s.charAt(1) === '/') {
      throw new Error('tp-i18n: only absolute same-origin paths allowed: ' + s);
    }
    // extension allowlist — services and modules only.
    var bareNoQuery = s.split('?')[0].split('#')[0];
    if (!/\.(js|mjs)$/.test(bareNoQuery)) {
      throw new Error('tp-i18n: script URL extension not allowed: ' + s);
    }
    // Path-prefix allowlist. today the only client-side script url
    // assigned at runtime is the service-worker registration; keep
    // the surface tight.
    var ALLOWED_PREFIXES = ['/sw.js', '/app.js', '/app-enhance.', '/cite.js', '/i18n-core.js', '/i18n/', '/verify/'];
    var ok = ALLOWED_PREFIXES.some(function (p) {
      return bareNoQuery === p || bareNoQuery.indexOf(p) === 0;
    });
    if (!ok) {
      throw new Error('tp-i18n: script URL not in allowlist: ' + s);
    }
    return s;
  }
  var ttPolicy = (typeof window !== 'undefined' && window.trustedTypes &&
                  typeof window.trustedTypes.createPolicy === 'function')
    ? window.trustedTypes.createPolicy('tp-i18n', {
        createHTML: function (s) { return s; },
        createScriptURL: _safeScriptURL
      })
    : null;
  function setTrustedHTML(el, value) {
    el.innerHTML = ttPolicy ? ttPolicy.createHTML(value) : value;
  }
  function trustedScriptURL(value) {
    return ttPolicy ? ttPolicy.createScriptURL(value) : value;
  }

  // ═══════════════════════════════════════════════════
  // i18n , language detection, lazy load, render, switch
  // i18n and LANG_CYCLE are prepended by generate_site.py at build time
  //
  // architecture (phase 20 rewrite):
  //   · `renderLanguage(lang)` is the pure dom mutator. it does not
  //     touch localstorage and does not flip aria-pressed.
  //   · `updateLangControls(lang)` flips aria-pressed only.
  //   · `loadOptionalLang(lang)` returns a promise that resolves
  //     true if the lazy bundle is resident in window.i18n, false
  //     if the load failed. callers must check the resolution.
  //   · `applyLanguage(lang)` is the orchestrator: load → render
  //     → persist + aria-pressed, but only after a successful
  //     render. on failure the page stays in whatever it last
  //     successfully rendered and aria-pressed reflects that.
  //   · `bootLanguage()` runs once at parse time: paints the
  //     synchronous core fallback, marks the stored optional
  //     language as pressed optimistically, then upgrades to
  //     the optional bundle when it lands (or snaps aria-pressed
  //     back to the core fallback if the load fails).
  // ═══════════════════════════════════════════════════

  function getVal(obj, key) {
    return key.split('.').reduce(function (o, k) { return o ? o[k] : undefined; }, obj);
  }

  // ═══════════════════════════════════════════════════
  // lazy i18n · /i18n-core.js ships en + fr to every page; the
  // both supported langs (en + fr) ship in the core bundle. there
  // is no lazy bundle and no runtime fetch. I18N_VTAG is retained as
  // an empty literal so the generate_site.py asset-version pipeline
  // (which still rewrites it during normalisation) keeps working
  // without churn; nothing reads it.
  // ═══════════════════════════════════════════════════
  var CORE_LANGS     = { en: 1, fr: 1 };
  var FALLBACK_LANG  = 'en';
  var I18N_VTAG      = '';

  function normaliseLang(value) {
    var l = String(value || '').trim().toLowerCase().slice(0, 2);
    if (CORE_LANGS[l]) return l;
    return FALLBACK_LANG;
  }

  // kept for binary-compat with any caller that still resolves a
  // language asynchronously; always resolves true synchronously
  // because every supported language is already in window.I18N.
  function loadOptionalLang(lang) {
    return Promise.resolve(true);
  }

  function updateLangControls(lang) {
    // phase 37 · one selector contract: [data-lang]. matches any
    // interactive control carrying the attribute regardless of where
    // it lives in the dom. plus the canonical footer markup uses the
    // html lang attribute on its language buttons.
    document.querySelectorAll('button[data-lang], a[data-lang]').forEach(function (control) {
      var l = control.getAttribute('data-lang');
      control.setAttribute('aria-pressed', l === lang ? 'true' : 'false');
    });
    document.querySelectorAll('.site-footer__language button[lang]').forEach(function (control) {
      var l = control.getAttribute('lang');
      control.setAttribute('aria-pressed', l === lang ? 'true' : 'false');
    });
  }

  // pure render — translates dom for `lang`. does not touch
  // localstorage and does not flip aria-pressed. returns true if
  // the language dictionary was found and applied; false if not
  // (e.g. caller asked for an optional lang that hasn't loaded
  // yet).
  function renderLanguage(lang) {
    var t = I18N[lang];
    if (!t) return false;
    var page = document.body && document.body.getAttribute('data-page') || 'home';

    document.documentElement.lang = lang;
    document.documentElement.setAttribute('data-lang', lang);

    if (t.meta && t.meta[page]) {
      document.title = t.meta[page].title;
      var md = document.querySelector('meta[name="description"]');
      if (md) md.setAttribute('content', t.meta[page].description);
    }

    if (page === 'home' && t.meta && t.meta.home) {
      var ot = document.querySelector('meta[property="og:title"]');
      var od = document.querySelector('meta[property="og:description"]');
      if (ot) ot.setAttribute('content', t.meta.home.og_title);
      if (od) od.setAttribute('content', t.meta.home.og_description);
    }

    var idMap = {
      'p-role':              t.hero && t.hero.body,
      'p-growth':            t.approach && t.approach.growth_title,
      'p-growth-detail':     t.approach && t.approach.growth_body,
      'p-adoption':          t.approach && t.approach.adoption_title,
      'p-adoption-detail':   t.approach && t.approach.adoption_body,
      'p-ai':                t.approach && t.approach.ai_title,
      'p-ai-detail':         t.approach && t.approach.ai_body,
      'p-governance':        t.approach && t.approach.governance_title,
      'p-governance-detail': t.approach && t.approach.governance_body,
      'p-taste':             t.approach && t.approach.taste_title,
      'p-taste-detail':      t.approach && t.approach.taste_body,
      'p-project-paris':     t.projects && t.projects.paris_desc
    };
    Object.keys(idMap).forEach(function (id) {
      var text = idMap[id];
      if (!text) return;
      var el = document.getElementById(id);
      if (el) el.textContent = text;
    });

    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var value = getVal(t, el.getAttribute('data-i18n'));
      if (value !== undefined) el.textContent = value;
    });
    document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
      var value = getVal(t, el.getAttribute('data-i18n-html'));
      if (value !== undefined) setTrustedHTML(el, value);
    });
    document.querySelectorAll('[data-i18n-aria-label]').forEach(function (el) {
      var value = getVal(t, el.getAttribute('data-i18n-aria-label'));
      if (value !== undefined) el.setAttribute('aria-label', value);
    });
    document.querySelectorAll('[data-i18n-list]').forEach(function (el) {
      var key = el.getAttribute('data-i18n-list');
      var value = getVal(t, key);
      if (!value) return;
      var html = value.split('\n').map(function (item) {
        return '<li>' + item + '</li>';
      }).join('');
      setTrustedHTML(el, html);
    });

    var copyBtn = document.querySelector('.code-copy');
    if (copyBtn) {
      var _ct = I18N[lang] || I18N['en'];
      var _copiedLabel = (_ct.integrity && _ct.integrity.copy_button_done) || 'Copied';
      if (copyBtn.textContent !== _copiedLabel) {
        copyBtn.textContent = (_ct.integrity && _ct.integrity.copy_button) || 'Copy';
      }
    }

    var pic = document.querySelector('picture[data-arch-base]');
    if (pic) {
      var base = pic.dataset.archBase;
      var src = pic.querySelector('source[media]');
      var img = pic.querySelector('img');
      if (src) src.srcset = '/images/architecture/' + base + '-mobile.' + lang + '.svg';
      if (img) img.src    = '/images/architecture/' + base + '.' + lang + '.svg';
    }
    return true;
  }

  // orchestrator. returns a promise that resolves to the lang
  // actually applied (the requested lang on success; whatever
  // fallback the page stayed in on failure). only writes
  // localstorage and aria-pressed after a successful render.
  function applyLanguage(lang) {
    var target = normaliseLang(lang);
    return loadOptionalLang(target).then(function (ok) {
      var applied = ok && renderLanguage(target);
      if (applied) {
        try { localStorage.setItem('tp-lang', target); } catch (_) {}
        updateLangControls(target);
        return target;
      }
      return document.documentElement.getAttribute('data-lang') || FALLBACK_LANG;
    });
  }

  // boot — synchronous core fallback + optimistic optional upgrade.
  //
  // first paint: pick the closest core language (en or fr) and
  // render it synchronously so the page never flashes blank. if
  // the stored preference is an optional language (it / es / de),
  // the english-on-english fast-path avoids the [data-i18n]
  // querySelectorAll walk when the dom is already authored in the
  // detected lang (~50–100 ms saved at first paint).
  function bootLanguage() {
    var stored;
    try { stored = localStorage.getItem('tp-lang'); } catch (_) { stored = null; }
    var nav  = /^fr\b/i.test((navigator.languages && navigator.languages[0]) || navigator.language || 'en') ? 'fr' : 'en';
    var pref = stored ? normaliseLang(stored) : nav;

    // fast path: dom already authored in `pref`. just sync aria.
    if (pref === 'en' && document.documentElement.lang === 'en') {
      document.documentElement.setAttribute('data-lang', 'en');
      updateLangControls('en');
      return;
    }
    renderLanguage(pref);
    updateLangControls(pref);
  }
  bootLanguage();

  // language switching · single contract (phase 37)
  // ── one selector everywhere: `[data-lang]`. event delegation on
  //    `document` so the contract works for the footer language row,
  //    any future inline switchers, and any element added at runtime.
  // ── viewport-anchor stabilisation keeps the scroll position put
  //    when translations of different lengths reflow the page.
  // ── settle animation polish on the closest language row container.
  (function () {
    function getViewportAnchor() {
      var navOffset = 64;
      var candidates = document.querySelectorAll(
        'main, section[id], .hero, .principle, .trajectory-item, ' +
        '.project-card, .page, .site-footer'
      );
      var best = null;
      var bestDistance = Infinity;
      for (var i = 0; i < candidates.length; i++) {
        var el = candidates[i];
        var rect = el.getBoundingClientRect();
        if (rect.bottom < navOffset) continue;
        if (rect.top > window.innerHeight) continue;
        var distance = Math.abs(rect.top - navOffset);
        if (distance < bestDistance) {
          best = el;
          bestDistance = distance;
        }
      }
      return best || document.querySelector('main') || document.body;
    }

    function switchLanguage(nextLang, trigger) {
      var root   = document.documentElement;
      var anchor = getViewportAnchor();
      var beforeTop = anchor.getBoundingClientRect().top;

      root.classList.add('is-language-switching');

      // applyLanguage() handles load → render → persist + aria-pressed
      // in the right order. only run the scroll-anchor + settle
      // animation polish after it has resolved, so the visible swap
      // and the visual cue happen on the same frame.
      applyLanguage(nextLang).then(function () {
        requestAnimationFrame(function () {
          requestAnimationFrame(function () {
            var afterTop = anchor.getBoundingClientRect().top;
            var delta = afterTop - beforeTop;
            if (Math.abs(delta) > 1) {
              window.scrollTo({
                top: window.scrollY + delta,
                behavior: 'auto'
              });
            }
            if (trigger && typeof trigger.focus === 'function') {
              try {
                trigger.focus({ preventScroll: true });
              } catch (_) {
                trigger.focus();
              }
            }
            root.classList.remove('is-language-switching');

            // quiet settle cue on the language row so it doesn't feel
            // mid-transition after the swap. find the closest language
            // container around the trigger (works regardless of which
            // surface the trigger lives in).
            var langRow = trigger && trigger.closest
              ? trigger.closest('.site-footer__language')
              : document.querySelector('.site-footer__language');
            if (langRow) {
              langRow.classList.remove('language-updated');
              void langRow.offsetWidth;
              langRow.classList.add('language-updated');
              setTimeout(function () {
                langRow.classList.remove('language-updated');
              }, 320);
            }
          });
        });
      });
    }

    document.addEventListener('click', function (event) {
      if (!event.target || !event.target.closest) return;
      // canonical footer language buttons carry `lang="en"` / `lang="fr"`
      // on the html element rather than a data attribute. check that
      // path first so the footer's own markup keeps working without
      // also needing a data-lang sweep across every page.
      var footerTrigger = event.target.closest('.site-footer__language button[lang]');
      if (footerTrigger) {
        var footerLang = footerTrigger.getAttribute('lang');
        if (footerLang) {
          event.preventDefault();
          switchLanguage(footerLang, footerTrigger);
          return;
        }
      }
      var trigger = event.target.closest('[data-lang]');
      if (!trigger) return;
      var nextLang = trigger.getAttribute('data-lang');
      if (!nextLang) return;
      var tag = trigger.tagName;
      if (tag !== 'BUTTON' && tag !== 'A') return;
      event.preventDefault();
      switchLanguage(nextLang, trigger);
    });
  })();

  // theme switching · canonical footer toggle.
  // ── localstorage key: tp-theme · values: light · system · dark.
  // ── dom contract: data-theme on <html>. "system" removes the attr.
  // ── on click on .site-footer__theme button[data-theme], persist
  //    the choice and rebalance aria-pressed across the three.
  // ── on boot, read storage and mirror aria-pressed. the inline
  //    <head> script has already stamped data-theme before paint to
  //    avoid fouc; this handler only adds runtime interactivity.
  (function () {
    var root = document.documentElement;

    function applyTheme(value) {
      if (value === 'light' || value === 'dark') {
        root.setAttribute('data-theme', value);
      } else {
        root.removeAttribute('data-theme');
      }
      try { localStorage.setItem('tp-theme', value); } catch (_) {}
      document.querySelectorAll('.site-footer__theme button[data-theme]').forEach(function (b) {
        b.setAttribute('aria-pressed', b.getAttribute('data-theme') === value ? 'true' : 'false');
      });
    }

    function bootTheme() {
      var stored;
      try { stored = localStorage.getItem('tp-theme'); } catch (_) { stored = null; }
      var value = (stored === 'light' || stored === 'dark') ? stored : 'system';
      document.querySelectorAll('.site-footer__theme button[data-theme]').forEach(function (b) {
        b.setAttribute('aria-pressed', b.getAttribute('data-theme') === value ? 'true' : 'false');
      });
    }
    bootTheme();

    document.addEventListener('click', function (event) {
      if (!event.target || !event.target.closest) return;
      var btn = event.target.closest('.site-footer__theme button[data-theme]');
      if (!btn) return;
      event.preventDefault();
      applyTheme(btn.getAttribute('data-theme'));
    });
  })();

  // ═══════════════════════════════════════════════════
  // service worker , deterministic offline navigation
  // ═══════════════════════════════════════════════════

  // defer registration until after `load` so it stays off the critical
  // render path. the sw already calls skipwaiting() + clients.claim()
  // so a deploy reaches return visitors on the next navigation without
  // a manual `reg.update()` nudge — and skipping the nudge avoids a
  // benign console line in lighthouse.
  //
  // serviceWorker.register is a trustedscripturl sink under csp
  // `require-trusted-types-for 'script'`; route through the tp-i18n
  // policy's createscripturl (which validates the url against the
  // same-origin allowlist).
  //
  // all errors are silenced in production; opt-in diagnostic logging
  // is gated by `?debug-sw=1` for operator-side troubleshooting.
  if ('serviceWorker' in navigator && location.protocol === 'https:') {
    var swDebug = location.search.indexOf('debug-sw=1') !== -1;
    window.addEventListener('load', function () {
      try {
        navigator.serviceWorker.register(trustedScriptURL('/sw.js'), { scope: '/' })
          .then(function (reg) {
            if (swDebug) {
              try { console.info('[tp] service worker registered', reg && reg.scope); } catch (_) {}
            }
          })
          .catch(function (err) {
            if (swDebug) {
              try { console.warn('[tp] service worker registration skipped', err); } catch (_) {}
            }
          });
      } catch (err) {
        if (swDebug) {
          try { console.warn('[tp] service worker registration unavailable', err); } catch (_) {}
        }
      }
    }, { once: true });
  }

  // ═══════════════════════════════════════════════════
  // utilities
  // ═══════════════════════════════════════════════════

  var prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ═══════════════════════════════════════════════════
  // homepage nav — minimal, deterministic, native-first
  // ═══════════════════════════════════════════════════
  // the mobile menu must initialise before the user can interact.
  // wired up synchronously here (not inside requestidlecallback) so
  // the closed state is enforced on first paint regardless of any
  // attribute drift in the static html.
  //
  // no resizeobserver overflow detection, no scroll-direction hide,
  // no scrollintoview interception. the css handles every visual
  // state via the `[hidden]` attribute and the `@media (max-width:
  // 760px)` / `@media (min-width: 761px)` split. the browser handles
  // anchor navigation natively; css scroll-margin-top on the section
  // ids handles the sticky-header offset.
  // nav-toggle / nav-links retired — the site no longer presents
  // section-jump navigation. the masthead is the only header
  // element; readers discover sections by scrolling.

  // ═══════════════════════════════════════════════════
  // deferred enhancements , scroll reveal + smooth scroll +
  //   email obfuscation. none affect first paint or accessibility,
  //   so they wait for the browser to be idle (or 200 ms, whichever
  //   comes first). cuts the synchronous main-thread cost on first
  //   load without delaying anything user-facing.
  // ═══════════════════════════════════════════════════

  function _deferEnhancements() {
    // scroll reveal · intersectionobserver. stagger is scoped per
    // selector group so the index always matches the dom order of
    // that list — the prior global counter (i % 5) wrapped item 5
    // of the principles list back to 0ms, which made governance
    // appear after taste on screen even though governance is
    // earlier in the dom.
    var revealGroups = ['.principle', '.trajectory-item', '.project-card'];
    var allRevealEls = document.querySelectorAll(revealGroups.join(', '));
    if (!prefersReducedMotion && 'IntersectionObserver' in window) {
      var observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) {
            entry.target.classList.add('visible');
            observer.unobserve(entry.target);
          }
        });
      }, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' });

      revealGroups.forEach(function (sel) {
        document.querySelectorAll(sel).forEach(function (el, i) {
          el.style.transitionDelay = (i * 80) + 'ms';
          observer.observe(el);
        });
      });
    } else {
      allRevealEls.forEach(function (el) {
        el.classList.add('visible');
      });
    }

    // email obfuscation (light, progressive)
    // Idempotent: if the server-rendered link already carries a mailto: href
    // and visible address text, this enhancement is a no-op. pages that still
    // ship the empty-href pattern continue to receive the JS-decoded address.
    var emailLinks = document.querySelectorAll('[data-contact]');
    emailLinks.forEach(function (el) {
      var existingHref = el.getAttribute('href') || '';
      if (existingHref.indexOf('mailto:') === 0) return;

      var user = el.getAttribute('data-contact');
      var domain = el.getAttribute('data-domain');
      if (!user || !domain) return;

      var addr = user + '@' + domain;
      var href = 'mailto:' + addr;
      var subject = el.getAttribute('data-contact-subject');
      if (subject) href += '?subject=' + encodeURIComponent(subject);
      el.setAttribute('href', href);

      var firstChild = el.firstElementChild;
      if (firstChild) {
        el.insertBefore(document.createTextNode(addr + ' '), firstChild);
      } else {
        el.textContent = addr;
      }
    });
  }

  if ('requestIdleCallback' in window) {
    requestIdleCallback(_deferEnhancements, { timeout: 2000 });
  } else {
    setTimeout(_deferEnhancements, 200);
  }

  // ═══════════════════════════════════════════════════
  // exports , share the inlined i18n + LANG_CYCLE with the
  // deferred enhancement bundle (/app-enhance.js). the two files
  // are intentionally split so the overlay + print lifecycle does
  // not enter the lcp critical parse window. trusted types
  // policy `tp-i18n` allows the same-origin script url.
  // ═══════════════════════════════════════════════════

  window.I18N       = I18N;
  window.LANG_CYCLE = LANG_CYCLE;

  function _tpLoadEnhance() {
    // phase 36 · app-enhance.js is now loaded statically by a
    // <script src="/app-enhance.js" defer> tag in each html page,
    // ordered after app.js and before cite.js. that guarantees
    // window.TP_OVERLAY is defined by the time cite.js wires its
    // click handlers, removing the race condition that left
    // "cite & verify" stranded on pages where the dynamic load
    // had not yet run. this dynamic loader stays as a defensive
    // fallback for environments that ship html without the static
    // tag (e.g. legacy frozen archives); the iife in app-enhance.js
    // re-exports TP_OVERLAY idempotently so a second execution is
    // harmless.
    if (window.__tpEnhanceLoaded || window.TP_OVERLAY) return;
    window.__tpEnhanceLoaded = true;
    try {
      // createelement + trustedscripturl on the same statement so the
      // tp-i18n policy gates the url before .src is assigned.
      var s = document.createElement('script'); s.src = trustedScriptURL('/app-enhance.js'); s.defer = true;
      document.head.appendChild(s);
    } catch (_) { /* tt rejection or dom error — page works without enhancements */ }
  }
  if ('requestIdleCallback' in window) {
    requestIdleCallback(_tpLoadEnhance, { timeout: 1500 });
  } else {
    setTimeout(_tpLoadEnhance, 500);
  }

})();
