feat(glossaire): harmonize portal pages and sticky reading ux
All checks were successful
SMOKE / smoke (push) Successful in 5s
CI / build-and-anchors (push) Successful in 54s
CI / build-and-anchors (pull_request) Successful in 43s

This commit is contained in:
2026-03-26 12:58:17 +01:00
parent 0cba8f868e
commit a9f2a5bbd4
9 changed files with 714 additions and 625 deletions

View File

@@ -8,7 +8,6 @@ import { getCollection } from "astro:content";
import { hrefOfGlossaryEntry } from "../../lib/glossary";
const entries = await getCollection("glossaire");
const hrefOf = hrefOfGlossaryEntry;
const collator = new Intl.Collator("fr", { sensitivity: "base", numeric: true });
@@ -79,8 +78,7 @@ const prolongerLinks = [
{
href: "/glossaire/",
title: "Accueil du glossaire",
text:
"Revenir à la cartographie générale du système archicratique.",
text: "Revenir à la cartographie générale du système archicratique.",
},
{
href: "/glossaire/concepts-fondamentaux/",
@@ -109,13 +107,39 @@ const prolongerLinks = [
stickyMode="glossary-portal"
>
<Fragment slot="aside">
<GlossaryPortalAside
ariaLabel="Navigation de lindex complet du glossaire"
title="Index complet"
meta={`${totalEntries} entrée${totalEntries > 1 ? "s" : ""} · ${totalLetters} lettre${totalLetters > 1 ? "s" : ""}`}
pageItems={pageItems}
usefulLinks={usefulLinks}
/>
<div class="gic-aside-stack">
<GlossaryPortalAside
ariaLabel="Navigation de lindex complet du glossaire"
title="Index complet"
meta={`${totalEntries} entrée${totalEntries > 1 ? "s" : ""} · ${totalLetters} lettre${totalLetters > 1 ? "s" : ""}`}
pageItems={pageItems}
usefulLinks={usefulLinks}
/>
<div class="gic-aside__block">
<h2 class="gic-aside__heading">Repères de lecture</h2>
<div class="gic-aside__pills" aria-label="Repères de lindex">
<span class="gic-aside__pill">
{paradigmesCount} paradigme{paradigmesCount > 1 ? "s" : ""}
</span>
<span class="gic-aside__pill">
{doctrinesCount} doctrine{doctrinesCount > 1 ? "s" : ""}
</span>
<span class="gic-aside__pill">
{verbesCount} verbe{verbesCount > 1 ? "s" : ""}
</span>
<span class="gic-aside__pill">
{casIaCount} entrée{casIaCount > 1 ? "s" : ""} cas IA
</span>
</div>
<p class="gic-aside__note">
Cet index complète les portails thématiques : il permet de retrouver
rapidement une fiche, puis de repartir vers les grandes cartographies du glossaire.
</p>
</div>
</div>
</Fragment>
<section class="gic-page">
@@ -138,21 +162,6 @@ const prolongerLinks = [
count={`${totalEntries} entrée${totalEntries > 1 ? "s" : ""}`}
intro="Naviguer par lettre permet de retrouver rapidement chaque fiche du glossaire, tout en conservant ses principaux marqueurs de lecture."
>
<div class="gic-stats" aria-label="Repères de lindex">
<span class="gic-stat-pill">
{paradigmesCount} paradigme{paradigmesCount > 1 ? "s" : ""}
</span>
<span class="gic-stat-pill">
{doctrinesCount} doctrine{doctrinesCount > 1 ? "s" : ""}
</span>
<span class="gic-stat-pill">
{verbesCount} verbe{verbesCount > 1 ? "s" : ""}
</span>
<span class="gic-stat-pill">
{casIaCount} entrée{casIaCount > 1 ? "s" : ""} cas IA
</span>
</div>
<nav class="gic-letters" id="gic-letters-source" aria-label="Lettres de lindex">
{groupedAlpha.map(([letter]) => (
<a href={`#letter-${letter}`}>{letter}</a>
@@ -174,10 +183,10 @@ const prolongerLinks = [
<div class="gic-groups">
{groupedAlpha.map(([letter, items]) => (
<section class="gic-group" id={`letter-${letter}`}>
<div class="gic-group__head">
<h3>{letter}</h3>
<span class="gic-group__count">
<section class="gic-section gic-group" id={`letter-${letter}`}>
<div class="gic-section__head">
<h2>{letter}</h2>
<span class="gic-section__count">
{items.length} entrée{items.length > 1 ? "s" : ""}
</span>
</div>
@@ -185,7 +194,7 @@ const prolongerLinks = [
<div class="gic-list">
{items.map((entry) => (
<article class="gic-item">
<a class="gic-term" href={hrefOf(entry)}>
<a class="gic-term" href={hrefOfGlossaryEntry(entry)}>
{entry.data.term}
</a>
@@ -207,6 +216,7 @@ const prolongerLinks = [
id="prolonger-la-lecture"
title="Prolonger la lecture"
intro="Cet index intégral complète les portails thématiques sans sy substituer. Il permet de repartir ensuite vers les grandes cartographies déjà stabilisées du glossaire."
final={true}
>
<div class="gic-cards">
{prolongerLinks.map((item) => (
@@ -217,90 +227,270 @@ const prolongerLinks = [
))}
</div>
</GlossaryPortalSection>
<GlossaryPortalSection
id="portee-densemble"
title="Portée densemble"
final={true}
>
<p>
Lindex complet ne remplace pas les parcours thématiques : il les complète.
Il offre une entrée alphabétique stable dans le lexique archicratique, afin
de retrouver rapidement une fiche, puis de repartir vers les grands portails
conceptuels, topologiques, dynamiques ou théoriques du glossaire.
</p>
</GlossaryPortalSection>
</section>
<GlossaryPortalStickySync
heroMoreId="gic-hero-more"
heroToggleId="gic-hero-toggle"
sectionHeadSelector=".glossary-portal-section__head, .gic-group__head"
/>
<script is:inline>
(() => {
const boot = () => {
const body = document.body;
const root = document.documentElement;
const follow = document.getElementById("reading-follow");
const lettersSource = document.getElementById("gic-letters-source");
const lettersFollow = document.getElementById("gic-follow-letters");
const groupSections = Array.from(document.querySelectorAll(".gic-group"));
if (!body) return;
body.classList.add("is-index-complet-page");
if (!body || !root || !follow || !lettersSource || !lettersFollow || groupSections.length === 0) {
return;
}
if (!follow || !lettersSource || !lettersFollow) return;
const DOCKED_CLASS = "gic-letters-docked";
const BODY_CLASS = "is-index-complet-page";
const LETTERS_DOCKED_CLASS = "gic-letters-docked";
const ACTIVE_CLASS = "is-active";
const mqMobile = window.matchMedia("(max-width: 860px)");
let raf = 0;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const EXTRA_GAP = 12;
const MAX_CORRECTION_PASSES = 8;
const hideFollowLetters = () => {
body.classList.remove(DOCKED_CLASS);
lettersFollow.hidden = true;
lettersFollow.setAttribute("aria-hidden", "true");
lettersFollow.style.top = "";
const sourceLinks = Array.from(lettersSource.querySelectorAll("a"));
const followLinks = Array.from(lettersFollow.querySelectorAll("a"));
const allLetterLinks = [...sourceLinks, ...followLinks];
body.classList.add(BODY_CLASS);
const computeFollowOn = () =>
!mqMobile.matches &&
follow.classList.contains("is-on") &&
follow.style.display !== "none" &&
follow.getAttribute("aria-hidden") !== "true";
const setAnchorOffset = (value) => {
root.style.setProperty(
"--gic-follow-letters-offset",
`${Math.max(0, Math.round(value))}px`
);
};
const sync = () => {
const syncFollowLettersTop = () => {
const inner = follow.querySelector(".reading-follow__inner");
const followIsOn =
!mqMobile.matches &&
follow.classList.contains("is-on") &&
follow.style.display !== "none" &&
follow.getAttribute("aria-hidden") !== "true";
if (!followIsOn) {
hideFollowLetters();
return;
}
const inner = follow.querySelector(".reading-follow__inner");
if (!inner) {
hideFollowLetters();
if (!followIsOn || !inner) {
lettersFollow.style.top = "";
return;
}
const followRect = follow.getBoundingClientRect();
const innerRect = inner.getBoundingClientRect();
const sourceRect = lettersSource.getBoundingClientRect();
const top = followRect.top + innerRect.height;
lettersFollow.style.top = `${Math.round(followRect.top + innerRect.height)}px`;
const shouldDock = sourceRect.top <= innerRect.bottom + 6;
body.classList.toggle(DOCKED_CLASS, shouldDock);
lettersFollow.hidden = !shouldDock;
lettersFollow.setAttribute("aria-hidden", shouldDock ? "false" : "true");
lettersFollow.style.top = `${Math.round(top)}px`;
};
const syncLettersDockState = () => {
if (mqMobile.matches || !computeFollowOn()) {
body.classList.remove(LETTERS_DOCKED_CLASS);
lettersFollow.hidden = true;
lettersFollow.setAttribute("aria-hidden", "true");
setAnchorOffset(0);
return;
}
const inner = follow.querySelector(".reading-follow__inner");
if (!inner) {
body.classList.remove(LETTERS_DOCKED_CLASS);
lettersFollow.hidden = true;
lettersFollow.setAttribute("aria-hidden", "true");
setAnchorOffset(0);
return;
}
const sourceRect = lettersSource.getBoundingClientRect();
const innerRect = inner.getBoundingClientRect();
const shouldDock = sourceRect.top <= (innerRect.bottom + 6);
body.classList.toggle(LETTERS_DOCKED_CLASS, shouldDock);
lettersFollow.hidden = !shouldDock;
lettersFollow.setAttribute("aria-hidden", shouldDock ? "false" : "true");
if (shouldDock) {
const dockedHeight = lettersFollow.getBoundingClientRect().height || 0;
setAnchorOffset(dockedHeight + EXTRA_GAP);
} else {
setAnchorOffset(0);
}
};
const getAnchorViewportTop = () => {
const inner = follow.querySelector(".reading-follow__inner");
const followOn = computeFollowOn();
if (!followOn || !inner) {
return 12;
}
const followLettersVisible =
!lettersFollow.hidden &&
lettersFollow.getAttribute("aria-hidden") === "false";
if (followLettersVisible) {
return Math.round(lettersFollow.getBoundingClientRect().bottom + EXTRA_GAP);
}
return Math.round(inner.getBoundingClientRect().bottom + EXTRA_GAP);
};
const getTargetHead = (section) =>
section?.querySelector(".gic-section__head") || section;
const correctAnchorAlignment = (section, pass = 0) => {
if (!section || pass >= MAX_CORRECTION_PASSES) return;
requestAnimationFrame(() => {
syncFollowLettersTop();
syncLettersDockState();
const head = getTargetHead(section);
if (!head) return;
const wantedTop = getAnchorViewportTop();
const headTop = head.getBoundingClientRect().top;
const delta = headTop - wantedTop;
if (Math.abs(delta) <= 1) return;
window.scrollBy({
top: delta,
behavior: "auto",
});
correctAnchorAlignment(section, pass + 1);
});
};
const scrollToLetterTarget = (targetId) => {
const section = document.getElementById(targetId);
if (!section) return;
const head = getTargetHead(section);
if (!head) return;
syncFollowLettersTop();
syncLettersDockState();
const targetTop = getAnchorViewportTop();
const rect = head.getBoundingClientRect();
const absoluteTop = window.scrollY + rect.top - targetTop;
if (window.location.hash !== `#${targetId}`) {
history.pushState(null, "", `#${targetId}`);
}
window.scrollTo({
top: Math.max(0, Math.round(absoluteTop)),
behavior: reducedMotion.matches ? "auto" : "smooth",
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
correctAnchorAlignment(section, 0);
});
});
window.setTimeout(() => correctAnchorAlignment(section, 0), 120);
window.setTimeout(() => correctAnchorAlignment(section, 0), 260);
};
const syncActiveLetter = () => {
let activeId = null;
const inner = follow.querySelector(".reading-follow__inner");
const followOn = computeFollowOn();
const followBottom = followOn && inner
? inner.getBoundingClientRect().bottom
: 0;
const dockedHeight =
!lettersFollow.hidden && lettersFollow.getAttribute("aria-hidden") === "false"
? lettersFollow.getBoundingClientRect().height
: 0;
const threshold = followBottom > 0
? followBottom + dockedHeight + 12
: 140;
for (const section of groupSections) {
const rect = section.getBoundingClientRect();
if (rect.top <= threshold) {
activeId = section.id;
} else {
break;
}
}
if (!activeId && groupSections[0]) {
activeId = groupSections[0].id;
}
const applyState = (links) => {
links.forEach((link) => {
const isActive = link.getAttribute("href") === `#${activeId}`;
link.classList.toggle(ACTIVE_CLASS, isActive);
if (isActive) {
link.setAttribute("aria-current", "true");
} else {
link.removeAttribute("aria-current");
}
});
};
applyState(sourceLinks);
applyState(followLinks);
};
const handleLetterClick = (event) => {
const link = event.currentTarget;
const href = link?.getAttribute("href") || "";
if (!href.startsWith("#letter-")) return;
event.preventDefault();
scrollToLetterTarget(href.slice(1));
};
allLetterLinks.forEach((link) => {
link.addEventListener("click", handleLetterClick);
});
let raf = 0;
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
sync();
syncFollowLettersTop();
syncLettersDockState();
syncActiveLetter();
});
};
const onScroll = () => {
schedule();
};
const onHashChange = () => {
const id = window.location.hash.replace(/^#/, "");
if (!id.startsWith("letter-")) return;
scrollToLetterTarget(id);
};
const followObserver = new MutationObserver(schedule);
followObserver.observe(follow, {
attributes: true,
@@ -308,9 +498,10 @@ const prolongerLinks = [
subtree: false,
});
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", schedule);
window.addEventListener("pageshow", schedule);
window.addEventListener("hashchange", onHashChange);
if (document.fonts?.ready) {
document.fonts.ready.then(schedule).catch(() => {});
@@ -322,7 +513,22 @@ const prolongerLinks = [
mqMobile.addListener(schedule);
}
if (reducedMotion.addEventListener) {
reducedMotion.addEventListener("change", schedule);
} else if (reducedMotion.addListener) {
reducedMotion.addListener(schedule);
}
setAnchorOffset(0);
schedule();
if (window.location.hash.startsWith("#letter-")) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToLetterTarget(window.location.hash.slice(1));
});
});
}
};
if (document.readyState === "loading") {
@@ -336,28 +542,53 @@ const prolongerLinks = [
<style>
.gic-page{
--gic-follow-letters-offset: 0px;
padding: 8px 0 24px;
}
.gic-stats{
.gic-aside-stack{
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
flex-direction: column;
gap: 14px;
}
.gic-stat-pill{
.gic-aside__block{
border: 1px solid rgba(127,127,127,0.22);
border-radius: 16px;
padding: 12px;
background: rgba(127,127,127,0.05);
}
.gic-aside__heading{
margin: 0 0 10px;
font-size: 13px;
font-weight: 800;
opacity: .9;
}
.gic-aside__pills{
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gic-aside__pill{
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 11px;
border: 1px solid rgba(127,127,127,0.22);
padding: 4px 9px;
border: 1px solid rgba(127,127,127,0.24);
border-radius: 999px;
background: rgba(127,127,127,0.04);
font-size: 12px;
line-height: 1.2;
line-height: 1.3;
opacity: .9;
white-space: nowrap;
}
.gic-aside__note{
margin: 10px 0 0;
font-size: 12px;
line-height: 1.45;
opacity: .78;
}
.gic-letters{
@@ -378,7 +609,9 @@ const prolongerLinks = [
transition:
transform 120ms ease,
background 120ms ease,
border-color 120ms ease;
border-color 120ms ease,
box-shadow 120ms ease,
color 120ms ease;
}
.gic-letters a:hover,
@@ -389,28 +622,37 @@ const prolongerLinks = [
text-decoration: none;
}
.gic-letters a.is-active,
.gic-follow-letters a.is-active,
.gic-letters a[aria-current="true"],
.gic-follow-letters a[aria-current="true"]{
border-color: rgba(0,217,255,0.34);
background:
linear-gradient(180deg, rgba(0,217,255,0.14), rgba(0,217,255,0.06)),
rgba(127,127,127,0.10);
color: var(--glossary-accent, #00d9ff);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.05),
0 0 0 1px rgba(0,217,255,0.06);
}
.gic-follow-letters{
position: fixed;
left: var(--reading-left);
width: var(--reading-width);
box-sizing: border-box;
z-index: 59;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px 10px;
border: 1px solid rgba(127,127,127,.20);
border-top: 0;
border-radius: 0 0 14px 14px;
background: rgba(255,255,255,.86);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 22px rgba(0,0,0,.06);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
@@ -423,7 +665,7 @@ const prolongerLinks = [
pointer-events: auto;
}
:global(body.is-index-complet-page.is-glossary-portal-page.glossary-portal-follow-on.gic-letters-docked #reading-follow .reading-follow__inner){
:global(body.is-index-complet-page.gic-letters-docked #reading-follow .reading-follow__inner){
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
@@ -432,33 +674,38 @@ const prolongerLinks = [
display: flex;
flex-direction: column;
gap: 28px;
margin-top: 28px;
}
.gic-group{
scroll-margin-top: calc(var(--sticky-offset-px, 96px) + 28px);
scroll-margin-top: calc(
var(--sticky-offset-px, 96px) + 28px + var(--gic-follow-letters-offset, 0px)
);
}
.gic-group h3{
scroll-margin-top: calc(var(--sticky-offset-px, 96px) + 28px);
.gic-section{
margin-top: 34px;
}
.gic-group__head{
.gic-section__head{
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
scroll-margin-top: calc(
var(--sticky-offset-px, 96px) + 28px + var(--gic-follow-letters-offset, 0px)
);
}
.gic-group__head h3{
.gic-section__head h2{
margin: 0;
font-size: clamp(1.15rem, 1.8vw, 1.35rem);
line-height: 1.2;
scroll-margin-top: calc(
var(--sticky-offset-px, 96px) + 28px + var(--gic-follow-letters-offset, 0px)
);
}
.gic-group__count{
.gic-section__count{
font-size: 13px;
opacity: .72;
white-space: nowrap;
@@ -478,11 +725,11 @@ const prolongerLinks = [
.gic-term{
display: inline-block;
margin-bottom: 6px;
font-weight: 800;
font-size: 1.04rem;
line-height: 1.3;
text-decoration: none;
margin-bottom: 6px;
}
.gic-def{
@@ -522,10 +769,7 @@ const prolongerLinks = [
border-radius: 16px;
background: rgba(127,127,127,0.05);
text-decoration: none;
transition:
transform 120ms ease,
background 120ms ease,
border-color 120ms ease;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
}
.gic-card:hover{
@@ -553,21 +797,31 @@ const prolongerLinks = [
}
@media (prefers-color-scheme: dark){
.gic-stat-pill,
.gic-aside__block,
.gic-aside__pill,
.gic-item,
.gic-card,
.gic-follow-letters{
.gic-card{
background: rgba(255,255,255,0.04);
}
.gic-follow-letters{
background: rgba(0,0,0,.58);
box-shadow: 0 10px 22px rgba(0,0,0,.18);
}
.gic-letters a:hover,
.gic-follow-letters a:hover,
.gic-card:hover{
background: rgba(255,255,255,0.07);
}
.gic-follow-letters{
box-shadow: 0 10px 22px rgba(0,0,0,.18);
.gic-letters a.is-active,
.gic-follow-letters a.is-active,
.gic-letters a[aria-current="true"],
.gic-follow-letters a[aria-current="true"]{
background:
linear-gradient(180deg, rgba(0,217,255,0.18), rgba(0,217,255,0.08)),
rgba(255,255,255,0.06);
}
}
</style>