Outiref

Code source de l'URL : https://hypermedia.be

<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Technologie HTMX, l'hypermedia et le web qui répond en HTML</title>
<meta name="description" content="hypermedia.be vous présente cette techno web sous forme de guide interactif : concepts, attributs, démos live et cas d'usage pour webmasters et développeurs francophones.">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='22' fill='%2312181c'/%3E%3Ctext x='50' y='67' font-size='46' text-anchor='middle' fill='%232a5fd9' font-family='monospace' font-weight='bold'%3E%3E_%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous"></script>
<style>

  :root{
    --ink:#12181C; --ink-soft:#4a555b; --paper:#EEF0EA; --paper-raised:#FFFFFF;
    --line:rgba(18,24,28,.14); --link:#2A5FD9; --visited:#7C4DFF; --focus:#F5A623;
    --ok:#1B8F63; --danger:#D93B31; --code-bg:#11161A; --code-fg:#E7EBEE;
    --code-comment:#7C8992; --code-attr:#7FD1D9; --code-string:#F5A623; --code-tag:#B79CFF;
    --radius:10px; --header-h:64px;
    --mono:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
    --display:'Space Grotesk', ui-sans-serif, system-ui, sans-serif;
    --serif:'Source Serif 4', Georgia, 'Times New Roman', serif;
  }
  [data-theme="dark"]{
    --ink:#ECEFEA; --ink-soft:#B7C1C6; --paper:#0F1417; --paper-raised:#171E22;
    --line:rgba(236,239,234,.14); --link:#7CA4FF; --visited:#C4AEFF;
  }
  *,*::before,*::after{ box-sizing:border-box; }
  html{ scroll-behavior:smooth; }
  @media (prefers-reduced-motion: reduce){
    html{ scroll-behavior:auto; }
    *,*::before,*::after{ animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
  }
  body{
    margin:0; background:var(--paper); color:var(--ink); font-family:var(--serif);
    line-height:1.65; font-size:1rem; -webkit-font-smoothing:antialiased;
    transition:background .25s ease, color .25s ease;
  }
  img,svg{ max-width:100%; display:block; }
  h1,h2,h3,h4{ font-family:var(--display); font-weight:600; line-height:1.15; margin:0 0 .6em; }
  h1{ font-size:clamp(2.1rem, 4.4vw + 1rem, 3.6rem); font-weight:700; }
  h2{ font-size:clamp(1.5rem, 2.4vw + 1rem, 2.2rem); }
  h3{ font-size:clamp(1.1rem, 1.2vw + .8rem, 1.35rem); }
  p{ margin:0 0 1em; color:var(--ink-soft); max-width:65ch; }
  a{ color:var(--link); text-decoration-thickness:2px; text-underline-offset:.18em; }
  a.hyperlink:visited{ color:var(--visited); }
  a:hover{ text-decoration-style:wavy; }
  :focus-visible{ outline:3px solid var(--focus); outline-offset:2px; border-radius:4px; }
  code, pre, kbd{ font-family:var(--mono); }
  code{ background:var(--line); padding:.15em .4em; border-radius:5px; font-size:.88em; }
  .wrap{ width:min(1180px, 92%); margin-inline:auto; }
  .visually-hidden{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
  .skip-link{
    position:absolute; left:1rem; top:-4rem; background:var(--ink); color:var(--paper);
    padding:.7em 1.1em; border-radius:8px; z-index:200; transition:top .2s ease;
  }
  .skip-link:focus{ top:1rem; }

  /* ====== HEADER ====== */
  .site-header{
    position:sticky; top:0; z-index:100; background:color-mix(in srgb, var(--paper) 88%, transparent);
    backdrop-filter:blur(10px); border-bottom:1px solid var(--line);
  }
  .header-inner{
    display:flex; align-items:center; justify-content:space-between; gap:1rem;
    min-height:var(--header-h); flex-wrap:wrap; padding-block:.4rem;
  }
  .logo{ font-family:var(--display); font-weight:700; font-size:1.15rem; color:var(--ink); text-decoration:none; display:flex; align-items:center; gap:.4rem; }
  .logo .tag{ color:var(--link); }
  .logo .status{ font-family:var(--mono); font-size:.6rem; font-weight:500; color:var(--ok); border:1px solid currentColor; border-radius:999px; padding:.15em .5em; margin-left:.35rem; }
  .header-controls{ display:flex; align-items:center; gap:.5rem; }
  .icon-btn{
    background:transparent; border:1.5px solid var(--line); color:var(--ink); border-radius:8px;
    width:2.4rem; height:2.4rem; display:grid; place-items:center; cursor:pointer; font-size:1rem;
  }
  .icon-btn:hover{ border-color:var(--link); }
  nav.site-nav{ width:100%; order:3; }
  nav.site-nav ul{ list-style:none; margin:0; padding:.75rem 0; display:none; flex-direction:column; gap:.15rem; }
  nav.site-nav.is-open ul{ display:flex; }
  nav.site-nav a{
    display:block; padding:.55em .3em; color:var(--ink); text-decoration:none; font-family:var(--display);
    font-size:.95rem; font-weight:500; border-bottom:1px solid var(--line);
  }
  nav.site-nav a:hover{ color:var(--link); }
  @media (min-width:800px){
    nav.site-nav{ width:auto; order:2; }
    nav.site-nav ul{ display:flex !important; flex-direction:row; padding:0; gap:1.3rem; }
    nav.site-nav a{ border-bottom:none; padding:.3em .1em; position:relative; }
    nav.site-nav a::after{ content:""; position:absolute; left:0; right:100%; bottom:-3px; height:2px; background:var(--link); transition:right .18s ease; }
    nav.site-nav a:hover::after{ right:0; }
    #nav-toggle{ display:none; }
  }

  /* ====== HERO ====== */
  .hero{ padding-block:clamp(2.5rem, 6vw, 5rem); }
  .hero-inner{ display:flex; flex-direction:column; gap:2.5rem; }
  @media (min-width:960px){ .hero-inner{ flex-direction:row; align-items:center; } .hero-text{ flex:1 1 52%; } .hero-demo{ flex:1 1 40%; } }
  .eyebrow{
    display:inline-flex; align-items:center; gap:.5em; font-family:var(--mono); font-size:.78rem;
    letter-spacing:.06em; text-transform:uppercase; color:var(--link); margin-bottom:1rem;
  }
  .eyebrow::before{ content:""; width:.5em; height:.5em; border-radius:50%; background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent); }
  h1 .bracket{ color:var(--link); }
  h1 .bracket.close{ color:var(--visited); }
  .hero-stats{ display:flex; flex-wrap:wrap; gap:1.4rem 2rem; margin-top:1.6rem; }
  .stat b{ display:block; font-family:var(--display); font-size:1.5rem; color:var(--ink); }
  .stat span{ font-size:.82rem; color:var(--ink-soft); }
  .hero-demo{
    background:var(--paper-raised); border:1px solid var(--line); border-radius:16px; padding:1.5rem;
    box-shadow:0 20px 45px -25px rgba(0,0,0,.35);
  }
  .hero-demo header{ display:flex; align-items:center; gap:.5rem; margin-bottom:1rem; }
  .dot{ width:.6rem; height:.6rem; border-radius:50%; }
  .dot.r{ background:#e05b52; } .dot.y{ background:#e5b93f; } .dot.g{ background:#3bb273; }
  .hero-demo-label{ font-family:var(--mono); font-size:.75rem; color:var(--ink-soft); margin-left:auto; }

  /* ====== BUTTONS ====== */
  .btn{
    font-family:var(--display); font-weight:600; font-size:.92rem; border-radius:9px;
    border:2px solid var(--ink); background:var(--ink); color:var(--paper); padding:.65em 1.2em;
    cursor:pointer; display:inline-flex; align-items:center; gap:.5em; transition:transform .12s ease, background .12s ease;
  }
  .btn:hover{ transform:translateY(-2px); }
  .btn:active{ transform:translateY(0); }
  .btn.ghost{ background:transparent; color:var(--ink); }
  .btn.danger{ border-color:var(--danger); background:var(--danger); color:#fff; }
  .btn.small{ padding:.45em .85em; font-size:.8rem; }
  .btn:disabled{ opacity:.6; cursor:default; transform:none; }

  /* ====== SECTIONS ====== */
  section{ padding-block:clamp(3rem, 6vw, 5.5rem); scroll-margin-top:var(--header-h); border-top:1px solid var(--line); }
  section#concepts{ border-top:none; }
  .section-head{ max-width:60ch; margin-bottom:2.5rem; }
  .section-num{ font-family:var(--mono); color:var(--link); font-size:.85rem; }

  .grid{ display:flex; flex-wrap:wrap; gap:1.4rem; }
  .grid > *{ flex:1 1 260px; }
  .card{
    background:var(--paper-raised); border:1px solid var(--line); border-radius:var(--radius);
    padding:1.3rem 1.4rem; display:flex; flex-direction:column; gap:.5rem;
  }
  .card h3{ margin-bottom:.2rem; }
  .card p{ margin-bottom:0; font-size:.94rem; }
  .pill{
    display:inline-block; font-family:var(--mono); font-size:.7rem; padding:.2em .6em; border-radius:999px;
    border:1px solid var(--line); color:var(--ink-soft); align-self:flex-start;
  }

  /* diagram */
  .diagram-figure{ margin:2rem 0 0; }
  .diagram-figure figcaption{ font-size:.85rem; color:var(--ink-soft); margin-top:.6rem; text-align:center; }

  /* table */
  .table-scroll{ overflow-x:auto; border:1px solid var(--line); border-radius:var(--radius); }
  table{ width:100%; border-collapse:collapse; min-width:620px; font-size:.92rem; }
  caption{ text-align:left; font-family:var(--display); font-weight:600; padding:.9rem 1rem 0; }
  th,td{ text-align:left; padding:.75rem 1rem; border-bottom:1px solid var(--line); vertical-align:top; }
  thead th{ font-family:var(--display); background:color-mix(in srgb, var(--link) 8%, transparent); }
  tbody tr:last-child td{ border-bottom:none; }
  td code, th code{ white-space:nowrap; }

  /* code blocks */
  pre{
    background:var(--code-bg); color:var(--code-fg); padding:1rem 1.1rem; border-radius:10px;
    overflow-x:auto; font-size:.82rem; line-height:1.6; margin:0;
  }
  pre code{ background:none; padding:0; color:inherit; font-size:1em; }
  .tk-tag{ color:var(--code-tag); } .tk-attr{ color:var(--code-attr); } .tk-str{ color:var(--code-string); } .tk-cmt{ color:var(--code-comment); font-style:italic; }
  details.code-toggle{ margin-top:.4rem; }
  details.code-toggle summary{
    cursor:pointer; font-family:var(--mono); font-size:.78rem; color:var(--link); list-style:none;
    display:flex; align-items:center; gap:.4em; padding:.3em 0;
  }
  details.code-toggle summary::-webkit-details-marker{ display:none; }
  details.code-toggle summary::before{ content:"▸"; transition:transform .15s ease; }
  details.code-toggle[open] summary::before{ transform:rotate(90deg); }
  details.code-toggle pre{ margin-top:.6rem; }

  /* demo cards */
  .demo-card{
    background:var(--paper-raised); border:1px solid var(--line); border-radius:var(--radius);
    padding:1.4rem; display:flex; flex-direction:column; gap:.9rem;
  }
  .demo-card h3{ display:flex; align-items:center; gap:.5em; margin-bottom:0; }
  .demo-card h3 .num{ font-family:var(--mono); color:var(--link); font-size:.85em; }
  .field{ display:flex; flex-direction:column; gap:.35rem; }
  .field label{ font-family:var(--display); font-size:.85rem; font-weight:600; }
  input[type=search], input[type=email], input[type=text]{
    font-family:var(--serif); font-size:1rem; padding:.6em .8em; border-radius:8px; border:1.5px solid var(--line);
    background:var(--paper); color:var(--ink);
  }
  input:focus-visible{ border-color:var(--link); }
  ul.result-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.4rem; }
  ul.result-list li{ padding:.55em .7em; background:var(--paper); border-radius:7px; font-size:.92rem; border:1px solid var(--line); }
  .htmx-indicator{ opacity:0; transition:opacity .2s ease; }
  .htmx-request.htmx-indicator, .htmx-request .htmx-indicator{ opacity:1; }
  .spinner{
    width:1.1rem; height:1.1rem; border-radius:50%; border:2.5px solid var(--line); border-top-color:var(--link);
    animation:spin .7s linear infinite; display:inline-block;
  }
  @keyframes spin{ to{ transform:rotate(360deg); } }
  .feedback{ font-size:.88rem; padding:.5em .7em; border-radius:7px; }
  .feedback.ok{ background:color-mix(in srgb, var(--ok) 15%, transparent); color:var(--ok); }
  .feedback.err{ background:color-mix(in srgb, var(--danger) 15%, transparent); color:var(--danger); }
  .progress-track{ width:100%; height:.6rem; background:var(--line); border-radius:999px; overflow:hidden; }
  .progress-fill{ height:100%; background:var(--link); transition:width .5s ease; }
  #toast-region{ position:fixed; bottom:1.2rem; right:1.2rem; z-index:150; display:flex; flex-direction:column; gap:.5rem; }
  .toast{
    background:var(--ink); color:var(--paper); padding:.7em 1.1em; border-radius:9px; font-size:.88rem;
    box-shadow:0 12px 30px -10px rgba(0,0,0,.4); animation:toast-in .25s ease;
  }
  @keyframes toast-in{ from{ opacity:0; transform:translateY(8px); } to{ opacity:1; transform:translateY(0); } }
  ul.task-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.5rem; }
  ul.task-list li{ display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.6em .8em; border:1px solid var(--line); border-radius:8px; font-size:.92rem; }

  /* overflow menu demo */
  .menu-wrap{ position:relative; display:inline-block; }
  .menu-panel{
    position:absolute; top:calc(100% + .4rem); left:0; background:var(--paper-raised); border:1px solid var(--line);
    border-radius:9px; padding:.4rem; min-width:170px; box-shadow:0 16px 34px -18px rgba(0,0,0,.4); z-index:20;
  }
  .menu-panel ul{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; }
  .menu-panel button{
    all:unset; padding:.55em .7em; border-radius:6px; cursor:pointer; font-size:.9rem; font-family:var(--serif); color:var(--ink);
  }
  .menu-panel button:hover, .menu-panel button:focus-visible{ background:var(--paper); }

  /* tabs */
  .tabs .tablist{ display:flex; flex-wrap:wrap; gap:.4rem; border-bottom:1px solid var(--line); margin-bottom:1rem; }
  .tabs [role=tab]{
    font-family:var(--display); font-size:.85rem; font-weight:600; background:transparent; border:none;
    padding:.6em 1em; cursor:pointer; color:var(--ink-soft); border-bottom:2px solid transparent; margin-bottom:-1px;
  }
  .tabs [role=tab][aria-selected="true"]{ color:var(--link); border-bottom-color:var(--link); }
  .tabs [role=tabpanel]{ display:none; }
  .tabs [role=tabpanel].is-active{ display:block; }

  /* attribute reference */
  dl.attr-ref{ display:flex; flex-direction:column; gap:1rem; margin:0; }
  dl.attr-ref div{ border-left:3px solid var(--link); padding-left:1rem; }
  dl.attr-ref dt{ font-family:var(--mono); font-weight:700; }
  dl.attr-ref dd{ margin:.25rem 0 0; color:var(--ink-soft); font-size:.92rem; }

  footer{
    background:var(--ink); color:var(--paper); padding-block:3rem;
  }
  footer a{ color:var(--paper); }
  footer .foot-grid{ display:flex; flex-wrap:wrap; gap:2.5rem; }
  footer .foot-grid > *{ flex:1 1 220px; }
  footer h4{ color:var(--paper); font-size:1rem; }
  footer ul{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.5rem; font-size:.9rem; }
  .foot-bottom{ margin-top:2.5rem; padding-top:1.5rem; border-top:1px solid rgba(255,255,255,.15); font-size:.8rem; color:#aab3b7; display:flex; flex-wrap:wrap; gap:1rem; justify-content:space-between; }

  /* two-col responsive helper */
  .two-col{ display:flex; flex-direction:column; gap:2rem; }
  @media (min-width:860px){ .two-col{ flex-direction:row; align-items:flex-start; } .two-col > *{ flex:1 1 0; } }
</style>
</head>
<body>
<a class="skip-link" href="#contenu">Aller au contenu principal</a>

<header class="site-header">
  <div class="wrap header-inner">
    <a class="logo" href="#top">
      <span class="tag">&lt;</span>hypermedia<span class="tag">.be</span><span class="tag">/&gt;</span>
      <span class="status" title="Bibliothèque htmx chargée sur cette page">htmx 2.0.10</span>
    </a>
    <div class="header-controls">
      <button class="icon-btn" id="theme-toggle" type="button" aria-pressed="false" aria-label="Activer le thème sombre">
        <span aria-hidden="true" id="theme-icon">☾</span>
      </button>
      <button class="icon-btn" id="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav" aria-label="Ouvrir le menu de navigation">
        <span aria-hidden="true">☰</span>
      </button>
    </div>
    <nav class="site-nav" id="site-nav" aria-label="Navigation principale">
      <ul>
        <li><a href="#concepts">Concepts</a></li>
        <li><a href="#htmx">HTMX</a></li>
        <li><a href="#scripting">Scripting</a></li>
        <li><a href="#apis">APIs</a></li>
        <li><a href="#mobile">Mobile</a></li>
        <li><a href="#ressources">Ressources</a></li>
      </ul>
    </nav>
  </div>
</header>

<main id="contenu">

  <!-- ============ HERO ============ -->
  <section id="top" class="hero" aria-labelledby="hero-h">
    <div class="wrap hero-inner">
      <div class="hero-text">
        <p class="eyebrow">Guide interactif — condensé de « Hypermedia Systems »</p>
        <h1 id="hero-h"><span class="bracket">&lt;html&gt;</span> pensé comme un hypermédia<span class="bracket close">&lt;/html&gt;</span></h1>
        <p style="font-size:1.08rem;max-width:56ch;">hypermedia.be rassemble, en un seul endroit et en français, l'essentiel du livre de référence sur htmx et l'architecture hypermédia : les concepts REST et HATEOAS, les attributs qui comptent vraiment, et des démonstrations qui tournent réellement dans cette page — sans build, sans framework JS côté client.</p>
        <div style="display:flex;gap:.9rem;flex-wrap:wrap;margin-top:1.4rem;">
          <a class="btn" href="#htmx">Voir les démos htmx</a>
          <a class="btn ghost" href="#concepts">Comprendre les concepts</a>
        </div>
        <div class="hero-stats">
          <div class="stat"><b>15</b><span>chapitres condensés</span></div>
          <div class="stat"><b>~14&nbsp;Ko</b><span>htmx minifié + gzippé</span></div>
          <div class="stat"><b>0</b><span>étape de build</span></div>
          <div class="stat"><b>3</b><span>approches de scripting comparées</span></div>
        </div>
      </div>
      <div class="hero-demo">
        <header>
          <span class="dot r" aria-hidden="true"></span><span class="dot y" aria-hidden="true"></span><span class="dot g" aria-hidden="true"></span>
          <span class="hero-demo-label">démo live — hx-post</span>
        </header>
        <p style="font-size:.9rem;">Ce bouton n'exécute aucun JavaScript personnalisé : l'attribut <code>hx-post</code> déclenche seul une requête, et htmx remplace le bouton par la réponse HTML reçue.</p>
        <button id="hero-counter" class="btn" hx-post="/demo/click" hx-swap="outerHTML" hx-target="this">
          Cliquez ici <span aria-hidden="true">→</span>
        </button>
        <details class="code-toggle">
          <summary>Voir le HTML source</summary>
          <pre><code><span class="tk-tag">&lt;button</span> <span class="tk-attr">hx-post</span>=<span class="tk-str">"/clicked"</span> <span class="tk-attr">hx-swap</span>=<span class="tk-str">"outerHTML"</span><span class="tk-tag">&gt;</span>
  Cliquez ici
<span class="tk-tag">&lt;/button&gt;</span></code></pre>
        </details>
      </div>
    </div>
  </section>

  <!-- ============ CONCEPTS ============ -->
  <section id="concepts" aria-labelledby="concepts-h">
    <div class="wrap">
      <div class="section-head">
        <p class="section-num">I. Concepts</p>
        <h2 id="concepts-h">Penser en hypermédia avant de penser en JavaScript</h2>
        <p>Un système hypermédia laisse le serveur décrire, à chaque réponse, les actions possibles sous forme de liens et de formulaires. Le client n'a besoin de connaître qu'une poignée de règles : suivre un lien, soumettre un formulaire, afficher ce qu'on lui donne. htmx ne fait qu'étendre ce principe à des éléments qui, historiquement, ne pouvaient pas déclencher de requêtes.</p>
      </div>

      <div class="grid" style="margin-bottom:2.5rem;">
        <div class="card">
          <span class="pill">HTML</span>
          <h3>La première vraie hypermédia</h3>
          <p>Les balises <code>&lt;a&gt;</code> et <code>&lt;form&gt;</code> encodent déjà tout ce qu'il faut : une action, une méthode, une destination. htmx ne fait qu'ouvrir cette capacité au clic sur un <code>&lt;div&gt;</code>, à la frappe dans un champ, ou à un défilement.</p>
        </div>
        <div class="card">
          <span class="pill">REST</span>
          <h3>Quatre contraintes, pas un format JSON</h3>
          <p>Client-serveur, absence d'état côté serveur entre requêtes, mise en cache, et une interface uniforme où les réponses contiennent elles-mêmes les liens vers les actions suivantes (HATEOAS).</p>
        </div>
        <div class="card">
          <span class="pill">Architecture</span>
          <h3>Un SPA sans le poids d'un SPA</h3>
          <p>Le rendu reste sur le serveur ; le client ne gère qu'un échange local de fragments. On garde la réactivité perçue d'une application monopage sans dupliquer la logique de vue côté client.</p>
        </div>
      </div>

      <figure class="diagram-figure">
        <svg viewbox="0 0 900 220" role="img" aria-labelledby="diagram-title diagram-desc" style="max-width:900px;margin-inline:auto;">
          <title id="diagram-title">Cycle d'une requête hypermédia</title>
          <desc id="diagram-desc">Le navigateur envoie une requête déclenchée par un attribut htmx ; le serveur répond avec un fragment HTML ; htmx échange ce fragment dans la page sans rechargement complet.</desc>
          <defs>
            <marker id="arrow" viewbox="0 0 10 10" refx="8" refy="5" markerwidth="6" markerheight="6" orient="auto-start-reverse">
              <path d="M0,0 L10,5 L0,10 z" fill="var(--link)"></path>
            </marker>
          </defs>
          <g font-family="JetBrains Mono, monospace" font-size="13">
            <rect x="30" y="60" width="190" height="100" rx="12" fill="none" stroke="var(--ink)" stroke-width="2"></rect>
            <text x="125" y="105" text-anchor="middle" fill="var(--ink)" font-weight="700">Navigateur</text>
            <text x="125" y="128" text-anchor="middle" fill="var(--ink-soft)" font-size="11">hx-get / hx-post…</text>

            <line x1="230" y1="90" x2="450" y2="90" stroke="var(--link)" stroke-width="2" marker-end="url(#arrow)"></line>
            <text x="340" y="80" text-anchor="middle" fill="var(--link)" font-size="11">requête HTTP</text>

            <rect x="450" y="60" width="200" height="100" rx="12" fill="none" stroke="var(--ink)" stroke-width="2"></rect>
            <text x="550" y="105" text-anchor="middle" fill="var(--ink)" font-weight="700">Serveur</text>
            <text x="550" y="128" text-anchor="middle" fill="var(--ink-soft)" font-size="11">rend un fragment HTML</text>

            <line x1="450" y1="130" x2="230" y2="130" stroke="var(--visited)" stroke-width="2" marker-end="url(#arrow)"></line>
            <text x="340" y="150" text-anchor="middle" fill="var(--visited)" font-size="11">fragment HTML</text>

            <rect x="700" y="60" width="170" height="100" rx="12" fill="none" stroke="var(--ok)" stroke-width="2" stroke-dasharray="5 4"></rect>
            <text x="785" y="105" text-anchor="middle" fill="var(--ok)" font-weight="700" font-size="12">hx-swap</text>
            <text x="785" y="128" text-anchor="middle" fill="var(--ink-soft)" font-size="11">échange le DOM</text>
            <line x1="650" y1="100" x2="700" y2="100" stroke="var(--ok)" stroke-width="2" marker-end="url(#arrow)"></line>
          </g>
        </svg>
        <figcaption>Le serveur reste seul responsable du rendu : le client n'exécute qu'un échange de fragment.</figcaption>
      </figure>

      <div class="two-col" style="margin-top:2.5rem;">
        <div class="card">
          <h3>Quand privilégier l'hypermédia</h3>
          <p style="margin-bottom:.6rem;">Sites de contenu, back-offices, tableaux de bord internes, formulaires métier, applications où le serveur détient déjà la vérité sur les données.</p>
        </div>
        <div class="card">
          <h3>Quand s'en écarter</h3>
          <p style="margin-bottom:.6rem;">Éditeurs collaboratifs temps réel, jeux, applications hors-ligne complexes, ou tout état client si volumineux qu'il ne peut raisonnablement transiter à chaque requête.</p>
        </div>
      </div>
    </div>
  </section>

  <!-- ============ HTMX ============ -->
  <section id="htmx" aria-labelledby="htmx-h">
    <div class="wrap">
      <div class="section-head">
        <p class="section-num">II. HTMX en pratique</p>
        <h2 id="htmx-h">Les attributs, puis les démonstrations</h2>
        <p>htmx ajoute une douzaine d'attributs qui suffisent à couvrir l'essentiel des interactions d'une application : déclenchement, ciblage, échange, historique. Chaque démo ci-dessous exécute réellement ces attributs dans votre navigateur.</p>
      </div>

      <div class="table-scroll" style="margin-bottom:3rem;">
        <table>
          <caption>Référence rapide des attributs les plus utilisés</caption>
          <thead>
            <tr><th scope="col">Attribut</th><th scope="col">Rôle</th></tr>
          </thead>
          <tbody>
            <tr><td><code>hx-get / hx-post / hx-put / hx-patch / hx-delete</code></td><td>Associe une méthode HTTP et une URL à n'importe quel élément, pas seulement <code>&lt;a&gt;</code> ou <code>&lt;form&gt;</code>.</td></tr>
            <tr><td><code>hx-trigger</code></td><td>Choisit l'événement déclencheur : <code>click</code>, <code>keyup changed delay:300ms</code>, <code>every 2s</code>, <code>revealed</code>…</td></tr>
            <tr><td><code>hx-target</code></td><td>Désigne l'élément qui recevra la réponse, via un sélecteur CSS ou des mots-clés comme <code>this</code> ou <code>closest tr</code>.</td></tr>
            <tr><td><code>hx-swap</code></td><td>Définit comment la réponse est insérée : <code>innerHTML</code>, <code>outerHTML</code>, <code>beforeend</code>, <code>delete</code>…</td></tr>
            <tr><td><code>hx-swap-oob</code></td><td>Permet à la réponse de mettre à jour un second endroit de la page, hors de la cible principale.</td></tr>
            <tr><td><code>hx-boost</code></td><td>Transforme discrètement les liens et formulaires existants en requêtes AJAX, sans changer le balisage.</td></tr>
            <tr><td><code>hx-indicator</code></td><td>Affiche un élément (spinner, barre) pendant la durée de la requête.</td></tr>
            <tr><td><code>hx-confirm</code></td><td>Demande une confirmation native avant d'envoyer la requête.</td></tr>
            <tr><td><code>hx-push-url</code></td><td>Met à jour l'URL et l'historique du navigateur après l'échange.</td></tr>
          </tbody>
        </table>
      </div>

      <h3 style="margin-bottom:1.5rem;">Démonstrations live</h3>
      <div class="grid">

        <!-- Demo: recherche active -->
        <article class="demo-card" style="flex-basis:100%;">
          <h3><span class="num">01</span> Recherche active (hx-trigger avec debounce)</h3>
          <p style="margin-bottom:0;">Tapez dans le champ : chaque frappe attend 300&nbsp;ms d'inactivité avant d'interroger le serveur, évitant une requête par lettre.</p>
          <div class="field">
            <label for="search-input">Rechercher un article (essayez « swap », « rest », « boost »)</label>
            <div style="display:flex;align-items:center;gap:.6rem;">
              <input type="search" id="search-input" name="q" placeholder="Tapez pour chercher…"
                hx-get="/demo/search" hx-trigger="keyup changed delay:300ms, search" hx-target="#search-results" hx-indicator="#search-spinner" autocomplete="off" style="flex:1;">
              <span id="search-spinner" class="htmx-indicator spinner" role="status" aria-label="Recherche en cours"></span>
            </div>
          </div>
          <ul class="result-list" id="search-results" aria-live="polite">
            <li>Tapez au moins une lettre pour lancer la recherche.</li>
          </ul>
          <details class="code-toggle">
            <summary>Voir le HTML source</summary>
            <pre><code><span class="tk-tag">&lt;input</span> <span class="tk-attr">type</span>=<span class="tk-str">"search"</span>
  <span class="tk-attr">hx-get</span>=<span class="tk-str">"/search"</span>
  <span class="tk-attr">hx-trigger</span>=<span class="tk-str">"keyup changed delay:300ms, search"</span>
  <span class="tk-attr">hx-target</span>=<span class="tk-str">"#search-results"</span>
  <span class="tk-attr">hx-indicator</span>=<span class="tk-str">"#search-spinner"</span><span class="tk-tag">&gt;</span></code></pre>
          </details>
        </article>

        <!-- Demo: validation email -->
        <article class="demo-card">
          <h3><span class="num">02</span> Validation d'email inline</h3>
          <p style="margin-bottom:0;">La validation se fait côté serveur, à chaque changement : c'est la même logique qui validera à la soumission finale.</p>
          <div class="field">
            <label for="email-input">Adresse email</label>
            <input type="email" id="email-input" name="email" placeholder="vous@exemple.be"
              hx-post="/demo/validate-email" hx-trigger="keyup changed delay:400ms, blur" hx-target="#email-feedback" hx-swap="innerHTML">
          </div>
          <div id="email-feedback" aria-live="polite"></div>
          <details class="code-toggle">
            <summary>Voir le HTML source</summary>
            <pre><code><span class="tk-tag">&lt;input</span> <span class="tk-attr">type</span>=<span class="tk-str">"email"</span>
  <span class="tk-attr">hx-post</span>=<span class="tk-str">"/validate-email"</span>
  <span class="tk-attr">hx-trigger</span>=<span class="tk-str">"keyup changed delay:400ms, blur"</span>
  <span class="tk-attr">hx-target</span>=<span class="tk-str">"#email-feedback"</span><span class="tk-tag">&gt;</span></code></pre>
          </details>
        </article>

        <!-- Demo: charger plus -->
        <article class="demo-card">
          <h3><span class="num">03</span> Pagination « charger plus »</h3>
          <p style="margin-bottom:0;">Le bouton se remplace lui-même par la réponse suivante, jusqu'à ce que le serveur cesse de renvoyer de bouton.</p>
          <ul class="result-list" id="items-list">
            <li>Article — Introduction à hx-boost</li>
            <li>Article — REST et HATEOAS en pratique</li>
          </ul>
          <button class="btn small" hx-get="/demo/more?page=2" hx-target="this" hx-swap="outerHTML">Charger plus ↓</button>
          <details class="code-toggle">
            <summary>Voir le HTML source</summary>
            <pre><code><span class="tk-tag">&lt;button</span>
  <span class="tk-attr">hx-get</span>=<span class="tk-str">"/contacts?page=2"</span>
  <span class="tk-attr">hx-target</span>=<span class="tk-str">"this"</span>
  <span class="tk-attr">hx-swap</span>=<span class="tk-str">"outerHTML"</span><span class="tk-tag">&gt;</span>
  Charger plus
<span class="tk-tag">&lt;/button&gt;</span></code></pre>
          </details>
        </article>

        <!-- Demo: polling -->
        <article class="demo-card">
          <h3><span class="num">04</span> Sondage de progression (polling)</h3>
          <p style="margin-bottom:0;">Après lancement, l'élément se ré-interroge lui-même toutes les 800&nbsp;ms grâce à <code>hx-trigger="every 800ms"</code>, jusqu'à ce que le serveur retire ce déclencheur de la réponse.</p>
          <button class="btn small" hx-post="/demo/start-job" hx-target="#job-status" hx-swap="innerHTML">Lancer le traitement</button>
          <div id="job-status" aria-live="polite"></div>
          <details class="code-toggle">
            <summary>Voir le HTML source</summary>
            <pre><code><span class="tk-cmt">&lt;!-- fragment renvoyé tant que le job tourne --&gt;</span>
<span class="tk-tag">&lt;div</span>
  <span class="tk-attr">hx-get</span>=<span class="tk-str">"/job-status"</span>
  <span class="tk-attr">hx-trigger</span>=<span class="tk-str">"load, every 800ms"</span>
  <span class="tk-attr">hx-swap</span>=<span class="tk-str">"outerHTML"</span><span class="tk-tag">&gt;</span>…<span class="tk-tag">&lt;/div&gt;</span></code></pre>
          </details>
        </article>

        <!-- Demo: suppression + oob -->
        <article class="demo-card" style="flex-basis:100%;">
          <h3><span class="num">05</span> Suppression inline &amp; mise à jour hors-cible (<code>hx-swap-oob</code>)</h3>
          <p style="margin-bottom:0;">Supprimer une tâche remplace la ligne par une réponse vide <em>et</em> fait apparaître une notification ailleurs sur la page — dans la même réponse HTTP.</p>
          <ul class="task-list" id="task-list">
            <li id="task-1">Rédiger la documentation htmx <button class="btn small danger" hx-delete="/demo/task/1" hx-target="#task-1" hx-swap="outerHTML" hx-confirm="Supprimer « Rédiger la documentation htmx » ?">Supprimer</button></li>
            <li id="task-2">Migrer le back-office vers hx-boost <button class="btn small danger" hx-delete="/demo/task/2" hx-target="#task-2" hx-swap="outerHTML" hx-confirm="Supprimer « Migrer le back-office vers hx-boost » ?">Supprimer</button></li>
            <li id="task-3">Ajouter un indicateur de chargement <button class="btn small danger" hx-delete="/demo/task/3" hx-target="#task-3" hx-swap="outerHTML" hx-confirm="Supprimer « Ajouter un indicateur de chargement » ?">Supprimer</button></li>
          </ul>
          <details class="code-toggle">
            <summary>Voir le HTML source (réponse du serveur)</summary>
            <pre><code><span class="tk-cmt">&lt;!-- corps de la réponse à hx-delete : vide pour la cible, --&gt;</span>
<span class="tk-cmt">&lt;!-- + un swap "out of band" pour la notification --&gt;</span>
<span class="tk-tag">&lt;div</span> <span class="tk-attr">id</span>=<span class="tk-str">"toast-region"</span> <span class="tk-attr">hx-swap-oob</span>=<span class="tk-str">"true"</span><span class="tk-tag">&gt;</span>
  <span class="tk-tag">&lt;div</span> <span class="tk-attr">class</span>=<span class="tk-str">"toast"</span><span class="tk-tag">&gt;</span>Tâche supprimée ✓<span class="tk-tag">&lt;/div&gt;</span>
<span class="tk-tag">&lt;/div&gt;</span></code></pre>
          </details>
        </article>
      </div>

      <div class="two-col" style="margin-top:3rem;">
        <div class="card">
          <h3>hx-boost : l'amélioration progressive</h3>
          <p>Posé sur un conteneur, <code>hx-boost="true"</code> intercepte tous les liens et formulaires enfants pour les transformer en requêtes AJAX, tout en conservant l'historique et le fonctionnement sans JavaScript comme filet de sécurité.</p>
          <pre><code><span class="tk-tag">&lt;body</span> <span class="tk-attr">hx-boost</span>=<span class="tk-str">"true"</span><span class="tk-tag">&gt;</span>
  <span class="tk-cmt">&lt;!-- tous les &lt;a&gt; et &lt;form&gt; deviennent AJAX --&gt;</span>
<span class="tk-tag">&lt;/body&gt;</span></code></pre>
        </div>
        <div class="card">
          <h3>Codes HTTP &amp; événements</h3>
          <p>htmx traite nativement les codes 2xx comme un succès et déclenche des événements JavaScript (<code>htmx:beforeRequest</code>, <code>htmx:afterSwap</code>, <code>htmx:responseError</code>…) sur lesquels du vanilla JS peut se brancher pour journaliser, animer ou déboguer.</p>
          <pre><code>document.body.addEventListener(<span class="tk-str">'htmx:afterSwap'</span>, (e) => {
  console.log(<span class="tk-str">'Échange terminé sur'</span>, e.detail.target);
});</code></pre>
        </div>
      </div>
    </div>
  </section>

  <!-- ============ SCRIPTING ============ -->
  <section id="scripting" aria-labelledby="scripting-h">
    <div class="wrap">
      <div class="section-head">
        <p class="section-num">III. Scripting pragmatique</p>
        <h2 id="scripting-h">htmx couvre le réseau, pas l'interaction locale</h2>
        <p>Un menu qui s'ouvre, une valeur qui se recalcule à l'écran : ce sont des comportements purement locaux, sans requête HTTP. htmx les laisse volontairement de côté. Trois approches courantes coexistent — le vanilla JS reste la référence utilisée sur cette page.</p>
      </div>

      <div class="demo-card" style="margin-bottom:2.5rem;">
        <h3><span class="num">Démo</span> Menu « overflow » en vanilla JS pur</h3>
        <p style="margin-bottom:0;">Un bouton révèle un menu, se ferme au clic extérieur ou avec <kbd>Échap</kbd>, et gère <code>aria-expanded</code> — le tout en une douzaine de lignes de JavaScript, sans dépendance.</p>
        <div class="menu-wrap">
          <button class="btn ghost small" id="menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="menu-panel">Actions ⋯</button>
          <div class="menu-panel" id="menu-panel" hidden>
            <ul>
              <li><button type="button" id="menu-action-1">Dupliquer</button></li>
              <li><button type="button" id="menu-action-2">Archiver</button></li>
              <li><button type="button" id="menu-action-3">Exporter en HTML</button></li>
            </ul>
          </div>
        </div>
        <p id="menu-feedback" style="font-size:.85rem;color:var(--ink-soft);margin:0;" aria-live="polite"></p>
      </div>

      <div class="tabs">
        <div class="tablist" role="tablist" aria-label="Comparaison des approches de scripting">
          <button role="tab" id="tab-vanilla" aria-controls="panel-vanilla" aria-selected="true">Vanilla JS</button>
          <button role="tab" id="tab-alpine" aria-controls="panel-alpine" aria-selected="false" tabindex="-1">Alpine.js</button>
          <button role="tab" id="tab-hyperscript" aria-controls="panel-hyperscript" aria-selected="false" tabindex="-1">_hyperscript</button>
        </div>
        <div role="tabpanel" id="panel-vanilla" aria-labelledby="tab-vanilla" class="is-active">
          <p>La cible de référence : disponible partout, sans dépendance, respecte la « localité du comportement » quand le script reste proche du balisage qu'il pilote.</p>
          <pre><code><span class="tk-tag">&lt;button</span> <span class="tk-attr">id</span>=<span class="tk-str">"menu-btn"</span><span class="tk-tag">&gt;</span>Actions ⋯<span class="tk-tag">&lt;/button&gt;</span>

<span class="tk-cmt">// script séparé, proche du composant</span>
menuBtn.addEventListener(<span class="tk-str">'click'</span>, () => {
  panel.hidden = !panel.hidden;
  menuBtn.setAttribute(<span class="tk-str">'aria-expanded'</span>, !panel.hidden);
});</code></pre>
        </div>
        <div role="tabpanel" id="panel-alpine" aria-labelledby="tab-alpine" hidden>
          <p>Alpine.js ramène l'état directement dans le balisage via <code>x-data</code>, au prix d'une petite bibliothèque supplémentaire chargée sur la page.</p>
          <pre><code><span class="tk-tag">&lt;div</span> <span class="tk-attr">x-data</span>=<span class="tk-str">"{ open: false }"</span><span class="tk-tag">&gt;</span>
  <span class="tk-tag">&lt;button</span> <span class="tk-attr">x-on:click</span>=<span class="tk-str">"open = ! open"</span><span class="tk-tag">&gt;</span>Actions ⋯<span class="tk-tag">&lt;/button&gt;</span>
  <span class="tk-tag">&lt;div</span> <span class="tk-attr">x-show</span>=<span class="tk-str">"open"</span><span class="tk-tag">&gt;</span>…<span class="tk-tag">&lt;/div&gt;</span>
<span class="tk-tag">&lt;/div&gt;</span></code></pre>
        </div>
        <div role="tabpanel" id="panel-hyperscript" aria-labelledby="tab-hyperscript" hidden>
          <p>_hyperscript vise une syntaxe proche du langage naturel, directement dans un attribut, pour des interactions très locales.</p>
          <pre><code><span class="tk-tag">&lt;button</span> <span class="tk-attr">_</span>=<span class="tk-str">"on click toggle .hidden on #menu-panel"</span><span class="tk-tag">&gt;</span>
  Actions ⋯
<span class="tk-tag">&lt;/button&gt;</span></code></pre>
        </div>
      </div>
    </div>
  </section>

  <!-- ============ APIS ============ -->
  <section id="apis" aria-labelledby="apis-h">
    <div class="wrap">
      <div class="section-head">
        <p class="section-num">IV. API hypermédia vs API JSON</p>
        <h2 id="apis-h">Deux API, deux publics, un seul serveur</h2>
        <p>Rien n'empêche de servir en parallèle une API hypermédia pour l'interface web et une API JSON pour les intégrations tierces : elles répondent à des besoins différents et peuvent partager la même couche métier.</p>
      </div>
      <div class="table-scroll">
        <table>
          <caption class="visually-hidden">Comparaison API hypermédia et API JSON</caption>
          <thead>
            <tr><th scope="col">Critère</th><th scope="col">API hypermédia (HTML)</th><th scope="col">API JSON classique</th></tr>
          </thead>
          <tbody>
            <tr><td>Format de réponse</td><td>Fragment HTML directement affichable</td><td>Données structurées, à interpréter côté client</td></tr>
            <tr><td>Couplage client / serveur</td><td>Fort et assumé : le serveur pilote la vue</td><td>Faible : le client construit sa propre vue</td></tr>
            <tr><td>Découvrabilité (HATEOAS)</td><td>Les actions suivantes sont dans la réponse</td><td>Généralement documentée à part (OpenAPI…)</td></tr>
            <tr><td>Consommateur typique</td><td>L'interface web de l'application elle-même</td><td>Applications mobiles, intégrations tierces, partenaires</td></tr>
            <tr><td>Évolution des champs</td><td>Peu de rupture : le HTML encapsule le détail</td><td>Toute évolution de forme peut casser des clients</td></tr>
          </tbody>
        </table>
      </div>
    </div>
  </section>

  <!-- ============ MOBILE ============ -->
  <section id="mobile" aria-labelledby="mobile-h">
    <div class="wrap">
      <div class="section-head">
        <p class="section-num">V. L'hypermédia sur mobile</p>
        <h2 id="mobile-h">Hyperview : le même principe, en HXML natif</h2>
        <p>Hyperview transpose l'idée d'htmx à une application mobile native : un format XML (HXML) décrit l'interface, un client React Native l'interprète, et le serveur reste seul maître de la logique et de l'état.</p>
      </div>
      <div class="grid">
        <div class="card">
          <h3>Le format</h3>
          <p>Des éléments comme <code>&lt;list&gt;</code>, <code>&lt;image&gt;</code> ou <code>&lt;text&gt;</code> décrivent l'écran ; le style est séparé, proche d'une feuille CSS restreinte.</p>
        </div>
        <div class="card">
          <h3>Les comportements</h3>
          <p>Des déclencheurs comme <em>long-press</em>, <em>load</em>, <em>visible</em> ou <em>refresh</em> associent une action réseau à un geste, exactement comme <code>hx-trigger</code> côté web.</p>
        </div>
        <div class="card">
          <h3>Le choix d'architecture</h3>
          <p>Entre une WebView, une application 100% native en JavaScript, et Hyperview : ce dernier vise le point d'équilibre pour des équipes déjà outillées côté serveur hypermédia.</p>
        </div>
      </div>
    </div>
  </section>

  <!-- ============ RESSOURCES ============ -->
  <section id="ressources" aria-labelledby="ressources-h">
    <div class="wrap">
      <div class="section-head">
        <p class="section-num">Pour aller plus loin</p>
        <h2 id="ressources-h">Les sources originales</h2>
        <p>Cette page condense et illustre ; les ressources ci-dessous font autorité.</p>
      </div>
      <ul class="grid" style="list-style:none;padding:0;margin:0;">
        <li class="card"><h3><a class="hyperlink" href="https://htmx.org" target="_blank" rel="noopener">htmx.org</a></h3><p>Documentation officielle, référence complète des attributs et des extensions.</p></li>
        <li class="card"><h3><a class="hyperlink" href="https://hypermedia.systems/book/contents/" target="_blank" rel="noopener">Hypermedia Systems</a></h3><p>Le livre condensé sur cette page, en accès libre.</p></li>
        <li class="card"><h3><a class="hyperlink" href="https://github.com/bigskysoftware/htmx" target="_blank" rel="noopener">Dépôt GitHub</a></h3><p>Code source, changelog et suivi des versions d'htmx.</p></li>
        <li class="card"><h3><a class="hyperlink" href="https://hyperview.org" target="_blank" rel="noopener">Hyperview.org</a></h3><p>L'hypermédia appliquée aux applications mobiles natives.</p></li>
      </ul>
    </div>
  </section>
</main>

<footer>
  <div class="wrap">
    <div class="foot-grid">
      <div>
        <a class="logo" href="#top" style="color:var(--paper);">&lt;hypermedia.be/&gt;</a>
        <p style="color:#c7ced1;font-size:.9rem;margin-top:.8rem;">Un condensé indépendant, en français, des idées portées par htmx et « Hypermedia Systems ».</p>
      </div>
      <div>
        <h4>Sections</h4>
        <ul>
          <li><a href="#concepts">Concepts</a></li>
          <li><a href="#htmx">HTMX</a></li>
          <li><a href="#scripting">Scripting</a></li>
          <li><a href="#apis">APIs</a></li>
          <li><a href="#mobile">Mobile</a></li>
        </ul>
      </div>
      <div>
        <h4>À propos des démos</h4>
        <p style="color:#c7ced1;font-size:.85rem;"></p>
      </div>
    </div>
    <div class="foot-bottom">
      <span>© 2014 - 2026 hypermedia.be, une page pédagogique indépendante, sans aucune affiliation.</span>
      <span></span>
    </div>
  </div>
</footer>

<div id="toast-region" aria-live="polite"></div>

<script>
(function(){
  "use strict";

  /* ---------- Thème clair / sombre (en mémoire, sans stockage navigateur) ---------- */
  var themeBtn = document.getElementById('theme-toggle');
  var themeIcon = document.getElementById('theme-icon');
  var root = document.documentElement;
  themeBtn.addEventListener('click', function(){
    var dark = root.getAttribute('data-theme') === 'dark';
    root.setAttribute('data-theme', dark ? 'light' : 'dark');
    themeBtn.setAttribute('aria-pressed', String(!dark));
    themeBtn.setAttribute('aria-label', dark ? 'Activer le thème sombre' : 'Activer le thème clair');
    themeIcon.textContent = dark ? '☾' : '☀';
  });

  /* ---------- Menu de navigation mobile ---------- */
  var navToggle = document.getElementById('nav-toggle');
  var nav = document.getElementById('site-nav');
  navToggle.addEventListener('click', function(){
    var open = nav.classList.toggle('is-open');
    navToggle.setAttribute('aria-expanded', String(open));
    navToggle.setAttribute('aria-label', open ? 'Fermer le menu de navigation' : 'Ouvrir le menu de navigation');
  });
  nav.addEventListener('click', function(e){
    if (e.target.tagName === 'A' && nav.classList.contains('is-open')) {
      nav.classList.remove('is-open');
      navToggle.setAttribute('aria-expanded', 'false');
    }
  });

  /* ---------- Menu "overflow" — exemple vanilla JS du livre ---------- */
  var menuBtn = document.getElementById('menu-btn');
  var menuPanel = document.getElementById('menu-panel');
  var menuFeedback = document.getElementById('menu-feedback');

  function closeMenu(){
    menuPanel.hidden = true;
    menuBtn.setAttribute('aria-expanded', 'false');
  }
  function openMenu(){
    menuPanel.hidden = false;
    menuBtn.setAttribute('aria-expanded', 'true');
  }
  menuBtn.addEventListener('click', function(e){
    e.stopPropagation();
    if (menuPanel.hidden) openMenu(); else closeMenu();
  });
  document.addEventListener('click', function(e){
    if (!menuPanel.hidden && !menuPanel.contains(e.target) && e.target !== menuBtn) closeMenu();
  });
  document.addEventListener('keydown', function(e){
    if (e.key === 'Escape' && !menuPanel.hidden) { closeMenu(); menuBtn.focus(); }
  });
  ['menu-action-1','menu-action-2','menu-action-3'].forEach(function(id){
    document.getElementById(id).addEventListener('click', function(){
      menuFeedback.textContent = 'Action « ' + this.textContent + ' » déclenchée localement (aucune requête réseau).';
      closeMenu();
      menuBtn.focus();
    });
  });

  /* ---------- Onglets ARIA (comparaison scripting) ---------- */
  var tabs = document.querySelectorAll('.tabs [role=tab]');
  tabs.forEach(function(tab){
    tab.addEventListener('click', function(){
      tabs.forEach(function(t){
        t.setAttribute('aria-selected', 'false');
        t.tabIndex = -1;
        document.getElementById(t.getAttribute('aria-controls')).hidden = true;
        document.getElementById(t.getAttribute('aria-controls')).classList.remove('is-active');
      });
      tab.setAttribute('aria-selected', 'true');
      tab.tabIndex = 0;
      var panel = document.getElementById(tab.getAttribute('aria-controls'));
      panel.hidden = false;
      panel.classList.add('is-active');
    });
    tab.addEventListener('keydown', function(e){
      var list = Array.prototype.slice.call(tabs);
      var i = list.indexOf(tab);
      if (e.key === 'ArrowRight') { e.preventDefault(); list[(i+1) % list.length].focus(); list[(i+1) % list.length].click(); }
      if (e.key === 'ArrowLeft') { e.preventDefault(); list[(i-1+list.length) % list.length].focus(); list[(i-1+list.length) % list.length].click(); }
    });
  });

  /* ---------- Toasts auto-effaçables (après un swap out-of-band) ---------- */
  document.body.addEventListener('htmx:afterSwap', function(evt){
    if (evt.target && evt.target.id === 'toast-region') {
      setTimeout(function(){ evt.target.innerHTML = ''; }, 3200);
    }
  });

  /* =====================================================================
     SERVEUR SIMULÉ CÔTÉ CLIENT
     Cette page est statique : pour que les démonstrations htmx restent
     pleinement fonctionnelles hors ligne, un faux XMLHttpRequest (la même
     technique utilisée dans de nombreux bacs à sable htmx) intercepte les
     routes "/demo/*" et renvoie de vrais fragments HTML, avec un léger
     délai réseau simulé. htmx ne voit aucune différence avec un vrai
     serveur : c'est strictement la même API du navigateur.
     ===================================================================== */
  var ARTICLES = [
    { titre: 'hx-boost : l’amélioration progressive en un attribut', tags: 'boost progressive' },
    { titre: 'hx-swap en détail : innerHTML, outerHTML, transitions', tags: 'swap transitions' },
    { titre: 'REST et HATEOAS, la théorie qui prend enfin sens en HTML', tags: 'rest hateoas' },
    { titre: 'hx-trigger : au-delà du clic, delay et debounce', tags: 'trigger debounce' },
    { titre: 'Server-Sent Events avec l’extension sse', tags: 'sse events' },
    { titre: 'WebSockets déclaratifs avec hx-ws', tags: 'websockets ws' },
    { titre: 'Content Security Policy et htmx', tags: 'securite csp' },
    { titre: 'hx-push-url et l’historique du navigateur', tags: 'boost historique' }
  ];
  var jobProgress = 0;
  var clickCount = 0;

  function esc(s){
    return String(s).replace(/[&<>"]/g, function(c){
      return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c];
    });
  }
  function qs(url, key){
    var m = url.split('?')[1];
    if (!m) return '';
    var params = new URLSearchParams(m);
    return params.get(key) || '';
  }

  var ROUTES = {
    'POST /demo/click': function(){
      clickCount++;
      return { status: 200, body:
        '<button id="hero-counter" class="btn" hx-post="/demo/click" hx-swap="outerHTML" hx-target="this">' +
        (clickCount === 1 ? '1 requête envoyée' : clickCount + ' requêtes envoyées') +
        ' <span aria-hidden="true">→</span></button>' };
    },
    'GET /demo/search': function(url){
      var q = qs(url, 'q').trim().toLowerCase();
      if (!q) return { status: 200, body: '<li>Tapez au moins une lettre pour lancer la recherche.</li>' };
      var found = ARTICLES.filter(function(a){
        return a.titre.toLowerCase().indexOf(q) !== -1 || a.tags.indexOf(q) !== -1;
      });
      if (found.length === 0) return { status: 200, body: '<li>Aucun résultat pour «\u00a0' + esc(q) + '\u00a0».</li>' };
      return { status: 200, body: found.map(function(a){ return '<li>' + esc(a.titre) + '</li>'; }).join('') };
    },
    'POST /demo/validate-email': function(url, body){
      var params = new URLSearchParams(body || '');
      var email = params.get('email') || '';
      var valid = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email);
      if (!email) return { status: 200, body: '' };
      if (valid) return { status: 200, body: '<div class="feedback ok">✓ Adresse valide, le serveur l’accepterait.</div>' };
      return { status: 200, body: '<div class="feedback err">✕ Format d’adresse invalide.</div>' };
    },
    'GET /demo/more': function(url){
      var page = parseInt(qs(url, 'page') || '2', 10);
      var extra = {
        2: ['Article — hx-trigger et le debounce', 'Article — La localité du comportement'],
        3: ['Article — Hyperview et le HXML', 'Article — Sécuriser une CSP avec htmx']
      };
      var items = (extra[page] || []).map(function(t){ return '<li>' + t + '</li>'; }).join('');
      if (page >= 3) {
        return { status: 200, body: items + '<li style="color:var(--ink-soft);font-style:italic;">Fin de la liste.</li>' };
      }
      return { status: 200, body: items +
        '<button class="btn small" hx-get="/demo/more?page=' + (page + 1) + '" hx-target="this" hx-swap="outerHTML">Charger plus ↓</button>' };
    },
    'POST /demo/start-job': function(){
      jobProgress = 0;
      return { status: 200, body: jobFragment() };
    },
    'GET /demo/job-status': function(){
      jobProgress = Math.min(100, jobProgress + 22);
      return { status: 200, body: jobFragment() };
    }
  };

  function jobFragment(){
    if (jobProgress >= 100) {
      return '<div><div class="feedback ok">✓ Traitement terminé.</div></div>';
    }
    return '<div hx-get="/demo/job-status" hx-trigger="load, every 800ms" hx-target="this" hx-swap="outerHTML">' +
      '<div class="progress-track"><div class="progress-fill" style="width:' + jobProgress + '%;"></div></div>' +
      '<p style="font-size:.82rem;color:var(--ink-soft);margin:.4rem 0 0;">' + jobProgress + '\u00a0%</p></div>';
  }

  // routes dynamiques /demo/task/:id (DELETE)
  var TASKS = {
    1: 'Rédiger la documentation htmx',
    2: 'Migrer le back-office vers hx-boost',
    3: 'Ajouter un indicateur de chargement'
  };
  function handleDynamic(method, path, body){
    var m = /^\/demo\/task\/(\d+)$/.exec(path);
    if (m && method === 'DELETE') {
      var id = m[1];
      var titre = TASKS[id] || 'Tâche';
      return { status: 200, body:
        '<div id="toast-region" hx-swap-oob="true"><div class="toast">Tâche «\u00a0' + esc(titre) + '\u00a0» supprimée ✓</div></div>' };
    }
    return null;
  }

  /* ---- Faux XMLHttpRequest, surface minimale utilisée par htmx ---- */
  function MockXHR(){
    this.readyState = 0;
    this.status = 0;
    this.responseText = '';
    this.response = '';
    this.withCredentials = false;
    this._headers = {};
    this._reqHeaders = {};
    this._listeners = {};
  }
  MockXHR.UNSENT = 0; MockXHR.OPENED = 1; MockXHR.HEADERS_RECEIVED = 2; MockXHR.LOADING = 3; MockXHR.DONE = 4;
  MockXHR.prototype.UNSENT = 0; MockXHR.prototype.OPENED = 1; MockXHR.prototype.HEADERS_RECEIVED = 2;
  MockXHR.prototype.LOADING = 3; MockXHR.prototype.DONE = 4;
  MockXHR.prototype.open = function(method, url){ this._method = method.toUpperCase(); this._url = url; this.readyState = 1; this._fire('readystatechange'); };
  MockXHR.prototype.setRequestHeader = function(k, v){ this._reqHeaders[k] = v; };
  MockXHR.prototype.getResponseHeader = function(k){ return this._headers[k.toLowerCase()] || null; };
  MockXHR.prototype.getAllResponseHeaders = function(){
    var h = this._headers, out = [];
    for (var k in h) out.push(k + ': ' + h[k]);
    return out.join('\r\n');
  };
  MockXHR.prototype.overrideMimeType = function(){};
  MockXHR.prototype.addEventListener = function(type, fn){
    (this._listeners[type] = this._listeners[type] || []).push(fn);
  };
  MockXHR.prototype.removeEventListener = function(type, fn){
    var l = this._listeners[type]; if (!l) return;
    var i = l.indexOf(fn); if (i > -1) l.splice(i, 1);
  };
  MockXHR.prototype._fire = function(type){
    var ev; try { ev = new Event(type); } catch (e) { ev = document.createEvent('Event'); ev.initEvent(type, false, false); }
    (this._listeners[type] || []).slice().forEach(function(fn){ fn.call(this, ev); }, this);
    var onName = 'on' + type;
    if (typeof this[onName] === 'function') this[onName](ev);
  };
  MockXHR.prototype.abort = function(){ this.readyState = 4; this.status = 0; this._fire('abort'); this._fire('loadend'); };
  MockXHR.prototype.send = function(body){
    var self = this;
    var path = (this._url || '').split('?')[0];
    var key = this._method + ' ' + path;
    self._fire('loadstart');
    setTimeout(function(){
      self.readyState = 2; self._fire('readystatechange');
      setTimeout(function(){
        self.readyState = 3; self._fire('readystatechange'); self._fire('progress');
        setTimeout(function(){
          var handler = ROUTES[key];
          var result = handler ? handler(self._url, body) : handleDynamic(self._method, path, body);
          if (!result) result = { status: 404, body: '<p class="feedback err">Route de démonstration introuvable : ' + esc(key) + '</p>' };
          self.status = result.status;
          self.statusText = result.status === 200 ? 'OK' : 'Not Found';
          self.responseText = result.body;
          self.response = result.body;
          self._headers = { 'content-type': 'text/html; charset=utf-8' };
          self.readyState = 4;
          self._fire('readystatechange');
          self._fire('load');
          self._fire('loadend');
        }, 260 + Math.random() * 220);
      }, 40);
    }, 20);
  };

  window.XMLHttpRequest = MockXHR;
})();
</script>

</body>
</html>