SPB Git forge

spb/uqo-imm1033

Public
5commits 1branches 0releases
30.2 MBsize
maindefault branch
4 h agolast push
JavaScript 68% CSS 32%
77.2 KB · 870 lines python
Raw Blame History
1"""templates.py — gabarits HTML v2 (thème clair UQO, menus, pages interactives, rapport marché)."""2from __future__ import annotations34import html5import json6import math7import re89ICONS = """10<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">11  <symbol id="i-book" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2zM22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/></symbol>12  <symbol id="i-formula" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 12h3l3 8 4-16 3 8h5"/></symbol>13  <symbol id="i-bulb" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></symbol>14  <symbol id="i-pencil" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>15  <symbol id="i-warning" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0zM12 9v4M12 17h.01"/></symbol>16  <symbol id="i-hardhat" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M2 18h20M4 18v-3a8 8 0 0 1 5-7.4V12M20 18v-3a8 8 0 0 0-5-7.4V12M10 4h4v8h-4z"/></symbol>17  <symbol id="i-pen" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 19l7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5zM2 2l7.6 7.6"/><circle cx="11" cy="11" r="2" fill="none" stroke="currentColor" stroke-width="2"/></symbol>18  <symbol id="i-map" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M1 6v16l7-4 8 4 7-4V2l-7 4-8-4zM8 2v16M16 6v16"/></symbol>19  <symbol id="i-star" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.9L12 17.8 5.8 21l1.2-6.9-5-4.9 6.9-1z"/></symbol>20  <symbol id="i-target" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>21  <symbol id="i-clipboard" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v0a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2zM9 14l2 2 4-4"/></symbol>22  <symbol id="i-check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M8 12l3 3 5-6"/></symbol>23  <symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M20 20l-3.5-3.5"/></symbol>24  <symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></symbol>25  <symbol id="i-menu" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16"/></symbol>26  <symbol id="i-text" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M4 7V5h16v2M12 5v14M9 19h6"/></symbol>27  <symbol id="i-slides" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M8 21h8M12 17v4"/></symbol>28  <symbol id="i-quiz" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/></symbol>29  <symbol id="i-cards" viewBox="0 0 24 24"><rect x="3" y="6" width="14" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" d="M7 2h12a2 2 0 0 1 2 2v14"/></symbol>30  <symbol id="i-calc" viewBox="0 0 24 24"><rect x="4" y="2" width="16" height="20" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M8 6h8M8 11h.01M12 11h.01M16 11h.01M8 15h.01M12 15h.01M16 15h.01M8 19h.01M12 19h.01M16 19h.01"/></symbol>31  <symbol id="i-download" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>32  <symbol id="i-eye" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/></symbol>33  <symbol id="i-arrow" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/></symbol>34  <symbol id="i-bib" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></symbol>35  <symbol id="i-list" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>36  <symbol id="i-note" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M14 3v4a1 1 0 0 0 1 1h4M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 13h6M9 17h6"/></symbol>37  <symbol id="i-audio" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M11 5 6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14"/></symbol>38  <symbol id="i-up" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 19V5M5 12l7-7 7 7"/></symbol>39  <symbol id="i-dumbbell" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M6 5v14M18 5v14M3 8v8M21 8v8M6 12h12"/></symbol>40  <symbol id="i-game" viewBox="0 0 24 24"><rect x="2" y="7" width="20" height="12" rx="4" fill="none" stroke="currentColor" stroke-width="2"/><path stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M7 11v4M5 13h4M15 12h.01M18 14h.01"/></symbol>41  <symbol id="i-chart" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 3v18h18M7 15l4-5 4 3 5-7"/></symbol>42  <symbol id="i-progress" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></symbol>43  <symbol id="i-network" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="4" cy="6" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="20" cy="6" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="4" cy="18" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="20" cy="18" r="2" fill="none" stroke="currentColor" stroke-width="2"/><path stroke="currentColor" stroke-width="2" d="M6 7l4 3M18 7l-4 3M6 17l4-3M18 17l-4-3"/></symbol>44  <symbol id="i-video" viewBox="0 0 24 24"><rect x="2" y="5" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" d="M16 10l6-3.5v11L16 14z"/></symbol>45  <symbol id="i-play" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path fill="currentColor" d="M10 8l6 4-6 4z"/></symbol>46  <symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M12 7v5l3 2"/></symbol>47  <symbol id="i-cc" viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M10.5 10.5a2.5 2.5 0 1 0 0 3M17.5 10.5a2.5 2.5 0 1 0 0 3"/></symbol>48  <symbol id="i-link" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></symbol>49  <symbol id="i-chat" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M21 12a8 8 0 0 1-8 8H6l-4 3V12a8 8 0 0 1 8-8h3a8 8 0 0 1 8 8z"/></symbol>50  <symbol id="i-report" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M8 17l3-4 2 2 3-5"/></symbol>51</svg>52"""53ICON_MAP = {"book": "i-book", "formula": "i-formula", "bulb": "i-bulb", "pencil": "i-pencil", "warning": "i-warning", "hardhat": "i-hardhat",54            "pen": "i-pen", "map": "i-map", "star": "i-star", "target": "i-target", "clipboard": "i-clipboard", "check": "i-check"}555657def icon(name: str, cls: str = "") -> str:58    return f'<svg class="{cls}" aria-hidden="true"><use href="#{name}"/></svg>'596061def inject_box_icons(h: str) -> str:62    return re.sub(r'<span class="box-icon" data-icon="([a-z]+)"></span>', lambda m: f'<span class="box-icon">{icon(ICON_MAP.get(m.group(1), "i-pen"))}</span>', h)636465def e(s) -> str:66    return html.escape(str(s), quote=True)676869def strip_tags(h: str) -> str:70    return html.unescape(re.sub(r"<[^>]+>", "", h or ""))717273def part_of(course: dict, n: int) -> tuple[int, str, str, str]:74    """(index 1-based, romain, titre, couleur)"""75    for i, (roman, title, nums) in enumerate(course["parts"]):76        if n in nums:77            return i + 1, roman, title, course["part_colors"][i]78    return 1, "", "", course["part_colors"][0]798081# -----------------------------------------------------------------------------82# Gabarit de page83# -----------------------------------------------------------------------------8485def nav_html(course: dict, chapters: list, active: str, has_report: bool) -> str:86    parts = []87    for i, (roman, ptitle, nums) in enumerate(course["parts"]):88        col = course["part_colors"][i]89        parts.append(f'<div class="dd-part"><i style="background:{col}"></i>Partie {roman} — {e(ptitle)}</div>')90        for n in nums:91            parts.append(f'<a href="/seance/{n:02d}/" data-ch="{n:02d}"><span class="pastille" style="background:{col}">{n}</span><span>{chapters[n - 1].title_html}</span></a>')92    if course.get("has_videos"):93        parts.append('<div class="dd-part">Enregistrements</div>')94        parts.append('<a href="/videos/"><span class="pastille" style="background:var(--rouge)">▶</span><span>Séances vidéo — enregistrements Zoom, transcription synchronisée</span></a>')95    parts.append('<div class="dd-part">Annexes</div>')96    parts.append('<a href="/aide-memoire/"><span class="pastille">A</span><span>Aide-mémoire des formules</span></a>')97    parts.append('<a href="/glossaire/"><span class="pastille">B</span><span>Glossaire</span></a>')98    if has_report:99        parts.append('<a href="/rapport/"><span class="pastille" style="background:var(--teal)">R</span><span>Rapport — Le marché de l’habitation au Québec</span></a>')100101    def item(href, ico, label, desc):102        return f'<a href="{href}"><span class="dd-ico">{icon(ico)}</span><span>{label}<span class="dd-desc">{desc}</span></span></a>'103    reviser = [item("/aide-memoire/", "i-formula", "Aide-mémoire", "Toutes les formules commentées"), item("/glossaire/", "i-book", "Glossaire", "Termes du métier, équivalents anglais"),104               item("/definitions/", "i-list", "Toutes les définitions", "Les formulations attendues aux examens"), item("/fiches/", "i-cards", "Fiches de révision", "Recto-verso, suivi d’acquisition"),105               item("/carte/", "i-network", "Carte du cours", "Les 14 séances et leurs objectifs en un coup d’œil"), item("/exercices/", "i-pencil", "Banque d’exercices", "Tous les exercices corrigés")]106    exercer = [item("/quiz/", "i-quiz", "Quiz et examens blancs", "QCM avec rétroaction, chronomètre"), item("/entrainement/", "i-dumbbell", "Entraînement", "Problèmes à données aléatoires, corrigés pas à pas"),107               item("/jeux/", "i-game", "Jeux de révision", "Association, vrai ou faux, remise en ordre"), item("/outils/", "i-calc", "Calculateurs", "Les formules en interactif"),108               item("/progression/", "i-progress", "Ma progression", "Lecture, quiz, objectifs, notes")]109    ressources = [item("/diapositives/", "i-slides", "Diapositives", "Les 14 présentations en ligne"), item("/ressources/", "i-download", "Téléchargements", "Manuel, plan, TP, gabarits Excel")]110    if course.get("has_videos"):111        ressources.insert(1, item("/videos/", "i-video", "Séances vidéo", "Enregistrements Zoom : chapitres, transcription, sous-titres"))112    if has_report:113        ressources.append(item("/rapport/", "i-report", "Rapport marché QC", "Indices de prix, macro, abordabilité — 26 sections, 67 figures"))114    ressources += [item("/bibliographie/", "i-bib", "Bibliographie", "Références citées"), item("/recherche/", "i-search", "Recherche", "Tout le manuel indexé")]115    groups = [("seances", "Séances", parts, "wide"), ("reviser", "Réviser", reviser, ""), ("exercer", "S’exercer", exercer, ""), ("ressources", "Ressources", ressources, "")]116    out = []117    for key, label, items, cls in groups:118        cur = " current" if key == active else ""119        out.append(f'<div class="dd{cur}"><button type="button" aria-expanded="false" aria-haspopup="true">{label} <span class="caret" aria-hidden="true">▾</span></button><div class="dd-menu {cls}">{"".join(items)}</div></div>')120    if course.get("has_videos"):121        cur = ' aria-current="page"' if active == "videos" else ""122        out.insert(1, f'<a class="nav-video" href="/videos/"{cur}>{icon("i-video")} Vidéos</a>')123    return "".join(out)124125126def layout(course: dict, chapters: list, *, title: str, body: str, active: str = "", desc: str = "", body_class: str = "",127           extra_head: str = "", extra_body: str = "", readbar: bool = False, canonical: str = "/", has_report: bool = True, body_attrs: str = "") -> str:128    code = course["code"]129    full_title = f"{title} · {code} — {course['title']}" if title else f"{code} — {course['title']} · Notes de cours interactives"130    desc = desc or course["subtitle"]131    return f"""<!DOCTYPE html>132<html lang="fr" data-theme="light">133<head>134<meta charset="utf-8">135<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">136<title>{e(full_title)}</title>137<meta name="description" content="{e(desc)}">138<meta name="author" content="Simon-Pierre Boucher — Université du Québec en Outaouais">139<meta name="theme-color" content="#0f6180">140<meta property="og:site_name" content="{e(code)} — UQO">141<meta property="og:title" content="{e(full_title)}">142<meta property="og:description" content="{e(desc)}">143<meta property="og:type" content="website">144<meta property="og:url" content="https://{course['domain']}{canonical}">145<meta property="og:image" content="https://{course['domain']}/assets/og.png">146<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">147<meta property="og:image:alt" content="{e(code)} — {e(course['title'])} — UQO">148<meta property="og:locale" content="fr_CA">149<meta name="twitter:card" content="summary_large_image">150<meta name="twitter:title" content="{e(full_title)}">151<meta name="twitter:description" content="{e(desc)}">152<meta name="twitter:image" content="https://{course['domain']}/assets/og.png">153<link rel="canonical" href="https://{course['domain']}{canonical}">154<link rel="icon" href="/favicon.svg" type="image/svg+xml">155<link rel="icon" href="/assets/brand/favicon-32.png" sizes="32x32" type="image/png">156<link rel="icon" href="/favicon.ico" sizes="any">157<link rel="apple-touch-icon" href="/assets/brand/apple-touch-icon.png">158<link rel="manifest" href="/manifest.webmanifest">159<link rel="preconnect" href="https://fonts.googleapis.com">160<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>161<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fira+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,600&family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&display=swap">162<link rel="stylesheet" href="/assets/katex/katex.min.css">163<link rel="stylesheet" href="/assets/css/site.css">164<script>(function(){{try{{var t=localStorage.getItem('uqo:theme');if(t)document.documentElement.dataset.theme=JSON.parse(t);var f=localStorage.getItem('uqo:fs');if(f&&JSON.parse(f))document.documentElement.dataset.fs=JSON.parse(f);}}catch(e){{}}}})();</script>165{extra_head}166</head>167<body class="course-{code.lower()} {body_class}" data-course="{code.lower()}"{body_attrs}>168{ICONS}169<a class="skip" href="#main">Aller au contenu</a>170<header class="topbar">171  <div class="topbar-inner">172    <a class="brand" href="/" aria-label="{e(code)} — accueil"><img class="brand-logo" src="/assets/brand/uqo-logo.png" alt="UQO" width="568" height="238"><span class="brand-sep"></span><span class="brand-text"><span class="brand-code">{code}</span><span class="brand-title">{e(course['title'])}</span></span></a>173    <nav class="topnav" aria-label="Navigation principale">{nav_html(course, chapters, active, has_report)}</nav>174    <div class="topbar-actions">175      <button class="icon-btn search-btn" type="button" data-action="search" aria-label="Rechercher">{icon('i-search')}<span>Rechercher</span><kbd>⌘K</kbd></button>176      <button class="icon-btn" type="button" data-action="theme" aria-label="Thème clair ou sombre" title="Thème">{icon('i-sun')}</button>177      <button class="icon-btn menu-btn" type="button" aria-label="Menu" aria-expanded="false">{icon('i-menu')}</button>178    </div>179  </div>180  {'<div class="readbar"><div class="readbar-fill"></div></div>' if readbar else ''}181</header>182{body}183<footer class="site-footer">184  <div class="footer-inner">185    <div class="footer-brand">186      <img src="/assets/brand/uqo-logo-white.png" alt="UQO" width="140" height="59" style="width:140px;height:auto;margin-bottom:10px">187      <div class="brand-code">{code}</div>188      <div>{e(course['title'])}</div>189      <div style="opacity:.7;margin-top:6px;font-size:.85rem">{e(course['term'])} · {e(course['credits'])}</div>190      <div style="margin-top:14px;font-size:.85rem">Simon-Pierre Boucher, professeur<br>Département des sciences administratives<br>Université du Québec en Outaouais<br><a href="mailto:simon-pierre.boucher@uqo.ca" style="display:inline;padding:0">simon-pierre.boucher@uqo.ca</a></div>191    </div>192    <div><h4>Étudier</h4><a href="/seance/01/">Séances</a>{'<a href="/videos/">Séances vidéo</a>' if course.get("has_videos") else ''}<a href="/aide-memoire/">Aide-mémoire</a><a href="/glossaire/">Glossaire</a><a href="/definitions/">Toutes les définitions</a><a href="/carte/">Carte du cours</a><a href="/exercices/">Banque d’exercices</a></div>193    <div><h4>S’exercer</h4><a href="/quiz/">Quiz et examens blancs</a><a href="/entrainement/">Entraînement</a><a href="/jeux/">Jeux de révision</a><a href="/fiches/">Fiches de révision</a><a href="/outils/">Calculateurs</a><a href="/progression/">Ma progression</a></div>194    <div><h4>Ressources</h4><a href="/diapositives/">Diapositives</a><a href="/ressources/">Téléchargements</a>{'<a href="/rapport/">Rapport — marché de l’habitation</a>' if has_report else ''}<a href="/bibliographie/">Bibliographie</a><a href="/recherche/">Recherche</a><a href="{course['sister']['url']}" rel="noopener">{course['sister']['code']} — {e(course['sister']['title'])}</a></div>195  </div>196  <div class="footer-legal"><div><span>© 2026 Simon-Pierre Boucher — Université du Québec en Outaouais. Document destiné aux étudiantes et étudiants inscrits au cours {code} ; toute reproduction ou diffusion sans autorisation écrite de l’auteur est interdite.</span><span>Généré depuis les sources LaTeX · KaTeX · TikZ</span></div></div>197</footer>198<div id="search-modal" class="search-modal" role="dialog" aria-modal="true" aria-label="Recherche dans les notes">199  <div class="search-box">200    <input type="search" placeholder="Rechercher une notion, une formule, un exemple…" autocomplete="off" spellcheck="false">201    <div class="search-results"></div>202    <div class="search-foot"><span><kbd>↑</kbd><kbd>↓</kbd> naviguer</span><span><kbd>↵</kbd> ouvrir</span><span><kbd>Échap</kbd> fermer</span><span style="margin-left:auto"><a href="/recherche/">Page de recherche complète</a></span></div>203  </div>204</div>205{extra_body}206<script src="/assets/js/app.js" defer></script>207</body>208</html>209"""210211212def page_head(eyebrow: str, title: str, lead: str = "") -> str:213    lead_html = '<p class="lead">' + lead + "</p>" if lead else ""214    return f'<div class="page-head"><div class="eyebrow">{e(eyebrow)}</div><h1>{title}</h1>{lead_html}</div>'215216217HERO_DECO = """<svg class="hero-deco" viewBox="0 0 1200 420" preserveAspectRatio="xMaxYMin slice" aria-hidden="true">218  <circle cx="1080" cy="40" r="120"/><circle cx="1150" cy="140" r="170"/><circle class="gold" cx="960" cy="200" r="50"/><circle class="dot" cx="880" cy="90" r="6"/><circle cx="80" cy="420" r="130"/>219</svg>"""220221222def kpi(label: str, value: str, small: str = "", cls: str = "", color: str = "") -> str:223    return f'<div class="kpi {cls}" style="--kc:{color}"><span>{label}</span><b>{value}</b>{f"<small>{small}</small>" if small else ""}</div>'224225226# -----------------------------------------------------------------------------227# Carte du cours (SVG)228# -----------------------------------------------------------------------------229230def course_map_svg(course: dict, chapters: list, compact: bool = False) -> str:231    n = len(chapters)232    cx, cy, R = 400, 400, 300233    out = [f'<svg class="course-map" viewBox="-130 -20 1060 840" role="img" aria-label="Carte du cours">']234    # arcs de partie235    for i, (roman, ptitle, nums) in enumerate(course["parts"]):236        a0 = (nums[0] - 1) / n * 2 * math.pi - math.pi / 2 - math.pi / n * .9237        a1 = (nums[-1] - 1) / n * 2 * math.pi - math.pi / 2 + math.pi / n * .9238        x0, y0 = cx + R * math.cos(a0), cy + R * math.sin(a0)239        x1, y1 = cx + R * math.cos(a1), cy + R * math.sin(a1)240        large = 1 if a1 - a0 > math.pi else 0241        col = course["part_colors"][i]242        out.append(f'<path class="ring-part" d="M{x0:.1f} {y0:.1f} A{R} {R} 0 {large} 1 {x1:.1f} {y1:.1f}" stroke="{col}"/>')243        am = (a0 + a1) / 2244        lx, ly = cx + (R + 88) * math.cos(am), cy + (R + 88) * math.sin(am)245        anchor = "middle" if abs(math.cos(am)) < .3 else ("start" if math.cos(am) > 0 else "end")246        out.append(f'<text class="part-lbl" x="{lx:.1f}" y="{ly:.1f}" text-anchor="{anchor}" fill="{col}">Partie {roman}</text>')247    # centre248    out.append(f'<circle class="center" cx="{cx}" cy="{cy}" r="82"/><text class="center-t" x="{cx}" y="{cy - 2}" text-anchor="middle">{course["code"]}</text><text class="center-s" x="{cx}" y="{cy + 22}" text-anchor="middle">{e(course["short"])}</text><text class="center-s" x="{cx}" y="{cy + 40}" text-anchor="middle">14 séances</text>')249    # nœuds250    for i, ch in enumerate(chapters, 1):251        a = (i - 1) / n * 2 * math.pi - math.pi / 2252        x, y = cx + R * math.cos(a), cy + R * math.sin(a)253        _, _, _, col = part_of(course, i)254        title = strip_tags(ch.title_html)255        # libellé raccourci sur 2 lignes256        words = title.replace(" — ", " : ").split()257        l1, l2 = "", ""258        for w in words:259            if len(l1) + len(w) < 20 and not l2:260                l1 = (l1 + " " + w).strip()261            elif len(l2) + len(w) < 20:262                l2 = (l2 + " " + w).strip()263            else:264                l2 = (l2 + "…") if not l2.endswith("…") else l2265                break266        lx, ly = cx + (R + 44) * math.cos(a), cy + (R + 44) * math.sin(a)267        anchor = "middle" if abs(math.cos(a)) < .25 else ("start" if math.cos(a) > 0 else "end")268        dy = 0 if abs(math.sin(a)) < .9 else (14 if math.sin(a) > 0 else -22)269        out.append(f'<a href="/seance/{i:02d}/" class="node" data-node="{i:02d}" style="--pc:{col}" tabindex="0"><circle cx="{x:.1f}" cy="{y:.1f}" r="24"/><text x="{x:.1f}" y="{y + 5:.1f}" text-anchor="middle">{i}</text>'270                   f'<text class="lbl" x="{lx:.1f}" y="{ly + dy:.1f}" text-anchor="{anchor}">{e(l1)}</text>{f"<text class=\"lbl\" x=\"{lx:.1f}\" y=\"{ly + dy + 13:.1f}\" text-anchor=\"{anchor}\">{e(l2)}</text>" if l2 else ""}</a>')271    out.append("</svg>")272    return "".join(out)273274275# -----------------------------------------------------------------------------276# Accueil277# -----------------------------------------------------------------------------278279def home_page(course: dict, chapters: list, stats: dict, files: dict, report_kpis: str, has_report: bool) -> str:280    code = course["code"]281    cards = []282    for i, (roman, ptitle, nums) in enumerate(course["parts"]):283        col = course["part_colors"][i]284        cards.append(f'<div class="part-title" style="--pc:{col}"><span class="roman"><i></i>Partie {roman}</span><span>{e(ptitle)}</span></div><div class="seances">')285        for n in nums:286            ch = chapters[n - 1]287            c = ch.counts288            ev = "".join(f'<span class="chip chip-{ {"tp": "or", "remise": "bleu", "exam": "rouge"}[k] }">{e(t)}</span>' for k, t in course["events"].get(n, []))289            cards.append(f"""<a class="seance-card reveal" href="/seance/{n:02d}/" style="--pc:{col}" data-n="{n:02d}">290              <div class="sc-top"><span class="sc-num">Séance {n:02d}</span><span class="ring" data-progress-ch="{n:02d}" style="--p:0" role="img" aria-label="Progression"></span></div>291              <h3>{ch.title_html}</h3>292              {f'<div class="sc-events">{ev}</div>' if ev else ''}293              <div class="sc-meta"><span>{icon('i-book')} {c['def']} déf.</span><span>{icon('i-formula')} {c['form']} form.</span><span>{icon('i-bulb')} {c['ex']} ex.</span><span>{icon('i-pencil')} {c['exo']} exo.</span><span class="chip" data-quiz-ch="{n:02d}" hidden></span></div>294            </a>""")295        cards.append("</div>")296    # ligne du temps297    tl = []298    for n in range(1, 15):299        _, _, _, col = part_of(course, n)300        evs = course["events"].get(n, [])301        ev_html = "".join(f'<span class="tl-{k}">{e(t)}</span>' for k, t in evs)302        tl.append(f'<a class="timeline-item" data-ch="{n:02d}" href="/seance/{n:02d}/" style="--tl:{col}" title="{e(strip_tags(chapters[n - 1].title_html))}"><span class="dot">{n}</span><span class="tl-ev">{ev_html}</span></a>')303    eval_rows = "".join(f"<tr><td><strong>{e(a)}</strong></td><td>{e(b)}</td><td class=\"al-c\">{e(c)}</td><td>{e(d)}</td></tr>" for a, b, c, d in course["evaluation"])304    features = [305        ("/seance/01/", "i-book", "Notes de cours intégrales", "Les 14 séances du manuel : définitions et formules numérotées, exemples chiffrés, pièges, contexte québécois, exercices corrigés, fiche synthèse.", False),306        ("/seance/03/", "i-chart", "Explorateurs interactifs", "Des graphiques à curseurs insérés dans les séances : offre et demande, cycle, sensibilité au TGA, DCF, ventilation de la dépréciation…", True),307        ("/entrainement/", "i-dumbbell", "Entraînement infini", "Problèmes à données aléatoires corrigés pas à pas, avec suivi de vos séries.", True),308        ("/quiz/", "i-quiz", "Quiz et examens blancs", f"{stats['quiz']} questions avec rétroaction, un examen blanc chronométré, vos meilleurs scores.", False),309        ("/jeux/", "i-game", "Jeux de révision", "Associez termes et définitions, tranchez vrai ou faux, remettez les étapes dans l’ordre.", True),310        ("/fiches/", "i-cards", "Fiches de révision", f"{stats['cards']} fiches recto-verso générées des définitions, du glossaire et des formules.", False),311        ("/outils/", "i-calc", "Calculateurs", "Les formules du cours en calculateurs : modifiez les hypothèses et voyez la valeur réagir.", False),312        ("/seance/01/#notes", "i-note", "Surlignage et notes", "Surlignez les passages, annotez-les, exportez vos notes en Markdown ; écoutez la séance en audio.", True),313        ("/progression/", "i-progress", "Tableau de bord", "Lecture, quiz, objectifs, fiches, entraînement : votre progression séance par séance.", True),314    ]315    if course.get("has_videos"):316        features.insert(1, ("/videos/", "i-video", "Séances vidéo", "Les enregistrements Zoom des cours : chapitres, transcription synchronisée cliquable, sous-titres, diapositives au fil de la vidéo.", True))317    feat_html = "".join(f'<a class="feature reveal" href="{u}"><div class="f-icon">{icon(i)}</div><div><h3>{t}{"<span class=\"f-new\">Nouveau</span>" if new else ""}</h3><p>{d}</p></div></a>' for u, i, t, d, new in features)318    report_html = f"""319    <section class="section" id="rapport">320      <div class="section-head"><div><div class="eyebrow">Ressource transversale</div><h2>Le marché de l’habitation au Québec</h2><p>Rapport de référence de 106 pages : indices de prix MLS® 2005–2026, six marchés québécois, cadre macroéconomique et abordabilité. Intégralement consultable, avec un explorateur interactif des données.</p></div><a class="btn" href="/rapport/">{icon('i-report')} Ouvrir le rapport</a></div>321      <div class="kpi-grid">{report_kpis}</div>322    </section>""" if has_report else ""323    body = f"""324<main id="main">325  <section class="hero">{HERO_DECO}326    <div class="hero-inner">327      <div>328        <div class="hero-brand"><img src="/assets/brand/uqo-logo.png" alt="UQO" width="568" height="238"><span>Université du Québec en Outaouais · Sciences administratives</span></div>329        <div class="eyebrow">Notes de cours interactives · {e(course['term'])}</div>330        <h1><span class="code">{code}</span>{e(course['title'])}</h1>331        <p class="lead">{e(course['subtitle'])}</p>332        <div class="hero-cta">333          <a class="btn btn-or" href="/seance/01/">{icon('i-arrow')} Commencer la séance 1</a>334          <a class="btn btn-ghost" href="/carte/">{icon('i-network')} Carte du cours</a>335          <a class="btn btn-ghost" href="/quiz/">{icon('i-quiz')} Quiz</a>336        </div>337        <div class="hero-stats">338          <div class="stat"><b>14</b><span>séances</span></div>339          <div class="stat"><b>{stats['def']}</b><span>définitions</span></div>340          <div class="stat"><b>{stats['form']}</b><span>formules</span></div>341          <div class="stat"><b>{stats['ex']}</b><span>exemples travaillés</span></div>342          <div class="stat"><b>{stats['exo']}</b><span>exercices corrigés</span></div>343          <div class="stat"><b>{stats['fig']}</b><span>figures et tableaux</span></div>344          <div class="stat"><b>{stats['quiz']}</b><span>questions de quiz</span></div>345          <div class="stat"><b>{format(stats['words'], ',').replace(',', ' ')}</b><span>mots</span></div>346        </div>347      </div>348      <div class="hero-card" id="resume-card" data-total="14">349        <h3>Votre progression</h3>350        <div class="resume-title">Aucune séance commencée</div>351        <div class="muted resume-pct">0/14 séances terminées · 0 %</div>352        <div class="bar"><i></i></div>353        <div class="muted resume-quiz">Les quiz réussis à 70 % marquent la séance comme terminée.</div>354        <a class="btn btn-or resume-link" href="/seance/01/">Commencer</a>355      </div>356    </div>357  </section>358359  <div class="page">360    <section class="section" id="parcours">361      <div class="section-head"><div><div class="eyebrow">Parcours de la session</div><h2>Quatorze séances, trois travaux, deux examens</h2><p>Survolez une séance ; les annonces et remises de TP et les examens sont indiqués.</p></div></div>362      <div class="timeline">{''.join(tl)}</div>363    </section>364365    <section class="section" id="seances">366      <div class="section-head"><div><div class="eyebrow">Programme</div><h2>Les 14 séances</h2><p>Un chapitre par séance, dans l’ordre du plan de cours. Les anneaux indiquent votre avancement ; chaque partie a sa couleur.</p></div><div style="display:flex;gap:10px;flex-wrap:wrap">{f'<a class="btn btn-ghost" href="/videos/">{icon("i-video")} Séances vidéo</a>' if course.get("has_videos") else ''}<a class="btn btn-ghost" href="/diapositives/">{icon('i-slides')} Diapositives</a></div></div>367      {''.join(cards)}368    </section>369370    <section class="section" id="outils">371      <div class="section-head"><div><div class="eyebrow">Étudier autrement</div><h2>Tout le manuel, en interactif</h2></div></div>372      <div class="grid-3">{feat_html}</div>373    </section>374375    <section class="section" id="evaluation">376      <div class="section-head"><div><div class="eyebrow">Évaluation</div><h2>Pondération et calendrier</h2><p>Travaux et examens à la maison ; IA tolérée avec déclaration obligatoire ; personnalisation par code permanent.</p></div></div>377      <div class="table-wrap"><table class="eval-table"><thead><tr><th>Composante</th><th>Matière et livrable</th><th class="al-c">Pondération</th><th>Échéance</th></tr></thead><tbody>{eval_rows}<tr class="total"><td>Total</td><td></td><td class="al-c">100 %</td><td></td></tr></tbody></table></div>378    </section>379    {report_html}380    <section class="section" id="telechargements">381      <div class="section-head"><div><div class="eyebrow">Téléchargements</div><h2>Documents du cours</h2></div><a class="btn btn-ghost" href="/ressources/">Tous les fichiers</a></div>382      <div class="grid-3">383        <a class="card reveal" href="{files['notes']}"><h3>{icon('i-download')} Notes de cours (PDF)</h3><p style="margin:0;color:var(--ink-2)">Le manuel complet, {files['notes_pages']} pages, mis en page pour l’impression.</p></a>384        <a class="card reveal" href="{files['plan']}"><h3>{icon('i-download')} Plan de cours (PDF)</h3><p style="margin:0;color:var(--ink-2)">Objectifs, calendrier, modalités d’évaluation et politiques.</p></a>385        <a class="card reveal" href="/diapositives/"><h3>{icon('i-slides')} Diapositives</h3><p style="margin:0;color:var(--ink-2)">Les 14 présentations projetées en classe, consultables en ligne.</p></a>386      </div>387    </section>388  </div>389</main>"""390    return layout(course, chapters, title="", body=body, active="", canonical="/", has_report=has_report)391392393# -----------------------------------------------------------------------------394# Page de séance395# -----------------------------------------------------------------------------396397def chapter_page(course: dict, chapters: list, ch, n: int, body_html: str, meta: dict, slide_url: str | None, has_report: bool, video_url: str | None = None) -> str:398    prev_ch = chapters[n - 2] if n > 1 else None399    next_ch = chapters[n] if n < 14 else None400    pidx, part_roman, part_title, col = part_of(course, n)401    toc_items = []402    steps = []403    for level, num, title_html, sid in ch.toc:404        if level > 3:405            continue406        toc_items.append(f'<li class="lvl{level}"><a href="#{sid}"><span class="toc-num">{num}</span><span>{title_html}</span></a></li>')407        if level == 2:408            steps.append(f'<a href="#{sid}"><span class="n">{num.split(".")[-1]}</span>{strip_tags(title_html)}</a>')409    events = course["events"].get(n, [])410    ev_html = "".join(f'<span class="chip">{e(t)}</span>' for _, t in events)411    c = ch.counts412    hero = f"""413<header class="ch-hero">414  <svg class="deco" viewBox="0 0 1200 300" preserveAspectRatio="xMaxYMin slice" aria-hidden="true"><circle cx="1090" cy="20" r="110"/><circle cx="1160" cy="110" r="160"/><circle class="gold" cx="990" cy="170" r="45"/></svg>415  <div class="watermark" aria-hidden="true">{n:02d}</div>416  <div class="ch-hero-inner">417    <div class="eyebrow"><span class="part-chip">Partie {part_roman}</span> {e(part_title)} · Séance {n} · {course['code']}</div>418    <h1>{ch.title_html}</h1>419    <div class="ch-sub">{e(course['title'])} · {e(course['term'])} · Simon-Pierre Boucher · UQO</div>420    <div class="ch-meta">421      <span class="chip">{icon('i-list')} {len([t for t in ch.toc if t[0] == 2])} sections</span>422      <span class="chip">{icon('i-book')} {c['def']} définition{'s' if c['def'] > 1 else ''}</span>423      <span class="chip">{icon('i-formula')} {c['form']} formule{'s' if c['form'] > 1 else ''}</span>424      <span class="chip">{icon('i-bulb')} {c['ex']} exemple{'s' if c['ex'] > 1 else ''}</span>425      <span class="chip">{icon('i-pencil')} {c['exo']} exercice{'s' if c['exo'] > 1 else ''}</span>426      <span class="chip">{icon('i-eye')} ≈ {meta['minutes']} min de lecture</span>427      {f'<span class="chip">{icon("i-chart")} {meta["widgets"]} explorateur{"s" if meta["widgets"] > 1 else ""} interactif{"s" if meta["widgets"] > 1 else ""}</span>' if meta.get("widgets") else ''}428      {ev_html}429    </div>430    <div class="ch-tools">431      {f'<a class="btn btn-ghost btn-video" href="{video_url}">{icon("i-video")} Enregistrement de la séance</a>' if video_url else ''}432      {f'<a class="btn btn-ghost" href="{slide_url}" target="_blank" rel="noopener">{icon("i-slides")} Diapositives (PDF)</a>' if slide_url else ''}433      <button class="btn btn-ghost" type="button" data-action="revision" aria-pressed="false">{icon('i-eye')} Mode révision</button>434      <button class="btn btn-ghost" type="button" data-action="tts">{icon('i-audio')} Écouter</button>435      <a class="btn btn-ghost" href="#quiz">{icon('i-quiz')} Quiz de la séance</a>436    </div>437  </div>438</header>439<div class="stepper-wrap"><nav class="stepper" aria-label="Sections">{''.join(steps)}<span class="rem" id="read-remaining"></span></nav></div>"""440    toc = f"""441<aside class="ch-toc" aria-label="Sommaire de la séance">442  <div class="toc-progress"><div class="bar"><i></i></div><span>0 % lu</span></div>443  <h4>Dans cette séance</h4>444  <ol>{''.join(toc_items)}</ol>445  <div class="toc-extra">446    <a href="#objectifs">{icon('i-target')} Objectifs d’apprentissage</a>447    <a href="#synthese">{icon('i-clipboard')} L’essentiel de la séance</a>448    <a href="#quiz">{icon('i-quiz')} Testez-vous</a>449    {f'<a href="{video_url}">{icon("i-video")} Enregistrement vidéo</a>' if video_url else ''}450    <a href="/fiches/?ch={n:02d}">{icon('i-cards')} Fiches de la séance</a>451    <a href="/entrainement/">{icon('i-dumbbell')} Entraînement</a>452    <a href="/aide-memoire/">{icon('i-formula')} Aide-mémoire</a>453  </div>454  <h4 style="margin-top:14px">Raccourcis</h4>455  <div style="font-size:.76rem;color:var(--gris)"><kbd>←</kbd> <kbd>→</kbd> séance précédente / suivante · <kbd>⌘K</kbd> recherche · sélectionnez un passage pour le surligner</div>456</aside>"""457    pager = f"""458<nav class="ch-pager" aria-label="Navigation entre les séances">459  {f'<a class="prev" href="/seance/{n-1:02d}/"><small>← Séance {n-1}</small><b>{prev_ch.title_html}</b></a>' if prev_ch else '<span class="spacer"></span>'}460  {f'<a class="next" href="/seance/{n+1:02d}/"><small>Séance {n+1} →</small><b>{next_ch.title_html}</b></a>' if next_ch else '<a class="next" href="/aide-memoire/"><small>Annexe A →</small><b>Aide-mémoire des formules</b></a>'}461</nav>"""462    tools = f"""463<div class="read-tools" role="toolbar" aria-label="Outils de lecture">464  <button type="button" data-action="notes" data-tip="Mes notes et surlignages" aria-pressed="false">{icon('i-note')}</button>465  <button type="button" data-action="tts" data-tip="Écouter la séance">{icon('i-audio')}</button>466  <button type="button" data-action="revision" data-tip="Mode révision" aria-pressed="false">{icon('i-eye')}</button>467  <hr>468  <button type="button" data-action="fontsize" data-tip="Taille du texte">{icon('i-text')}</button>469  <button type="button" data-action="theme" data-tip="Thème clair / sombre">{icon('i-sun')}</button>470  <button type="button" data-action="top" data-tip="Haut de page">{icon('i-up')}</button>471</div>472<aside class="notes-panel" id="notes-panel" aria-label="Mes notes">473  <div class="notes-head"><h3>Mes notes — séance {n}</h3><button class="icon-btn" type="button" data-action="notes-close" aria-label="Fermer">✕</button></div>474  <div class="notes-body">475    <div><h4>Notes personnelles <span id="notes-saved"></span></h4><textarea placeholder="Vos notes pour cette séance (enregistrées automatiquement sur cet appareil)…"></textarea></div>476    <div><h4>Passages surlignés</h4><ul id="hl-list"></ul></div>477  </div>478  <div class="notes-foot"><button class="btn btn-sm btn-ghost" type="button" data-action="notes-export">{icon('i-download')} Exporter (Markdown)</button><a class="btn btn-sm btn-ghost" href="/progression/">Toutes mes notes</a></div>479</aside>480<div class="tts-bar" id="tts-bar" hidden><button type="button" id="tts-prev" aria-label="Paragraphe précédent">⏮</button><button type="button" id="tts-play" aria-label="Lecture / pause">⏸</button><button type="button" id="tts-next" aria-label="Paragraphe suivant">⏭</button><select id="tts-rate" aria-label="Vitesse"><option value="0.85">0,85×</option><option value="1" selected>1×</option><option value="1.15">1,15×</option><option value="1.3">1,3×</option></select><span>Lecture audio</span><button type="button" id="tts-stop" aria-label="Arrêter">■</button></div>"""481    body = f"""482<main id="main" class="chapter-page" data-chapter="{n:02d}" data-title="Séance {n} — {e(ch.title_text)}" data-words="{meta['words']}">483  {hero}484  <div class="ch-layout">485    {toc}486    <div class="ch-body">487      <div class="revision-banner">{icon('i-eye')}<span><strong>Mode révision</strong> — seuls les définitions, formules, mises en garde, « À retenir », notes marginales et la fiche synthèse sont affichés.</span><button class="btn btn-sm btn-ghost" type="button" data-action="revision-off">Quitter</button></div>488      <div style="display:flex;justify-content:flex-end;margin-bottom:8px"><button class="btn btn-sm btn-ghost" type="button" data-action="toggle-solutions">Révéler toutes les solutions</button></div>489      <article class="prose" id="notes">490        {body_html}491      </article>492    </div>493  </div>494  <section class="ch-quiz" id="quiz"><div class="ch-quiz-inner"><div id="quiz-app"><p class="gl-count">Chargement du quiz…</p></div></div></section>495  {pager}496  {tools}497</main>"""498    desc = f"Séance {n} du cours {course['code']} ({course['title']}) : {ch.title_text}. Notes de cours complètes, explorateurs interactifs, exercices corrigés et quiz."499    return layout(course, chapters, title=f"Séance {n} — {ch.title_text}", body=body, active="seances", desc=desc, readbar=True,500                  canonical=f"/seance/{n:02d}/", has_report=has_report, body_attrs=f' data-part="{pidx}"')501502503# -----------------------------------------------------------------------------504# Pages génériques505# -----------------------------------------------------------------------------506507def two_col_page(course, chapters, *, title, eyebrow, lead, content_html, side_html, active, canonical, extra_head="", extra_body="", has_report=True, body_class="", body_attrs=""):508    body = f"""509<main id="main" class="page">510  {page_head(eyebrow, title, lead)}511  <div class="two-col">512    <article class="prose">{content_html}</article>513    <aside class="side">{side_html}</aside>514  </div>515</main>"""516    return layout(course, chapters, title=strip_tags(title), body=body, active=active, canonical=canonical, extra_head=extra_head, extra_body=extra_body, has_report=has_report, body_class=body_class, body_attrs=body_attrs)517518519def toc_side(toc, heading="Sommaire", extra="") -> str:520    items = "".join(f'<li class="lvl{lvl}"><a href="#{sid}">{(num + " ") if num else ""}{t}</a></li>' for lvl, num, t, sid in toc if lvl <= 3)521    return f"<h4>{heading}</h4><ol>{items}</ol>{extra}"522523524def glossary_page(course, chapters, entries, intro_html, has_report=True) -> str:525    letters = sorted({en["letter"] for en in entries})526    nav = "".join(f'<a href="#lettre-{l}">{l}</a>' for l in letters)527    parts, cur = [], None528    for en in entries:529        if en["letter"] != cur:530            cur = en["letter"]531            parts.append(f'<h2 class="gl-letter" id="lettre-{cur}" data-letter="{cur}">{cur}</h2>')532        parts.append(f'<div class="gl-entry" id="{en["id"]}" data-letter="{en["letter"]}"><h3>{en["term_html"]}</h3><div class="gl-def">{en["html"]}</div></div>')533    body = f"""534<main id="main" class="page">535  {page_head('Annexe B', 'Glossaire', '')}536  <div class="prose" style="max-width:none">{intro_html}</div>537  <div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin:10px 0 0"><input id="gl-search" class="input gl-search" type="search" placeholder="Filtrer les termes…" autocomplete="off"><span class="gl-count" id="gl-count">{len(entries)} termes</span><a class="btn btn-sm btn-ghost" href="/jeux/?jeu=assoc">{icon('i-game')} Jouer avec le glossaire</a></div>538  <nav class="gl-letters" aria-label="Lettres">{nav}</nav>539  <div class="prose" style="max-width:none">{''.join(parts)}</div>540</main>"""541    return layout(course, chapters, title="Glossaire", body=body, active="reviser", canonical="/glossaire/", has_report=has_report,542                  desc=f"Glossaire du cours {course['code']} : {len(entries)} termes définis, avec équivalents anglais.")543544545def quiz_page(course, chapters, n_questions, has_report=True) -> str:546    body = f"""<main id="main" class="page">{page_head('S’exercer', 'Quiz et examens blancs', f'{n_questions} questions à choix multiples couvrant les 14 séances. Chaque réponse est expliquée et renvoie à la section des notes. Vos meilleurs scores sont conservés sur cet appareil ; un score de 70 % marque la séance comme terminée.')}<div id="quiz-page"><p class="gl-count">Chargement…</p></div></main>"""547    return layout(course, chapters, title="Quiz", body=body, active="exercer", canonical="/quiz/", has_report=has_report)548549550def flash_page(course, chapters, n_cards, has_report=True) -> str:551    body = f"""552<main id="main" class="page">553  {page_head('Réviser', 'Fiches de révision', f'{n_cards} fiches recto-verso générées à partir des définitions numérotées, du glossaire et des formules du cours. Retournez la carte, puis indiquez si vous la saviez : trois bonnes réponses successives la classent comme acquise.')}554  <div class="fc-toolbar">555    <select id="fc-ch" class="select" aria-label="Séance"></select>556    <select id="fc-type" class="select" aria-label="Type"><option value="">Tous les types</option><option value="def">Définitions</option><option value="form">Formules</option><option value="gloss">Glossaire</option></select>557    <select id="fc-mode" class="select" aria-label="Mode"><option value="all">Toutes les fiches</option><option value="review">À réviser (non acquises)</option><option value="hard">Difficiles (ratées)</option></select>558    <button class="btn btn-sm btn-ghost" id="fc-reset" type="button" style="margin-left:auto">Réinitialiser</button>559  </div>560  <div class="fc-stats" id="fc-stats"></div>561  <div class="fc-stage" id="fc-stage"></div>562  <div class="fc-actions">563    <button class="btn btn-ko" id="fc-ko" type="button">✗ À revoir <kbd style="margin-left:6px">←</kbd></button>564    <button class="btn btn-ghost" id="fc-flip" type="button">Retourner <kbd style="margin-left:6px">Espace</kbd></button>565    <button class="btn btn-ghost" id="fc-skip" type="button">Passer</button>566    <button class="btn btn-ok" id="fc-ok" type="button">✓ Je savais <kbd style="margin-left:6px">→</kbd></button>567  </div>568  <div class="fc-nav" id="fc-nav"></div>569  <div id="flash-app"></div>570</main>"""571    return layout(course, chapters, title="Fiches de révision", body=body, active="reviser", canonical="/fiches/", has_report=has_report)572573574def tools_page(course, chapters, n_tools, has_report=True) -> str:575    body = f"""<main id="main" class="page">{page_head('Calculer', 'Calculateurs du cours', f'{n_tools} calculateurs reprennent les formules du manuel avec des données réalistes du marché québécois. Modifiez une hypothèse : le résultat, les étapes intermédiaires et les vérifications se recalculent instantanément.')}<nav class="tools-nav" id="tools-nav" aria-label="Calculateurs"></nav><div id="tools-app"></div><p class="gl-count">Repères pédagogiques tirés des cas du cours, pas des normes : à l’examen, utilisez toujours les données de l’énoncé.</p></main>"""576    return layout(course, chapters, title="Calculateurs", body=body, active="exercer", canonical="/outils/", extra_body='<script src="/data/outils.js"></script>', has_report=has_report)577578579def training_page(course, chapters, n_gen, has_report=True) -> str:580    body = f"""<main id="main" class="page">{page_head('S’exercer', 'Entraînement', f'{n_gen} types de problèmes aux données tirées au hasard : chaque énoncé est nouveau, la réponse est vérifiée avec une tolérance d’arrondi et la solution est détaillée étape par étape. Enchaînez les bonnes réponses pour allonger votre série.')}<div class="tchips" id="training-menu"></div><div id="training-app"></div><p class="gl-count" style="margin-top:20px">Réponses acceptées : nombres avec ou sans espaces, virgule ou point décimal, symbole $ ou % facultatif.</p></main>"""581    return layout(course, chapters, title="Entraînement", body=body, active="exercer", canonical="/entrainement/", extra_body='<script src="/data/entrainement.js"></script>', has_report=has_report)582583584def games_page(course, chapters, sequences, has_report=True) -> str:585    body = f"""<main id="main" class="page">{page_head('S’exercer', 'Jeux de révision', 'Trois jeux rapides construits à partir des définitions, du glossaire et des séquences du cours. Idéal pour cinq minutes de révision active.')}586  <div class="game-tabs" role="tablist"><button type="button" role="tab" aria-selected="true" data-game="assoc">{icon('i-cards')} Association</button><button type="button" role="tab" aria-selected="false" data-game="vf">{icon('i-check')} Vrai ou faux</button><button type="button" role="tab" aria-selected="false" data-game="ordre">{icon('i-list')} Remise en ordre</button></div>587  <div id="games-app"><p class="gl-count">Chargement…</p></div></main>"""588    seq_json = json.dumps(sequences, ensure_ascii=False)589    return layout(course, chapters, title="Jeux de révision", body=body, active="exercer", canonical="/jeux/", extra_body=f"<script>window.UQO_SEQUENCES = {seq_json};</script>", has_report=has_report)590591592def progress_page(course, chapters, meta_json: str, has_report=True) -> str:593    body = f"""<main id="main" class="page">{page_head('Suivi', 'Ma progression', 'Tout ce que vous avez lu, réussi, coché et noté dans ce cours, sur cet appareil. Exportez votre progression pour la retrouver ailleurs.')}<div id="progress-app"><p class="gl-count">Chargement…</p></div></main>"""594    return layout(course, chapters, title="Ma progression", body=body, active="exercer", canonical="/progression/", extra_body=f"<script>window.UQO_META = {meta_json};</script>", has_report=has_report)595596597def map_page(course, chapters, map_json: str, has_report=True) -> str:598    body = f"""<main id="main" class="page">{page_head('Vue d’ensemble', 'Carte du cours', 'Les 14 séances disposées par partie. Survolez ou touchez une séance pour voir ses objectifs d’apprentissage et votre avancement ; cliquez pour l’ouvrir.')}599  <div class="map-layout"><div>{course_map_svg(course, chapters)}</div><aside id="map-panel"></aside></div></main>"""600    return layout(course, chapters, title="Carte du cours", body=body, active="reviser", canonical="/carte/", extra_body=f"<script>window.UQO_MAP = {map_json};</script>", has_report=has_report)601602603def search_page(course, chapters, has_report=True) -> str:604    body = f"""<main id="main" class="page search-page">{page_head('Chercher', 'Recherche dans les notes', 'Chaque paragraphe, définition, formule, exemple, exercice, tableau, entrée de glossaire et section du rapport est indexé. Les résultats renvoient à l’emplacement exact.')}<input class="input gl-search" type="search" placeholder="Ex. : taux global d’actualisation, dépréciation curable, RPV…" autocomplete="off" style="max-width:none;margin-bottom:20px"><div class="search-results"></div></main>"""605    return layout(course, chapters, title="Recherche", body=body, active="ressources", canonical="/recherche/", has_report=has_report)606607608def resources_page(course, chapters, rows, has_report=True) -> str:609    def fi(kind):610        return f'<span class="file-icon file-{kind}">{kind.upper()}</span>'611    groups = {}612    for r in rows:613        groups.setdefault(r["group"], []).append(r)614    parts = []615    for g, items in groups.items():616        trs = []617        for r in items:618            target = ' target="_blank" rel="noopener"' if r["kind"] == "pdf" else ""619            desc = '<div style="font-size:.85rem;color:var(--gris)">' + e(r["desc"]) + "</div>" if r.get("desc") else ""620            trs.append(f'<tr><td>{fi(r["kind"])}<a href="{r["url"]}"{target}>{e(r["label"])}</a>{desc}</td><td class="size">{r["size"]}</td><td><a class="btn btn-sm btn-ghost" href="{r["url"]}" download>{icon("i-download")} Télécharger</a></td></tr>')621        parts.append(f'<section class="section" style="padding-top:28px"><div class="section-head"><h2 style="font-size:1.4rem">{e(g)}</h2></div><div class="table-wrap"><table class="res-table"><thead><tr><th>Document</th><th>Taille</th><th></th></tr></thead><tbody>{"".join(trs)}</tbody></table></div></section>')622    body = f"""<main id="main" class="page">{page_head('Ressources', 'Téléchargements', 'Le manuel, le plan de cours, les diapositives des 14 séances, les énoncés de travaux pratiques, les gabarits Excel « Dashboard » et le rapport sur le marché de l’habitation avec ses données. Les solutionnaires et les questionnaires d’examen ne sont pas publiés ici.')}{''.join(parts)}</main>"""623    return layout(course, chapters, title="Ressources", body=body, active="ressources", canonical="/ressources/", has_report=has_report)624625626def slides_page(course, chapters, slides, has_report=True) -> str:627    tabs = "".join(f'<button type="button" role="tab" aria-selected="false" data-n="{s["n"]:02d}" data-src="{s["url"]}" data-title="Séance {s["n"]} — {e(s["title"])}">{s["n"]}</button>' for s in slides)628    lis = "".join(f'<li><a href="{s["url"]}" target="_blank" rel="noopener">Séance {s["n"]} — {s["title_html"]}</a> <span class="size">({s["size"]})</span></li>' for s in slides)629    body = f"""<main id="main" class="page">{page_head('Diapositives', 'Les 14 séances en diapositives', 'Les présentations Beamer projetées en classe, consultables ici ou téléchargeables en PDF. Les notes de cours en sont le prolongement écrit.')}630  <div class="slide-tabs" role="tablist" aria-label="Séances">{tabs}</div>631  <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap"><h2 id="slide-title" style="margin:0;font-size:1.2rem;color:var(--bleu)"></h2><a id="slide-download" class="btn btn-sm btn-ghost" href="#" download>{icon('i-download')} Télécharger ce PDF</a></div>632  <div class="slide-viewer"><iframe title="Diapositives de la séance" src="about:blank" loading="lazy"></iframe></div>633  <h2 style="font-size:1.2rem;color:var(--bleu)">Toutes les séances</h2><ul class="prose" style="max-width:none">{lis}</ul></main>"""634    return layout(course, chapters, title="Diapositives", body=body, active="ressources", canonical="/diapositives/", has_report=has_report)635636637def bib_page(course, chapters, entries_html, n, has_report=True, extra_title="") -> str:638    body = f"""<main id="main" class="page">{page_head('Références', 'Bibliographie et références', f'{n} ouvrages, normes, lois, publications statistiques et sources en ligne citées dans les notes de cours{extra_title} (style auteur-année).')}<ul class="bib-list prose" style="max-width:none">{''.join(entries_html)}</ul></main>"""639    return layout(course, chapters, title="Bibliographie", body=body, active="ressources", canonical="/bibliographie/", has_report=has_report)640641642def listing_page(course, chapters, *, eyebrow, title, lead, sections_html, active, canonical, side_html, has_report=True):643    body = f"""<main id="main" class="page">{page_head(eyebrow, title, lead)}<div class="two-col"><article class="prose" style="max-width:none">{sections_html}</article><aside class="side">{side_html}</aside></div></main>"""644    return layout(course, chapters, title=title, body=body, active=active, canonical=canonical, has_report=has_report)645646647def not_found_page(course, chapters, has_report=True) -> str:648    body = f"""<main id="main" class="page" style="text-align:center;padding:80px 20px"><div class="eyebrow" style="justify-content:center">Erreur 404</div><h1 style="color:var(--bleu);font-family:var(--display)">Cette page n’existe pas</h1><p class="lead" style="margin:0 auto 24px">La valeur d’une page introuvable est nulle — mais la substitution est facile.</p><div class="hero-cta" style="justify-content:center"><a class="btn" href="/">Accueil</a><a class="btn btn-ghost" href="/recherche/">Rechercher</a></div></main>"""649    return layout(course, chapters, title="Page introuvable", body=body, canonical="/404.html", has_report=has_report)650651652# -----------------------------------------------------------------------------653# Rapport — marché de l'habitation654# -----------------------------------------------------------------------------655656def report_index_page(course, chapters, sections, kpis_html: str, files: list, cover_html: str) -> str:657    toc = []658    cur_app = None659    for s in sections:660        if s.appendix != cur_app:661            cur_app = s.appendix662            toc.append(f'<li class="part">{"Annexes" if s.appendix else "Chapitres"}</li>')663        toc.append(f'<li><a href="/rapport/{s.num}/"><span class="n">{s.num}</span><span>{s.title_html}</span></a></li>')664    files_html = "".join(f'<a class="card reveal" href="{f["url"]}"><h3>{icon("i-download")} {e(f["label"])}</h3><p style="margin:0;color:var(--ink-2)">{e(f["desc"])}</p></a>' for f in files)665    body = f"""666<main id="main" class="page">667  {page_head('Ressource transversale · juillet 2026', 'Le marché de l’habitation au Québec', 'Document de référence : analyse de l’indice des prix des propriétés MLS® (janvier 2005 – juin 2026) et du contexte macroéconomique canadien (FRED). Vingt ans de prix, six marchés québécois, types de propriétés, saisonnalité, risque, abordabilité — avec les données interactives.')}668  <div class="prose" style="max-width:none">{cover_html}</div>669  <h2 class="h-section" style="margin-top:1.5em"><span class="h-text">Métriques clés — {e(strip_tags(kpis_html.split('data-obs="')[1].split('"')[0]) if 'data-obs="' in kpis_html else 'dernière observation')}</span></h2>670  <div class="kpi-grid">{kpis_html}</div>671  <section class="widget" style="margin-top:2em"><div class="w-head"><span class="w-badge">Explorateur interactif</span><h4>Indices de prix MLS® : comparez les marchés</h4><p>Choisissez les marchés, la mesure et la période : indice composite, prix de référence, variation annuelle ou segments. Survolez la courbe pour lire les valeurs ; les indicateurs se recalculent sur la période affichée.</p></div><div id="hpi-explorer" class="hpi-wrap" data-src="/data/hpi.json"><p class="gl-count" style="padding:16px">Chargement des données…</p></div></section>672  <section class="widget"><div class="w-head"><span class="w-badge">Explorateur interactif</span><h4>Cadre macroéconomique canadien (FRED)</h4><p>Taux d’intérêt, inflation, chômage, PIB, population et taux de change : les séries qui expliquent le cycle immobilier 2020–2026.</p></div><div id="macro-explorer" class="hpi-wrap" data-src="/data/fred.json"><p class="gl-count" style="padding:16px">Chargement des données…</p></div></section>673  <h2 class="h-section"><span class="h-text">Table des matières</span></h2>674  <ul class="report-toc">{''.join(toc)}</ul>675  <h2 class="h-section"><span class="h-text">Télécharger le rapport et ses données</span></h2>676  <div class="grid-3">{files_html}</div>677</main>"""678    return layout(course, chapters, title="Rapport — Le marché de l’habitation au Québec", body=body, active="ressources", canonical="/rapport/", body_class="report-page",679                  desc="Rapport de référence sur le marché de l’habitation au Québec : indices de prix MLS® 2005–2026, contexte macroéconomique, six marchés québécois, abordabilité.")680681682def report_section_page(course, chapters, sections, s, idx: int, body_html: str) -> str:683    prev_s = sections[idx - 1] if idx > 0 else None684    next_s = sections[idx + 1] if idx + 1 < len(sections) else None685    side_items = "".join(f'<li class="lvl2"><a href="/rapport/{x.num}/" class="{"active" if x is s else ""}"><span class="toc-num">{x.num}</span> {x.title_html}</a></li>' for x in sections)686    inner = "".join(f'<li class="lvl{lvl}"><a href="#{sid}">{num} {t}</a></li>' for lvl, num, t, sid in s.toc if lvl <= 3)687    side = f'<h4>Dans cette section</h4><ol>{inner}</ol><h4>Rapport</h4><ol>{side_items}</ol><div class="toc-extra"><a href="/rapport/">← Sommaire et métriques</a></div>'688    label = ("Annexe " if s.appendix else "Chapitre ") + s.num689    pager = f"""<nav class="ch-pager" style="padding:0;margin-top:40px">{f'<a class="prev" href="/rapport/{prev_s.num}/"><small>← {("Annexe" if prev_s.appendix else "Chapitre")} {prev_s.num}</small><b>{prev_s.title_html}</b></a>' if prev_s else '<a class="prev" href="/rapport/"><small>←</small><b>Sommaire du rapport</b></a>'}{f'<a class="next" href="/rapport/{next_s.num}/"><small>{("Annexe" if next_s.appendix else "Chapitre")} {next_s.num} →</small><b>{next_s.title_html}</b></a>' if next_s else '<a class="next" href="/rapport/"><small>→</small><b>Retour au sommaire</b></a>'}</nav>"""690    body = f"""691<main id="main" class="page">692  {page_head(f'Le marché de l’habitation au Québec · {label}', s.title_html, '')}693  <div class="two-col"><article class="prose">{body_html}{pager}</article><aside class="side">{side}</aside></div>694</main>"""695    return layout(course, chapters, title=f"{label} — {s.title_text} · Rapport", body=body, active="ressources", canonical=f"/rapport/{s.num}/", body_class="report-page",696                  desc=f"{label} du rapport sur le marché de l’habitation au Québec : {s.title_text}.")697698699# -----------------------------------------------------------------------------700# Séances vidéo (enregistrements Zoom)701# -----------------------------------------------------------------------------702703def _hms(t: float) -> str:704    t = max(0, int(round(t)))705    h, m, s = t // 3600, (t % 3600) // 60, t % 60706    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"707708709def videos_index_page(course, chapters, videos, has_report=True) -> str:710    by_n = {v["n"]: v for v in videos}711    cards = []712    for n in range(1, 15):713        ch = chapters[n - 1]714        pidx, roman, ptitle, col = part_of(course, n)715        v = by_n.get(n)716        if v:717            m = v["meta"]718            cards.append(f"""<a class="vid-card reveal" href="{v['url']}" style="--pc:{col}" data-video-card="{v['nn']}">719  <div class="vid-thumb"><img src="/videos/{v['nn']}/poster.webp" alt="" loading="lazy" decoding="async"><span class="vid-dur">{icon('i-clock')} {e(v['duration_text'])}</span><span class="vid-play">{icon('i-play')}</span></div>720  <div class="vid-card-body">721    <div class="sc-num">Séance {n:02d} · {e(m.get('date_text', m.get('date', '')))}</div>722    <h3>{ch.title_html}</h3>723    <div class="sc-meta"><span>{icon('i-list')} {len(v['chapters'])} chapitres</span><span>{icon('i-cc')} sous-titres</span><span>{icon('i-text')} transcription</span></div>724    <div class="vid-prog" data-video-progress="{v['nn']}"><div class="bar"><i></i></div><span>Non commencée</span></div>725  </div>726</a>""")727        else:728            cards.append(f"""<div class="vid-card vid-card-off" style="--pc:{col}"><div class="vid-thumb vid-thumb-off"><span>{n:02d}</span></div><div class="vid-card-body"><div class="sc-num">Séance {n:02d}</div><h3>{ch.title_html}</h3><div class="sc-meta"><span>Enregistrement à venir</span></div></div></div>""")729    total = sum(v["duration"] for v in videos)730    lead = (f"{len(videos)} séance{'s' if len(videos) > 1 else ''} enregistrée{'s' if len(videos) > 1 else ''} sur Zoom ({_hms(total)} au total). Chaque enregistrement est découpé en chapitres, sous-titré et accompagné de sa transcription synchronisée : cliquez sur une phrase pour y aller dans la vidéo, cherchez un mot, reprenez où vous étiez. "731            "Les enregistrements sont ajoutés après chaque séance.")732    body = f"""<main id="main" class="page videos-index">{page_head('Séances vidéo', 'Les enregistrements des séances', lead)}733  <div class="vid-grid">{''.join(cards)}</div>734  <p class="gl-count" style="margin-top:26px">Enregistrements destinés aux étudiantes et étudiants inscrits au cours {e(course['code'])}. La transcription est automatique (modèle Whisper) et n’a pas été corrigée : en cas de doute, la parole du professeur prévaut ; les notes de cours restent la référence écrite.</p>735</main>"""736    return layout(course, chapters, title="Séances vidéo", body=body, active="videos", canonical="/videos/", has_report=has_report, desc=f"Enregistrements Zoom des séances du cours {course['code']} avec chapitres, transcription synchronisée et sous-titres.")737738739def video_page(course, chapters, v, videos, has_report=True) -> str:740    n, nn, m = v["n"], v["nn"], v["meta"]741    pidx, roman, ptitle, col = part_of(course, n)742    ch = chapters[n - 1]743    base = f"/videos/{nn}"744    chaps = v["chapters"]745    prev_v = next((x for x in reversed(videos) if x["n"] < n), None)746    next_v = next((x for x in videos if x["n"] > n), None)747    start_t = m.get("start_t")748    # chapitres (panneau)749    chap_items = []750    for c in chaps:751        kind = c.get("kind", "")752        link = f'<a class="vc-link" href="{c["link"]}" title="Voir dans les notes de cours">{icon("i-book")}</a>' if c.get("link") else ""753        chap_items.append(f'<li class="vc {kind}" data-t="{c["t"]}" data-end="{c["end"]}" id="{c["id"]}"><button type="button" class="vc-btn"><span class="vc-time">{_hms(c["t"])}</span><span class="vc-text"><b>{e(c["title"])}</b>{f"<small>{e(c.get('desc', ''))}</small>" if c.get("desc") else ""}</span></button>{link}</li>')754    # questions755    q_items = "".join(f'<li class="vq"><button type="button" class="vq-t" data-t="{q["t"]}">{icon("i-play")} {_hms(q["t"])}</button><div><b>{e(q["q"])}</b><p>{e(q["a"])}</p></div></li>' for q in m.get("questions", []))756    # clavardage757    chat_items = "".join(f'<li><span class="vchat-time">{e(c["time"])}</span><b>{e(c["who"])}</b><span>{e(c["text"])}</span></li>' for c in v["chat"]) or '<li class="muted">Aucun message dans le clavardage.</li>'758    # déroulé (tableau)759    rows = "".join(f'<tr><td class="al-r"><button type="button" class="btn btn-sm btn-ghost vt-seek" data-t="{c["t"]}">{icon("i-play")} {_hms(c["t"])}</button></td><td><b>{e(c["title"])}</b>{f"<div class=muted>{e(c.get('desc', ''))}</div>" if c.get("desc") else ""}</td><td class="al-r muted">{_hms(c["end"] - c["t"])}</td><td>{f"<a href={chr(34)}{c['link']}{chr(34)}>{'Notes' if c['link'].startswith('/seance') else 'Ouvrir'} ↗</a>" if c.get("link") else ""}</td></tr>' for c in chaps)760    # téléchargements761    def size(name):762        b = v["sizes"].get(name)763        if not b:764            return ""765        for unit in ("o", "Ko", "Mo", "Go"):766            if b < 1024:767                return f"{b:.0f} {unit}" if unit == "o" else f"{b:.1f} {unit}".replace(".", ",")768            b /= 1024769        return ""770    dl = [("MP4", f"{base}/video.mp4", f"Vidéo complète (MP4, {size('video.mp4')})", "L’enregistrement Zoom original : écran partagé et voix."),771          ("VTT", f"{base}/captions.vtt", "Sous-titres (WebVTT)", f"{v['n_cues']} sous-titres synchronisés."),772          ("SRT", f"{base}/transcript.srt", "Sous-titres (SRT)", "Pour VLC, QuickTime ou tout lecteur."),773          ("TXT", f"{base}/transcript.txt", "Transcription horodatée (texte)", f"{format(v['words'], ',').replace(',', ' ')} mots, chapitres et minutage."),774          ("VTT", f"{base}/chapters.vtt", "Chapitres (WebVTT)", f"{len(chaps)} chapitres.")]775    if v["has_audio"]:776        dl.insert(1, ("M4A", f"{base}/audio.m4a", f"Audio seulement (M4A, {size('audio.m4a')})", "Pour écouter la séance en déplacement."))777    if v["chat"]:778        dl.append(("TXT", f"{base}/chat.txt", "Clavardage Zoom", f"{len(v['chat'])} message{'s' if len(v['chat']) > 1 else ''}."))779    dl_rows = "".join(f'<tr><td><span class="file-icon file-{k.lower()}">{k}</span><a href="{u}" download>{e(l)}</a><div style="font-size:.85rem;color:var(--gris)">{e(d)}</div></td><td><a class="btn btn-sm btn-ghost" href="{u}" download>{icon("i-download")} Télécharger</a></td></tr>' for k, u, l, d in dl)780    related = "".join(f'<a class="chip" href="{r["url"]}"{" target=_blank rel=noopener" if r["url"].startswith("http") else ""}>{e(r["label"])}</a>' for r in m.get("related", []))781    summary = "".join(f"<p>{e(p)}</p>" for p in m.get("summary", []))782    keypoints = "".join(f"<li>{k}</li>" for k in m.get("keypoints", []))783    note = f'<p class="vid-note">{icon("i-warning")} {e(m["note"])}</p>' if m.get("note") else ""784    hero = f"""785<header class="ch-hero vid-hero">786  <svg class="deco" viewBox="0 0 1200 300" preserveAspectRatio="xMaxYMin slice" aria-hidden="true"><circle cx="1090" cy="20" r="110"/><circle cx="1160" cy="110" r="160"/><circle class="gold" cx="990" cy="170" r="45"/></svg>787  <div class="watermark" aria-hidden="true">{nn}</div>788  <div class="ch-hero-inner">789    <div class="eyebrow"><span class="part-chip">{icon('i-video')} Séance vidéo</span> Enregistrement {e(m.get('platform', 'Zoom'))} · {e(m.get('date_text', m.get('date', '')))} · {course['code']}</div>790    <h1>Séance {n} — {ch.title_html}</h1>791    <div class="ch-sub">{e(course['title'])} · {e(course['term'])} · Simon-Pierre Boucher · UQO</div>792    <div class="ch-meta">793      <span class="chip">{icon('i-clock')} {e(v['duration_text'])}</span>794      <span class="chip">{icon('i-list')} {len(chaps)} chapitres</span>795      <span class="chip">{icon('i-cc')} sous-titres français</span>796      <span class="chip">{icon('i-text')} {format(v['words'], ',').replace(',', ' ')} mots transcrits</span>797      <span class="chip">{icon('i-slides')} {len(v['storyboard'])} vues d’écran</span>798      <span class="chip chip-vert" data-video-done="{nn}" hidden>{icon('i-check')} Séance visionnée</span>799    </div>800    <div class="ch-tools">801      <a class="btn btn-ghost" href="/seance/{nn}/">{icon('i-book')} Notes de la séance {n}</a>802      <a class="btn btn-ghost" href="/files/slides/seance{nn}.pdf" target="_blank" rel="noopener">{icon('i-slides')} Diapositives (PDF)</a>803      <a class="btn btn-ghost" href="/seance/{nn}/#quiz">{icon('i-quiz')} Quiz de la séance</a>804      <a class="btn btn-ghost" href="#telechargements">{icon('i-download')} Télécharger</a>805    </div>806  </div>807</header>"""808    player = f"""809<div class="vid-layout" id="vid-layout">810  <section class="vid-main" aria-label="Lecteur vidéo">811    <div class="vid-player" id="vid-player" data-state="idle">812      <video id="vid" controls playsinline preload="metadata" poster="{base}/poster.webp" data-video-src="{base}/video.mp4" data-audio-src="{base}/audio.m4a"{f' data-start="{start_t}"' if start_t else ''}>813        <source src="{base}/video.mp4" type="video/mp4">814        <track kind="subtitles" srclang="fr" label="Français (transcription automatique)" src="{base}/captions.vtt">815        <track kind="chapters" srclang="fr" label="Chapitres" src="{base}/chapters.vtt">816        Votre navigateur ne lit pas la vidéo : <a href="{base}/video.mp4">téléchargez le MP4</a>.817      </video>818      <div class="vid-resume" id="vid-resume" hidden><button type="button" class="btn btn-or" data-vid="resume">{icon('i-play')} Reprendre à <span id="vid-resume-t"></span></button><button type="button" class="btn btn-ghost" data-vid="restart">{'Au début du cours' if start_t else 'Depuis le début'}</button></div>819    </div>820    <div class="vid-bar" role="toolbar" aria-label="Autres commandes">821      <span class="vid-chapnow" id="vid-chapnow"></span>822      {f'<button type="button" class="btn btn-sm btn-ghost" data-vid="audio" aria-pressed="false">{icon("i-audio")} Audio seulement</button>' if v['has_audio'] else ''}823      <button type="button" class="btn btn-sm btn-ghost" data-vid="link">{icon('i-link')} Copier le lien à cet instant</button>824      <a class="btn btn-sm btn-ghost" href="{base}/video.mp4" download>{icon('i-download')} MP4</a>825    </div>826    <div class="vid-sb-wrap"><div class="vid-sb-head"><h2>Écran au fil de la vidéo</h2><span class="muted">{len(v['storyboard'])} changements d’image détectés · cliquez pour y aller</span></div><div class="vid-sb" id="vid-sb" tabindex="0"></div></div>827  </section>828  <aside class="vid-side" aria-label="Chapitres et transcription">829    <div class="vid-tabs" role="tablist"><button type="button" role="tab" aria-selected="true" data-pane="chapters">{icon('i-list')} Chapitres</button><button type="button" role="tab" aria-selected="false" data-pane="transcript">{icon('i-text')} Transcription</button><button type="button" role="tab" aria-selected="false" data-pane="questions">{icon('i-quiz')} Questions</button><button type="button" role="tab" aria-selected="false" data-pane="chat">{icon('i-chat')} Clavardage</button></div>830    <div class="vid-pane" data-pane="chapters"><ol class="vid-chaps" id="vid-chaps">{''.join(chap_items)}</ol></div>831    <div class="vid-pane" data-pane="transcript" hidden>832      <div class="tr-tools"><input class="input" type="search" id="tr-search" placeholder="Chercher dans la transcription…" autocomplete="off"><label class="tr-follow"><input type="checkbox" id="tr-follow" checked> Suivre</label></div>833      <div class="tr-count" id="tr-count"></div>834      <div class="tr-list" id="tr-list"><p class="gl-count">Chargement de la transcription…</p></div>835    </div>836    <div class="vid-pane" data-pane="questions" hidden><ul class="vid-q">{q_items or '<li class="muted">Aucune question relevée.</li>'}</ul></div>837    <div class="vid-pane" data-pane="chat" hidden><ul class="vid-chat">{chat_items}</ul></div>838  </aside>839</div>"""840    pager = f"""841<nav class="ch-pager" aria-label="Navigation entre les séances vidéo">842  {f'<a class="prev" href="{prev_v["url"]}"><small>← Séance vidéo {prev_v["n"]}</small><b>{prev_v["title_html"]}</b></a>' if prev_v else '<a class="prev" href="/videos/"><small>←</small><b>Toutes les séances vidéo</b></a>'}843  {f'<a class="next" href="{next_v["url"]}"><small>Séance vidéo {next_v["n"]} →</small><b>{next_v["title_html"]}</b></a>' if next_v else f'<a class="next" href="/seance/{nn}/"><small>Poursuivre →</small><b>Notes de la séance {n}</b></a>'}844</nav>"""845    body = f"""846<main id="main" class="video-page" data-video="{nn}" data-src="{base}/transcript.json" data-title="Séance vidéo {n} — {e(v['title'])}"{f' data-start="{start_t}"' if start_t else ''}>847  {hero}848  {player}849  <div class="page">850    {note}851    <section class="section" id="resume">852      <div class="section-head"><div><div class="eyebrow">Pour reconstruire la séance</div><h2>Ce qui s’est dit</h2></div><div class="vid-related">{related}</div></div>853      <div class="vid-two"><div class="prose" style="max-width:none">{summary}</div><div class="box box-ret vid-ret"><div class="box-head"><span class="box-icon">{icon('i-star')}</span><span class="box-label">À retenir de la séance</span></div><div class="box-body"><ul>{keypoints}</ul></div></div></div>854    </section>855    <section class="section" id="deroule">856      <div class="section-head"><div><div class="eyebrow">Déroulé</div><h2>Les {len(chaps)} chapitres, minute par minute</h2><p>Chaque chapitre renvoie au passage de la vidéo et, lorsqu’il existe, à la section correspondante des notes de cours.</p></div></div>857      <div class="table-wrap"><table class="res-table vid-table"><thead><tr><th>Début</th><th>Chapitre</th><th class="al-r">Durée</th><th></th></tr></thead><tbody>{rows}</tbody></table></div>858    </section>859    <section class="section" id="telechargements">860      <div class="section-head"><div><div class="eyebrow">Téléchargements</div><h2>Emporter la séance</h2></div></div>861      <div class="table-wrap"><table class="res-table"><thead><tr><th>Fichier</th><th></th></tr></thead><tbody>{dl_rows}</tbody></table></div>862      <p class="gl-count">Transcription automatique (Whisper large-v3-turbo) non corrigée : les noms propres, les chiffres et certains termes techniques peuvent être erronés. Enregistrement destiné aux personnes inscrites au cours ; ne pas rediffuser.</p>863    </section>864    {pager}865  </div>866</main>"""867    desc = f"Enregistrement Zoom de la séance {n} du cours {course['code']} ({v['title']}) : {len(chaps)} chapitres, transcription synchronisée, sous-titres, résumé et points à retenir."868    return layout(course, chapters, title=f"Séance vidéo {n} — {v['title']}", body=body, active="videos", desc=desc, canonical=f"{base}/", has_report=has_report, body_attrs=f' data-part="{pidx}"',869                  extra_head=f'<meta property="og:video" content="https://{course["domain"]}{base}/video.mp4"><meta property="og:video:type" content="video/mp4">')870