/*! 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);
}
})();