/*! 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 // in time for the first paint sequence. cheap, idempotent. // ═══════════════════════════════════════════════════ try { document.documentElement.classList.add('js'); } catch (_) {} // phase 37 · once the dom is ready, mark 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. 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'); }); } // 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-mission': t.hero && t.hero.statement, '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 '
  • ' + item + '
  • '; }).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) { var trigger = event.target && event.target.closest ? event.target.closest('[data-lang]') : null; if (!trigger) return; var nextLang = trigger.getAttribute('data-lang'); if (!nextLang) return; // ignore the attribute carrier (only triggers // are interactive elements: button, a). guards against accidental // bubbled clicks from translated text living inside a [data-lang] // ancestor that happens to be a wrapping element. var tag = trigger.tagName; if (tag !== 'BUTTON' && tag !== 'A') return; event.preventDefault(); switchLanguage(nextLang, trigger); }); })(); // ═══════════════════════════════════════════════════ // 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 var revealEls = document.querySelectorAll('.principle, .trajectory-item, .project-card'); 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' }); revealEls.forEach(function (el, i) { el.style.transitionDelay = (i % 5 * 80) + 'ms'; observer.observe(el); }); } else { revealEls.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 //