/*! 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
//