Le projet : une palette qu'on peut piquer en un clic
Deuxième projet, on monte d'un cran. Le projet 1 affichait du texte ; ici, on va vraiment fabriquer des éléments et interagir avec. Au menu : un générateur de palette de couleurs. Cinq couleurs côte à côte, un bouton pour en tirer de nouvelles, et un clic sur une couleur copie son code hex dans le presse-papier. C'est typiquement le petit outil qu'on ouvre pour de vrai quand on démarre une maquette et qu'on cherche une ambiance.
Pourquoi ce projet maintenant ? Parce qu'il introduit une chose nouvelle : on génère du HTML à la volée (les cinq pastilles), on le rend cliquable, et on lit une donnée dessus. Et c'est précisément sur ce terrain que l'IA va prendre deux raccourcis « qui marchent » à l'écran… mais qui laissent de côté tous ceux qui n'ont pas une souris et de bons yeux. On reprend la même boucle qu'au projet 1 : on code vite, on relit lentement.
Toujours la même contrainte : un seul fichier HTML, zéro dépendance. Tu pourras le télécharger et l'ouvrir tel quel à la fin.
- Générer du HTML dynamiquement en JavaScript (
createElement+appendChild) plutôt qu'écrire les éléments en dur dans le fichier. - Calculer la luminance WCAG d'une couleur pour choisir automatiquement noir ou blanc : fini le texte illisible sur fond clair.
- Rendre un élément cliquable accessible au clavier en utilisant
<button>au lieu d'une<div>avec unonclick.
- chaque couleur de la palette est un
<button>qu'on peut atteindre et activer à la touche Tab puis Entrée ; - le code hex reste lisible sur toutes les couleurs tirées, claires comme foncées, sans jamais l'écrire en dur ;
- l'action de copie est annoncée dans une zone
aria-live, audible par un lecteur d'écran.
Prompt 1 : poser le cadre
Comme avant, on commence par cadrer la demande au lieu de lâcher une idée floue. On précise le rendu (cinq couleurs côte à côte), l'interaction (clic = copie), et ce qu'on veut voir (le code hex sur chaque couleur) :
Crée une mini-app web autonome, un seul fichier HTML (HTML + CSS + JS, zéro dépendance, pas de framework). Objectif : afficher 5 couleurs aléatoires côte à côte, avec un bouton « Nouvelle palette ». Quand on clique une couleur, on copie son code hex. Affiche le code hex sur chaque couleur. Bilingue FR/EN. Code simple et lisible.
L'IA livre une version qui marche. Pour rendre chaque couleur cliquable, elle fait au plus court : une <div> avec un onclick, et du texte blanc dessus.
function render() {
palette.innerHTML = '';
colors.forEach(function (hex) {
const d = document.createElement('div');
d.className = 'swatch';
d.style.background = hex;
d.style.color = 'white'; // ⚠️ texte toujours blanc
d.textContent = hex;
d.onclick = function () { copy(hex); }; // ⚠️ div cliquable
palette.appendChild(d);
});
}
À l'écran, ça a l'air parfait : les couleurs s'affichent, le clic copie. Sauf que « à l'écran » et « à la souris » ne sont pas tout le monde. Deux problèmes dorment dans ces quelques lignes, et le second est sournois parce qu'il n'apparaît qu'une fois sur dix.
Sous le capot : comment le DOM se construit en direct. Quand on écrit document.createElement('button'), le navigateur crée un nœud en mémoire, un objet JavaScript qui représente un bouton, mais qui n'est pas encore visible dans la page. Pense à un arbre vivant : le bouton existe en pépinière, il n'est pas encore planté dans le jardin. C'est paletteEl.appendChild(b) qui l'attache à l'arbre du document, et à cet instant précis le navigateur le rend à l'écran. Ce qui transite de l'un à l'autre, ce n'est pas du texte HTML : c'est l'objet nœud lui-même. La ligne paletteEl.innerHTML = '' en début de renderPalette() sert à vider cet arbre avant de le reconstruire. Si on oublie ce reset, chaque appel à « Nouvelle palette » ajoute cinq pastilles aux cinq précédentes au lieu de les remplacer : les vieilles couleurs s'empilent indéfiniment.
Relis ces quelques lignes de render() sans descendre. La couleur s'affiche, le clic copie. À ton avis, quel problème d'accessibilité se cache là, et qui ne le verra jamais ?
const d = document.createElement('div');
d.style.color = 'white';
d.onclick = function () { copy(hex); };
Vérifier ma prédiction
Deux problèmes, et les deux sont invisibles à la souris. Le premier : cette <div> n'est pas atteignable au clavier. La touche Tab la saute complètement, Entrée ne fait rien. Quiconque navigue sans souris ne peut pas se servir de l'outil. Le second : color: white écrit en dur tombe mal dès qu'une couleur claire est tirée, texte blanc sur fond beige, illisible. Aucune erreur en console, aucun test au rouge : seul l'œil humain voit le problème, et encore, seulement s'il tombe sur le mauvais tirage.
Ma relecture humaine : 3 trucs que l'IA a laissés passer
Sur le projet 1, le réflexe-clé était la sécurité (injection) et le contraste. Ici, c'est surtout l'accessibilité qui va parler, comment quelqu'un qui n'utilise pas de souris, ou qui ne voit pas l'écran, peut quand même se servir de l'outil. Voici les trois points repris, et pourquoi.
1. Une <div> cliquable n'est pas un bouton
Mettre un onclick sur une <div>, ça marche… à la souris, et seulement à la souris. Pour le reste du monde, cette div est invisible : on ne peut pas l'atteindre avec la touche Tab, elle n'annonce aucun rôle à un lecteur d'écran, et appuyer sur Entrée ou Espace ne fait rien. Un <button>, lui, apporte tout ça gratuitement : le focus clavier, le rôle « bouton », l'activation au clavier. La règle est simple et tient pour toujours : tout ce qui se clique et déclenche une action est un <button>, pas une div déguisée.
2. Le texte blanc disparaît sur les couleurs claires
C'est le piège vicieux. color: white écrit en dur, c'est nickel sur un bleu nuit… et totalement illisible sur un jaune pâle ou un beige. Or les couleurs sont tirées au hasard : impossible de deviner à l'avance. Et comme au projet 1, rien ne te prévient, il faut tomber sur le mauvais tirage pour le voir. La bonne réponse n'est pas de tâtonner, c'est de calculer : on mesure la luminance de chaque couleur (la formule officielle du WCAG, celle de l'accessibilité web) et on met le texte en noir sur les couleurs claires, en blanc sur les foncées. Quelle que soit la couleur tirée, le code hex reste lisible.
3. La copie doit être annoncée
Quand on copie un code, un petit « Copié : #xxxxxx » s'affiche. Visible pour qui regarde l'écran, muet pour qui utilise un lecteur d'écran. On le place donc dans une zone aria-live="polite", qui est relue dès que son contenu change. Et tant qu'on y est, chaque bouton de couleur reçoit un aria-label parlant (« Copier la couleur #xxxxxx ») au lieu de laisser le lecteur d'écran ânonner six caractères hexadécimaux hors contexte.
Prompt 2 : durcir après relecture
Trois constats, trois corrections. On repasse la main à l'IA avec une consigne précise pour chacune :
Trois corrections : (1) remplace les div cliquables par de vrais <button> (focus clavier + activation Entrée/Espace). (2) Ne mets plus le texte en blanc en dur : calcule la luminance relative (WCAG) de chaque couleur et choisis un texte noir ou blanc pour garantir le contraste. (3) Ajoute aria-live="polite" sur le statut et un aria-label explicite sur chaque bouton de couleur.
// Luminance relative (WCAG) : noir sur les couleurs claires, blanc sur les foncées
function readableTextColor(hex) {
var r = parseInt(hex.slice(1,3),16)/255,
g = parseInt(hex.slice(3,5),16)/255,
b = parseInt(hex.slice(5,7),16)/255;
function lin(c){ return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
var L = 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
return L > 0.179 ? '#111111' : '#ffffff';
}
// Un vrai bouton : focusable et activable au clavier
var b = document.createElement('button');
b.type = 'button';
b.style.color = readableTextColor(hex);
b.setAttribute('aria-label', 'Copier la couleur ' + hex);
Tu remarques le fil rouge avec le projet 1 ? Le contraste, encore. Là où on avait choisi des dégradés sûrs, ici on le calcule. Deux façons de régler le même réflexe : ne jamais laisser le hasard décider de la lisibilité.
Héberger et tester
Comme le projet 1, c'est un fichier statique : on le dépose sur le serveur, au même endroit que le site, et c'est en ligne. Côté tests, on garde les bons réflexes, plus un spécifique à ce projet :
- Naviguer au clavier seul (touche Tab) : on doit pouvoir atteindre chaque couleur et la copier avec Entrée. C'est le test qui prouve que les
<button>ont servi à quelque chose. - Cliquer « Nouvelle palette » une vingtaine de fois : sur chaque couleur, le code hex doit rester lisible, clair comme foncé.
- Vérifier la copie (coller ailleurs), la bascule FR / EN, et la console (zéro erreur).
Le rendu final
Le projet terminé, en vrai, dans la page :
Le code complet (et téléchargeable)
Le fichier entier, exactement celui qui tourne au-dessus. Télécharge-le, ouvre-le, lis-le.
Télécharger le code (.html · 243 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 palette de couleurs</title>
<meta name="description" content="Génère une palette de couleurs et copie un code hex en un clic. 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 PALETTE — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (les 5 pastilles, les boutons, le responsive).
2. <body> : la structure (en-tête + zone palette + bouton).
3. <script> : la logique (tirer des couleurs, choisir noir/blanc, copier).
Point clé du projet : le texte de chaque couleur passe en noir OU blanc
automatiquement, selon la luminosité du fond, pour rester toujours lisible.
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
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: 680px; }
header { text-align: center; margin-bottom: 22px; }
h1 { font-size: 1.5rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.sub { color: var(--muted); font-size: 0.95rem; margin: 0 0 16px; }
.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; }
.lang-btn:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }
/* La palette = 5 pastilles côte à côte (flex). Chaque pastille est un <button>. */
.palette { display: flex; gap: 0; border-radius: 16px; overflow: hidden; box-shadow: 0 16px 38px rgba(15,25,35,0.16); }
.swatch {
flex: 1 1 0; min-height: 220px; border: 0; cursor: pointer;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 18px; font-family: 'JetBrains Mono', ui-monospace, monospace;
font-weight: 700; font-size: 0.95rem; letter-spacing: 0.02em;
transition: flex-grow 0.2s ease;
}
.swatch:hover { flex-grow: 1.25; } /* la pastille survolée s'élargit un peu */
.swatch:focus-visible { outline: 3px solid #111; outline-offset: -3px; } /* contour clavier */
.actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; justify-content: center; }
.btn { min-height: 50px; padding: 0 26px; 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:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }
.hint { text-align: center; color: var(--muted); font-size: 0.85rem; margin-top: 14px; }
.status { text-align: center; min-height: 20px; margin-top: 6px; font-size: 0.85rem; font-weight: 600; color: var(--accent); }
.credit { text-align: center; color: var(--muted); font-size: 0.78rem; margin-top: 28px; }
.credit a { color: var(--accent); }
/* Sur petit écran : les pastilles s'empilent verticalement. */
@media (max-width: 560px) {
.palette { flex-direction: column; }
.swatch { min-height: 60px; align-items: center; padding-bottom: 0; }
.swatch:hover { flex-grow: 1; }
}
@media (prefers-reduced-motion: reduce) { .swatch, .btn { transition: none; } }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Palette de couleurs</h1>
<p class="sub" data-i18n="sub">Génère une palette, clique une couleur pour copier son code hex.</p>
<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>
<!-- Cette zone est vide au départ : le JS y insère les 5 pastilles. -->
<div class="palette" id="palette" role="group" aria-label="Palette"></div>
<div class="actions">
<button class="btn btn-primary" id="gen" type="button" data-i18n="gen">Nouvelle palette</button>
</div>
<p class="hint" data-i18n="hint">Clique une couleur pour copier son code hex.</p>
<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>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES DE L'INTERFACE (traduits FR / EN).
--------------------------------------------------------------------------- */
var UI = {
fr: {
title: "Palette de couleurs",
sub: "Génère une palette, clique une couleur pour copier son code hex.",
gen: "Nouvelle palette",
hint: "Clique une couleur pour copier son code hex.",
credit: 'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
copied: "Copié : ",
copyfail: "Copie impossible — sélectionne le code à la main.",
copyLabel: "Copier la couleur "
},
en: {
title: "Color palette",
sub: "Generate a palette, click a color to copy its hex code.",
gen: "New palette",
hint: "Click a color to copy its hex code.",
credit: 'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
copied: "Copied: ",
copyfail: "Couldn't copy — select the code manually.",
copyLabel: "Copy color "
}
};
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS DE LA PAGE + L'ÉTAT (la langue, les couleurs affichées).
--------------------------------------------------------------------------- */
var paletteEl = document.getElementById('palette');
var statusEl = document.getElementById('status');
var genBtn = document.getElementById('gen');
var lang = 'fr';
try { lang = localStorage.getItem('palette-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
var colors = []; // les 5 codes hex de la palette en cours
/* ---------------------------------------------------------------------------
3. LES OUTILS COULEUR — tirer une couleur, choisir un texte lisible.
--------------------------------------------------------------------------- */
// Tire une couleur au hasard et la renvoie au format "#rrggbb".
function randomHex() {
var n = Math.floor(Math.random() * 0x1000000); // un nombre entre 0 et 16 777 215
return '#' + ('000000' + n.toString(16)).slice(-6); // converti en hexa, complété à 6 caractères
}
// Renvoie '#111111' (noir) ou '#ffffff' (blanc) selon la LUMINOSITÉ du fond,
// pour que le texte reste lisible quelle que soit la couleur. (Formule officielle WCAG.)
function readableTextColor(hex) {
// 1) on sépare les composantes rouge / vert / bleu, ramenées entre 0 et 1
var r = parseInt(hex.slice(1, 3), 16) / 255;
var g = parseInt(hex.slice(3, 5), 16) / 255;
var b = parseInt(hex.slice(5, 7), 16) / 255;
// 2) petite correction (gamma) demandée par la norme
function lin(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
// 3) la luminance perçue : l'œil est surtout sensible au vert, peu au bleu
var L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
// 4) fond clair -> texte noir ; fond foncé -> texte blanc
return L > 0.179 ? '#111111' : '#ffffff';
}
/* ---------------------------------------------------------------------------
4. LA COPIE — copier un code hex, avec repli pour vieux navigateurs.
--------------------------------------------------------------------------- */
function copy(hex) {
var done = function (ok) { statusEl.textContent = ok ? (UI[lang].copied + hex) : UI[lang].copyfail; };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(hex).then(function () { done(true); }, function () { done(false); });
} else {
try {
var ta = document.createElement('textarea');
ta.value = hex; 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); }
}
}
/* ---------------------------------------------------------------------------
5. L'AFFICHAGE — construire les 5 pastilles à partir du tableau "colors".
--------------------------------------------------------------------------- */
function renderPalette() {
paletteEl.innerHTML = ''; // on vide la zone avant de la reconstruire
colors.forEach(function (hex) {
// Un VRAI <button> (et non une <div>) : il est focusable et utilisable au clavier.
var b = document.createElement('button');
b.type = 'button';
b.className = 'swatch';
b.style.background = hex;
b.style.color = readableTextColor(hex); // texte noir ou blanc, selon le fond
b.textContent = hex;
b.setAttribute('aria-label', UI[lang].copyLabel + hex); // libellé clair pour lecteur d'écran
b.addEventListener('click', function () { copy(hex); });
paletteEl.appendChild(b);
});
}
/* ---------------------------------------------------------------------------
6. LES ACTIONS — nouvelle palette, changement de langue.
--------------------------------------------------------------------------- */
function newPalette() {
colors = [];
for (var i = 0; i < 5; i++) colors.push(randomHex());
statusEl.textContent = '';
renderPalette();
}
function setLang(next) {
lang = next;
try { localStorage.setItem('palette-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
document.querySelectorAll('[data-lang-btn]').forEach(function (bb) {
bb.setAttribute('aria-pressed', bb.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
statusEl.textContent = '';
renderPalette(); // on ré-affiche pour mettre à jour les aria-label dans la bonne langue
}
/* ---------------------------------------------------------------------------
7. LE DÉMARRAGE.
--------------------------------------------------------------------------- */
genBtn.addEventListener('click', newPalette);
document.querySelectorAll('[data-lang-btn]').forEach(function (bb) {
bb.addEventListener('click', function () { setLang(bb.getAttribute('data-lang-btn')); });
});
newPalette(); // une première palette dès l'ouverture
setLang(lang); // puis on applique la langue
})();
</script>
</body>
</html>
À toi de jouer
Le projet est solide ; maintenant amuse-toi à le pousser. Quelques pistes ouvertes pour t'approprier le code :
- Génère des couleurs harmonieuses (même teinte, luminosités différentes) plutôt que totalement au hasard.
- Affiche un petit aperçu de la palette en miniature dans le titre de l'onglet, via l'API Canvas.
Ajoute un bouton « Copier toute la palette » qui copie les cinq codes hex d'un coup, séparés par des virgules, et affiche un feedback audible par un lecteur d'écran. Essaie seul d'abord : c'est en produisant sans modèle qu'on sort vraiment du tutoriel. Tu ne déplies le corrigé qu'après avoir essayé.
Tu as réussi si :
- le bouton est un vrai
<button type="button">, accessible au clavier comme les pastilles ; - le texte copié contient bien les cinq hex séparés par
,(exemple :#a1b2c3, #4d5e6f, …) ; - un message de confirmation apparaît dans la zone
aria-livedéjà présente, pour que le feedback soit annoncé au lecteur d'écran.
Avant de regarder le corrigé : complète ce squelette. Les lignes marquées /* à toi : … */ sont les seules à écrire. Le reste est donné.
var copyAllBtn = document.createElement('button');
copyAllBtn.type = 'button';
copyAllBtn.className = 'btn btn-primary';
/* à toi : donne-lui un texte selon la langue (lang === 'fr') */
copyAllBtn.textContent = ____________;
copyAllBtn.addEventListener('click', function () {
/* à toi : construis la chaîne des 5 hex séparés par ", " */
var text = ____________;
navigator.clipboard.writeText(text).then(function () {
statusEl.textContent = /* à toi : message de confirmation */ ____________;
});
});
document.querySelector('.actions').appendChild(copyAllBtn);
Voir une solution possible
// On ajoute le bouton une seule fois, dans la zone .actions existante
var copyAllBtn = document.createElement('button');
copyAllBtn.type = 'button';
copyAllBtn.className = 'btn btn-primary';
// textContent et non innerHTML : on n'a pas besoin de balises ici
copyAllBtn.textContent = lang === 'fr' ? 'Copier la palette' : 'Copy palette';
copyAllBtn.addEventListener('click', function () {
// colors est le tableau des 5 hex déjà en mémoire — pas besoin de re-lire le DOM
var text = colors.join(', ');
navigator.clipboard.writeText(text)
.then(function () {
// La zone statusEl a déjà aria-live="polite" : tout changement de textContent
// est annoncé automatiquement par les lecteurs d'écran.
statusEl.textContent = lang === 'fr'
? 'Palette copiée : ' + text
: 'Palette copied: ' + text;
})
.catch(function () {
statusEl.textContent = lang === 'fr' ? 'Copie impossible' : 'Copy failed';
});
});
document.querySelector('.actions').appendChild(copyAllBtn);
Le réflexe clé : on réutilise la zone aria-live déjà en place plutôt d'en créer une nouvelle. Rien de spectaculaire, mais ça évite deux attributs redondants et garde le DOM cohérent. C'est exactement ça, relire et mutualiser plutôt qu'empiler.
À chaque ajout, repose-toi les questions du TD : est-ce cliquable au clavier ? le texte reste-t-il lisible ? l'action est-elle annoncée ?
The project: a palette you can grab in one click
Second project, one notch up. Project 1 displayed text; here, we'll actually build elements and interact with them. On the menu: a color palette generator. Five colors side by side, a button to draw new ones, and a click on a color copies its hex code to the clipboard. Exactly the little tool you really open when starting a mockup and hunting for a mood.
Why this project now? Because it introduces something new: we generate HTML on the fly (the five swatches), make it clickable, and read data on it. And that's precisely the ground where the AI will take two shortcuts that "work" on screen… but leave out everyone without a mouse and good eyesight. We reuse the same loop as project 1: code fast, review slowly.
Same constraint as always: a single HTML file, zero dependencies. You'll be able to download it and open it as-is at the end.
- Generate HTML dynamically in JavaScript (
createElement+appendChild) rather than hardcoding elements in the file. - Compute the WCAG luminance of a color to automatically pick black or white text — no more unreadable text on light backgrounds.
- Make a clickable element keyboard-accessible by using
<button>instead of a<div>with anonclick.
- every color swatch is a
<button>you can reach and activate with Tab then Enter; - the hex code stays readable on every generated color — light or dark — without ever being hardcoded;
- the copy action is announced in an
aria-liveregion, audible to a screen reader.
Prompt 1: set the frame
As before, we start by framing the request instead of tossing out a vague idea. We specify the look (five colors side by side), the interaction (click = copy), and what we want to see (the hex code on each color):
Create a standalone web mini-app, a single HTML file (HTML + CSS + JS, zero dependencies, no framework). Goal: show 5 random colors side by side, with a "New palette" button. Clicking a color copies its hex code. Show the hex code on each color. Bilingual FR/EN. Simple, readable code.
The AI ships a working version. To make each color clickable, it takes the shortest path: a <div> with an onclick, and white text on top.
function render() {
palette.innerHTML = '';
colors.forEach(function (hex) {
const d = document.createElement('div');
d.className = 'swatch';
d.style.background = hex;
d.style.color = 'white'; // ⚠️ always white text
d.textContent = hex;
d.onclick = function () { copy(hex); }; // ⚠️ clickable div
palette.appendChild(d);
});
}
On screen, it looks perfect: colors show up, clicking copies. Except "on screen" and "with a mouse" aren't everyone. Two problems are sleeping in those few lines — and the second is sneaky, because it only shows up one time out of ten.
Under the hood: how the DOM gets built live. When you write document.createElement('button'), the browser creates a node in memory — a JavaScript object that represents a button, but isn't visible in the page yet. Think of a living tree: the button exists in the nursery, it hasn't been planted in the garden yet. It's paletteEl.appendChild(b) that attaches it to the document tree — and at that exact moment the browser renders it on screen. What travels between the two is not HTML text: it's the node object itself. The paletteEl.innerHTML = '' line at the top of renderPalette() clears that tree before rebuilding it. Skip that reset and every click on "New palette" adds five swatches on top of the previous five instead of replacing them — old colors stack up indefinitely.
Re-read those few lines from render() without scrolling down. Colors show up, clicking copies. What accessibility problem do you think is hiding there — and who will never see it?
const d = document.createElement('div');
d.style.color = 'white';
d.onclick = function () { copy(hex); };
Check my prediction
Two problems, and both are invisible with a mouse. First: this <div> is unreachable by keyboard. Tab skips it entirely, Enter does nothing. Anyone navigating without a mouse simply can't use the tool. Second: a hardcoded color: white fails whenever a light color is drawn — white text on a beige background is unreadable. No console error, no failing test: only the human eye catches it, and only if it hits the wrong draw at the right moment.
My human review: 3 things the AI let slip
On project 1, the key reflex was security (injection) and contrast. Here, it's mostly accessibility that will speak up — how someone who doesn't use a mouse, or who can't see the screen, can still use the tool. Here are the three points I redid, and why.
1. A clickable <div> is not a button
Putting an onclick on a <div> works… with a mouse, and only with a mouse. For everyone else, that div is invisible: you can't reach it with Tab, it announces no role to a screen reader, and pressing Enter or Space does nothing. A <button> brings all of that for free: keyboard focus, the "button" role, keyboard activation. The rule is simple and holds forever: anything that's clicked and triggers an action is a <button>, not a div in disguise.
2. White text disappears on light colors
This is the nasty one. A hardcoded color: white is great on navy blue… and utterly unreadable on pale yellow or beige. But the colors are random: impossible to guess ahead of time. And like project 1, nothing warns you — you have to hit the wrong draw to see it. The right answer isn't trial and error, it's to compute: we measure each color's luminance (the official WCAG formula, the one for web accessibility) and put the text in black on light colors, white on dark ones. Whatever color comes up, the hex code stays readable.
3. Copying must be announced
When you copy a code, a little "Copied: #xxxxxx" appears. Visible to whoever looks at the screen, silent to whoever uses a screen reader. So we put it in an aria-live="polite" region, which is re-read whenever its content changes. And while we're at it, each color button gets a meaningful aria-label ("Copy color #xxxxxx") instead of letting the screen reader mumble six hex characters out of context.
Prompt 2: harden after review
Three findings, three fixes. We hand it back to the AI with a precise instruction for each:
Three fixes: (1) replace the clickable divs with real <button> (keyboard focus + Enter/Space activation). (2) Stop hardcoding white text: compute the relative luminance (WCAG) of each color and pick black or white text to guarantee contrast. (3) Add aria-live="polite" on the status and an explicit aria-label on each color button.
// Relative luminance (WCAG): black on light colors, white on dark ones
function readableTextColor(hex) {
var r = parseInt(hex.slice(1,3),16)/255,
g = parseInt(hex.slice(3,5),16)/255,
b = parseInt(hex.slice(5,7),16)/255;
function lin(c){ return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
var L = 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
return L > 0.179 ? '#111111' : '#ffffff';
}
// A real button: focusable and keyboard-activatable
var b = document.createElement('button');
b.type = 'button';
b.style.color = readableTextColor(hex);
b.setAttribute('aria-label', 'Copy color ' + hex);
Notice the through-line with project 1? Contrast, again. Where we chose safe gradients, here we compute it. Two ways to handle the same reflex: never let chance decide readability.
Host and test
Like project 1, it's a static file: drop it on the server, in the same place as the site, and it's online. For testing, keep the good reflexes, plus one specific to this project:
- Navigate with the keyboard only (Tab key): you must be able to reach each color and copy it with Enter. That's the test that proves the
<button>earned their place. - Click "New palette" about twenty times: on every color, the hex code must stay readable, light or dark.
- Check copying (paste elsewhere), the FR / EN toggle, and the console (zero error).
The finished result
The finished project, live, right in the page:
The full code (and downloadable)
The entire file, exactly the one running above. Download it, open it, read it.
Download the code (.html · 243 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 palette de couleurs</title>
<meta name="description" content="Génère une palette de couleurs et copie un code hex en un clic. 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 PALETTE — un seul fichier : HTML + CSS + JS, zéro dépendance.
1. <style> : l'apparence (les 5 pastilles, les boutons, le responsive).
2. <body> : la structure (en-tête + zone palette + bouton).
3. <script> : la logique (tirer des couleurs, choisir noir/blanc, copier).
Point clé du projet : le texte de chaque couleur passe en noir OU blanc
automatiquement, selon la luminosité du fond, pour rester toujours lisible.
============================================================================
-->
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
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: 680px; }
header { text-align: center; margin-bottom: 22px; }
h1 { font-size: 1.5rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.sub { color: var(--muted); font-size: 0.95rem; margin: 0 0 16px; }
.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; }
.lang-btn:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }
/* La palette = 5 pastilles côte à côte (flex). Chaque pastille est un <button>. */
.palette { display: flex; gap: 0; border-radius: 16px; overflow: hidden; box-shadow: 0 16px 38px rgba(15,25,35,0.16); }
.swatch {
flex: 1 1 0; min-height: 220px; border: 0; cursor: pointer;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 18px; font-family: 'JetBrains Mono', ui-monospace, monospace;
font-weight: 700; font-size: 0.95rem; letter-spacing: 0.02em;
transition: flex-grow 0.2s ease;
}
.swatch:hover { flex-grow: 1.25; } /* la pastille survolée s'élargit un peu */
.swatch:focus-visible { outline: 3px solid #111; outline-offset: -3px; } /* contour clavier */
.actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; justify-content: center; }
.btn { min-height: 50px; padding: 0 26px; 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:focus-visible { outline: 3px solid rgba(38,125,66,0.4); outline-offset: 2px; }
.hint { text-align: center; color: var(--muted); font-size: 0.85rem; margin-top: 14px; }
.status { text-align: center; min-height: 20px; margin-top: 6px; font-size: 0.85rem; font-weight: 600; color: var(--accent); }
.credit { text-align: center; color: var(--muted); font-size: 0.78rem; margin-top: 28px; }
.credit a { color: var(--accent); }
/* Sur petit écran : les pastilles s'empilent verticalement. */
@media (max-width: 560px) {
.palette { flex-direction: column; }
.swatch { min-height: 60px; align-items: center; padding-bottom: 0; }
.swatch:hover { flex-grow: 1; }
}
@media (prefers-reduced-motion: reduce) { .swatch, .btn { transition: none; } }
</style>
</head>
<body>
<main class="app">
<header>
<h1 data-i18n="title">Palette de couleurs</h1>
<p class="sub" data-i18n="sub">Génère une palette, clique une couleur pour copier son code hex.</p>
<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>
<!-- Cette zone est vide au départ : le JS y insère les 5 pastilles. -->
<div class="palette" id="palette" role="group" aria-label="Palette"></div>
<div class="actions">
<button class="btn btn-primary" id="gen" type="button" data-i18n="gen">Nouvelle palette</button>
</div>
<p class="hint" data-i18n="hint">Clique une couleur pour copier son code hex.</p>
<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>
(function () {
'use strict';
/* ---------------------------------------------------------------------------
1. LES TEXTES DE L'INTERFACE (traduits FR / EN).
--------------------------------------------------------------------------- */
var UI = {
fr: {
title: "Palette de couleurs",
sub: "Génère une palette, clique une couleur pour copier son code hex.",
gen: "Nouvelle palette",
hint: "Clique une couleur pour copier son code hex.",
credit: 'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
copied: "Copié : ",
copyfail: "Copie impossible — sélectionne le code à la main.",
copyLabel: "Copier la couleur "
},
en: {
title: "Color palette",
sub: "Generate a palette, click a color to copy its hex code.",
gen: "New palette",
hint: "Click a color to copy its hex code.",
credit: 'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
copied: "Copied: ",
copyfail: "Couldn't copy — select the code manually.",
copyLabel: "Copy color "
}
};
/* ---------------------------------------------------------------------------
2. LES ÉLÉMENTS DE LA PAGE + L'ÉTAT (la langue, les couleurs affichées).
--------------------------------------------------------------------------- */
var paletteEl = document.getElementById('palette');
var statusEl = document.getElementById('status');
var genBtn = document.getElementById('gen');
var lang = 'fr';
try { lang = localStorage.getItem('palette-lang') || 'fr'; } catch (e) {}
if (lang !== 'fr' && lang !== 'en') lang = 'fr';
var colors = []; // les 5 codes hex de la palette en cours
/* ---------------------------------------------------------------------------
3. LES OUTILS COULEUR — tirer une couleur, choisir un texte lisible.
--------------------------------------------------------------------------- */
// Tire une couleur au hasard et la renvoie au format "#rrggbb".
function randomHex() {
var n = Math.floor(Math.random() * 0x1000000); // un nombre entre 0 et 16 777 215
return '#' + ('000000' + n.toString(16)).slice(-6); // converti en hexa, complété à 6 caractères
}
// Renvoie '#111111' (noir) ou '#ffffff' (blanc) selon la LUMINOSITÉ du fond,
// pour que le texte reste lisible quelle que soit la couleur. (Formule officielle WCAG.)
function readableTextColor(hex) {
// 1) on sépare les composantes rouge / vert / bleu, ramenées entre 0 et 1
var r = parseInt(hex.slice(1, 3), 16) / 255;
var g = parseInt(hex.slice(3, 5), 16) / 255;
var b = parseInt(hex.slice(5, 7), 16) / 255;
// 2) petite correction (gamma) demandée par la norme
function lin(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
// 3) la luminance perçue : l'œil est surtout sensible au vert, peu au bleu
var L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
// 4) fond clair -> texte noir ; fond foncé -> texte blanc
return L > 0.179 ? '#111111' : '#ffffff';
}
/* ---------------------------------------------------------------------------
4. LA COPIE — copier un code hex, avec repli pour vieux navigateurs.
--------------------------------------------------------------------------- */
function copy(hex) {
var done = function (ok) { statusEl.textContent = ok ? (UI[lang].copied + hex) : UI[lang].copyfail; };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(hex).then(function () { done(true); }, function () { done(false); });
} else {
try {
var ta = document.createElement('textarea');
ta.value = hex; 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); }
}
}
/* ---------------------------------------------------------------------------
5. L'AFFICHAGE — construire les 5 pastilles à partir du tableau "colors".
--------------------------------------------------------------------------- */
function renderPalette() {
paletteEl.innerHTML = ''; // on vide la zone avant de la reconstruire
colors.forEach(function (hex) {
// Un VRAI <button> (et non une <div>) : il est focusable et utilisable au clavier.
var b = document.createElement('button');
b.type = 'button';
b.className = 'swatch';
b.style.background = hex;
b.style.color = readableTextColor(hex); // texte noir ou blanc, selon le fond
b.textContent = hex;
b.setAttribute('aria-label', UI[lang].copyLabel + hex); // libellé clair pour lecteur d'écran
b.addEventListener('click', function () { copy(hex); });
paletteEl.appendChild(b);
});
}
/* ---------------------------------------------------------------------------
6. LES ACTIONS — nouvelle palette, changement de langue.
--------------------------------------------------------------------------- */
function newPalette() {
colors = [];
for (var i = 0; i < 5; i++) colors.push(randomHex());
statusEl.textContent = '';
renderPalette();
}
function setLang(next) {
lang = next;
try { localStorage.setItem('palette-lang', lang); } catch (e) {}
document.documentElement.lang = lang;
var t = UI[lang];
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var k = el.getAttribute('data-i18n'); if (!t[k]) return;
if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
});
document.querySelectorAll('[data-lang-btn]').forEach(function (bb) {
bb.setAttribute('aria-pressed', bb.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
});
statusEl.textContent = '';
renderPalette(); // on ré-affiche pour mettre à jour les aria-label dans la bonne langue
}
/* ---------------------------------------------------------------------------
7. LE DÉMARRAGE.
--------------------------------------------------------------------------- */
genBtn.addEventListener('click', newPalette);
document.querySelectorAll('[data-lang-btn]').forEach(function (bb) {
bb.addEventListener('click', function () { setLang(bb.getAttribute('data-lang-btn')); });
});
newPalette(); // une première palette dès l'ouverture
setLang(lang); // puis on applique la langue
})();
</script>
</body>
</html>
Your turn
The project is solid; now have fun pushing it. A few open ideas to make the code yours:
- Generate harmonious colors (same hue, different lightness) instead of fully random.
- Show a tiny palette preview in the browser tab title, via the Canvas API.
Add a "Copy whole palette" button that copies all five hex codes at once, comma-separated, and gives feedback that a screen reader can announce. Try it on your own first — producing without a model is how you truly leave the tutorial behind. Only unfold the solution after you've tried.
You've succeeded if:
- the button is a real
<button type="button">, keyboard-accessible just like the swatches; - the copied text contains all five hex codes separated by
,(e.g.,#a1b2c3, #4d5e6f, …); - a confirmation message appears in the existing
aria-liveregion, so the feedback is announced to screen readers.
Before looking at the solution: fill in this skeleton. Only the lines marked /* your turn: … */ need to be written. Everything else is given.
var copyAllBtn = document.createElement('button');
copyAllBtn.type = 'button';
copyAllBtn.className = 'btn btn-primary';
/* your turn: set the label based on the current language (lang === 'fr') */
copyAllBtn.textContent = ____________;
copyAllBtn.addEventListener('click', function () {
/* your turn: build the comma-separated string of all 5 hex codes */
var text = ____________;
navigator.clipboard.writeText(text).then(function () {
statusEl.textContent = /* your turn: confirmation message */ ____________;
});
});
document.querySelector('.actions').appendChild(copyAllBtn);
See one possible solution
// Add the button once, inside the existing .actions area
var copyAllBtn = document.createElement('button');
copyAllBtn.type = 'button';
copyAllBtn.className = 'btn btn-primary';
// textContent, not innerHTML — no tags needed here
copyAllBtn.textContent = lang === 'fr' ? 'Copier la palette' : 'Copy palette';
copyAllBtn.addEventListener('click', function () {
// colors is the in-memory array — no need to re-read the DOM
var text = colors.join(', ');
navigator.clipboard.writeText(text)
.then(function () {
// statusEl already has aria-live="polite": any textContent change
// is automatically announced by screen readers.
statusEl.textContent = lang === 'fr'
? 'Palette copiée : ' + text
: 'Palette copied: ' + text;
})
.catch(function () {
statusEl.textContent = lang === 'fr' ? 'Copie impossible' : 'Copy failed';
});
});
document.querySelector('.actions').appendChild(copyAllBtn);
The key reflex: reuse the aria-live region already in place rather than creating a new one. Nothing flashy, but it avoids two redundant attributes and keeps the DOM consistent. That's exactly what reviewing and sharing code looks like — instead of stacking.
On every addition, ask the build's questions again: is it keyboard-clickable? does the text stay readable? is the action announced?
Tu lui as demandé de remplacer la <div> cliquable par un vrai bouton. L'IA renvoie ça. Tu l'acceptes tel quel ou tu le rejettes ?
const d = document.createElement('div');
d.className = 'swatch';
d.setAttribute('role', 'button');
d.style.color = 'white';
d.onclick = function () { copy(hex); };
role="button" sur une <div> ment au lecteur d'écran (il annonce « bouton ») mais ne donne rien d'autre : pas de focus clavier, pas d'activation à Entrée/Espace. Et le color: white en dur est toujours là, illisible sur les couleurs claires. La vraie réponse reste celle du TD : un <button> natif (qui apporte focus + clavier gratuitement) et une couleur de texte calculée par luminance.Sans remonter dans la leçon : pourquoi une couleur cliquable doit-elle être un <button> et pas une <div>, et pourquoi calcule-t-on la luminance au lieu d'écrire color: white en dur ?
<button> apporte gratuitement le focus clavier, le rôle « bouton » annoncé au lecteur d'écran et l'activation à Entrée/Espace ; une <div> avec onclick ne marche qu'à la souris. Côté texte, les couleurs sont tirées au hasard : color: white en dur devient illisible sur un fond clair. On mesure donc la luminance relative (formule WCAG) et on choisit noir ou blanc, pour que le code hex reste lisible quelle que soit la couleur.Ton générateur de palette crache maintenant des couleurs harmonieuses à volonté. Au projet suivant, tu construis un suivi d'habitudes : une appli qui mémorise tes coches jour après jour et te montre tes séries.
Leçon 3 : Suivi d'habitudes →