);
}
/* ---------- QR placeholder (visually realistic — V1.1) ---------- */
function QRPlaceholder({ size = "0.9in", seed = 42, fg = "#0F1A3D", bg = "white", quiet = true }) {
// 33×33 grid (Version 4 QR). Three finder patterns, one alignment pattern,
// timing rows, and a deterministic dense data field. Looks real, isn't scannable.
const grid = 33;
const cells = React.useMemo(() => {
let s = seed * 1103515245 + 12345;
const rand = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return (s >>> 0) / 0x7fffffff; };
const inFinder = (x, y) =>
(x < 7 && y < 7) || (x > grid - 8 && y < 7) || (x < 7 && y > grid - 8);
const finderRing = (x, y) => {
const fx = x > grid - 8 ? grid - 1 - x : x;
const fy = y > grid - 8 ? grid - 1 - y : y;
if (fx === 0 || fx === 6 || fy === 0 || fy === 6) return 1;
if (fx >= 2 && fx <= 4 && fy >= 2 && fy <= 4) return 1;
return 0;
};
// Alignment pattern (5×5) at bottom-right interior
const ax = grid - 9, ay = grid - 9;
const inAlign = (x, y) => x >= ax && x < ax + 5 && y >= ay && y < ay + 5;
const alignCell = (x, y) => {
const dx = x - ax, dy = y - ay;
if (dx === 0 || dx === 4 || dy === 0 || dy === 4) return 1;
if (dx === 2 && dy === 2) return 1;
return 0;
};
const out = [];
for (let y = 0; y < grid; y++) {
for (let x = 0; x < grid; x++) {
let v;
if (inFinder(x, y)) v = finderRing(x, y);
else if (inAlign(x, y)) v = alignCell(x, y);
else if (x === 6) v = y % 2 === 0 ? 1 : 0; // timing column
else if (y === 6) v = x % 2 === 0 ? 1 : 0; // timing row
else v = rand() > 0.48 ? 1 : 0; // data field
if (v) out.push([x, y]);
}
}
return out;
}, [seed]);
// Quiet zone is part of the visual block (1 module padding embedded in viewBox)
const q = quiet ? 1 : 0;
return (
);
}
/* ---------- Footer (V1.2 — square white-bg QR for KDP print fidelity) ---------- */
function PageFooter({
score = "/ 10",
scoreVisible = true,
qrVisible = true,
qrSlug, // production: builds the URL via factoryUrl
qrUrl = "alloeducation.fr",
qrLabel = "Corriger avec Ketty",
qrSubLabel, // optional second line shown above the URL
page = 3,
}) {
// qrSlug overrides qrUrl when present (production wiring)
const url = qrSlug ? `alloeducation.fr/qr/${qrSlug}` : qrUrl;
return (
);
}
/* ---------- Ketty slot (placeholder for 3D PNG) ---------- */
const KETTY_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`
`)}`;
function KettySlot({
pose = "hello", // alias accepted: kettyPose -> pose
kettyPose,
kettyImageSrc, // production: real PNG path (e.g. "img/ketty/pointing.png")
kettyAlt, // production: alt text for accessibility
className = "",
style = {},
}) {
const finalPose = kettyPose || pose;
if (kettyImageSrc) {
return (
);
}
return (
Ketty PNG transparent
pose : {finalPose}
);
}
/* ---------- Full page wrapper ---------- */
// V1.2.2 — density is now LOCKED per template (not driven by a global tweak).
// The optional `splitOf` prop renders a "1 / 2" badge in the header and is
// the only safe way to declare a content-too-long page split.
function AePage({
matiere = "Mode d'emploi",
niveau = "Primaire",
page = 3,
scale = 0.7,
density = "std", // LOCKED per template — air | std | dense
qrVisible = true,
scoreVisible = true,
showGrid = false,
showMargins = false,
// V1.2 official QR props
qrSlug,
qrUrl,
qrLabel = "Corriger avec Ketty",
qrSubLabel,
// V1.2.2 split-page support
splitOf, // e.g. {n: 1, total: 2}
// V1.2.2 auditor mode — when true (set by sidebar toggle), flags violations
audit = false,
children,
}) {
const contentRef = React.useRef(null);
const [overflow, setOverflow] = React.useState(false);
// Overflow detection — runs after layout in auditor mode
React.useEffect(() => {
if (!audit || !contentRef.current) return;
const el = contentRef.current;
setOverflow(el.scrollHeight - el.clientHeight > 2);
}, [audit, children]);
const cls = [
"ae-page",
"scaled",
`density-${density}`,
showGrid && "show-grid",
showMargins && "show-margins",
].filter(Boolean).join(" ");
return (
{children}
);
}
/* ---------- Page-frame wrapper for previews in mini-site ---------- */
function PageFrame({ label = "8.5 × 11 in", scale = 0.62, children }) {
// children is an AePage; we render it scaled and reserve actual scaled space.
// Width/height of scaled content = page * scale.
const w = `calc(${8.5} * ${scale} * 96px)`;
const h = `calc(${11} * ${scale} * 96px)`;
return (