Seed from NAS prod snapshot 20260130-190531
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

This commit is contained in:
archicratia
2026-01-31 10:51:38 +00:00
commit 60d88939b0
142 changed files with 33443 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
---
import { SITE_RELEASE, BUILD_TIME_ISO } from "../lib/buildInfo";
---
<footer class="build-stamp" aria-label="Informations dédition">
<small>
Édition web — release <strong>{SITE_RELEASE}</strong> · build <time datetime={BUILD_TIME_ISO}>{BUILD_TIME_ISO}</time>
</small>
</footer>

View File

@@ -0,0 +1,52 @@
---
const {
kind = "note", // note | definition | these | objection | limite | scene | procedure
title,
} = Astro.props;
const labels = {
note: "Note",
definition: "Définition",
these: "Thèse",
objection: "Objection",
limite: "Limite",
scene: "Scène",
procedure: "Procédure",
};
const label = labels[kind] ?? "Note";
---
<section class={`callout callout-${kind}`}>
<div class="callout-head">
<span class="callout-badge">{label}</span>
{title ? <strong class="callout-title">{title}</strong> : null}
</div>
<div class="callout-body">
<slot />
</div>
</section>
<style>
.callout {
border: 1px solid rgba(127,127,127,0.35);
border-radius: 14px;
padding: 12px 14px;
margin: 14px 0;
background: rgba(127,127,127,0.06);
}
.callout-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 8px;
}
.callout-badge {
font-size: 12px;
border: 1px solid rgba(127,127,127,0.45);
border-radius: 999px;
padding: 2px 8px;
opacity: 0.95;
}
.callout-title { font-weight: 700; }
.callout-body :global(p:last-child) { margin-bottom: 0; }
</style>

View File

@@ -0,0 +1,165 @@
---
import { getCollection } from "astro:content";
const { currentSlug } = Astro.props;
const entries = (await getCollection("archicratie"))
.filter((e) => e.slug.startsWith("archicrat-ia/"))
.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
const href = (slug) => `/archicratie/${slug}/`;
---
<nav class="toc-global" aria-label="Table des matières — ArchiCraT-IA">
<div class="toc-global__head">
<div class="toc-global__title">Table des matières</div>
</div>
<ol class="toc-global__list">
{entries.map((e) => {
const active = e.slug === currentSlug;
return (
<li class={`toc-item ${active ? "is-active" : ""}`}>
<a class="toc-link" href={href(e.slug)} aria-current={active ? "page" : undefined}>
<span class="toc-link__row">
{active ? (
<span class="toc-active-indicator" aria-hidden="true">👉</span>
) : (
<span class="toc-active-spacer" aria-hidden="true"></span>
)}
<span class="toc-link__title">{e.data.title}</span>
{active && (
<span class="toc-badge" aria-label="Chapitre en cours">
En cours
</span>
)}
</span>
{active && <span class="toc-underline" aria-hidden="true"></span>}
</a>
</li>
);
})}
</ol>
</nav>
<style>
.toc-global{
border: 1px solid rgba(127,127,127,0.22);
border-radius: 16px;
padding: 12px;
background: rgba(127,127,127,0.06);
}
.toc-global__head{
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px dashed rgba(127,127,127,0.25);
}
.toc-global__title{
font-size: 13px;
font-weight: 800;
letter-spacing: .2px;
opacity: .88;
}
/* On garde <ol> mais on neutralise tout marker/numéro */
.toc-global__list{
list-style: none;
margin: 0;
padding: 0;
}
.toc-global__list li::marker{ content: ""; }
.toc-item{ margin: 6px 0; }
.toc-link{
display: block;
text-decoration: none;
border-radius: 14px;
padding: 8px 10px;
transition: transform 120ms ease, background 120ms ease;
}
.toc-link:hover{
background: rgba(127,127,127,0.10);
text-decoration: none;
transform: translateY(-1px);
}
.toc-link__row{
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
}
.toc-active-indicator{
font-size: 14px;
line-height: 1;
}
.toc-active-spacer{
width: 14px;
}
.toc-link__title{
font-size: 13px;
line-height: 1.25;
max-width: 100%;
}
.toc-badge{
font-size: 11px;
font-weight: 800;
letter-spacing: .2px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(127,127,127,0.30);
background: rgba(127,127,127,0.10);
opacity: .92;
white-space: nowrap;
}
.toc-item.is-active .toc-link{
background: rgba(127,127,127,0.12);
}
.toc-item.is-active .toc-link__title{
font-weight: 800;
}
.toc-underline{
display: block;
margin-top: 8px;
height: 1px;
width: 100%;
background: rgba(127,127,127,0.35);
border-radius: 999px;
}
.toc-global__list{
max-height: 44vh;
overflow: auto;
padding-right: 8px;
scrollbar-gutter: stable;
}
@media (prefers-color-scheme: dark){
.toc-global{ background: rgba(255,255,255,0.04); }
.toc-link:hover{ background: rgba(255,255,255,0.06); }
.toc-item.is-active .toc-link{ background: rgba(255,255,255,0.06); }
.toc-badge{ background: rgba(255,255,255,0.06); }
}
</style>
<script is:inline>
(() => {
const active = document.querySelector(".toc-global .toc-item.is-active");
if (active) active.scrollIntoView({ block: "nearest" });
})();
</script>

View File

@@ -0,0 +1,35 @@
---
const { initialLevel = 1 } = Astro.props;
---
<div class="level-toggle" role="group" aria-label="Niveau de lecture">
<button type="button" class="lvl-btn" data-level="1" aria-pressed="true">Niveau 1</button>
<button type="button" class="lvl-btn" data-level="2" aria-pressed="false">Niveau 2</button>
<button type="button" class="lvl-btn" data-level="3" aria-pressed="false">Niveau 3</button>
</div>
<script is:inline>
(() => {
const KEY = "archicratie.readingLevel";
const buttons = Array.from(document.querySelectorAll(".lvl-btn"));
function apply(level) {
document.body.setAttribute("data-reading-level", String(level));
buttons.forEach((b) => b.setAttribute("aria-pressed", b.dataset.level === String(level) ? "true" : "false"));
}
// Valeur par défaut : si rien n'est stocké, on met 1 (citoyen).
// Si JS est absent/casse, le site reste lisible (tout s'affiche).
const stored = Number(localStorage.getItem(KEY));
const level = (stored === 1 || stored === 2 || stored === 3) ? stored : 1;
apply(level);
buttons.forEach((b) => {
b.addEventListener("click", () => {
const lvl = Number(b.dataset.level);
localStorage.setItem(KEY, String(lvl));
apply(lvl);
});
});
})();
</script>

View File

@@ -0,0 +1,182 @@
---
const { headings } = Astro.props;
// H2/H3 seulement
const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
---
{items.length > 0 && (
<nav class="toc-local" aria-label="Dans ce chapitre">
<div class="toc-local__title">Dans ce chapitre</div>
<ol class="toc-local__list">
{items.map((h) => (
<li
class={`toc-local__item d${h.depth}`}
data-toc-item
data-depth={h.depth}
data-id={h.slug}
>
<a href={`#${h.slug}`} data-toc-link data-slug={h.slug}>
{h.text}
</a>
</li>
))}
</ol>
</nav>
)}
<script is:inline>
(() => {
function init() {
const toc = document.querySelector(".toc-local");
if (!toc) return;
const itemEls = Array.from(toc.querySelectorAll("[data-toc-item]"));
if (!itemEls.length) return;
const ordered = itemEls
.map((li) => {
const a = li.querySelector("a[data-toc-link]");
const id = li.getAttribute("data-id") || a?.dataset.slug || "";
const depth = Number(li.getAttribute("data-depth") || "0");
const el = id ? document.getElementById(id) : null; // span.details-anchor OU h3[id]
return (a && id && el) ? { id, depth, li, a, el } : null;
})
.filter(Boolean);
if (!ordered.length) return;
const clear = () => {
for (const t of ordered) {
t.a.removeAttribute("aria-current");
t.li.classList.remove("is-current");
}
};
const openDetailsIfNeeded = (el) => {
const d = el?.closest?.("details");
if (d && !d.open) d.open = true;
};
let current = "";
const setCurrent = (id) => {
if (!id || id === current) return;
const t = ordered.find((x) => x.id === id);
if (!t) return;
current = id;
clear();
openDetailsIfNeeded(t.el);
t.a.setAttribute("aria-current", "true");
t.li.classList.add("is-current");
// ✅ IMPORTANT: plus de scrollIntoView ici
// sinon ça scroll l'aside pendant le scroll du reading => TOC global “disparaît”.
};
const computeActive = () => {
const visible = ordered.filter((t) => {
const d = t.el.closest?.("details");
if (d && !d.open) {
// Si l'élément est dans <summary>, il reste visible même details fermé
const inSummary = !!t.el.closest?.("summary");
if (!inSummary) return false;
}
return true;
});
if (!visible.length) return;
const y = 120;
let best = null;
for (const t of visible) {
const r = t.el.getBoundingClientRect();
if (!r || (r.width === 0 && r.height === 0)) continue;
if (r.top <= y) best = t;
else break;
}
if (!best) best = visible[0];
setCurrent(best.id);
};
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
computeActive();
});
};
const syncFromHash = () => {
const id = (location.hash || "").slice(1);
if (!id) { computeActive(); return; }
const el = document.getElementById(id);
if (el) openDetailsIfNeeded(el);
setCurrent(id);
};
toc.addEventListener("click", (ev) => {
const a = ev.target?.closest?.("a[data-toc-link]");
if (!a) return;
const id = a.dataset.slug || "";
if (!id) return;
const el = document.getElementById(id);
if (el) openDetailsIfNeeded(el);
setCurrent(id);
});
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
window.addEventListener("hashchange", syncFromHash);
syncFromHash();
onScroll();
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();
</script>
<style>
.toc-local{margin-top:12px;border:1px solid rgba(127,127,127,.25);border-radius:16px;padding:12px}
.toc-local__title{font-size:13px;opacity:.85;margin-bottom:8px}
.toc-local__list{list-style:none;margin:0;padding:0}
.toc-local__item::marker{content:""}
.toc-local__item{margin:6px 0}
.toc-local__item.d3{margin-left:12px;opacity:.9}
.toc-local__item.is-current > a{
font-weight: 750;
text-decoration: underline;
}
.toc-local a{
display:inline-block;
max-width:100%;
text-decoration:none;
}
.toc-local a:hover{ text-decoration: underline; }
.toc-local__list{
max-height: 44vh;
overflow: auto;
padding-right: 8px;
scrollbar-gutter: stable;
}
</style>

View File

@@ -0,0 +1,273 @@
<dialog id="propose-modal" aria-label="Qualifier la proposition" data-step="1">
<form method="dialog" class="box">
<header class="hd">
<h2>Qualifier la proposition</h2>
<button value="cancel" class="x" aria-label="Fermer">✕</button>
</header>
<!-- STEP 1 -->
<section class="step step-1" aria-label="Choisir le type">
<p class="sub">
Dabord : quel type de ticket veux-tu ouvrir ?
</p>
<div class="grid">
<button type="button" data-kind="correction">✍️ Correction (rédaction / clarté)</button>
<button type="button" data-kind="fact">🔎 Vérification factuelle / sources</button>
</div>
<footer class="ft">
<small>Étape 1/2 — <kbd>Esc</kbd> pour fermer.</small>
</footer>
</section>
<!-- STEP 2 -->
<section class="step step-2" aria-label="Choisir la catégorie">
<p class="sub">
Optionnel : choisis une intention (pour tri & traitement éditorial). Sinon, continue sans préciser.
</p>
<div class="grid">
<button type="button" data-category="cat/style">Style / lisibilité</button>
<button type="button" data-category="cat/lexique">Lexique / terminologie</button>
<button type="button" data-category="cat/argument">Argument / structure</button>
<button type="button" data-category="cat/redondance">Redondance</button>
<button type="button" data-category="cat/source">Source / vérification</button>
<button type="button" data-category="">Continuer sans préciser</button>
</div>
<footer class="ft row">
<button type="button" class="back" data-back="1">← Retour</button>
<small>Étape 2/2 — <kbd>Esc</kbd> pour fermer.</small>
</footer>
</section>
</form>
</dialog>
<style>
dialog { border: none; padding: 0; border-radius: 18px; width: min(760px, calc(100vw - 2rem)); }
dialog::backdrop { background: rgba(0,0,0,.55); }
.box { padding: 1rem 1rem .85rem; }
.hd { display:flex; align-items:center; justify-content:space-between; gap:.75rem; }
.hd h2 { margin:0; font-size:1.1rem; }
.x { border:0; background:transparent; font-size:1.1rem; cursor:pointer; }
.sub { margin:.55rem 0 1rem; opacity:.92; line-height: 1.35; }
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:.55rem; }
.grid button {
padding: .8rem .9rem;
border-radius: 14px;
border: 1px solid rgba(0,0,0,.18);
background: rgba(255,255,255,.96);
color: #111;
cursor: pointer;
text-align: left;
line-height: 1.2;
}
.grid button:hover { background: #fff; }
.ft { margin-top:.85rem; opacity:.88; }
.ft.row { display:flex; align-items:center; justify-content:space-between; gap:1rem; }
.back {
border: 1px solid rgba(0,0,0,.18);
background: rgba(255,255,255,.96);
color:#111;
border-radius: 12px;
padding: .55rem .7rem;
cursor: pointer;
}
kbd { padding:0 .35rem; border:1px solid rgba(0,0,0,.2); border-radius:6px; }
.step { display:none; }
dialog[data-step="1"] .step-1 { display:block; }
dialog[data-step="2"] .step-2 { display:block; }
</style>
<script is:inline>
(() => {
const dlg = document.getElementById("propose-modal");
if (!dlg) return;
/** @type {URL|null} */
let pending = null;
/** @type {"correction"|"fact"|""} */
let kind = "";
// Doit rester cohérent avec EditionLayout
const URL_HARD_LIMIT = 6500;
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const upsertLine = (text, key, value) => {
const re = new RegExp(`^\\s*${esc(key)}\\s*:\\s*.*$`, "mi");
// value vide => supprimer la ligne si elle existe
if (!value) {
if (!re.test(text)) return text;
return (
text
.replace(re, "")
.replace(/\n{3,}/g, "\n\n")
.trimEnd() + "\n"
);
}
// remplace si existe
if (re.test(text)) return text.replace(re, `${key}: ${value}`);
// sinon append
const sep = text && !text.endsWith("\n") ? "\n" : "";
return text + sep + `${key}: ${value}\n`;
};
const setTitlePrefix = (u, prefix) => {
const t = u.searchParams.get("title") || "";
const cleaned = t.replace(/^\[[^\]]+\]\s*/,"");
u.searchParams.set("title", `[${prefix}] ${cleaned}`.trim());
};
const tryUpgradeBodyWithFull = (u, full) => {
if (!full) return;
let body = u.searchParams.get("body") || "";
if (!body) return;
// Si déjà en "copie exacte", rien à faire
if (body.includes("Texte actuel (copie exacte du paragraphe)")) return;
// On ne tente que si le body est en mode extrait
if (!body.includes("Texte actuel (extrait):")) return;
const quoted = full.split(/\r?\n/).map(l => `> ${l}`.trimEnd()).join("\n");
// Remplace le bloc "Texte actuel (extrait)" jusqu'à "Proposition (remplacer par):"
const re = /Texte actuel \(extrait\):[\s\S]*?\n\nProposition \(remplacer par\):/m;
const next = body.replace(
re,
`Texte actuel (copie exacte du paragraphe):\n${quoted}\n\nProposition (remplacer par):`
);
if (next === body) return;
u.searchParams.set("body", next);
// garde-fou URL
if (u.toString().length > URL_HARD_LIMIT) {
// revert
u.searchParams.set("body", body);
}
};
// ✅ Ouvre EN NOUVEL ONGLET sans jamais remplacer longlet courant
const openInNewTab = (url) => {
// 1) tente window.open
try {
const w = window.open(url, "_blank", "noopener,noreferrer");
if (w) return true;
} catch {}
// 2) fallback "anchor click" (souvent mieux toléré)
try {
const a = document.createElement("a");
a.href = url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
return true;
} catch {}
// 3) dernier recours: on ne quitte PAS la page
window.prompt("Popup bloquée. Copiez ce lien pour ouvrir le ticket :", url);
return false;
};
const openWith = (url) => {
pending = url;
kind = "";
dlg.dataset.step = "1";
if (typeof dlg.showModal === "function") dlg.showModal();
else openInNewTab(url.toString());
};
// Intercepte UNIQUEMENT les liens marqués data-propose
document.addEventListener("click", async (e) => {
const a = e.target?.closest?.("a[data-propose]");
if (!a) return;
e.preventDefault();
e.stopPropagation();
const rawUrl = a.dataset.url || a.getAttribute("href") || "";
if (!rawUrl || rawUrl === "#") return;
const full = (a.dataset.full || "").trim();
try {
const u = new URL(rawUrl);
// Option B.1 : copie presse-papier du texte complet (si dispo)
if (full) {
try { await navigator.clipboard.writeText(full); } catch {}
// Option B.2 : upgrade du body (si l'URL reste raisonnable)
tryUpgradeBodyWithFull(u, full);
}
openWith(u);
} catch {
openInNewTab(rawUrl);
}
});
// Step 1: type
dlg.addEventListener("click", (e) => {
const btn = e.target?.closest?.("button[data-kind]");
if (!btn || !pending) return;
kind = btn.getAttribute("data-kind") || "";
let body = pending.searchParams.get("body") || "";
if (kind === "fact") {
body = upsertLine(body, "Type", "type/fact-check");
body = upsertLine(body, "State", "state/a-sourcer");
setTitlePrefix(pending, "Fact-check");
} else {
body = upsertLine(body, "Type", "type/correction");
body = upsertLine(body, "State", "state/recevable");
setTitlePrefix(pending, "Correction");
}
pending.searchParams.set("body", body);
dlg.dataset.step = "2";
});
// Back
dlg.addEventListener("click", (e) => {
const back = e.target?.closest?.("button[data-back]");
if (!back) return;
dlg.dataset.step = "1";
});
// Step 2: category + open
dlg.addEventListener("click", (e) => {
const btn = e.target?.closest?.("button[data-category]");
if (!btn || !pending) return;
const cat = btn.getAttribute("data-category") || "";
let body = pending.searchParams.get("body") || "";
body = upsertLine(body, "Category", cat);
pending.searchParams.set("body", body);
const u = pending.toString();
dlg.close();
// ✅ ouvre en nouvel onglet, sans jamais remplacer longlet courant
openInNewTab(u);
});
})();
</script>

View File

@@ -0,0 +1,11 @@
<nav class="site-nav" aria-label="Navigation principale">
<a href="/">Accueil</a><span aria-hidden="true"> · </span>
<a href="/editions/">Carte des œuvres</a><span aria-hidden="true"> · </span>
<a href="/methode/">Méthode</a><span aria-hidden="true"> · </span>
<a href="/recherche/">Recherche</a><span aria-hidden="true"> · </span>
<a href="/archicratie/">Essai-thèse</a><span aria-hidden="true"> · </span>
<a href="/traite/">Traité</a><span aria-hidden="true"> · </span>
<a href="/ia/">Cas IA</a><span aria-hidden="true"> · </span>
<a href="/glossaire/">Glossaire</a><span aria-hidden="true"> · </span>
<a href="/atlas/">Atlas</a>
</nav>

131
src/components/Term.astro Normal file
View File

@@ -0,0 +1,131 @@
---
const { term, slug } = Astro.props;
const href = slug ? `/glossaire/${slug}/` : "/glossaire/";
---
<a class="term" href={href} data-term={term} data-term-slug={slug}>
<slot>{term}</slot>
</a>
<script is:inline>
(() => {
// Popover léger, zéro dépendance, index statique.
const API = "/api/glossaire.json";
const CACHE_KEY = "__archicratieGlossaryIndex";
const CACHE_TIME_KEY = "__archicratieGlossaryIndexTime";
const TTL_MS = 5 * 60 * 1000;
function now() { return Date.now(); }
async function getIndex() {
try {
const t = Number(sessionStorage.getItem(CACHE_TIME_KEY));
const cached = sessionStorage.getItem(CACHE_KEY);
if (cached && t && (now() - t) < TTL_MS) return JSON.parse(cached);
const res = await fetch(API);
if (!res.ok) return null;
const data = await res.json();
sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
sessionStorage.setItem(CACHE_TIME_KEY, String(now()));
return data;
} catch {
return null;
}
}
function findEntry(index, slug, term) {
if (!index) return null;
if (slug) {
const bySlug = index.find((e) => e.slug === slug);
if (bySlug) return bySlug;
}
const lc = (term || "").toLowerCase();
return index.find((e) =>
e.term.toLowerCase() === lc ||
(e.aliases || []).some((a) => String(a).toLowerCase() === lc)
) || null;
}
let pop;
function ensurePopover() {
if (pop) return pop;
pop = document.createElement("div");
pop.className = "term-popover";
pop.style.position = "absolute";
pop.style.zIndex = "9999";
pop.style.maxWidth = "320px";
pop.style.padding = "10px 12px";
pop.style.border = "1px solid rgba(127,127,127,0.45)";
pop.style.borderRadius = "12px";
pop.style.background = "Canvas";
pop.style.boxShadow = "0 10px 30px rgba(0,0,0,0.15)";
pop.style.display = "none";
document.body.appendChild(pop);
document.addEventListener("click", (e) => {
if (!pop) return;
const target = e.target;
if (target && target.closest && target.closest(".term")) return;
pop.style.display = "none";
});
return pop;
}
function placePopover(anchor, node) {
const r = anchor.getBoundingClientRect();
const top = window.scrollY + r.bottom + 8;
const left = window.scrollX + Math.min(r.left, window.innerWidth - 340);
node.style.top = `${top}px`;
node.style.left = `${left}px`;
}
async function show(anchor) {
const slug = anchor.getAttribute("data-term-slug");
const term = anchor.getAttribute("data-term");
const index = await getIndex();
const entry = findEntry(index, slug, term);
const node = ensurePopover();
if (!entry) {
node.innerHTML = `<strong>${term || "Terme"}</strong><div style="margin-top:6px;opacity:0.85;">Voir la page du glossaire.</div>`;
} else {
node.innerHTML = `
<div style="display:flex;justify-content:space-between;gap:10px;align-items:baseline;">
<strong>${entry.term}</strong>
<a href="${entry.href}" style="font-size:12px;opacity:0.85;">ouvrir</a>
</div>
<div style="margin-top:6px;opacity:0.95;">${entry.definitionShort}</div>
`;
}
placePopover(anchor, node);
node.style.display = "block";
}
// Délégation d'événements : un seul handler pour tout le document
if (!window.__archicratieTermPopoverBound) {
window.__archicratieTermPopoverBound = true;
document.addEventListener("mouseover", (e) => {
const a = e.target && e.target.closest ? e.target.closest(".term") : null;
if (!a) return;
// éviter popover si pas de slug (cas rares)
show(a);
});
document.addEventListener("focusin", (e) => {
const a = e.target && e.target.closest ? e.target.closest(".term") : null;
if (!a) return;
show(a);
});
}
})();
</script>
<style>
.term {
text-decoration: underline;
text-underline-offset: 3px;
cursor: help;
}
</style>