Leçon 1/12 12 min

Projet 1 : une carte de punchline avec l'IA

TD corrigé : on construit une mini-app partageable avec l'IA. Les vrais prompts, ce que l'IA a sorti, ce que j'ai relu et corrigé en humain, l'hébergement et les tests.

FR EN

Le projet : une carte qu'on a envie de partager

Pour ce tout premier projet, on commence par quelque chose qu'on a vraiment envie de finir : un générateur de punchlines de développeur. Le principe tient en une phrase. Tu cliques, une réplique culte s'affiche sur une jolie carte en dégradé (« Ça marche sur ma machine. »), un bouton la copie, et tu la ressors fièrement à la prochaine réunion. C'est petit, c'est un peu bête, mais à la fin tu as un vrai projet en ligne, avec une adresse, que tu peux montrer à quelqu'un.

Pourquoi démarrer là, plutôt que par un truc « sérieux » ? Parce que ce projet réunit les trois qualités idéales pour apprendre : il est petit (on en fait le tour en une seule fois), il est visuel (on voit immédiatement si ça marche), et l'IA est redoutable dessus (trois prompts et c'est debout). Et c'est précisément cette facilité qui cache le piège. Obtenir un truc qui s'affiche, l'IA le fait en trente secondes. Obtenir un truc fiable, qu'on assume de mettre en ligne, c'est un autre métier : le tien. Tout l'objet de ce TD, c'est de voir, concrètement, ce qu'un humain doit reprendre derrière la machine.

La méthode qu'on va suivre tient en trois temps, et elle ne changera plus de tout le parcours : l'IA code vite, tu relis lentement, tu lui redonnes des instructions précises. On va la dérouler une première fois, en entier.

Une contrainte qu'on s'impose dès le départ : un seul fichier HTML, zéro dépendance, zéro serveur. Tout (structure, style, logique) tient dans un fichier. C'est ce qui rendra l'hébergement ridiculement simple, et le projet partageable d'un seul lien.

Prompt 1 : poser le cadre

La première étape est la plus sous-estimée : bien demander. Un prompt vague (« fais un générateur de citations ») donne un résultat vague, et tu passes ensuite dix minutes à rattraper les malentendus. Un bon prompt plante le décor : le quoi, les contraintes techniques, et le style attendu. On ne laisse pas l'IA deviner ce qu'on a en tête.

Crée une mini-app web autonome, un seul fichier HTML (HTML + CSS + JS, zéro dépendance, pas de framework). Objectif : afficher une « punchline de développeur » sur une carte avec un dégradé, et un bouton pour en tirer une nouvelle au hasard. Texte blanc sur la carte. Bilingue FR/EN avec un petit bouton de bascule. Code simple et lisible.

L'IA renvoie une première version qui fonctionne. Le cœur ressemble à ça :

const quotes = ["Ça marche sur ma machine.", "git commit -m 'fix'", /* ... */];

function newQuote() {
  const q = quotes[Math.floor(Math.random() * quotes.length)];
  document.getElementById('quote').innerHTML = q;   // ⚠️ on y reviendra
}

Ça tourne, et honnêtement c'est déjà satisfaisant. Mais si tu lis le code au lieu de seulement regarder l'écran, un détail doit te faire tiquer : ce innerHTML pour afficher du simple texte. On le note dans un coin de la tête — on y reviendra — et on continue d'abord à rendre la carte présentable.

Prompt 2 : soigner le rendu

La carte marche, mais elle est un peu triste : un rectangle, du texte, point. On garde tout ce qui fonctionne et on demande à l'IA d'embellir, sans repartir de zéro. C'est ça, itérer : on avance par petites couches plutôt que de tout réécrire à chaque idée.

Rends la carte plus jolie : coins arrondis, ombre portée, et un dégradé différent à chaque tirage. Ajoute un bouton « Copier le texte ».

Pour faire « varié », l'IA prend le chemin le plus direct : deux couleurs tirées complètement au hasard.

function randomGradient() {
  const c1 = '#' + Math.floor(Math.random() * 16777215).toString(16);
  const c2 = '#' + Math.floor(Math.random() * 16777215).toString(16);
  card.style.background = `linear-gradient(135deg, ${c1}, ${c2})`;
}

Sur les premiers essais, en redimensionnant la fenêtre, c'est joli, les couleurs dansent, on est content. Et c'est exactement l'instant le plus dangereux : celui où on a envie de déployer et de passer à la suite. On va faire l'inverse. On s'arrête, et on relit.

Ma relecture humaine : 3 trucs que l'IA a laissés passer

L'IA optimise pour une seule chose : que ça ait l'air de marcher, tout de suite. Pas que ça tienne dans six mois, pas que ce soit utilisable par tout le monde, pas que ce soit sûr. Cette partie-là — la fiabilité — c'est la nôtre. Voici les trois points que j'ai repris sur ce projet, et surtout pourquoi ils comptent.

1. innerHTML : un réflexe à corriger

innerHTML ne se contente pas d'afficher du texte : il interprète ce qu'on lui donne. Si la chaîne contient des balises, le navigateur les exécute. Aujourd'hui mes punchlines viennent d'un tableau que j'ai écrit moi-même : le risque est nul, « ça passe ». Le problème, c'est l'habitude. Le jour où ce texte viendra d'un champ rempli par un visiteur ou d'une réponse d'API, ce même innerHTML deviendra une faille XSS grande ouverte — et personne ne se souviendra de revenir le corriger. Autant prendre le bon réflexe maintenant : textContent insère du texte comme du texte, jamais comme du HTML. Zéro surface d'attaque, et ça ne coûte pas une ligne de plus.

2. Le dégradé aléatoire casse le contraste

Deux couleurs tirées totalement au hasard, statistiquement, ça tombe de temps en temps sur une combinaison claire : un jaune pâle, un beige. Et sur un fond clair, du texte blanc devient illisible. Le vrai piège, c'est que rien ne te prévient : pas d'erreur en console, aucun test au rouge. Seul l'œil humain le repère, et encore, seulement si on tombe sur le mauvais tirage au bon moment. Le correctif assume un petit compromis : au lieu du hasard total, on se donne une liste de dégradés choisis, tous assez sombres pour que le texte blanc passe à tous les coups. On perd un soupçon d'imprévu, on gagne une garantie.

3. L'accessibilité, invisible mais réelle

Quand on clique « Copier », un petit « Copié ! » apparaît. Parfait pour qui voit l'écran — invisible pour qui utilise un lecteur d'écran. On le signale avec aria-live="polite" : la zone est alors lue à voix haute dès que son contenu change. J'ajoute aussi un :focus-visible net (pour qui navigue au clavier et a besoin de savoir où il se trouve) et le respect de prefers-reduced-motion (couper les animations pour les personnes qui les supportent mal). Rien de spectaculaire, mais c'est toute la différence entre « ça marche pour moi » et « ça marche pour tout le monde ». L'IA n'y pense presque jamais seule.

Prompt 3 : durcir après relecture

Une fois ces trois points repérés, le bon réflexe n'est pas de bricoler le code à la main en soupirant. C'est de redonner la main à l'IA, mais avec des instructions chirurgicales : on lui dit exactement ce qu'on a vu et ce qu'on veut à la place.

Trois corrections : (1) insère le texte avec textContent, pas innerHTML. (2) Remplace les dégradés aléatoires par une liste de dégradés sombres fixes, pour garantir le contraste avec le texte blanc. (3) Ajoute aria-live="polite" sur la zone de statut, un focus-visible sur les boutons, et respecte prefers-reduced-motion.

Et voilà le code corrigé sur les points clés :

// 1. textContent : le texte est inséré comme texte, jamais interprété
quoteEl.textContent = text;

// 2. des dégradés choisis, tous assez sombres pour du texte blanc
const GRADIENTS = [['#0f1923','#267d42'], ['#1a1a2e','#16213e'], /* ... */];

// 3. statut annoncé aux lecteurs d'écran
// <p class="status" role="status" aria-live="polite"></p>

Retiens cette boucle, c'est le cœur de tout le parcours : l'IA code vite, tu relis lentement, tu re-prompts précis. Ton rôle n'est pas de taper le code à sa place, mais de décider ce qui est acceptable et ce qui ne l'est pas. C'est toi le responsable, pas elle.

Héberger : un seul fichier, point

Le code est propre, relu, on l'assume. Reste à le mettre entre les mains des gens. Et comme on s'est imposé un fichier HTML autonome, l'hébergement est d'une simplicité presque vexante : on dépose le fichier sur un serveur, et c'est en ligne. Pas de build, pas de Node, pas de base de données. Le fichier est le projet.

# déposer le fichier sur le serveur (ici par FTP/déploiement du site)
apprendre/projets/demos/carte-punchline.html  →  en ligne, accessible par son URL

C'est tout l'avantage du « tout client » : un fichier statique se met en ligne n'importe où (ton hébergeur, GitHub Pages, Netlify…) sans la moindre configuration. Tu rencontreras des projets plus exigeants plus tard dans le parcours ; celui-là, tu le partages d'un lien.

Tester comme un humain (pas seulement en dev tools)

Une dernière chose avant de dire « fini ». Les dev tools mentent un peu : tout y paraît parfait. Le vrai test, c'est de se comporter comme un utilisateur, à la main, et de chercher les angles morts :

  • Ouvrir dans un vrai navigateur, pas seulement l'aperçu de l'éditeur.
  • Redimensionner à la main : mobile, tablette, desktop. On vérifie que les boutons passent bien en pleine largeur sur petit écran.
  • Basculer FR / EN et vérifier que tout suit, y compris l'état du bouton actif.
  • Cliquer quinze fois sur « Nouvelle punchline » : c'est l'œil qui valide que chaque dégradé garde le texte lisible. Aucun test automatique ne fera ça à ta place.
  • Ouvrir la console : zéro erreur rouge.
  • Tester « Copier », puis coller ailleurs pour confirmer que ça a bien fonctionné.

Le rendu final

Assez parlé : voici le projet terminé, en vrai, qui tourne dans la page. Clique, copie, change de langue.

Ouvrir le projet en plein écran

Le code complet (et téléchargeable)

Voici le fichier entier, exactement celui qui tourne juste au-dessus. Un seul fichier : tu le télécharges, tu l'ouvres dans ton navigateur, et ça marche. Ouvre-le aussi dans ton éditeur pour le relire ligne par ligne — c'est en lisant du code propre qu'on apprend à en écrire.

Télécharger le code (.html · 347 lignes)

Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Générateur de punchlines de développeur</title>
<meta name="description" content="Génère une punchline de développeur stylée et partage-la. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  GÉNÉRATEUR DE PUNCHLINES — un seul fichier : HTML + CSS + JS, zéro dépendance.
  Le code se lit en 3 temps :
    1. <style>  : l'apparence (la carte, les boutons, le responsive).
    2. <body>   : la structure (ce que voit l'utilisateur).
    3. <script> : la logique (choisir une phrase, copier, changer de langue).
  ============================================================================
-->

<style>
  /* Variables de couleur : définies une fois, réutilisées partout (DRY). */
  :root {
    --ink: #1a1d24;      /* texte principal */
    --muted: #5a6270;    /* texte secondaire */
    --accent: #267d42;   /* vert d'accent */
    --border: #e2e6ea;
    --radius: 18px;
  }

  /* box-sizing: la largeur inclut padding + bordure. Évite les surprises de mise en page. */
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }

  /* Le body centre tout le contenu dans une colonne. */
  body {
    font-family: 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
    background: #f4f6f8;
    color: var(--ink);
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 32px 18px 48px;
    line-height: 1.5;
  }
  .app { width: 100%; max-width: 560px; }
  header { text-align: center; margin-bottom: 24px; }
  h1 { font-size: 1.5rem; margin: 0 0 6px; letter-spacing: -0.01em; }
  .sub { color: var(--muted); font-size: 0.95rem; margin: 0 0 18px; }

  /* Le sélecteur de langue FR / EN. */
  .lang-switch { display: inline-flex; border: 1.5px solid var(--border); border-radius: 999px; overflow: hidden; background: #fff; }
  .lang-btn { border: 0; background: transparent; padding: 7px 18px; font: inherit; font-weight: 700; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
  .lang-btn[aria-pressed="true"] { background: var(--accent); color: #fff; }   /* le bouton actif est mis en vert */
  .lang-btn:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }

  /* La carte qui affiche la punchline. */
  .card {
    position: relative;
    border-radius: var(--radius);
    color: #fff;
    padding: 44px 34px;
    min-height: 240px;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    box-shadow: 0 18px 40px rgba(15,25,35,0.18);
    background: linear-gradient(135deg, #0f1923, #267d42);   /* dégradé par défaut, remplacé par le JS */
    transition: opacity 0.28s ease, transform 0.28s ease;
  }
  /* Le gros guillemet décoratif en fond de carte (::before = pseudo-élément). */
  .card::before {
    content: "\201C";
    position: absolute;
    top: 2px; left: 16px;
    font-family: Georgia, 'Times New Roman', serif;
    font-size: 96px;
    line-height: 1;
    color: rgba(255, 255, 255, 0.13);
    pointer-events: none;
  }
  /* z-index: 1 pour que le texte passe DEVANT le guillemet décoratif. */
  .card-quote, .card-foot { position: relative; z-index: 1; }
  .card.is-swapping { opacity: 0; transform: translateY(8px); }   /* état "en train de changer" (petit fondu) */
  .card-quote { font-size: 1.5rem; font-weight: 700; line-height: 1.34; margin: 0; text-shadow: 0 1px 2px rgba(0,0,0,0.18); }
  .card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 28px; font-size: 0.82rem; opacity: 0.92; }
  .card-mark { font-weight: 700; letter-spacing: 0.04em; }
  .card-num { font-variant-numeric: tabular-nums; }

  /* Les deux boutons d'action. */
  .actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
  .btn {
    flex: 1 1 auto; min-width: 140px; min-height: 50px;   /* min-height 50px = cible tactile confortable */
    border-radius: 12px; border: 1.5px solid transparent;
    font: inherit; font-weight: 700; font-size: 0.98rem; cursor: pointer;
    transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
  }
  .btn-primary { background: var(--accent); color: #fff; box-shadow: 0 6px 16px rgba(38,125,66,0.28); }
  .btn-primary:hover { background: #1f6a37; transform: translateY(-1px); }
  .btn-ghost { background: #fff; color: var(--ink); border-color: var(--border); }
  .btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
  .btn:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }   /* contour clavier visible */

  .status { text-align: center; min-height: 20px; margin-top: 14px; font-size: 0.85rem; font-weight: 600; color: var(--accent); }
  .credit { text-align: center; color: var(--muted); font-size: 0.78rem; margin-top: 30px; }
  .credit a { color: var(--accent); }

  /* Sur petit écran : texte plus petit et boutons sur toute la largeur. */
  @media (max-width: 480px) {
    .card-quote { font-size: 1.28rem; }
    .btn { flex-basis: 100%; }
  }
  /* Pour les personnes qui préfèrent moins d'animations : on les coupe. */
  @media (prefers-reduced-motion: reduce) {
    .card, .btn { transition: none; }
  }
</style>
</head>
<body>

<!-- STRUCTURE : un en-tête (titre + langue), la carte, les boutons, un statut, un crédit. -->
<main class="app">
  <header>
    <h1 data-i18n="title">Punchline de développeur</h1>
    <p class="sub" data-i18n="sub">Le truc qu'on dit (ou qu'on pense très fort) en codant.</p>
    <!-- aria-pressed indique au lecteur d'écran quel bouton de langue est actif. -->
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <!-- La carte. Le texte (#quote) et le numéro (#num) sont remplis par le JS. -->
  <figure class="card" id="card">
    <blockquote class="card-quote" id="quote">Ça marche sur ma machine.</blockquote>
    <figcaption class="card-foot">
      <span class="card-mark">&lt;/&gt; dev.punchline</span>
      <span class="card-num" id="num">#01</span>
    </figcaption>
  </figure>

  <div class="actions">
    <button class="btn btn-primary" id="next" type="button" data-i18n="next">Nouvelle punchline</button>
    <button class="btn btn-ghost" id="copy" type="button" data-i18n="copy">Copier le texte</button>
  </div>

  <!-- aria-live="polite" : un lecteur d'écran lit ce message quand il change (ex. "Copié !"). -->
  <p class="status" id="status" role="status" aria-live="polite"></p>

  <p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>

<script>
// Tout le code est dans une fonction qui s'exécute aussitôt (une "IIFE").
// Avantage : nos variables restent privées et ne polluent pas le reste de la page.
(function () {
  'use strict';   // mode strict : JavaScript signale plus d'erreurs au lieu de les ignorer.

  /* ---------------------------------------------------------------------------
     1. LES DONNÉES — nos punchlines (en FR et EN) et la liste de dégradés.
     --------------------------------------------------------------------------- */
  var QUOTES = {
    fr: [
      "Ça marche sur ma machine.",
      "Je ne suis pas bloqué, je réfléchis stratégiquement.",
      "Ce n'est pas un bug, c'est une fonctionnalité non documentée.",
      "git commit -m « fix » (pour la 14e fois).",
      "// TODO : refactorer un jour.",
      "J'ai mis 4 heures à automatiser une tâche de 5 minutes. Aucun regret.",
      "Ce n'est pas moi, c'est le cache.",
      "Le déploiement du vendredi : une histoire d'amour et de courage.",
      "Mon code est auto-documenté. Je n'ai juste pas eu le temps de le lire.",
      "Je teste en production, comme un grand.",
      "Tout est prioritaire, donc rien ne l'est.",
      "Le projet est fini à 90 %. Il reste les 90 % les plus durs.",
      "rm -rf node_modules && npm install : la prière du désespoir.",
      "Ça compile ! On verra le reste en prod.",
      "Un dev qui dit « c'est presque fini » parle en années-lumière.",
      "Le meilleur code est celui que je n'ai pas eu à écrire."
    ],
    en: [
      "It works on my machine.",
      "I'm not stuck, I'm strategically rethinking.",
      "It's not a bug, it's an undocumented feature.",
      "git commit -m \"fix\" (for the 14th time).",
      "// TODO: refactor someday.",
      "Spent 4 hours automating a 5-minute task. No regrets.",
      "It's not me, it's the cache.",
      "Friday deploys: a tale of love and courage.",
      "My code is self-documenting. I just haven't had time to read it.",
      "I test in production, like a grown-up.",
      "Everything is top priority, so nothing is.",
      "The project is 90% done. Only the hard 90% left.",
      "rm -rf node_modules && npm install: the desperation prayer.",
      "It compiles! We'll sort out the rest in prod.",
      "A dev saying \"almost done\" is speaking in light-years.",
      "The best code is the code I didn't have to write."
    ]
  };

  // Dégradés CHOISIS, tous assez sombres pour que le texte blanc reste lisible.
  // (On évite des couleurs au hasard, qui tomberaient parfois sur du clair illisible.)
  var GRADIENTS = [
    ['#0f1923', '#267d42'], ['#1a1a2e', '#16213e'], ['#2d1b4e', '#5b2a86'], ['#0b3d2e', '#1e6f5c'],
    ['#3a1c40', '#7d2d52'], ['#102a43', '#334e68'], ['#241023', '#5c1a4b'], ['#0d3b3b', '#1f6f6f']
  ];

  // Les textes de l'interface (boutons, messages), eux aussi traduits.
  var UI = {
    fr: {
      title: "Punchline de développeur",
      sub: "Le truc qu'on dit (ou qu'on pense très fort) en codant.",
      next: "Nouvelle punchline",
      copy: "Copier le texte",
      credit: 'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
      copied: "Copié dans le presse-papier !",
      copyfail: "Copie impossible — sélectionnez le texte à la main."
    },
    en: {
      title: "Developer punchline",
      sub: "The thing you say (or think very loudly) while coding.",
      next: "New punchline",
      copy: "Copy text",
      credit: 'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
      copied: "Copied to clipboard!",
      copyfail: "Couldn't copy — please select the text manually."
    }
  };

  /* ---------------------------------------------------------------------------
     2. LES ÉLÉMENTS DE LA PAGE — on les récupère une fois pour toutes.
     --------------------------------------------------------------------------- */
  var card = document.getElementById('card');
  var quoteEl = document.getElementById('quote');
  var numEl = document.getElementById('num');
  var statusEl = document.getElementById('status');
  var nextBtn = document.getElementById('next');
  var copyBtn = document.getElementById('copy');

  /* ---------------------------------------------------------------------------
     3. L'ÉTAT — ce qui peut changer : la langue et la punchline affichée.
     --------------------------------------------------------------------------- */
  var lang = 'fr';
  var index = 0;   // position de la punchline affichée dans le tableau
  // On relit la langue choisie la dernière fois (si elle existe).
  try { lang = localStorage.getItem('punchline-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';   // garde-fou : valeur inattendue -> on repart sur 'fr'

  /* ---------------------------------------------------------------------------
     4. L'AFFICHAGE — mettre la bonne punchline et le bon dégradé sur la carte.
     --------------------------------------------------------------------------- */
  function pad(n) { return (n < 10 ? '0' : '') + n; }   // 7 -> "07", pour un joli "#07"

  function render(animate) {
    var text = QUOTES[lang][index];
    var g = GRADIENTS[index % GRADIENTS.length];   // % : on boucle sur les dégradés si plus de phrases que de dégradés

    function apply() {
      // textContent (et NON innerHTML) : le texte est inséré comme du texte,
      // jamais interprété comme du HTML. C'est ce qui évite les failles d'injection.
      quoteEl.textContent = text;
      numEl.textContent = '#' + pad(index + 1);
      card.style.background = 'linear-gradient(135deg, ' + g[0] + ', ' + g[1] + ')';
    }

    // Petit fondu au changement, sauf si la personne préfère moins d'animations.
    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (animate && !reduce) {
      card.classList.add('is-swapping');
      setTimeout(function () { apply(); card.classList.remove('is-swapping'); }, 200);
    } else {
      apply();
    }
  }

  /* ---------------------------------------------------------------------------
     5. LES ACTIONS — changer de langue, tirer une punchline, copier.
     --------------------------------------------------------------------------- */
  function setLang(next) {
    lang = next;
    try { localStorage.setItem('punchline-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;   // met à jour l'attribut lang de la page

    // On traduit chaque élément marqué data-i18n avec le texte correspondant.
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var key = el.getAttribute('data-i18n');
      if (!t[key]) return;
      // 'credit' contient un lien <a>, donc innerHTML ; ici le texte vient de NOUS, pas de l'utilisateur.
      if (key === 'credit') el.innerHTML = t[key];
      else el.textContent = t[key];
    });

    // On met à jour le bouton de langue actif (pour l'œil et pour le lecteur d'écran).
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });

    statusEl.textContent = '';
    render(false);
  }

  function nextQuote() {
    var n = QUOTES[lang].length;
    // On tire au hasard, mais on évite de retomber sur la même phrase qu'à l'instant.
    var i = index;
    while (i === index && n > 1) { i = Math.floor(Math.random() * n); }
    index = i;
    statusEl.textContent = '';
    render(true);
  }

  function copyText() {
    var text = QUOTES[lang][index];
    var done = function (ok) { statusEl.textContent = ok ? UI[lang].copied : UI[lang].copyfail; };

    // Méthode moderne (presse-papier). Si le navigateur ne la connaît pas, on bascule sur la méthode B.
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
    } else {
      // Méthode B (anciens navigateurs) : on copie depuis un champ texte temporaire.
      try {
        var ta = document.createElement('textarea');
        ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
        document.body.appendChild(ta); ta.select();
        document.execCommand('copy'); document.body.removeChild(ta);
        done(true);
      } catch (e) { done(false); }
    }
  }

  /* ---------------------------------------------------------------------------
     6. LE DÉMARRAGE — on branche les boutons, puis on affiche la 1re carte.
     --------------------------------------------------------------------------- */
  nextBtn.addEventListener('click', nextQuote);
  copyBtn.addEventListener('click', copyText);
  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);   // applique la langue (et déclenche le premier affichage)
})();
</script>
</body>
</html>

À toi de jouer

La meilleure façon de retenir tout ça, c'est de continuer à bidouiller ce projet. Reprends-le et fais-le grandir, en gardant les mêmes réflexes de relecture à chaque ajout :

  • Ajoute tes propres punchlines (les meilleures sont celles qu'on a vraiment vécues).
  • Un bouton « Partager sur X » qui pré-remplit le texte du tweet.
  • Un bouton « Télécharger en image » de la carte (cherche du côté de canvas ou de la lib html2canvas) ; et pose-toi la question : nouvelle dépendance = nouvelle surface à relire.

À chaque fonctionnalité que l'IA t'ajoute, repose-toi les trois questions de ce TD : injection, contraste, accessibilité. C'est exactement ça, piloter l'IA au lieu de la subir.

Projet suivant
Générateur de palette de couleurs →

The project: a card people actually want to share

For this very first project, we start with something you'll actually want to finish: a developer punchline generator. The idea fits in one sentence. You click, a classic line lands on a pretty gradient card ("It works on my machine."), a button copies it, and you drop it proudly into your next meeting. It's small, it's a little silly, but in the end you have a real project online, with an address, that you can show someone.

Why start here, rather than with something "serious"? Because this project has the three ideal qualities for learning: it's small (you can take it all in at once), it's visual (you instantly see if it works), and the AI is frighteningly good at it (three prompts and it stands up). And that very ease is what hides the trap. Getting something that displays, the AI does in thirty seconds. Getting something reliable, that you'd stand behind online, is another craft: yours. The whole point of this build is to see, concretely, what a human must redo behind the machine.

The method we'll follow has three beats, and it won't change for the rest of the course: the AI codes fast, you review slowly, you give it precise instructions back. Let's run through it once, in full.

A constraint we set from the start: a single HTML file, zero dependencies, no server. Everything (structure, style, logic) lives in one file. That's what will make hosting absurdly simple, and the project shareable from a single link.

Prompt 1: set the frame

The first step is the most underrated: asking well. A vague prompt ("make a quote generator") gives a vague result, and then you spend ten minutes patching up the misunderstandings. A good prompt sets the scene: the what, the technical constraints, and the expected style. We don't let the AI guess what we have in mind.

Create a standalone web mini-app, a single HTML file (HTML + CSS + JS, zero dependencies, no framework). Goal: show a "developer punchline" on a card with a gradient, and a button to draw a new random one. White text on the card. Bilingual FR/EN with a small toggle. Simple, readable code.

The AI returns a first working version. The core looks like this:

const quotes = ["It works on my machine.", "git commit -m 'fix'", /* ... */];

function newQuote() {
  const q = quotes[Math.floor(Math.random() * quotes.length)];
  document.getElementById('quote').innerHTML = q;   // ⚠️ we'll come back to this
}

It runs, and honestly it's already satisfying. But if you read the code instead of just looking at the screen, one detail should make you pause: that innerHTML to display plain text. We note it in the back of our mind — we'll come back to it — and first keep making the card presentable.

Prompt 2: polish the look

The card works, but it's a bit sad: a rectangle, some text, that's it. We keep everything that works and ask the AI to beautify, without starting over. That's what iterating means: we move in small layers instead of rewriting everything on each idea.

Make the card prettier: rounded corners, drop shadow, and a different gradient on each draw. Add a "Copy text" button.

To make it "varied", the AI takes the most direct path: two fully random colors.

function randomGradient() {
  const c1 = '#' + Math.floor(Math.random() * 16777215).toString(16);
  const c2 = '#' + Math.floor(Math.random() * 16777215).toString(16);
  card.style.background = `linear-gradient(135deg, ${c1}, ${c2})`;
}

On the first tries, resizing the window, it looks nice, the colors dance, we're happy. And that's exactly the most dangerous moment: the one where you want to deploy and move on. We're going to do the opposite. We stop, and we review.

My human review: 3 things the AI let slip

The AI optimizes for one thing: that it looks like it works, right now. Not that it holds up in six months, not that it's usable by everyone, not that it's secure. That part — reliability — is ours. Here are the three points I redid on this project, and above all why they matter.

1. innerHTML: a reflex to fix

innerHTML doesn't just display text: it interprets what you give it. If the string contains tags, the browser runs them. Today my punchlines come from an array I wrote myself: the risk is nil, "it's fine". The problem is the habit. The day this text comes from a field filled by a visitor or from an API response, that same innerHTML becomes a wide-open XSS hole — and nobody will remember to come back and fix it. Better take the good habit now: textContent inserts text as text, never as HTML. Zero attack surface, and it doesn't cost an extra line.

2. The random gradient breaks contrast

Two fully random colors, statistically, occasionally land on a light combination: a pale yellow, a beige. And on a light background, white text becomes unreadable. The real trap is that nothing warns you: no console error, no failing test. Only the human eye catches it, and even then, only if you hit the wrong draw at the right moment. The fix accepts a small trade-off: instead of total randomness, we give ourselves a list of chosen gradients, all dark enough that white text always passes. We lose a hint of surprise, we gain a guarantee.

3. Accessibility, invisible but real

When you click "Copy", a little "Copied!" appears. Perfect for whoever sees the screen — invisible to whoever uses a screen reader. We flag it with aria-live="polite": the area is then read aloud whenever its content changes. I also add a clear :focus-visible (for keyboard users who need to know where they are) and respect for prefers-reduced-motion (cutting animations for people who don't tolerate them well). Nothing spectacular, but it's the whole difference between "it works for me" and "it works for everyone". The AI almost never thinks of it on its own.

Prompt 3: harden after review

Once those three points are spotted, the right reflex isn't to patch the code by hand with a sigh. It's to hand it back to the AI, but with surgical instructions: we tell it exactly what we saw and what we want instead.

Three fixes: (1) insert the text with textContent, not innerHTML. (2) Replace the random gradients with a fixed list of dark gradients, to guarantee contrast with white text. (3) Add aria-live="polite" on the status area, a focus-visible on the buttons, and respect prefers-reduced-motion.

And here's the code fixed on the key points:

// 1. textContent: the text is inserted as text, never interpreted
quoteEl.textContent = text;

// 2. chosen gradients, all dark enough for white text
const GRADIENTS = [['#0f1923','#267d42'], ['#1a1a2e','#16213e'], /* ... */];

// 3. status announced to screen readers
// <p class="status" role="status" aria-live="polite"></p>

Remember this loop, it's the heart of the whole course: the AI codes fast, you read slowly, you re-prompt precisely. Your role isn't to type the code for it, but to decide what's acceptable and what isn't. You're the one accountable, not it.

Hosting: a single file, that's it

The code is clean, reviewed, we stand behind it. Now to get it into people's hands. And since we forced ourselves into a standalone HTML file, hosting is almost insultingly simple: you drop the file on a server, and it's online. No build, no Node, no database. The file is the project.

# drop the file on the server (here via the site's FTP/deploy)
apprendre/projets/demos/carte-punchline.html  →  online, reachable by its URL

That's the whole advantage of "all client-side": a static file goes online anywhere (your host, GitHub Pages, Netlify…) with no configuration at all. You'll meet more demanding projects later in the course; this one, you share with a link.

Test like a human (not just in dev tools)

One last thing before saying "done". Dev tools lie a little: everything looks perfect there. The real test is to behave like a user, by hand, and hunt for the blind spots:

  • Open it in a real browser, not just the editor preview.
  • Resize by hand: mobile, tablet, desktop. Check the buttons go full-width on small screens.
  • Toggle FR / EN and check everything follows, including the active button's state.
  • Click fifteen times on "New punchline": your eye is what validates that every gradient keeps the text readable. No automated test does that for you.
  • Open the console: zero red error.
  • Test "Copy", then paste elsewhere to confirm it actually worked.

The finished result

Enough talk: here's the finished project, live, running in the page. Click, copy, switch language.

Open the project full screen

The full code (and downloadable)

Here's the entire file, exactly the one running just above. A single file: download it, open it in your browser, and it works. Open it in your editor too, to read it line by line — reading clean code is how you learn to write it.

Download the code (.html · 347 lines)

View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Générateur de punchlines de développeur</title>
<meta name="description" content="Génère une punchline de développeur stylée et partage-la. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  GÉNÉRATEUR DE PUNCHLINES — un seul fichier : HTML + CSS + JS, zéro dépendance.
  Le code se lit en 3 temps :
    1. <style>  : l'apparence (la carte, les boutons, le responsive).
    2. <body>   : la structure (ce que voit l'utilisateur).
    3. <script> : la logique (choisir une phrase, copier, changer de langue).
  ============================================================================
-->

<style>
  /* Variables de couleur : définies une fois, réutilisées partout (DRY). */
  :root {
    --ink: #1a1d24;      /* texte principal */
    --muted: #5a6270;    /* texte secondaire */
    --accent: #267d42;   /* vert d'accent */
    --border: #e2e6ea;
    --radius: 18px;
  }

  /* box-sizing: la largeur inclut padding + bordure. Évite les surprises de mise en page. */
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }

  /* Le body centre tout le contenu dans une colonne. */
  body {
    font-family: 'Segoe UI', system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
    background: #f4f6f8;
    color: var(--ink);
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 32px 18px 48px;
    line-height: 1.5;
  }
  .app { width: 100%; max-width: 560px; }
  header { text-align: center; margin-bottom: 24px; }
  h1 { font-size: 1.5rem; margin: 0 0 6px; letter-spacing: -0.01em; }
  .sub { color: var(--muted); font-size: 0.95rem; margin: 0 0 18px; }

  /* Le sélecteur de langue FR / EN. */
  .lang-switch { display: inline-flex; border: 1.5px solid var(--border); border-radius: 999px; overflow: hidden; background: #fff; }
  .lang-btn { border: 0; background: transparent; padding: 7px 18px; font: inherit; font-weight: 700; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
  .lang-btn[aria-pressed="true"] { background: var(--accent); color: #fff; }   /* le bouton actif est mis en vert */
  .lang-btn:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }

  /* La carte qui affiche la punchline. */
  .card {
    position: relative;
    border-radius: var(--radius);
    color: #fff;
    padding: 44px 34px;
    min-height: 240px;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    box-shadow: 0 18px 40px rgba(15,25,35,0.18);
    background: linear-gradient(135deg, #0f1923, #267d42);   /* dégradé par défaut, remplacé par le JS */
    transition: opacity 0.28s ease, transform 0.28s ease;
  }
  /* Le gros guillemet décoratif en fond de carte (::before = pseudo-élément). */
  .card::before {
    content: "\201C";
    position: absolute;
    top: 2px; left: 16px;
    font-family: Georgia, 'Times New Roman', serif;
    font-size: 96px;
    line-height: 1;
    color: rgba(255, 255, 255, 0.13);
    pointer-events: none;
  }
  /* z-index: 1 pour que le texte passe DEVANT le guillemet décoratif. */
  .card-quote, .card-foot { position: relative; z-index: 1; }
  .card.is-swapping { opacity: 0; transform: translateY(8px); }   /* état "en train de changer" (petit fondu) */
  .card-quote { font-size: 1.5rem; font-weight: 700; line-height: 1.34; margin: 0; text-shadow: 0 1px 2px rgba(0,0,0,0.18); }
  .card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 28px; font-size: 0.82rem; opacity: 0.92; }
  .card-mark { font-weight: 700; letter-spacing: 0.04em; }
  .card-num { font-variant-numeric: tabular-nums; }

  /* Les deux boutons d'action. */
  .actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
  .btn {
    flex: 1 1 auto; min-width: 140px; min-height: 50px;   /* min-height 50px = cible tactile confortable */
    border-radius: 12px; border: 1.5px solid transparent;
    font: inherit; font-weight: 700; font-size: 0.98rem; cursor: pointer;
    transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
  }
  .btn-primary { background: var(--accent); color: #fff; box-shadow: 0 6px 16px rgba(38,125,66,0.28); }
  .btn-primary:hover { background: #1f6a37; transform: translateY(-1px); }
  .btn-ghost { background: #fff; color: var(--ink); border-color: var(--border); }
  .btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
  .btn:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }   /* contour clavier visible */

  .status { text-align: center; min-height: 20px; margin-top: 14px; font-size: 0.85rem; font-weight: 600; color: var(--accent); }
  .credit { text-align: center; color: var(--muted); font-size: 0.78rem; margin-top: 30px; }
  .credit a { color: var(--accent); }

  /* Sur petit écran : texte plus petit et boutons sur toute la largeur. */
  @media (max-width: 480px) {
    .card-quote { font-size: 1.28rem; }
    .btn { flex-basis: 100%; }
  }
  /* Pour les personnes qui préfèrent moins d'animations : on les coupe. */
  @media (prefers-reduced-motion: reduce) {
    .card, .btn { transition: none; }
  }
</style>
</head>
<body>

<!-- STRUCTURE : un en-tête (titre + langue), la carte, les boutons, un statut, un crédit. -->
<main class="app">
  <header>
    <h1 data-i18n="title">Punchline de développeur</h1>
    <p class="sub" data-i18n="sub">Le truc qu'on dit (ou qu'on pense très fort) en codant.</p>
    <!-- aria-pressed indique au lecteur d'écran quel bouton de langue est actif. -->
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <!-- La carte. Le texte (#quote) et le numéro (#num) sont remplis par le JS. -->
  <figure class="card" id="card">
    <blockquote class="card-quote" id="quote">Ça marche sur ma machine.</blockquote>
    <figcaption class="card-foot">
      <span class="card-mark">&lt;/&gt; dev.punchline</span>
      <span class="card-num" id="num">#01</span>
    </figcaption>
  </figure>

  <div class="actions">
    <button class="btn btn-primary" id="next" type="button" data-i18n="next">Nouvelle punchline</button>
    <button class="btn btn-ghost" id="copy" type="button" data-i18n="copy">Copier le texte</button>
  </div>

  <!-- aria-live="polite" : un lecteur d'écran lit ce message quand il change (ex. "Copié !"). -->
  <p class="status" id="status" role="status" aria-live="polite"></p>

  <p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>

<script>
// Tout le code est dans une fonction qui s'exécute aussitôt (une "IIFE").
// Avantage : nos variables restent privées et ne polluent pas le reste de la page.
(function () {
  'use strict';   // mode strict : JavaScript signale plus d'erreurs au lieu de les ignorer.

  /* ---------------------------------------------------------------------------
     1. LES DONNÉES — nos punchlines (en FR et EN) et la liste de dégradés.
     --------------------------------------------------------------------------- */
  var QUOTES = {
    fr: [
      "Ça marche sur ma machine.",
      "Je ne suis pas bloqué, je réfléchis stratégiquement.",
      "Ce n'est pas un bug, c'est une fonctionnalité non documentée.",
      "git commit -m « fix » (pour la 14e fois).",
      "// TODO : refactorer un jour.",
      "J'ai mis 4 heures à automatiser une tâche de 5 minutes. Aucun regret.",
      "Ce n'est pas moi, c'est le cache.",
      "Le déploiement du vendredi : une histoire d'amour et de courage.",
      "Mon code est auto-documenté. Je n'ai juste pas eu le temps de le lire.",
      "Je teste en production, comme un grand.",
      "Tout est prioritaire, donc rien ne l'est.",
      "Le projet est fini à 90 %. Il reste les 90 % les plus durs.",
      "rm -rf node_modules && npm install : la prière du désespoir.",
      "Ça compile ! On verra le reste en prod.",
      "Un dev qui dit « c'est presque fini » parle en années-lumière.",
      "Le meilleur code est celui que je n'ai pas eu à écrire."
    ],
    en: [
      "It works on my machine.",
      "I'm not stuck, I'm strategically rethinking.",
      "It's not a bug, it's an undocumented feature.",
      "git commit -m \"fix\" (for the 14th time).",
      "// TODO: refactor someday.",
      "Spent 4 hours automating a 5-minute task. No regrets.",
      "It's not me, it's the cache.",
      "Friday deploys: a tale of love and courage.",
      "My code is self-documenting. I just haven't had time to read it.",
      "I test in production, like a grown-up.",
      "Everything is top priority, so nothing is.",
      "The project is 90% done. Only the hard 90% left.",
      "rm -rf node_modules && npm install: the desperation prayer.",
      "It compiles! We'll sort out the rest in prod.",
      "A dev saying \"almost done\" is speaking in light-years.",
      "The best code is the code I didn't have to write."
    ]
  };

  // Dégradés CHOISIS, tous assez sombres pour que le texte blanc reste lisible.
  // (On évite des couleurs au hasard, qui tomberaient parfois sur du clair illisible.)
  var GRADIENTS = [
    ['#0f1923', '#267d42'], ['#1a1a2e', '#16213e'], ['#2d1b4e', '#5b2a86'], ['#0b3d2e', '#1e6f5c'],
    ['#3a1c40', '#7d2d52'], ['#102a43', '#334e68'], ['#241023', '#5c1a4b'], ['#0d3b3b', '#1f6f6f']
  ];

  // Les textes de l'interface (boutons, messages), eux aussi traduits.
  var UI = {
    fr: {
      title: "Punchline de développeur",
      sub: "Le truc qu'on dit (ou qu'on pense très fort) en codant.",
      next: "Nouvelle punchline",
      copy: "Copier le texte",
      credit: 'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
      copied: "Copié dans le presse-papier !",
      copyfail: "Copie impossible — sélectionnez le texte à la main."
    },
    en: {
      title: "Developer punchline",
      sub: "The thing you say (or think very loudly) while coding.",
      next: "New punchline",
      copy: "Copy text",
      credit: 'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
      copied: "Copied to clipboard!",
      copyfail: "Couldn't copy — please select the text manually."
    }
  };

  /* ---------------------------------------------------------------------------
     2. LES ÉLÉMENTS DE LA PAGE — on les récupère une fois pour toutes.
     --------------------------------------------------------------------------- */
  var card = document.getElementById('card');
  var quoteEl = document.getElementById('quote');
  var numEl = document.getElementById('num');
  var statusEl = document.getElementById('status');
  var nextBtn = document.getElementById('next');
  var copyBtn = document.getElementById('copy');

  /* ---------------------------------------------------------------------------
     3. L'ÉTAT — ce qui peut changer : la langue et la punchline affichée.
     --------------------------------------------------------------------------- */
  var lang = 'fr';
  var index = 0;   // position de la punchline affichée dans le tableau
  // On relit la langue choisie la dernière fois (si elle existe).
  try { lang = localStorage.getItem('punchline-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';   // garde-fou : valeur inattendue -> on repart sur 'fr'

  /* ---------------------------------------------------------------------------
     4. L'AFFICHAGE — mettre la bonne punchline et le bon dégradé sur la carte.
     --------------------------------------------------------------------------- */
  function pad(n) { return (n < 10 ? '0' : '') + n; }   // 7 -> "07", pour un joli "#07"

  function render(animate) {
    var text = QUOTES[lang][index];
    var g = GRADIENTS[index % GRADIENTS.length];   // % : on boucle sur les dégradés si plus de phrases que de dégradés

    function apply() {
      // textContent (et NON innerHTML) : le texte est inséré comme du texte,
      // jamais interprété comme du HTML. C'est ce qui évite les failles d'injection.
      quoteEl.textContent = text;
      numEl.textContent = '#' + pad(index + 1);
      card.style.background = 'linear-gradient(135deg, ' + g[0] + ', ' + g[1] + ')';
    }

    // Petit fondu au changement, sauf si la personne préfère moins d'animations.
    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (animate && !reduce) {
      card.classList.add('is-swapping');
      setTimeout(function () { apply(); card.classList.remove('is-swapping'); }, 200);
    } else {
      apply();
    }
  }

  /* ---------------------------------------------------------------------------
     5. LES ACTIONS — changer de langue, tirer une punchline, copier.
     --------------------------------------------------------------------------- */
  function setLang(next) {
    lang = next;
    try { localStorage.setItem('punchline-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;   // met à jour l'attribut lang de la page

    // On traduit chaque élément marqué data-i18n avec le texte correspondant.
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var key = el.getAttribute('data-i18n');
      if (!t[key]) return;
      // 'credit' contient un lien <a>, donc innerHTML ; ici le texte vient de NOUS, pas de l'utilisateur.
      if (key === 'credit') el.innerHTML = t[key];
      else el.textContent = t[key];
    });

    // On met à jour le bouton de langue actif (pour l'œil et pour le lecteur d'écran).
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });

    statusEl.textContent = '';
    render(false);
  }

  function nextQuote() {
    var n = QUOTES[lang].length;
    // On tire au hasard, mais on évite de retomber sur la même phrase qu'à l'instant.
    var i = index;
    while (i === index && n > 1) { i = Math.floor(Math.random() * n); }
    index = i;
    statusEl.textContent = '';
    render(true);
  }

  function copyText() {
    var text = QUOTES[lang][index];
    var done = function (ok) { statusEl.textContent = ok ? UI[lang].copied : UI[lang].copyfail; };

    // Méthode moderne (presse-papier). Si le navigateur ne la connaît pas, on bascule sur la méthode B.
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
    } else {
      // Méthode B (anciens navigateurs) : on copie depuis un champ texte temporaire.
      try {
        var ta = document.createElement('textarea');
        ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
        document.body.appendChild(ta); ta.select();
        document.execCommand('copy'); document.body.removeChild(ta);
        done(true);
      } catch (e) { done(false); }
    }
  }

  /* ---------------------------------------------------------------------------
     6. LE DÉMARRAGE — on branche les boutons, puis on affiche la 1re carte.
     --------------------------------------------------------------------------- */
  nextBtn.addEventListener('click', nextQuote);
  copyBtn.addEventListener('click', copyText);
  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);   // applique la langue (et déclenche le premier affichage)
})();
</script>
</body>
</html>

Your turn

The best way to remember all this is to keep tinkering with this project. Take it and grow it, keeping the same review reflexes on every addition:

  • Add your own punchlines (the best ones are the ones you've actually lived).
  • A "Share on X" button that pre-fills the tweet text.
  • A "Download as image" button for the card (look into canvas or the html2canvas lib); and ask yourself: a new dependency means a new surface to review.

For every feature the AI adds, ask the three questions from this build again: injection, contrast, accessibility. That's exactly what piloting AI looks like, instead of being driven by it.

Next project
Color palette generator →
Accepter ou rejeter le code de l'IA

Pour le bouton « Copier », l'IA te propose ce gestionnaire. Tu viens de poser aria-live="polite" sur la zone de statut au prompt 3. Relecteur : tu acceptes tel quel, ou tu rejettes ?

copyBtn.addEventListener('click', () => {
  navigator.clipboard.writeText(quoteEl.textContent);
  statusEl.textContent = 'Copié !';
});
À rejeter, à un détail près. navigator.clipboard.writeText renvoie une promesse qui peut échouer (permission refusée, page non sécurisée) : sans .catch, l'échec est silencieux et l'utilisateur lit quand même « Copié ! » alors que rien n'a été copié. C'est le piège du TD répété ici : rien ne te prévient. On annonce le vrai résultat :
navigator.clipboard.writeText(quoteEl.textContent)
  .then(() => { statusEl.textContent = 'Copié !'; })
  .catch(() => { statusEl.textContent = 'Copie impossible'; });
Rappel libre

Sans remonter dans la leçon : quels sont les trois points que la relecture humaine a corrigés après le passage de l'IA, et pourquoi le passage de innerHTML à textContent compte même si « ça marche » déjà ?

Les trois reprises : (1) injection : remplacer innerHTML par textContent ; (2) contraste : abandonner les dégradés 100 % aléatoires pour une liste de dégradés sombres garantissant le texte blanc lisible ; (3) accessibilité : aria-live="polite", :focus-visible et prefers-reduced-motion. Le textContent compte parce que innerHTML interprète les balises : aujourd'hui les punchlines sont sûres, mais le jour où le texte viendra d'un visiteur ou d'une API, ce même code devient une faille XSS. On prend le bon réflexe avant d'en avoir besoin.
Prochaine étape

Tu viens de faire naître ta première carte de punchline en pilotant l'IA. Au projet suivant, tu passes à un vrai petit outil : un générateur de palette de couleurs qui te ressort des combinaisons harmonieuses à la demande.

Leçon 2 : Palette de couleurs →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement