/*! trentpower.fr · authored source */ /* trentpower.fr citation overlay Role: renders the page-record overlay opened by the footer cite button. peer of the homepage view project modal; same .modal-overlay / .modal css family, same focus-trap, escape and click-outside lifecycle exposed by app.js as window.TP_OVERLAY. Source: edited here as cite.template.js; compiled to cite.js by generate_site.py, which substitutes the edition literal so the on-screen citation text matches identity_canonical.json. Data: window.TP_VERIFICATION_MAP, built by generate_verification_map.py, is the single record source shared with /verify/. loaded as a sibling script on every active html so the overlay carries a full record on every page. Constraints: - no fetch; csp connect-src 'none' is preserved - no inline event handlers; csp script-src 'self' plus nonces holds - no tracking, no third-party scripts */ (function () { 'use strict'; var EDITION = '2026-05-02'; function getLang() { var t = document.documentElement.lang || 'en'; if (window.I18N && window.I18N[t]) return t; var nav = (navigator.languages && navigator.languages[0]) || navigator.language || 'en'; return /^fr\b/i.test(nav) ? 'fr' : 'en'; } function tt(key, fallback) { var lang = getLang(); var ref = (window.I18N && window.I18N[lang]) || {}; var parts = key.split('.'); for (var i = 0; i < parts.length; i++) { if (ref == null || typeof ref !== 'object') return fallback || ''; ref = ref[parts[i]]; } return (typeof ref === 'string') ? ref : (fallback || ''); } // ─── path normalisation: same shape as /verify/'s normalisepath ──────── function normalisePath(raw) { if (!raw) return '/'; raw = String(raw).trim(); if (!raw) return '/'; if (raw.charAt(0) !== '/') raw = '/' + raw; raw = raw.replace(/\/index\.html$/, '/'); if (raw.indexOf('.') === -1 && raw.charAt(raw.length - 1) !== '/') raw += '/'; return raw; } function currentRecord() { var map = (typeof window !== 'undefined' && window.TP_VERIFICATION_MAP) || {}; var p = normalisePath(location.pathname || '/'); return map[p] || null; } // ─── citation fallback (when route is not in the verification map) ───── function pageTitle() { var raw = document.title || ''; if (raw.indexOf('|') !== -1) { return raw.replace(/^[^|]+\|\s*/, '').trim().replace(/\s*&\s*/g, ' and '); } return raw.replace(/\s*[—\-]\s*Trent Power$/, '').trim(); } function canonicalUrl() { var link = document.querySelector('link[rel="canonical"]'); return link ? link.href : location.href; } function fallbackCitation() { return 'Trent Power. "' + pageTitle() + '." ' + tt('cite.site_label', 'Personal Site') + '. Paris, France. ' + tt('cite.edition_label', 'Edition') + ' ' + EDITION + '. ' + canonicalUrl(); } function verifyUrl() { var p = normalisePath(location.pathname || '/'); return '/verify/?path=' + encodeURIComponent(p); } // ─── print label , page-aware. trust / utility / error pages print as // a "sheet" (one-page a4 layout). verify is a utility sheet too. the // homepage is the executive profile. anything else gets a generic // "print page" wording. one label per page; no responsive twin. var PRINT_PAGE_SHEET = { privacy: 1, integrity: 1, security: 1, source: 1, releases: 1, verify: 1, forbidden: 1, 'not-found': 1, 'server-error': 1 }; function printLabel() { var page = (document.body && document.body.dataset && document.body.dataset.page) || (document.documentElement.dataset && document.documentElement.dataset.page) || ''; if (page === 'home') return tt('cite.overlay.action.print_home', 'Print profile'); if (PRINT_PAGE_SHEET[page]) return tt('cite.overlay.action.print_sheet', 'Print this sheet'); return tt('cite.overlay.action.print_page', 'Print page'); } // ─── dom helpers ─────────────────────────────────────────────────────── function el(tag, attrs, text) { var n = document.createElement(tag); if (attrs) for (var k in attrs) { if (Object.prototype.hasOwnProperty.call(attrs, k) && attrs[k] != null) { n.setAttribute(k, attrs[k]); } } if (text != null) n.textContent = text; return n; } function safeHref(href) { if (!href) return '#'; if (href.charAt(0) === '/' || /^https?:\/\//.test(href)) return href; return '#'; } // ─── build overlay dom lazily ────────────────────────────────────────── var overlay = null; var copyBtnAction = {}; // action name → button element // ─── overlay shape: a quiet record card ──────────────────────────────── // four elements, top-down: close × · header (kicker + title + lede) · // metadata
(edition + signed release) · actions stack (copy // citation · verify · view source · print). nothing else. the visual // weight target is "printed colophon", not "control surface" — no // status chips, no hash row, no related-records nav, no technical- // details disclosure. /verify/ and /integrity/ already carry the // forensic surface; the modal is just the publication record. // resolve the editorial publication title for this page. priority: // 1. explicit `` override // 2. i18n key `cite.overlay.page_title.` (the canonical // route — translates per language so the homepage reads as // "client strategy & growth systems" / "stratégie client …") // 3. verification-map record title (already curated, but generic // pages like "homepage" are intentionally retired upstream) // 4. document.title minus the "— trent power" suffix as a final // fallback so the modal never blanks. function resolveTitle(rec) { var explicit = document.body && document.body.dataset && document.body.dataset.citationTitle; if (explicit) return explicit; var page = (document.body && document.body.dataset && document.body.dataset.page) || ''; if (page) { var key = 'cite.overlay.page_title.' + page; var translated = tt(key, ''); if (translated) return translated; } return (rec && rec.title) || pageTitle() || tt('cite.site_label', 'Personal Site'); } function buildOverlay() { var rec = currentRecord(); var citation = (rec && rec.citation) || fallbackCitation(); var title = resolveTitle(rec); overlay = el('div', { 'class': 'modal-overlay cite-overlay', 'id': 'cite-dialog', 'role': 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'cite-modal-title', 'aria-hidden': 'true', }); var card = el('div', { 'class': 'modal cite-modal' }); // one close affordance: top-right ×. focus trap + escape + // click-outside are wired by window.TP_OVERLAY from app-enhance.js. var closeX = el('button', { type: 'button', 'class': 'cite-modal-close-x', 'data-cite-action': 'close', 'aria-label': tt('cite.overlay.action.close', 'Close'), }, '\u00d7'); card.appendChild(closeX); // ── header · eyebrow + headline + lede ── // kicker reads as a small-caps publication mark; the title // preserves the page's document.title verbatim (so the homepage // shows the editorial doc title with the author suffix); the // lede is the single-sentence descriptor. var header = el('header', { 'class': 'cite-modal-header' }); header.appendChild(el('p', { 'class': 'cite-modal-kicker' }, tt('cite.overlay.kicker', 'This page'))); header.appendChild(el('h2', { 'class': 'cite-modal-title', 'id': 'cite-modal-title' }, title)); header.appendChild(el('p', { 'class': 'cite-modal-lede' }, tt('cite.overlay.lede', 'Canonical publication record for this page.'))); card.appendChild(header); // ── metadata · two rows that read as a record card ── // edition value comes from the build-time edition constant. the // second row is a standalone label with no value column. var meta = el('dl', { 'class': 'cite-modal-meta' }); var editionRow = el('div', { 'class': 'cite-modal-meta-row' }); editionRow.appendChild(el('dt', null, tt('cite.overlay.edition_label', 'Edition'))); var editionDd = el('dd', null); editionDd.appendChild(el('time', { 'datetime': EDITION }, EDITION)); editionRow.appendChild(editionDd); meta.appendChild(editionRow); var releaseRow = el('div', { 'class': 'cite-modal-meta-row' }); releaseRow.appendChild(el('dt', null, tt('cite.overlay.release_status', 'Signed release'))); meta.appendChild(releaseRow); card.appendChild(meta); // ── actions · two tiers ── // primary (verify · view source) — the inspectable record. carries // the philosophical weight: "this page can be verified, this page // has its source". // secondary (copy citation · print) — utilities. less prominent. // both tiers share the .cite-modal-action class for layout; the // primary / secondary modifiers govern the visual hierarchy. var actions = el('div', { 'class': 'cite-modal-actions' }); var primary = el('div', { 'class': 'cite-modal-actions-row cite-modal-actions-row--primary' }); primary.appendChild(el('a', { 'href': safeHref(verifyUrl()), 'class': 'cite-modal-action cite-modal-action--primary', 'data-cite-action': 'verify', }, tt('cite.overlay.action.verify', 'Verify this page'))); // page-aware "view source" target. for routes carried by the // verification map (every active page on the site), rec.reader is // the per-page source viewer url. rec.source is the raw mirror as // a secondary. /source/ catalogue lands as a final fallback. var sourceHref = (rec && rec.reader) || (rec && rec.source) || '/source/'; primary.appendChild(el('a', { 'href': safeHref(sourceHref), 'class': 'cite-modal-action cite-modal-action--primary', 'data-cite-action': 'open-source', }, tt('cite.overlay.action.open_source', 'View source'))); actions.appendChild(primary); var secondary = el('div', { 'class': 'cite-modal-actions-row cite-modal-actions-row--secondary' }); var copyBtn = el('button', { type: 'button', 'class': 'cite-modal-action cite-modal-action--secondary', 'data-cite-action': 'copy-citation', }, tt('cite.overlay.action.copy_citation', 'Copy citation')); copyBtn.dataset.payload = citation; secondary.appendChild(copyBtn); copyBtnAction['copy-citation'] = copyBtn; var printBtn = el('button', { type: 'button', 'class': 'cite-modal-action cite-modal-action--secondary', 'data-cite-action': 'print', }, printLabel()); secondary.appendChild(printBtn); copyBtnAction['print'] = printBtn; actions.appendChild(secondary); card.appendChild(actions); // ── aria-live region for copy feedback. polite priority so // screen readers announce 'citation copied' without interrupting. var liveStatus = el('output', { 'class': 'visually-hidden', 'aria-live': 'polite', 'id': 'cite-copy-status', }); card.appendChild(liveStatus); overlay.appendChild(card); document.body.appendChild(overlay); overlay.addEventListener('click', function (e) { if (e.target === overlay) { if (window.TP_OVERLAY && window.TP_OVERLAY.close) window.TP_OVERLAY.close(); } }); card.addEventListener('click', handleActionClick); } function handleActionClick(e) { var t = e.target && e.target.closest ? e.target.closest('[data-cite-action]') : null; if (!t) return; var action = t.dataset.citeAction; if (action === 'close') { if (window.TP_OVERLAY && window.TP_OVERLAY.close) window.TP_OVERLAY.close(); return; } if (action === 'print') { // ios safari requires window.print() synchronously inside the // gesture handler — settimeout breaks the gesture context. the // print stylesheet hides .modal-overlay across every data-page, // so calling print() with the overlay still mounted is safe; the // close runs after to tidy on-screen state. window.print(); if (window.TP_OVERLAY && window.TP_OVERLAY.close) window.TP_OVERLAY.close(); return; } if (action === 'copy-citation') { e.preventDefault(); var payload = t.dataset.payload || ''; if (!payload) return; if (!navigator.clipboard) return; var prev = t.textContent; var toastKey = 'cite.overlay.toast.citation_copied'; var toastFallback = 'citation copied'; navigator.clipboard.writeText(payload).then(function () { var toastText = tt(toastKey, toastFallback); t.textContent = toastText; t.setAttribute('data-state', 'copied'); // announce via aria-live so screen readers receive the // confirmation. re-set the same text after a tick if it was // already there to force the live-region change event. var status = document.getElementById('cite-copy-status'); if (status) { status.textContent = ''; // microtask defer so the empty-then-text transition reliably // fires the at live-region notification. setTimeout(function () { status.textContent = toastText; }, 0); } setTimeout(function () { t.textContent = prev; t.removeAttribute('data-state'); if (status) status.textContent = ''; }, 1400); }, function () { /* clipboard denied , silent */ }); return; } // 'verify' and 'open-source' are plain elements — let the // browser navigate, no interception needed. } // ─── wire the cite trigger ───────────────────────────────────────────── // single trigger contract: anything carrying `[data-cite-open]` opens // the citation overlay. `.cite-btn` survives as a styling hook only. // history-integrated open pushes #cite onto the url so the browser // back button closes the overlay naturally and direct links auto-open // it. if the overlay infra (window.TP_OVERLAY) is missing for any // reason, the handler falls back to navigating to /verify/ so the // user is never stranded. function openCite(e, opener) { if (!opener && e && e.currentTarget) opener = e.currentTarget; if (!window.TP_OVERLAY || !window.TP_OVERLAY.open) { if (e && typeof e.preventDefault === 'function') e.preventDefault(); try { console.warn('cite overlay unavailable, falling back to /verify/'); } catch (_) {} window.location.href = '/verify/'; return; } if (e && typeof e.preventDefault === 'function') e.preventDefault(); if (!overlay) buildOverlay(); window.TP_OVERLAY.open(overlay, opener || null, { hash: '#cite' }); } function init() { var triggers = document.querySelectorAll('[data-cite-open]'); if (!triggers.length) return; Array.prototype.forEach.call(triggers, function (trigger) { trigger.addEventListener('click', function (e) { openCite(e, trigger); }); }); // deep-link · if the page is loaded with #cite in the url (shared // link, bookmark, browser-history navigation), auto-open the // overlay after the trigger is wired. skipped if the hash matches // an in-page anchor that exists (e.g. #contact) — only #cite is // claimed by this overlay. if (window.location.hash === '#cite') { // defer to the next tick so any other domcontentloaded handlers // finish first (notably the smooth-scroll and language-switcher // handlers in app.js). setTimeout(function () { openCite(null, triggers[0]); }, 0); } // popstate forward navigation: if we're back at #cite without an // active overlay, open. mirrors the back-button case so forward // re-opens cleanly. tagged with frompopstate so the open doesn't // push another history entry. window.addEventListener('popstate', function () { var st = history.state; if (window.location.hash === '#cite' && st && st.tp_overlay === 'cite' && !document.querySelector('.modal-overlay.active')) { if (!overlay) buildOverlay(); if (window.TP_OVERLAY && window.TP_OVERLAY.open) { window.TP_OVERLAY.open(overlay, triggers[0], { hash: '#cite', fromPopstate: true }); } } }); // re-render overlay if the language changes after first open. cheap: // we just discard it; next click rebuilds. var observer = new MutationObserver(function (mutations) { for (var i = 0; i < mutations.length; i++) { if (mutations[i].attributeName === 'lang') { if (overlay) { overlay.parentNode && overlay.parentNode.removeChild(overlay); overlay = null; copyBtnAction = {}; } break; } } }); observer.observe(document.documentElement, { attributes: true }); } // defer init off the critical render path. the cite-button click // happens long after first paint, so building the overlay shell at // domcontentloaded was wasted main-thread time (~50 ms long task on // mobile). requestidlecallback runs after the browser is idle; // settimeout 500 ms is the fallback for engines without it. function _scheduleInit() { if ('requestIdleCallback' in window) { window.requestIdleCallback(init, { timeout: 1500 }); } else { window.setTimeout(init, 500); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', _scheduleInit); } else { _scheduleInit(); } // fingerprint + verify-command copy buttons. defer the dom walk + // listener attachment to idle: clicks happen well after first paint, // and walking the document for these selectors before lcp costs // main-thread time for no user-visible benefit. function _wireCopyButtons() { var copyBtns = document.querySelectorAll('.copy-fingerprint[data-copy-target], .verify-command-copy[data-copy-target]'); copyBtns.forEach(function (b) { var resting = b.textContent; var collapse = b.classList.contains('copy-fingerprint'); b.addEventListener('click', function () { var target = document.getElementById(b.dataset.copyTarget); if (!target || !navigator.clipboard) return; var raw = target.textContent || ''; var text = collapse ? raw.replace(/\s+/g, ' ').trim() : raw.replace(/\s+$/, ''); navigator.clipboard.writeText(text).then(function () { b.textContent = tt('cite.copied', 'Copied'); b.setAttribute('data-state', 'copied'); setTimeout(function () { b.textContent = resting; b.removeAttribute('data-state'); }, 1500); }, function () { /* silent */ }); }); }); } if ('requestIdleCallback' in window) { window.requestIdleCallback(_wireCopyButtons, { timeout: 1500 }); } else { window.setTimeout(_wireCopyButtons, 500); } })();