Files
archicratie-edition/src/layouts/EditionLayout.astro
archicratia 60d88939b0
All checks were successful
CI / build-and-anchors (push) Successful in 1m25s
SMOKE / smoke (push) Successful in 11s
CI / build-and-anchors (pull_request) Successful in 1m20s
Seed from NAS prod snapshot 20260130-190531
2026-01-31 10:51:38 +00:00

832 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import ProposeModal from "../components/ProposeModal.astro";
import SiteNav from "../components/SiteNav.astro";
import LevelToggle from "../components/LevelToggle.astro";
import BuildStamp from "../components/BuildStamp.astro";
import "../styles/global.css";
const {
title,
editionLabel,
editionKey,
statusLabel,
statusKey,
level,
version,
} = Astro.props;
const lvl = level ?? 1;
const canonical = Astro.site
? new URL(Astro.url.pathname, Astro.site).href
: Astro.url.href;
// Cible Gitea (injectée au build)
const GITEA_BASE = import.meta.env.PUBLIC_GITEA_BASE ?? "";
const GITEA_OWNER = import.meta.env.PUBLIC_GITEA_OWNER ?? "";
const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
---
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title ? `${title} — Archicratie` : "Archicratie"}</title>
<link rel="canonical" href={canonical} />
<link rel="sitemap" href="/sitemap-index.xml" />
<meta data-pagefind-filter="edition[content]" content={String(editionKey ?? editionLabel)} />
<meta data-pagefind-filter="level[content]" content={String(lvl)} />
<meta data-pagefind-filter="status[content]" content={String(statusKey ?? statusLabel)} />
<meta data-pagefind-meta={`edition:${String(editionKey ?? editionLabel)}`} />
<meta data-pagefind-meta={`level:${String(lvl)}`} />
<meta data-pagefind-meta={`status:${String(statusKey ?? statusLabel)}`} />
<meta data-pagefind-meta={`version:${String(version ?? "")}`} />
</head>
<body data-doc-title={title} data-doc-version={version}>
<header>
<SiteNav />
<div class="edition-bar">
<span class="badge"><strong>Édition</strong> : {editionLabel}</span>
<span class="badge"><strong>Statut</strong> : {statusLabel}</span>
<span class="badge"><strong>Niveau</strong> : {lvl}</span>
<span class="badge"><strong>Version</strong> : {version}</span>
<a class="resume-btn" id="resume-btn" href="#" hidden>Reprendre la lecture</a>
<LevelToggle initialLevel={lvl} />
</div>
</header>
<main class="page">
<div class="page-shell">
<aside class="page-aside" aria-label="Navigation de lecture">
<div class="page-aside__scroll" aria-label="Sommaires">
<slot name="aside" />
</div>
</aside>
<article class="reading" data-pagefind-body>
<slot />
<BuildStamp />
</article>
</div>
</main>
<!-- Bandeau lecture (H1/H2/H3 uniquement) -->
<div class="reading-follow" id="reading-follow" aria-hidden="true">
<div class="reading-follow__inner">
<button class="rf-line rf-h1" id="rf-h1" type="button" hidden></button>
<button class="rf-line rf-h2" id="rf-h2" type="button" hidden></button>
<button class="rf-line rf-h3" id="rf-h3" type="button" hidden></button>
<!-- Actions : haut chapitre / haut section -->
<div class="rf-actions" aria-label="Navigation rapide">
<button
class="rf-btn"
id="rf-top-chapter"
type="button"
hidden
aria-label="Haut du chapitre"
title="Haut du chapitre"
>↑</button>
<button
class="rf-btn"
id="rf-top-section"
type="button"
hidden
aria-label="Haut de la section"
title="Haut de la section"
>↥</button>
</div>
</div>
</div>
<ProposeModal />
<style>
.page{
padding: var(--page-gap) 16px 48px;
}
.page-shell{
--aside-w: 320px;
--reading-w: 78ch;
--gap: 18px;
display: grid;
grid-template-columns: 1fr var(--aside-w) minmax(0, var(--reading-w)) 1fr;
column-gap: var(--gap);
align-items: start;
}
.page-aside{
grid-column: 2;
position: sticky;
/* colle sous header + padding haut */
top: calc(var(--sticky-header-h) + var(--page-gap));
/* ⛔️ plus de scroll ici */
overflow: visible;
max-height: none;
}
.page-aside__scroll{
/* ✅ le scroll est ici, donc englobe TOC global + local */
max-height: calc(100vh - (var(--sticky-header-h) + var(--page-gap) + 12px));
overflow: auto;
padding-right: 8px;
padding-top: 6px;
scrollbar-gutter: stable;
}
.reading{
grid-column: 3;
max-width: none;
margin: 0;
}
@media (max-width: 1100px){
.page-shell{
max-width: 1180px;
margin: 0 auto;
grid-template-columns: var(--aside-w) minmax(0, 1fr);
}
.page-aside{ grid-column: 1; }
.reading{
grid-column: 2;
max-width: 78ch;
margin: 0 auto;
}
}
@media (max-width: 860px){
.page-shell{
max-width: 78ch;
margin: 0 auto;
grid-template-columns: 1fr;
}
.page-aside{
grid-column: 1;
position: static;
overflow: visible;
margin-bottom: 12px;
}
.page-aside__scroll{
max-height: none;
overflow: visible;
padding-right: 0;
padding-top: 0;
}
.reading{
grid-column: 1;
max-width: 78ch;
margin: 0 auto;
}
}
</style>
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
(() => {
try {
const PAGE_GAP = 12; // doit matcher --page-gap (css)
const HYST = 4; // micro-hystérésis px
const headerEl = document.querySelector("header");
const followEl = document.getElementById("reading-follow");
const reading = document.querySelector("article.reading");
const rfH1 = document.getElementById("rf-h1");
const rfH2 = document.getElementById("rf-h2");
const rfH3 = document.getElementById("rf-h3");
const btnTopChapter = document.getElementById("rf-top-chapter");
const btnTopSection = document.getElementById("rf-top-section");
function px(n){ return `${Math.max(0, Math.round(n))}px`; }
function setRootVar(name, value) {
document.documentElement.style.setProperty(name, value);
}
function headerH() {
return headerEl ? headerEl.getBoundingClientRect().height : 0;
}
function syncHeaderH() {
setRootVar("--sticky-header-h", px(headerH()));
setRootVar("--page-gap", px(PAGE_GAP));
}
function syncReadingRect() {
if (!reading) return;
const r = reading.getBoundingClientRect();
setRootVar("--reading-left", px(r.left));
setRootVar("--reading-width", px(r.width));
}
function syncHeadingMetrics() {
if (!reading) return;
const h1 = reading.querySelector("h1");
const h2 = reading.querySelector("h2");
const h3 = reading.querySelector("h3");
const sync = (el, prefix) => {
if (!el) return;
const cs = getComputedStyle(el);
if (cs.fontSize) setRootVar(`--rf-${prefix}-size`, cs.fontSize);
if (cs.lineHeight) setRootVar(`--rf-${prefix}-lh`, cs.lineHeight);
if (cs.fontWeight) setRootVar(`--rf-${prefix}-fw`, cs.fontWeight);
};
sync(h1, "h1");
sync(h2, "h2");
sync(h3, "h3");
}
syncHeaderH();
syncReadingRect();
syncHeadingMetrics();
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
syncHeaderH();
syncReadingRect();
syncHeadingMetrics();
}).catch(() => {});
}
window.addEventListener("resize", () => {
requestAnimationFrame(() => {
syncHeaderH();
syncReadingRect();
syncHeadingMetrics();
});
});
function getStickyOffsetPx() {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--sticky-offset-px")
.trim();
const n = Number.parseFloat(raw);
return Number.isFinite(n) ? n : (headerH() + PAGE_GAP);
}
function absTop(el) {
const r = el.getBoundingClientRect();
return r.top + (window.scrollY || 0);
}
function scrollToElWithOffset(el, extra = 10, behavior = "auto") {
const y = absTop(el) - getStickyOffsetPx() - extra;
window.scrollTo({ top: Math.max(0, y), behavior });
}
function openDetailsIfNeeded(el) {
const d = el && el.closest && el.closest("details");
if (d && !d.open) d.open = true;
}
// ==========================
// Bookmarks (pinned + auto)
// ==========================
const PINNED_KEY = "archicratie:bookmark:pinned";
const LAST_KEY = `archicratie:bookmark:last:${window.location.pathname}`;
function readJSON(key) {
try { return JSON.parse(localStorage.getItem(key) || "null"); }
catch { return null; }
}
function writeJSON(key, obj) {
try { localStorage.setItem(key, JSON.stringify(obj)); } catch {}
}
function updateResumeButton() {
const btn = document.getElementById("resume-btn");
if (!btn) return;
const bm = readJSON(PINNED_KEY) || readJSON(LAST_KEY);
if (!bm || !bm.url) { btn.hidden = true; return; }
btn.hidden = false;
btn.href = bm.url;
btn.title = bm.title ? `Reprendre : ${bm.title}` : "Reprendre la lecture";
btn.onclick = (ev) => {
try {
const here = new URL(window.location.href);
here.search = "";
const target = new URL(bm.url, window.location.origin);
target.search = "";
if (here.pathname === target.pathname) {
ev.preventDefault();
const id = (target.hash || "").slice(1);
if (!id) return;
const el = document.getElementById(id);
if (!el) return;
openDetailsIfNeeded(el);
scrollToElWithOffset(el, 12, "auto");
history.replaceState(null, "", here.pathname + "#" + id);
}
} catch {}
};
}
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
if (window.location.search.includes("body=")) {
history.replaceState(null, "", window.location.pathname + window.location.hash);
}
// Hotfix compat citabilité : fallback #p-<idx>-<hash>
(function resolveLegacyParagraphHash() {
const h = window.location.hash || "";
const m = h.match(/^#p-(\d+)-[0-9a-f]{8}$/i);
if (!m) return;
const oldId = h.slice(1);
if (document.getElementById(oldId)) return;
const idx = m[1];
const prefix = `p-${idx}-`;
const replacement = document.querySelector(`[id^="${prefix}"]`);
if (!replacement) return;
console.warn("[anchors] legacy hash fallback used:", `#${oldId}`, "→", `#${replacement.id}`);
scrollToElWithOffset(replacement, 12, "auto");
})();
const docTitle = document.body.dataset.docTitle || document.title;
const docVersion = document.body.dataset.docVersion || "";
updateResumeButton();
// ==========================
// Gitea propose (optional)
// ==========================
const giteaReady = Boolean(GITEA_BASE && GITEA_OWNER && GITEA_REPO);
const FULL_TEXT_SOFT_LIMIT = 1600;
const URL_HARD_LIMIT = 6500;
const quoteBlock = (s) =>
String(s || "")
.split(/\r?\n/)
.map((l) => (`> ${l}`).trimEnd())
.join("\n");
function buildIssueURL(anchorId, fullText, excerpt) {
const base = String(GITEA_BASE).replace(/\/+$/, "");
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
const local = new URL(window.location.href);
local.search = "";
local.hash = anchorId;
const path = local.pathname;
const issueTitle = `[Correction] ${anchorId} — ${docTitle}`;
const hasFull = Boolean(fullText && fullText.length);
const canTryFull = hasFull && fullText.length <= FULL_TEXT_SOFT_LIMIT;
const makeBody = (embedFull) => {
const header = [
`Chemin: ${path}`,
`URL locale: ${local.toString()}`,
`Ancre: #${anchorId}`,
`Version: ${docVersion || "(non renseignée)"}`,
`Type: type/correction`,
`State: state/recevable`,
``,
];
const texteActuel = embedFull
? [`Texte actuel (copie exacte du paragraphe):`, quoteBlock(fullText)]
: [
`Texte actuel (extrait):`,
quoteBlock(excerpt || ""),
``,
`Note: paragraphe long → extrait (pour éviter une URL trop longue).`,
];
const footer = [
``,
`Proposition (remplacer par):`,
``,
`Justification:`,
``,
`---`,
`Note: issue générée depuis le site (pré-remplissage).`,
];
return header.concat(texteActuel, footer).join("\n");
};
issue.searchParams.set("title", issueTitle);
issue.searchParams.set("body", makeBody(Boolean(canTryFull)));
if (issue.toString().length > URL_HARD_LIMIT) {
issue.searchParams.set("body", makeBody(false));
}
return issue.toString();
}
// ==========================
// Paragraph tools (+ pinned bookmark)
// ==========================
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
for (const p of paras) {
if (p.querySelector(".para-tools")) continue;
const tools = document.createElement("span");
tools.className = "para-tools";
const row = document.createElement("span");
row.className = "para-tools-row";
const a = document.createElement("a");
a.className = "para-anchor";
a.href = `#${p.id}`;
a.setAttribute("aria-label", "Lien vers ce paragraphe");
a.textContent = "¶";
const citeBtn = document.createElement("button");
citeBtn.type = "button";
citeBtn.className = "para-cite";
citeBtn.textContent = "Citer";
citeBtn.addEventListener("click", async () => {
const pageUrl = new URL(window.location.href);
pageUrl.search = "";
pageUrl.hash = p.id;
const cite = `${docTitle}${docVersion ? ` (v${docVersion})` : ""} — ${pageUrl.toString()}`;
try {
await navigator.clipboard.writeText(cite);
const prev = citeBtn.textContent;
citeBtn.textContent = "Copié";
setTimeout(() => (citeBtn.textContent = prev), 900);
} catch {
window.prompt("Copiez la citation :", cite);
}
});
row.appendChild(a);
row.appendChild(citeBtn);
if (giteaReady) {
const propose = document.createElement("a");
propose.className = "para-propose";
propose.textContent = "Proposer";
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
const issueUrl = buildIssueURL(p.id, raw, excerpt);
propose.href = issueUrl;
// 🔥 indispensables pour réactiver ProposeModal (les 2 filtres)
propose.dataset.propose = "1";
propose.dataset.url = issueUrl;
// optionnel mais top : permet à ProposeModal de copier le texte complet
propose.dataset.full = raw;
// ✅ CRUCIAL : permet à ProposeModal dintercepter le clic (2 étapes)
propose.setAttribute("data-propose", "1");
propose.setAttribute("data-url", issueUrl);
// ✅ Bonus : ProposeModal peut copier/upgrade avec le texte complet si dispo
// (tu peux commenter si tu veux éviter de stocker le texte en attribut)
propose.setAttribute("data-full", raw);
// ✅ Fallback si JS casse : on ouvre quand même le lien (idéalement en nouvel onglet)
propose.href = issueUrl;
propose.target = "_blank";
propose.rel = "noopener noreferrer";
row.appendChild(propose);
}
tools.appendChild(row);
const bmBtn = document.createElement("button");
bmBtn.type = "button";
bmBtn.className = "para-bookmark";
bmBtn.textContent = "Marque-page";
bmBtn.addEventListener("click", () => {
const pageUrl = new URL(window.location.href);
pageUrl.search = "";
pageUrl.hash = p.id;
writeJSON(PINNED_KEY, {
url: pageUrl.toString(),
path: pageUrl.pathname,
anchor: p.id,
title: docTitle,
version: docVersion,
ts: Date.now(),
});
updateResumeButton();
const prev = bmBtn.textContent;
bmBtn.textContent = "Marqué ✓";
setTimeout(() => (bmBtn.textContent = prev), 900);
});
tools.appendChild(bmBtn);
p.appendChild(tools);
}
// Auto-checkpoint
let lastAuto = 0;
function writeLastSeen(id) {
const now = Date.now();
if (now - lastAuto < 800) return;
lastAuto = now;
const u = new URL(window.location.href);
u.search = "";
u.hash = id;
writeJSON(LAST_KEY, {
url: u.toString(),
path: u.pathname,
anchor: id,
title: docTitle,
version: docVersion,
ts: now,
});
}
const ioPara = new IntersectionObserver((entries) => {
const best = entries
.filter(e => e.isIntersecting)
.sort((a,b) => (b.intersectionRatio - a.intersectionRatio))[0];
if (!best) return;
const el = best.target;
if (el && el.id) {
writeLastSeen(el.id);
updateResumeButton();
}
}, { threshold: [0.6] });
for (const p of paras) ioPara.observe(p);
// ==========================
// Reading-follow (H1/H2/H3)
// ==========================
const h1 = reading ? reading.querySelector("h1") : null;
// H2 : soit via details-anchor (pour citabilité stable), soit via h2[id]
const h2Anchors = Array.from(document.querySelectorAll("span.details-anchor[id]"))
.map((s) => {
const d = s.closest("details");
const h2 = d ? d.querySelector(".details-body h2") : null;
const title = (h2?.textContent || "").trim() || "Section";
return h2 ? { id: s.id, anchor: s, marker: s, title, h2 } : null;
})
.filter(Boolean);
const h2Plain = Array.from(document.querySelectorAll(".reading h2[id]"))
.map((h2) => ({
id: h2.id,
anchor: h2,
marker: h2,
title: (h2.textContent || "").trim() || "Section",
h2
}));
// On préfère le mode "anchors" s'il existe
const H2 = (h2Anchors.length ? h2Anchors : h2Plain)
.slice()
.sort((a,b) => absTop(a.marker) - absTop(b.marker));
const H3 = Array.from(document.querySelectorAll(".reading h3[id]"))
.map((h3) => ({ id: h3.id, el: h3, title: (h3.textContent || "").trim() || "Sous-section" }));
let curH2 = null;
let curH3 = null;
let lastY = window.scrollY || 0;
function computeLineY(followH) {
// “ligne” = bas du bandeau (header + page-gap + bandeau)
return headerH() + PAGE_GAP + (followH || 0) + HYST;
}
function pickH2(lineY) {
let cand = null;
for (const t of H2) {
const top = t.marker.getBoundingClientRect().top;
if (top <= lineY) cand = t; else break;
}
if (!cand && H2.length) cand = H2[0];
const down = (window.scrollY || 0) >= lastY;
if (!down && cand && curH2 && cand.id !== curH2.id) {
const topNew = cand.marker.getBoundingClientRect().top;
if (topNew > lineY - (HYST * 2)) cand = curH2;
}
return cand;
}
function isAfter(start, el) {
if (!start || !el) return false;
return Boolean(start.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING);
}
function isBefore(end, el) {
if (!end || !el) return true; // pas de borne haute => OK
return Boolean(end.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);
}
function pickH3(lineY, activeH2) {
if (!activeH2) return null;
// H3 visibles (pas dans un details fermé)
const visible = H3.filter((t) => {
const d = t.el.closest?.("details");
return !(d && !d.open);
});
// Scope : même <details> si applicable (plus robuste)
const d2 = activeH2.h2?.closest?.("details") || activeH2.anchor?.closest?.("details") || null;
let scoped = d2 ? visible.filter((t) => t.el.closest?.("details") === d2) : visible;
// Scope DOM : entre H2 courant et H2 suivant (gère aussi les pages "plates")
const i2 = H2.findIndex((t) => t.id === activeH2.id);
const nextH2 = (i2 >= 0 && i2 + 1 < H2.length) ? H2[i2 + 1] : null;
const startNode = activeH2.anchor || activeH2.h2;
const endNode = nextH2 ? (nextH2.anchor || nextH2.h2) : null;
scoped = scoped.filter((t) => isAfter(startNode, t.el) && isBefore(endNode, t.el));
// Choisir le dernier H3 du scope qui a passé la ligne
let cand = null;
for (const t of scoped) {
const top = t.el.getBoundingClientRect().top;
if (top <= lineY) cand = t;
else break;
}
return cand;
}
function updateFollow() {
if (!followEl) return;
// PASS 1 : H1 visible quand le H1 du reading passe sous la barre
const baseY = headerH() + PAGE_GAP;
const showH1 = !!(h1 && (h1.getBoundingClientRect().bottom <= baseY + HYST));
if (rfH1 && h1 && showH1) {
rfH1.hidden = false;
rfH1.textContent = (h1.textContent || "").trim();
} else if (rfH1) {
rfH1.hidden = true;
}
// PASS 2 : estimation H2/H3 sans bandeau (pour mesurer hauteur réelle)
const lineY0 = computeLineY(0);
curH2 = pickH2(lineY0);
curH3 = pickH3(lineY0, curH2);
if (rfH2 && curH2) { rfH2.hidden = false; rfH2.textContent = curH2.title; }
else if (rfH2) rfH2.hidden = true;
if (rfH3 && curH3) { rfH3.hidden = false; rfH3.textContent = curH3.title; }
else if (rfH3) rfH3.hidden = true;
const any0 = (rfH1 && !rfH1.hidden) || (rfH2 && !rfH2.hidden) || (rfH3 && !rfH3.hidden);
followEl.classList.toggle("is-on", Boolean(any0));
followEl.setAttribute("aria-hidden", any0 ? "false" : "true");
const inner = followEl.querySelector(".reading-follow__inner");
const followH = (any0 && inner) ? inner.getBoundingClientRect().height : 0;
// PASS 3 : recalc H2/H3 avec la vraie ligne (bas du bandeau)
const lineY = computeLineY(followH);
curH2 = pickH2(lineY);
curH3 = pickH3(lineY, curH2);
// H2 napparaît que sil est “actif” (titre passé sous la ligne)
if (rfH2 && curH2) {
const on = curH2.marker.getBoundingClientRect().top <= lineY;
rfH2.hidden = !on;
if (on) rfH2.textContent = curH2.title;
} else if (rfH2) rfH2.hidden = true;
// H3 : actif seulement si un H3 du H2 actif a passé la ligne
if (rfH3 && curH3) {
const on = curH3.el.getBoundingClientRect().top <= lineY;
rfH3.hidden = !on;
if (on) rfH3.textContent = curH3.title;
} else if (rfH3) rfH3.hidden = true;
const any = (rfH1 && !rfH1.hidden) || (rfH2 && !rfH2.hidden) || (rfH3 && !rfH3.hidden);
followEl.classList.toggle("is-on", Boolean(any));
followEl.setAttribute("aria-hidden", any ? "false" : "true");
const inner2 = followEl.querySelector(".reading-follow__inner");
const followH2 = (any && inner2) ? inner2.getBoundingClientRect().height : 0;
setRootVar("--followbar-h", px(followH2));
setRootVar("--sticky-offset-px", String(Math.round(headerH() + PAGE_GAP + followH2)));
// Boutons actions
if (btnTopChapter) btnTopChapter.hidden = !any;
if (btnTopSection) {
const ok = Boolean(curH2 && rfH2 && !rfH2.hidden);
btnTopSection.hidden = !ok;
}
lastY = window.scrollY || 0;
// recalage horizontal
syncReadingRect();
}
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
updateFollow();
});
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
// clics bandeau
if (rfH1) rfH1.addEventListener("click", () => {
if (!h1) return;
scrollToElWithOffset(h1, 12, "smooth");
});
if (rfH2) rfH2.addEventListener("click", () => {
if (!curH2) return;
openDetailsIfNeeded(curH2.anchor);
scrollToElWithOffset(curH2.anchor, 12, "smooth");
history.replaceState(null, "", `${window.location.pathname}#${curH2.id}`);
});
if (rfH3) rfH3.addEventListener("click", () => {
if (!curH3) return;
openDetailsIfNeeded(curH3.el);
scrollToElWithOffset(curH3.el, 12, "smooth");
history.replaceState(null, "", `${window.location.pathname}#${curH3.id}`);
});
// clics actions
if (btnTopChapter) btnTopChapter.addEventListener("click", () => {
if (!h1) return;
scrollToElWithOffset(h1, 12, "smooth");
});
if (btnTopSection) btnTopSection.addEventListener("click", () => {
if (!curH2) return;
openDetailsIfNeeded(curH2.anchor);
scrollToElWithOffset(curH2.anchor, 12, "smooth");
history.replaceState(null, "", `${window.location.pathname}#${curH2.id}`);
});
updateFollow();
// arrivée avec hash => ouvrir + offset
const initialHash = (location.hash || "").slice(1);
if (initialHash) {
const el = document.getElementById(initialHash);
if (el) {
openDetailsIfNeeded(el);
setTimeout(() => scrollToElWithOffset(el, 12, "auto"), 0);
}
}
} catch (err) {
console.error("[EditionLayout] init failed:", err);
}
})();
</script>
</body>
</html>