fix(glossaire): harmonize portal sticky hero and follow behavior
All checks were successful
SMOKE / smoke (push) Successful in 3s
CI / build-and-anchors (push) Successful in 45s
CI / build-and-anchors (pull_request) Successful in 39s

This commit is contained in:
2026-03-21 20:15:13 +01:00
parent d902c2bf98
commit f6a2347278
58 changed files with 5541 additions and 560 deletions

View File

@@ -15,12 +15,14 @@ const {
statusKey,
level,
version,
stickyMode = "default",
} = Astro.props;
const lvl = level ?? 1;
const isGlossaryEdition = String(editionKey ?? "") === "glossaire";
const showLevelToggle = !isGlossaryEdition;
const stickyModeValue = String(stickyMode || "default");
const canonical = Astro.site
? new URL(Astro.url.pathname, Astro.site).href
@@ -201,6 +203,7 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
data-doc-version={version}
data-reading-level={String(lvl)}
data-edition-key={String(editionKey ?? "")}
data-sticky-mode={stickyModeValue}
>
<header>
<SiteNav />
@@ -262,6 +265,10 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
<ProposeModal />
<style>
:global(:root){
--glossary-local-sticky-h: 0px;
}
.page{
padding: var(--page-gap) 16px 48px;
}
@@ -479,6 +486,7 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
const PAGE_GAP = 12;
const HYST = 4;
const body = document.body;
const headerEl = document.querySelector("header");
const followEl = document.getElementById("reading-follow");
const reading = document.querySelector("article.reading");
@@ -492,15 +500,94 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
const docTitle = document.body?.dataset?.docTitle || document.title || "Archicratie";
const docVersion = document.body?.dataset?.docVersion || "";
const docEditionKey = document.body?.dataset?.editionKey || "";
const docStickyMode = String(document.body?.dataset?.stickyMode || "default");
const isGlossaryEdition = docEditionKey === "glossaire";
const hasLocalGlossaryFollow =
isGlossaryEdition && Boolean(document.getElementById("glossary-hero-follow"));
const isGlossaryHomeMode =
docStickyMode === "glossary-home" || hasLocalGlossaryFollow;
const isGlossaryEntryMode =
isGlossaryEdition && docStickyMode === "glossary-entry";
const isGlossaryPortalMode =
isGlossaryEdition && docStickyMode === "glossary-portal";
function syncGlossaryFollowState(isOn) {
if (!body || !isGlossaryEdition) return;
body.classList.toggle(
"glossary-follow-on",
Boolean(isOn && isGlossaryPortalMode)
);
}
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 readPxVar(name) {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
const n = Number.parseFloat(raw);
return Number.isFinite(n) ? n : 0;
}
function autoMeasureGlossaryLocalStickyHeight() {
if (isGlossaryEntryMode) {
const head = document.querySelector(".glossary-entry-head");
return head ? Math.round(head.getBoundingClientRect().height) : 0;
}
if (isGlossaryPortalMode) {
const hero =
document.querySelector(".scene-hero") ||
document.querySelector(".glossary-portal-hero") ||
document.querySelector(".glossary-page-hero");
return hero ? Math.round(hero.getBoundingClientRect().height) : 0;
}
return 0;
}
function getLocalStickyHeight() {
const explicit = readPxVar("--glossary-local-sticky-h");
if (explicit > 0) return explicit;
return autoMeasureGlossaryLocalStickyHeight();
}
function syncFollowTop() {
if (!followEl) return;
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
followEl.style.top = px(headerH() + PAGE_GAP + localH);
}
window.__archiSetLocalStickyHeight = (n) => {
const value = Math.max(0, Math.round(Number(n) || 0));
setRootVar("--glossary-local-sticky-h", px(value));
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
};
window.__archiResetLocalStickyHeight = () => {
setRootVar("--glossary-local-sticky-h", "0px");
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
};
function syncHeaderH() {
setRootVar("--sticky-header-h", px(headerH()));
setRootVar("--page-gap", px(PAGE_GAP));
syncFollowTop();
}
function syncReadingRect() {
@@ -543,7 +630,15 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
.getPropertyValue("--sticky-offset-px")
.trim();
const n = Number.parseFloat(raw);
return Number.isFinite(n) ? n : (headerH() + PAGE_GAP);
if (Number.isFinite(n)) return n;
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
return headerH() + PAGE_GAP + localH;
}
function absTop(el) {
@@ -1362,6 +1457,25 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
safe("reading-follow", () => {
if (!reading || !followEl) return;
// Home du glossaire : le hero local gère déjà son propre follow.
// Le follow global doit s'effacer totalement.
if (isGlossaryHomeMode) {
if (rfH1) rfH1.hidden = true;
if (rfH2) rfH2.hidden = true;
if (rfH3) rfH3.hidden = true;
if (btnTopChapter) btnTopChapter.hidden = true;
if (btnTopSection) btnTopSection.hidden = true;
followEl.classList.remove("is-on");
followEl.setAttribute("aria-hidden", "true");
followEl.style.display = "none";
setRootVar("--followbar-h", "0px");
setRootVar("--sticky-offset-px", String(Math.round(headerH() + PAGE_GAP)));
syncGlossaryFollowState(false);
return;
}
const h1 = reading.querySelector("h1");
const h2Anchors = Array.from(reading.querySelectorAll(".details-anchor[id]"))
@@ -1386,11 +1500,17 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
.filter(Boolean);
const h2Plain = Array.from(reading.querySelectorAll("h2[id]"))
.map((h2) => ({ id: h2.id, anchor: h2, marker: h2, title: (h2.textContent || "").trim() || "Section", h2 }));
.map((h2) => ({
id: h2.id,
anchor: h2,
marker: h2,
title: (h2.textContent || "").trim() || "Section",
h2,
}));
const H2 = (h2Anchors.length ? h2Anchors : h2Plain)
.slice()
.sort((a,b) => absTop(a.marker) - absTop(b.marker));
.sort((a, b) => absTop(a.marker) - absTop(b.marker));
const H3 = Array.from(reading.querySelectorAll("h3[id]"))
.map((h3) => ({ id: h3.id, el: h3, title: (h3.textContent || "").trim() || "Sous-section" }));
@@ -1401,24 +1521,37 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
let lastOpenedH2 = "";
function computeLineY(followH) {
return headerH() + PAGE_GAP + (followH || 0) + HYST;
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
return headerH() + PAGE_GAP + localH + (followH || 0) + HYST;
}
function hasCrossed(el, lineY) {
return Boolean(el && el.getBoundingClientRect().top <= lineY);
}
function pickH2(lineY) {
if (!H2.length) return null;
let cand = null;
for (const t of H2) {
const top = t.marker.getBoundingClientRect().top;
if (top <= lineY) cand = t;
if (hasCrossed(t.marker, lineY)) cand = t;
else break;
}
if (!cand) cand = H2[0];
// Tant quaucun H2 na franchi la ligne de suivi,
// on ne montre rien.
if (!cand) return null;
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;
}
@@ -1426,6 +1559,7 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
if (!start || !el) return false;
return Boolean(start.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING);
}
function isBefore(end, el) {
if (!end || !el) return true;
return Boolean(end.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);
@@ -1452,85 +1586,112 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
let cand = null;
for (const t of scoped) {
const top = t.el.getBoundingClientRect().top;
if (top <= lineY) cand = t;
if (hasCrossed(t.el, lineY)) cand = t;
else break;
}
return cand;
}
function maybeOpenActiveSection(activeH2, lineY) {
if (!activeH2 || !activeH2.id) return;
const pre = activeH2.marker.getBoundingClientRect().top <= (lineY + 140);
if (!pre) return;
if (activeH2.id !== lastOpenedH2) {
lastOpenedH2 = activeH2.id;
openDetailsIfNeeded(activeH2.anchor || activeH2.h2 || activeH2.marker);
}
}
function updateFollow() {
if (!followEl) return;
function renderPass(lineY) {
// Pour le glossaire, on évite le doublon du H1 dans la barre flottante.
const showH1 =
!isGlossaryEdition &&
!!(h1 && h1.getBoundingClientRect().bottom <= (lineY - HYST));
const baseY = headerH() + PAGE_GAP;
const showH1 = !!(h1 && (h1.getBoundingClientRect().bottom <= baseY + HYST));
if (rfH1) {
rfH1.hidden = !showH1;
if (showH1) rfH1.textContent = (h1.textContent || "").trim();
}
if (rfH1 && h1 && showH1) { rfH1.hidden = false; rfH1.textContent = (h1.textContent || "").trim(); }
else if (rfH1) rfH1.hidden = true;
const nextH2 = pickH2(lineY);
maybeOpenActiveSection(nextH2, lineY);
const lineY0 = computeLineY(0);
curH2 = pickH2(lineY0);
maybeOpenActiveSection(curH2, lineY0);
curH3 = pickH3(lineY0, curH2);
const nextH3 = pickH3(lineY, nextH2);
try { syncTocLocal((curH3 && curH3.id) ? curH3.id : (curH2 && curH2.id) ? curH2.id : ""); } catch {}
curH2 = nextH2;
curH3 = nextH3;
if (rfH2 && curH2) { rfH2.hidden = false; rfH2.textContent = curH2.title; }
else if (rfH2) rfH2.hidden = true;
if (rfH2) {
const show = Boolean(curH2 && hasCrossed(curH2.marker, lineY));
rfH2.hidden = !show;
if (show) rfH2.textContent = curH2.title;
}
if (rfH3 && curH3) { rfH3.hidden = false; rfH3.textContent = curH3.title; }
else if (rfH3) rfH3.hidden = true;
if (rfH3) {
const show = Boolean(curH3 && hasCrossed(curH3.el, lineY));
rfH3.hidden = !show;
if (show) rfH3.textContent = curH3.title;
}
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 any =
(rfH1 && !rfH1.hidden) ||
(rfH2 && !rfH2.hidden) ||
(rfH3 && !rfH3.hidden);
const inner = followEl.querySelector(".reading-follow__inner");
const followH = (any0 && inner) ? inner.getBoundingClientRect().height : 0;
const lineY = computeLineY(followH);
curH2 = pickH2(lineY);
maybeOpenActiveSection(curH2, lineY);
curH3 = pickH3(lineY, curH2);
try { syncTocLocal((curH3 && curH3.id) ? curH3.id : (curH2 && curH2.id) ? curH2.id : ""); } catch {}
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;
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");
try {
syncTocLocal(
(curH3 && rfH3 && !rfH3.hidden) ? curH3.id :
(curH2 && rfH2 && !rfH2.hidden) ? curH2.id :
""
);
} catch {}
return any;
}
function updateFollow() {
followEl.style.display = "";
syncFollowTop();
const lineY0 = computeLineY(0);
renderPass(lineY0);
const inner = followEl.querySelector(".reading-follow__inner");
const followH0 =
followEl.classList.contains("is-on") && inner
? inner.getBoundingClientRect().height
: 0;
const lineY1 = computeLineY(followH0);
renderPass(lineY1);
const inner2 = followEl.querySelector(".reading-follow__inner");
const followH2 = (any && inner2) ? inner2.getBoundingClientRect().height : 0;
const followH =
followEl.classList.contains("is-on") && inner2
? inner2.getBoundingClientRect().height
: 0;
setRootVar("--followbar-h", px(followH2));
setRootVar("--sticky-offset-px", String(Math.round(headerH() + PAGE_GAP + followH2)));
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
setRootVar("--followbar-h", px(followH));
setRootVar("--sticky-offset-px", String(Math.round(headerH() + PAGE_GAP + localH + followH)));
syncGlossaryFollowState(followEl.classList.contains("is-on") && followH > 0);
if (btnTopChapter) {
btnTopChapter.hidden = !(rfH1 && !rfH1.hidden);
}
if (btnTopChapter) btnTopChapter.hidden = !any;
if (btnTopSection) {
const ok = Boolean(curH2 && rfH2 && !rfH2.hidden);
btnTopSection.hidden = !ok;
btnTopSection.hidden = !(curH2 && rfH2 && !rfH2.hidden);
}
lastY = window.scrollY || 0;
@@ -1553,27 +1714,44 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
if (rfH1) rfH1.addEventListener("click", () => { if (h1) scrollToElWithOffset(h1, 12, "smooth"); });
if (rfH2) rfH2.addEventListener("click", () => {
if (!curH2) return;
openDetailsIfNeeded(curH2.anchor || curH2.h2 || curH2.marker);
scrollToElWithOffset(curH2.marker, 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}`);
});
if (rfH1) {
rfH1.addEventListener("click", () => {
if (h1) scrollToElWithOffset(h1, 12, "smooth");
});
}
if (btnTopChapter) btnTopChapter.addEventListener("click", () => { if (h1) scrollToElWithOffset(h1, 12, "smooth"); });
if (btnTopSection) btnTopSection.addEventListener("click", () => {
if (!curH2) return;
openDetailsIfNeeded(curH2.anchor || curH2.h2 || curH2.marker);
scrollToElWithOffset(curH2.marker, 12, "smooth");
history.replaceState(null, "", `${window.location.pathname}#${curH2.id}`);
});
if (rfH2) {
rfH2.addEventListener("click", () => {
if (!curH2) return;
openDetailsIfNeeded(curH2.anchor || curH2.h2 || curH2.marker);
scrollToElWithOffset(curH2.marker, 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}`);
});
}
if (btnTopChapter) {
btnTopChapter.addEventListener("click", () => {
if (h1) scrollToElWithOffset(h1, 12, "smooth");
});
}
if (btnTopSection) {
btnTopSection.addEventListener("click", () => {
if (!curH2) return;
openDetailsIfNeeded(curH2.anchor || curH2.h2 || curH2.marker);
scrollToElWithOffset(curH2.marker, 12, "smooth");
history.replaceState(null, "", `${window.location.pathname}#${curH2.id}`);
});
}
updateFollow();
@@ -1600,6 +1778,7 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
window.addEventListener("resize", () => {
afterReflow(() => {
syncHeaderH(); syncReadingRect(); syncHeadingMetrics();
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
scheduleActive();
});
@@ -1608,6 +1787,7 @@ const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ??
window.addEventListener("archicratie:readingLevel", () => {
afterReflow(() => {
syncHeaderH(); syncReadingRect(); syncHeadingMetrics();
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
const ctx = window.__archiLevelSwitchCtx || null;