/*! trentpower.fr · authored source */
/* app-enhance source. edit here, not in app-enhance.js. the build
   header is prepended at build time by generate_site.py.

   loaded after first paint by app.js via requestidlecallback +
   document.createElement('script') — the script url passes through
   the tp-i18n trusted types policy. carries the project-overlay
   lifecycle (focus trap, click-outside, escape) and the
   print-time document.title swap. none of this is needed for
   first paint, language detection or accessibility, so it does
   not enter the lcp critical path. */

(function () {
  'use strict';

  // shared globals come from /app.js: window.I18N, window.LANG_CYCLE.
  // /app.js exports those before scheduling this bundle.
  var I18N = window.I18N || null;

  // ═══════════════════════════════════════════════════
  // overlay , accessible, focus-trapped, reusable
  // the same lifecycle (blur backdrop, scroll lock, inert siblings,
  // focus trap, escape close, click-outside close) is used by both
  // the homepage's "view project" modal and the cite-this-page
  // overlay rendered by cite.js. exposed via window.TP_OVERLAY so
  // cite.js can register its own (overlay, trigger) pair without
  // duplicating the lifecycle.
  // ═══════════════════════════════════════════════════

  var siteContent = document.getElementById('main');
  var supportsInert = 'inert' in document.documentElement;

  function setInert(el, state) {
    if (!el) return;
    if (state) {
      if (supportsInert) el.setAttribute('inert', '');
      el.setAttribute('aria-hidden', 'true');
    } else {
      if (supportsInert) el.removeAttribute('inert');
      el.removeAttribute('aria-hidden');
    }
  }

  function focusableIn(el) {
    return el.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])');
  }

  var activeOverlay = null;
  var activeTrigger = null;
  // history integration · whether this opener pushed a state. only set
  // when opts.hash is supplied. cleared whenever an overlay closes.
  var pushedHistoryState = false;

  function openOverlay(overlay, trigger, opts) {
    if (!overlay) return;
    activeOverlay = overlay;
    activeTrigger = trigger || null;
    overlay.setAttribute('aria-hidden', 'false');
    overlay.classList.add('active');
    document.body.style.overflow = 'hidden';
    setInert(siteContent, true);
    if (trigger) trigger.setAttribute('aria-expanded', 'true');
    var focusable = focusableIn(overlay);
    if (focusable.length > 0) focusable[0].focus();
    // optional history integration. caller passes opts.hash (e.g. '#cite')
    // and the overlay becomes back-button-closable + deep-linkable. the
    // opts.fromPopstate flag lets the popstate listener re-open without
    // pushing an extra entry.
    pushedHistoryState = false;
    if (opts && opts.hash && !opts.fromPopstate &&
        typeof history !== 'undefined' && typeof history.pushState === 'function') {
      try {
        history.pushState({ tp_overlay: opts.hash.replace(/^#/, '') }, '', opts.hash);
        pushedHistoryState = true;
      } catch (_) { /* sandboxed iframe etc. — silently no-op */ }
    }
  }

  function closeOverlay(opts) {
    if (!activeOverlay) return;
    var overlay = activeOverlay;
    var trigger = activeTrigger;
    overlay.classList.remove('active');
    overlay.setAttribute('aria-hidden', 'true');
    document.body.style.overflow = '';
    setInert(siteContent, false);
    if (trigger) {
      trigger.setAttribute('aria-expanded', 'false');
      trigger.focus();
    }
    activeOverlay = null;
    activeTrigger = null;
    // if we pushed a history entry on open and this close is user-
    // initiated (× / esc / click-outside, not a popstate-driven close),
    // pop our entry so the url reverts cleanly. wrapped in try because
    // history.back() can fail when the page is the only entry.
    if (pushedHistoryState && !(opts && opts.fromPopstate) &&
        typeof history !== 'undefined' && typeof history.back === 'function') {
      try { history.back(); } catch (_) { /* no prior entry — stay put */ }
    }
    pushedHistoryState = false;
  }

  // popstate · browser back/forward. if an overlay is open and the new
  // location no longer carries our state, close from popstate so we
  // don't double-pop the history. Open-on-forward isn't supported in
  // this version — initial-hash auto-open is handled by the overlay's
  // own init code (cite.template.js).
  window.addEventListener('popstate', function () {
    if (activeOverlay) {
      var st = history.state;
      var stillOpen = st && st.tp_overlay;
      if (!stillOpen) closeOverlay({ fromPopstate: true });
    }
  });

  document.addEventListener('keydown', function (e) {
    if (!activeOverlay) return;
    if (e.key === 'Escape') { closeOverlay(); return; }
    if (e.key === 'Tab') {
      var focusable = focusableIn(activeOverlay);
      if (focusable.length === 0) return;
      var first = focusable[0];
      var last = focusable[focusable.length - 1];
      if (e.shiftKey) {
        if (document.activeElement === first) { e.preventDefault(); last.focus(); }
      } else {
        if (document.activeElement === last) { e.preventDefault(); first.focus(); }
      }
    }
  });

  // expose for cite.js (and any future overlay).
  window.TP_OVERLAY = { open: openOverlay, close: closeOverlay };

  // project "view project" modal , wires its trigger / close button into
  // the shared lifecycle. pages without #modal silently skip.
  var modal = document.getElementById('modal');
  var btn = document.getElementById('access-btn');
  var closeBtn = document.getElementById('modal-close');

  if (modal && btn && closeBtn) {
    btn.addEventListener('click', function () { openOverlay(modal, btn); });
    closeBtn.addEventListener('click', closeOverlay);
    modal.addEventListener('click', function (e) {
      if (e.target === modal) closeOverlay();
    });
  }

  // ═══════════════════════════════════════════════════
  // print , temporarily swap document.title so the browser's
  // "save as pdf" dialogue suggests a clean filename, then restore
  // on afterprint so the on-screen tab title is unaffected.
  // Reveal-classed elements are forced visible defensively (the
  // live screen layout is hidden by the print.css direct-child rule,
  // but the visibility flag is harmless on screen and protects
  // against future regressions).
  // ═══════════════════════════════════════════════════

  var savedDocTitle = null;

  function onBeforePrint() {
    var lang = document.documentElement.lang || 'en';
    var t = (I18N && I18N[lang]) || (I18N && I18N.en) || null;
    var page = (document.body && document.body.getAttribute('data-page')) || 'home';
    var printTitle =
      (page === 'privacy'      && t && t.privacy      && t.privacy.print      && t.privacy.print.doc_title) ||
      (page === 'integrity'    && t && t.integrity    && t.integrity.print    && t.integrity.print.doc_title) ||
      (page === 'security'     && t && t.security     && t.security.print     && t.security.print.doc_title) ||
      (page === 'source'       && t && t.source       && t.source.print       && t.source.print.doc_title) ||
      (page === 'releases'     && t && t.releases     && t.releases.print     && t.releases.print.doc_title) ||
      (page === 'forbidden'    && t && t.error        && t.error['403']       && t.error['403'].print && t.error['403'].print.doc_title) ||
      (page === 'not-found'    && t && t.error        && t.error['404']       && t.error['404'].print && t.error['404'].print.doc_title) ||
      (page === 'server-error' && t && t.error        && t.error['500']       && t.error['500'].print && t.error['500'].print.doc_title) ||
      (page === 'verify'       && t && t.verify       && t.verify.doc_title) ||
      (t && t.print && t.print.doc_title);
    if (printTitle) {
      savedDocTitle = document.title;
      document.title = printTitle;
    }
    var revealSelector = '.principle, .trajectory-item, .project-card, '
      + '.hero-name, .hero-statement, .hero-body, .trust-mark';
    document.querySelectorAll(revealSelector).forEach(function (el) {
      el.classList.add('visible');
    });
    document.documentElement.classList.add('is-printing');
  }

  function onAfterPrint() {
    if (savedDocTitle !== null) {
      document.title = savedDocTitle;
      savedDocTitle = null;
    }
    document.documentElement.classList.remove('is-printing');
  }

  if (window.matchMedia) {
    try {
      window.matchMedia('print').addEventListener('change', function (e) {
        if (e.matches) onBeforePrint(); else onAfterPrint();
      });
    } catch (_) { /* older browsers fall back to onbeforeprint */ }
  }
  window.addEventListener('beforeprint', onBeforePrint);
  window.addEventListener('afterprint', onAfterPrint);

  // ═══════════════════════════════════════════════════
  // POST-LCP FULL-FONT upgrade
  // /fonts-full.css carries the full editorial @font-face declarations
  // and a `.fonts-loaded` :root override that flips --serif / --sans /
  // --mono to put the full families first. loaded as a same-origin
  // stylesheet (csp `style-src 'self'` allows it; no trusted types
  // gate applies — `require-trusted-types-for 'script'` only governs
  // script-URL sinks). subset and full font share identical glyph
  // metrics so the swap is glyph-by-glyph with cls = 0.
  // ═══════════════════════════════════════════════════

  (function _loadFullFonts() {
    if (document.querySelector('link[data-tp-fonts-full]')) return;
    var link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = '/fonts-full.css';
    link.setAttribute('data-tp-fonts-full', '');
    // wait until every pending font load has resolved (loaded or hit
    // its font-display timeout), then run the class flip inside the
    // next paint frame so the recalc rides the browser's normal paint
    // pipeline rather than appearing as a free-standing reflow.
    function paintFlip() {
      if (typeof requestAnimationFrame === 'function') {
        requestAnimationFrame(function () {
          document.documentElement.classList.add('fonts-loaded');
        });
      } else {
        document.documentElement.classList.add('fonts-loaded');
      }
    }
    link.addEventListener('load', function () {
      if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') {
        document.fonts.ready.then(paintFlip);
      } else {
        setTimeout(paintFlip, 1500);
      }
    });
    document.head.appendChild(link);
  })();

  // footer · proof imprint · walks /integrity.json on every visit.
  // edition  → manifest's "generated" date.
  // sha      → sha-256 of the manifest's exact bytes, computed
  //            client-side (matches `shasum -a 256 integrity.json`).
  // verified → days since "generated"; today/yesterday tint oxblood.
  // fail-open: offline / csp block / json corruption / no subtlecrypto
  // → em-dash placeholders stay, is-loading class is removed.
  (function () {
    var line = document.getElementById('footerImprint');
    if (!line) return;

    function set(key, text, cls) {
      var n = line.querySelector('[data-proof="' + key + '"]');
      if (!n) return;
      n.textContent = text;
      n.setAttribute('data-proof-set', 'true');
      if (cls) n.classList.add(cls);
    }

    function isPlaceholder(text) {
      if (!text) return true;
      var s = text.replace(/\s+/g, '');
      if (s === '' || s === '—' || s === '-') return true;
      // accept both uppercase ("SHA256:—" — the inline default in the
      // sha-link element) and lowercase ("sha256:…" — what shortSha
      // emits before the hash payload arrives). also catch the case
      // where the hash payload is empty after stripping base64 padding
      // (e.g. a stray "sha256-=" lookup that resolves to nothing).
      var lower = s.toLowerCase();
      return lower === 'sha256:—' || lower === 'sha256:-' || lower === 'sha256:';
    }

    function pruneUnsetSegments() {
      // new imprint structure: <dl> > (<dt>, <dd>)* — hide each dt+dd
      // pair whose data-proof value never received a real payload.
      // when every row is unset (network/csp/parse failure) the whole
      // <dl> hides so the footer doesn't ship as a stale integrity row.
      var values = line.querySelectorAll('[data-proof]');
      var visible = 0;
      for (var i = 0; i < values.length; i++) {
        var v = values[i];
        var dd = v.closest('dd');
        if (!dd) continue;
        var dt = dd.previousElementSibling;
        // walk back past stray description spans to find the matching dt.
        while (dt && dt.tagName !== 'DT') dt = dt.previousElementSibling;
        var hide = !v.getAttribute('data-proof-set') || isPlaceholder(v.textContent);
        if (hide) {
          dd.hidden = true;
          if (dt) dt.hidden = true;
        } else {
          visible++;
        }
      }
      if (visible === 0) line.hidden = true;
    }

    function currentPagePath() {
      // map window.location.pathname to the manifest key convention:
      // no leading slash; "/" → "index.html"; trailing-slash urls
      // append "index.html". matches the keys in /integrity.json's
      // files object exactly.
      var p = window.location.pathname;
      if (!p || p === '/') return 'index.html';
      p = p.replace(/^\/+/, '');
      if (p.endsWith('/')) p += 'index.html';
      return p;
    }

    function shortSha(value) {
      // accept either "sha256-<base64>" (manifest format) or bare
      // base64; trim padding, take 5 chars head + 4 chars tail, join
      // with "…". short enough to read inline, long enough to be a
      // useful fingerprint at a glance.
      var clean = String(value).replace(/^sha256-/, '').replace(/=+$/, '');
      if (clean.length < 12) return 'sha256:' + clean;
      return 'sha256:' + clean.slice(0, 5) + '…' + clean.slice(-4);
    }

    function relativeStrings() {
      // pull from window.I18N for the current lang; fall back to english
      // if the dictionary isn't loaded yet or the key shape changed.
      var lang = (document.documentElement.lang || 'en');
      var bag = (window.I18N && window.I18N[lang] && window.I18N[lang].footer
        && window.I18N[lang].footer.proof && window.I18N[lang].footer.proof.relative) || {};
      return {
        today:     bag.today     || 'today',
        yesterday: bag.yesterday || 'yesterday',
        days:      bag.days      || '{n} days ago',
        months:    bag.months    || '{n} months ago',
        years:     bag.years     || '{n} years ago'
      };
    }

    function daysAgo(iso) {
      var t = Date.parse(iso);
      if (isNaN(t)) return { text: '', fresh: false };
      var d = Math.floor((Date.now() - t) / 86400000);
      var r = relativeStrings();
      if (d <= 0)  return { text: r.today,     fresh: true };
      if (d === 1) return { text: r.yesterday, fresh: true };
      if (d < 30)  return { text: r.days.replace('{n}', d),                  fresh: false };
      if (d < 365) return { text: r.months.replace('{n}', Math.round(d/30)), fresh: false };
      return         { text: r.years.replace('{n}', Math.round(d/365)),       fresh: false };
    }

    fetch('/integrity.json', { cache: 'no-store' })
      .then(function (r) { return r.ok ? r.text() : null; })
      .then(function (text) {
        if (!text) return null;
        var json;
        try { json = JSON.parse(text); } catch (_) { return null; }

        // edition reads the editorial cycle from identity_canonical;
        // verified-since uses the build/generated timestamp because
        // freshness should track when the bytes were last re-signed,
        // not when the editor last cut a new edition.
        var editionStamp = json.edition || json.generated;
        if (editionStamp) set('edition', editionStamp);
        var verifyStamp = json.generated || json.edition;
        if (verifyStamp) {
          var ago = daysAgo(verifyStamp);
          if (ago.text) set('verified', ago.text, ago.fresh ? 'v--fresh' : null);
        }

        // per-page sha: look up this page's path in the manifest's
        // signed files map. paths not in the map (e.g. frozen
        // archive routes) keep the em-dash placeholder, honouring
        // the fail-open contract.
        if (json.files) {
          var entry = json.files[currentPagePath()];
          if (entry) set('sha', shortSha(entry));
        }
      })
      .catch(function () { /* fail open · unset segs get hidden below */ })
      .finally(function () {
        line.classList.remove('is-loading');
        pruneUnsetSegments();
      });
  })();

  // quiet hello to anyone reading the console.
  // one line, once per page load. no tracking, no network, no ui.
  var canLog = window.console && typeof console.info === "function";
  if (canLog) {
    console.info("trentpower.fr · static, signed, inspectable · ctrl+u still works");
  }

})();
