Aller au contenu
nella

Créations

Des exemples qui fonctionnent.

Chaque exemple est une vraie page HTML, CSS et JavaScript. Il s’exécute ici, dans un cadre isolé, comme une création de Nella. Cliquez, tapez, jouez.

Ces exemples ont été préparés pour cette page afin de montrer le type de résultat obtenu. Ils sont fictifs et rédigés en français.

Calculateur de pourboire Exemple en français
Code Aperçu
<!doctype html>
<html lang="fr-CA">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calculateur de pourboire</title>
<style>
  :root{--ink:#0f1f1a;--muted:#5b6b65;--teal:#0f766e;--teal-soft:#e6f4f1;--line:#dfe7e4;--bg:#f6f8f7}
  *{box-sizing:border-box}
  body{margin:0;min-height:100vh;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);display:grid;place-items:center;padding:28px 16px}
  .card{width:100%;max-width:820px;background:#fff;border:1px solid var(--line);border-radius:24px;display:grid;grid-template-columns:1.1fr 1fr;overflow:hidden;box-shadow:0 30px 60px -40px rgba(15,31,26,.35)}
  .form{padding:28px;display:grid;gap:20px;align-content:start}
  h1{margin:0;font-size:22px;letter-spacing:-.02em}
  .hint{margin:4px 0 0;color:var(--muted);font-size:14px}
  label,.label{display:block;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:.06em}
  .money{position:relative}
  .money input{width:100%;font:600 28px/1 system-ui,sans-serif;padding:14px 44px 14px 16px;border:1px solid var(--line);border-radius:14px;background:var(--bg);color:var(--ink);font-variant-numeric:tabular-nums}
  .money span{position:absolute;right:16px;top:50%;transform:translateY(-50%);font-size:22px;color:var(--muted)}
  input:focus,button:focus-visible{outline:2px solid var(--teal);outline-offset:2px}
  .chips{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
  .chips button{border:1px solid var(--line);background:#fff;border-radius:12px;padding:11px 0;font:600 15px system-ui,sans-serif;color:var(--ink);cursor:pointer}
  .chips button[aria-pressed="true"]{background:var(--teal);border-color:var(--teal);color:#fff}
  .custom{display:none;margin-top:8px}
  .custom.on{display:flex;align-items:center;gap:8px}
  .custom input{width:90px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;font:600 16px system-ui,sans-serif}
  .switch{display:flex;gap:6px;background:var(--bg);border:1px solid var(--line);border-radius:12px;padding:4px}
  .switch button{flex:1;border:0;background:none;border-radius:9px;padding:9px 6px;font:600 14px system-ui,sans-serif;color:var(--muted);cursor:pointer}
  .switch button[aria-pressed="true"]{background:#fff;color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.08)}
  .people{display:flex;align-items:center;gap:14px}
  .people button{width:40px;height:40px;border-radius:12px;border:1px solid var(--line);background:#fff;font:600 20px/1 system-ui,sans-serif;cursor:pointer;color:var(--ink)}
  .people output{font:700 22px system-ui,sans-serif;min-width:2ch;text-align:center;font-variant-numeric:tabular-nums}
  .result{background:var(--ink);color:#e8f1ee;padding:28px;display:flex;flex-direction:column;gap:6px}
  .line{display:flex;justify-content:space-between;font-size:15px;color:#a9bbb4;padding:7px 0;border-bottom:1px solid rgba(255,255,255,.08);font-variant-numeric:tabular-nums}
  .line b{color:#e8f1ee;font-weight:600}
  .line.total{color:#fff;font-size:17px;border:0;padding-top:12px}
  .line.total b{font-size:20px}
  .each{margin-top:auto;padding-top:22px}
  .each small{display:block;color:#7fd1c4;font-size:13px;text-transform:uppercase;letter-spacing:.08em;font-weight:600}
  .each strong{display:block;font-size:46px;letter-spacing:-.03em;line-height:1.1;font-variant-numeric:tabular-nums}
  .each p{margin:6px 0 0;font-size:13px;color:#a9bbb4}
  @media (max-width:680px){.card{grid-template-columns:1fr}.each strong{font-size:38px}}
</style>
</head>
<body>
<main class="card">
  <div class="form">
    <div>
      <h1>Calculateur de pourboire</h1>
      <p class="hint">Avec la TPS (5 %) et la TVQ (9,975 %) du Québec.</p>
    </div>

    <div>
      <label for="amount">Montant avant taxes</label>
      <div class="money"><input id="amount" type="text" inputmode="decimal" value="84,50" autocomplete="off"><span>$</span></div>
    </div>

    <div>
      <span class="label" id="tip-label">Pourboire</span>
      <div class="chips" role="group" aria-labelledby="tip-label">
        <button type="button" data-tip="15" aria-pressed="false">15 %</button>
        <button type="button" data-tip="18" aria-pressed="true">18 %</button>
        <button type="button" data-tip="20" aria-pressed="false">20 %</button>
        <button type="button" data-tip="autre" aria-pressed="false">Autre</button>
      </div>
      <div class="custom" id="custom">
        <input id="customTip" type="number" min="0" max="100" step="0.5" value="22" aria-label="Pourboire personnalisé en pourcentage"> <span>%</span>
      </div>
    </div>

    <div>
      <span class="label" id="base-label">Pourboire calculé sur</span>
      <div class="switch" role="group" aria-labelledby="base-label">
        <button type="button" data-base="avant" aria-pressed="true">Avant taxes</button>
        <button type="button" data-base="apres" aria-pressed="false">Après taxes</button>
      </div>
    </div>

    <div>
      <span class="label">Nombre de personnes</span>
      <div class="people">
        <button type="button" id="less" aria-label="Une personne de moins">−</button>
        <output id="people" aria-live="polite">2</output>
        <button type="button" id="more" aria-label="Une personne de plus">+</button>
      </div>
    </div>
  </div>

  <div class="result" aria-live="polite">
    <div class="line"><span>Sous-total</span><b id="sub">0,00 $</b></div>
    <div class="line"><span>TPS (5 %)</span><b id="tps">0,00 $</b></div>
    <div class="line"><span>TVQ (9,975 %)</span><b id="tvq">0,00 $</b></div>
    <div class="line"><span>Pourboire (<span id="pct">18</span> %)</span><b id="tip">0,00 $</b></div>
    <div class="line total"><span>Total</span><b id="total">0,00 $</b></div>
    <div class="each">
      <small>Par personne</small>
      <strong id="each">0,00 $</strong>
      <p id="split">Partagé entre 2 personnes.</p>
    </div>
  </div>
</main>

<script>
  const money = new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD' });
  const cents = n => Math.round(n * 100) / 100;
  const state = { tip: 18, custom: false, base: 'avant', people: 2 };
  const $ = id => document.getElementById(id);

  function amount() {
    const n = parseFloat($('amount').value.replace(/\s/g, '').replace(',', '.'));
    return Number.isFinite(n) && n > 0 ? n : 0;
  }

  function update() {
    const sub = cents(amount());
    const tps = cents(sub * 0.05);
    const tvq = cents(sub * 0.09975);
    const pct = state.custom ? Math.max(0, parseFloat($('customTip').value) || 0) : state.tip;
    const base = state.base === 'avant' ? sub : sub + tps + tvq;
    const tip = cents(base * pct / 100);
    const total = cents(sub + tps + tvq + tip);
    $('sub').textContent = money.format(sub);
    $('tps').textContent = money.format(tps);
    $('tvq').textContent = money.format(tvq);
    $('pct').textContent = String(pct).replace('.', ',');
    $('tip').textContent = money.format(tip);
    $('total').textContent = money.format(total);
    $('each').textContent = money.format(Math.ceil(total / state.people * 100) / 100);
    $('people').textContent = state.people;
    $('split').textContent = state.people === 1
      ? 'Une seule personne paie.'
      : 'Partagé entre ' + state.people + ' personnes, arrondi au cent supérieur.';
  }

  document.querySelectorAll('[data-tip]').forEach(b => b.addEventListener('click', () => {
    document.querySelectorAll('[data-tip]').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
    state.custom = b.dataset.tip === 'autre';
    if (!state.custom) state.tip = Number(b.dataset.tip);
    $('custom').classList.toggle('on', state.custom);
    if (state.custom) $('customTip').focus();
    update();
  }));
  document.querySelectorAll('[data-base]').forEach(b => b.addEventListener('click', () => {
    document.querySelectorAll('[data-base]').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
    state.base = b.dataset.base;
    update();
  }));
  $('less').addEventListener('click', () => { state.people = Math.max(1, state.people - 1); update(); });
  $('more').addEventListener('click', () => { state.people = Math.min(20, state.people + 1); update(); });
  $('amount').addEventListener('input', update);
  $('customTip').addEventListener('input', update);
  update();
</script>
</body>
</html>

Application

Calculateur de pourboire

La demande

« Un calculateur de pourboire pour le Québec : TPS, TVQ, pourcentage au choix et partage entre plusieurs personnes. »

Ce qui fonctionne

  • TPS de 5 % et TVQ de 9,975 %, arrondies au cent.
  • Pourboire calculé avant ou après taxes.
  • Partage de la facture, arrondi au cent supérieur.
Mie & Levain Exemple en français
Code Aperçu
<!doctype html>
<html lang="fr-CA">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mie & Levain · Boulangerie</title>
<style>
  :root{--ink:#1f1b16;--muted:#6d665c;--green:#22443a;--gold:#c98f2b;--soft:#f4f2ee;--line:#e7e3dc}
  *{box-sizing:border-box}
  html{scroll-behavior:smooth}
  body{margin:0;font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:#fff}
  h1,h2,.logo{font-family:Georgia,"Times New Roman",serif;font-weight:400;letter-spacing:-.01em}
  a{color:inherit}
  .top{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:20px;padding:14px 6vw;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
  .logo{font-size:22px;text-decoration:none}
  .logo i{color:var(--gold)}
  nav{display:flex;gap:18px;margin-left:auto;font-size:14px}
  nav a{text-decoration:none;color:var(--muted)}
  nav a:hover{color:var(--ink)}
  .status{font-size:12.5px;font-weight:600;padding:5px 10px;border-radius:99px;background:var(--soft);white-space:nowrap}
  .status.open{background:#e3f1e8;color:#1d6b43}
  .status.closed{background:#f6e7e2;color:#9a3b22}
  .hero{display:grid;grid-template-columns:1.1fr .9fr;gap:4vw;align-items:center;padding:56px 6vw 48px}
  .kicker{font-size:13px;text-transform:uppercase;letter-spacing:.12em;color:var(--gold);font-weight:600;margin:0 0 12px}
  h1{font-size:clamp(34px,5vw,56px);line-height:1.05;margin:0 0 16px}
  .hero p{color:var(--muted);max-width:42ch;margin:0 0 24px}
  .btn{display:inline-block;background:var(--green);color:#fff;text-decoration:none;padding:12px 20px;border-radius:999px;font-weight:600;font-size:15px;border:0;cursor:pointer}
  .btn:hover{background:#1a362d}
  .hero svg{width:100%;height:auto;max-height:280px}
  section{padding:40px 6vw}
  h2{font-size:34px;margin:0 0 6px}
  .sub{color:var(--muted);margin:0 0 22px}
  .filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}
  .filters button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:7px 14px;font:inherit;font-size:14px;cursor:pointer}
  .filters button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
  .item{border:1px solid var(--line);border-radius:16px;padding:16px;display:flex;flex-direction:column;gap:6px}
  .item b{font-size:17px}
  .item span{color:var(--muted);font-size:14px;flex:1}
  .row{display:flex;align-items:center;justify-content:space-between;margin-top:8px}
  .price{font-weight:700;font-variant-numeric:tabular-nums}
  .add{border:0;background:var(--soft);border-radius:999px;padding:6px 12px;font:inherit;font-size:13.5px;font-weight:600;cursor:pointer}
  .add:hover{background:#ebe7df}
  .basket{margin-top:18px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:14px 16px;border-radius:14px;background:var(--soft);font-size:14.5px}
  .basket b{font-variant-numeric:tabular-nums}
  .basket .btn{padding:9px 16px;font-size:14px;margin-left:auto}
  .basket .btn:disabled{opacity:.45;cursor:default}
  #note{width:100%;margin:0;color:var(--green);font-weight:600}
  table{width:100%;max-width:460px;border-collapse:collapse;font-size:15px}
  td{padding:10px 0;border-bottom:1px solid var(--line)}
  td:last-child{text-align:right;font-variant-numeric:tabular-nums}
  tr.today td{font-weight:700;color:var(--green)}
  footer{padding:28px 6vw 36px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--line)}
  @media (max-width:640px){.hero{grid-template-columns:1fr;padding-top:36px}.hero svg{max-height:180px}nav{display:none}.status{margin-left:auto}}
</style>
</head>
<body>
<header class="top">
  <a class="logo" href="#accueil">Mie <i>&amp;</i> Levain</a>
  <nav><a href="#pains">Nos pains</a><a href="#horaire">Horaire</a></nav>
  <span class="status" id="status">Horaire</span>
</header>

<section class="hero" id="accueil">
  <div>
    <p class="kicker">Boulangerie de quartier · Montréal</p>
    <h1>Le pain du quartier, cuit chaque matin.</h1>
    <p>Farines moulues au Québec, levain naturel et fermentation lente. Première fournée à 7 h.</p>
    <a class="btn" href="#pains">Voir les pains du jour</a>
  </div>
  <svg viewBox="0 0 320 220" aria-hidden="true">
    <ellipse cx="160" cy="196" rx="132" ry="12" fill="#f1ede6"/>
    <path d="M38 150c0-58 54-98 122-98s122 40 122 98c0 26-20 40-52 40H90c-32 0-52-14-52-40Z" fill="#d79a3c"/>
    <path d="M52 146c4-46 50-78 108-78s104 32 108 78" fill="none" stroke="#e9b864" stroke-width="10" stroke-linecap="round" opacity=".7"/>
    <g fill="none" stroke="#a8691f" stroke-width="7" stroke-linecap="round">
      <path d="M92 118c14-16 30-22 46-22"/><path d="M140 104c14-14 30-18 46-16"/><path d="M190 100c14-8 28-8 40 0"/>
    </g>
    <circle cx="268" cy="54" r="10" fill="#c98f2b" opacity=".35"/><circle cx="286" cy="80" r="5" fill="#c98f2b" opacity=".25"/>
  </svg>
</section>

<section id="pains">
  <h2>Nos pains</h2>
  <p class="sub">Réservez aujourd’hui, passez les prendre demain matin.</p>
  <div class="filters" role="group" aria-label="Filtrer">
    <button type="button" data-f="tout" aria-pressed="true">Tout</button>
    <button type="button" data-f="pain" aria-pressed="false">Pains</button>
    <button type="button" data-f="vien" aria-pressed="false">Viennoiseries</button>
  </div>
  <div class="grid" id="grid"></div>
  <div class="basket">
    <span>Ma réservation : <b id="count">0 article</b> · <b id="total">0,00 $</b></span>
    <button class="btn" type="button" id="book" disabled>Réserver</button>
    <p id="note" hidden></p>
  </div>
</section>

<section id="horaire">
  <h2>Horaire</h2>
  <p class="sub">Fermé le lundi. La journée en cours est en vert.</p>
  <table><tbody id="hours"></tbody></table>
</section>

<footer>Mie &amp; Levain · Plateau-Mont-Royal, Montréal · Boulangerie fictive créée pour un exemple.</footer>

<script>
  const products = [
    { name: 'Campagne au levain', desc: 'Croûte épaisse, mie alvéolée.', price: 6.5, cat: 'pain' },
    { name: 'Baguette tradition', desc: 'Pétrie lentement, cuite deux fois par jour.', price: 3.5, cat: 'pain' },
    { name: 'Seigle et noix', desc: 'Dense, parfait avec les fromages.', price: 7.25, cat: 'pain' },
    { name: 'Croissant au beurre', desc: 'Feuilleté sur trois jours.', price: 2.75, cat: 'vien' },
    { name: 'Chocolatine', desc: 'Deux barres de chocolat noir.', price: 3.25, cat: 'vien' },
    { name: 'Brioche à l’érable', desc: 'Glacée au sirop d’érable.', price: 4.0, cat: 'vien' },
  ];
  const hours = [
    ['Dimanche', '8 h à 16 h', 8, 16], ['Lundi', 'Fermé', 0, 0], ['Mardi', '7 h à 18 h', 7, 18],
    ['Mercredi', '7 h à 18 h', 7, 18], ['Jeudi', '7 h à 18 h', 7, 18], ['Vendredi', '7 h à 19 h', 7, 19],
    ['Samedi', '7 h à 17 h', 7, 17],
  ];
  const money = new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD' });
  const basket = [];

  function renderGrid(filter) {
    const grid = document.getElementById('grid');
    grid.innerHTML = '';
    products.filter(p => filter === 'tout' || p.cat === filter).forEach(p => {
      const el = document.createElement('article');
      el.className = 'item';
      el.innerHTML = `<b>${p.name}</b><span>${p.desc}</span>
        <div class="row"><span class="price">${money.format(p.price)}</span>
        <button class="add" type="button">Ajouter</button></div>`;
      el.querySelector('.add').addEventListener('click', () => { basket.push(p); renderBasket(); });
      grid.appendChild(el);
    });
  }

  function renderBasket() {
    const n = basket.length;
    document.getElementById('count').textContent = n + (n > 1 ? ' articles' : ' article');
    document.getElementById('total').textContent = money.format(basket.reduce((s, p) => s + p.price, 0));
    document.getElementById('book').disabled = n === 0;
  }

  document.querySelectorAll('.filters button').forEach(b => b.addEventListener('click', () => {
    document.querySelectorAll('.filters button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
    renderGrid(b.dataset.f);
  }));

  document.getElementById('book').addEventListener('click', () => {
    const note = document.getElementById('note');
    note.textContent = 'Merci ! Votre réservation de ' + basket.length + ' article(s) vous attend demain dès 7 h.';
    note.hidden = false;
    basket.length = 0;
    renderBasket();
  });

  const now = new Date();
  const tbody = document.getElementById('hours');
  [2, 3, 4, 5, 6, 0, 1].forEach(d => {
    const tr = document.createElement('tr');
    if (d === now.getDay()) tr.className = 'today';
    tr.innerHTML = `<td>${hours[d][0]}</td><td>${hours[d][1]}</td>`;
    tbody.appendChild(tr);
  });
  const [, , open, close] = hours[now.getDay()];
  const h = now.getHours() + now.getMinutes() / 60;
  const status = document.getElementById('status');
  const isOpen = h >= open && h < close;
  status.textContent = isOpen ? 'Ouvert maintenant' : 'Fermé pour le moment';
  status.className = 'status ' + (isOpen ? 'open' : 'closed');

  document.querySelectorAll('a[href^="#"]').forEach(a => a.addEventListener('click', e => {
    e.preventDefault();
    document.querySelector(a.getAttribute('href')).scrollIntoView({ behavior: 'smooth' });
  }));

  renderGrid('tout');
  renderBasket();
</script>
</body>
</html>

Site vitrine

Mie & Levain

La demande

« Le site de ma boulangerie : nos pains, nos prix, l’horaire et la réservation pour le lendemain. »

Ce qui fonctionne

  • Filtre des produits.
  • Réservation avec total en dollars.
  • Mention « Ouvert maintenant » selon l’heure réelle.
Paires Exemple en français
Code Aperçu
<!doctype html>
<html lang="fr-CA">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Paires · Jeu de mémoire</title>
<style>
  :root{--ink:#18181b;--muted:#71717a;--bg:#f4f4f5;--card:#27272a;--line:#e4e4e7}
  *{box-sizing:border-box}
  body{margin:0;min-height:100vh;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);display:grid;place-items:center;padding:24px 14px}
  .game{width:100%;max-width:440px}
  header{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:16px}
  h1{margin:0;font-size:28px;letter-spacing:-.03em}
  h1 span{display:block;font-size:13px;font-weight:500;color:var(--muted);letter-spacing:0}
  .stats{display:flex;gap:8px}
  .stat{background:#fff;border:1px solid var(--line);border-radius:12px;padding:6px 12px;text-align:center;min-width:72px}
  .stat small{display:block;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
  .stat b{font-variant-numeric:tabular-nums;font-size:18px}
  .board{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;perspective:800px}
  .card{aspect-ratio:1;border:0;padding:0;background:none;cursor:pointer;position:relative;transform-style:preserve-3d;transition:transform .35s ease;border-radius:14px}
  .card:focus-visible{outline:3px solid #f97316;outline-offset:3px}
  .card.up,.card.done{transform:rotateY(180deg)}
  .face{position:absolute;inset:0;border-radius:14px;display:grid;place-items:center;backface-visibility:hidden}
  .back{background:var(--card);background-image:radial-gradient(circle at 30% 30%,#3f3f46 0 2px,transparent 3px);background-size:14px 14px}
  .front{background:#fff;border:1px solid var(--line);transform:rotateY(180deg)}
  .front svg{width:56%;height:56%}
  .card.done .front{background:#f0fdf4;border-color:#bbf7d0}
  .bar{display:flex;justify-content:space-between;align-items:center;margin-top:16px;gap:12px}
  .bar p{margin:0;color:var(--muted);font-size:14px}
  button.new{border:0;background:var(--ink);color:#fff;border-radius:999px;padding:10px 18px;font:600 14px system-ui,sans-serif;cursor:pointer}
  .win{margin-top:14px;padding:14px 16px;border-radius:14px;background:#fff7ed;border:1px solid #fed7aa;font-size:15px}
  @media (prefers-reduced-motion:reduce){.card{transition:none}}
</style>
</head>
<body>
<main class="game">
  <header>
    <h1>Paires <span>Retrouvez les 8 paires de formes.</span></h1>
    <div class="stats">
      <div class="stat"><small>Coups</small><b id="moves">0</b></div>
      <div class="stat"><small>Temps</small><b id="time">0:00</b></div>
    </div>
  </header>
  <div class="board" id="board" aria-label="Plateau de jeu"></div>
  <div class="win" id="win" role="status" hidden></div>
  <div class="bar">
    <p id="best">Meilleur score : aucun pour l’instant</p>
    <button class="new" type="button" id="restart">Nouvelle partie</button>
  </div>
</main>

<script>
  const shapes = {
    rond: ['Rond', '<circle cx="12" cy="12" r="9" fill="#f97316"/>'],
    carre: ['Carré', '<rect x="4" y="4" width="16" height="16" rx="3" fill="#0ea5e9"/>'],
    triangle: ['Triangle', '<path d="M12 3 22 20H2Z" fill="#22c55e"/>'],
    losange: ['Losange', '<path d="M12 2 21 12 12 22 3 12Z" fill="#e11d48"/>'],
    etoile: ['Étoile', '<path d="m12 2 2.9 6.6 7.1.6-5.4 4.7 1.6 7L12 17.3 5.8 21l1.6-7L2 9.2l7.1-.6Z" fill="#eab308"/>'],
    anneau: ['Anneau', '<circle cx="12" cy="12" r="7.5" fill="none" stroke="#8b5cf6" stroke-width="4"/>'],
    croix: ['Croix', '<path d="M9 3h6v6h6v6h-6v6H9v-6H3V9h6Z" fill="#14b8a6"/>'],
    lune: ['Lune', '<path d="M15 3a9 9 0 1 0 6 15A8 8 0 0 1 15 3Z" fill="#64748b"/>'],
  };
  let first = null, lock = false, moves = 0, found = 0, seconds = 0, timer = null, best = null;
  const $ = id => document.getElementById(id);

  function shuffle(list) {
    for (let i = list.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [list[i], list[j]] = [list[j], list[i]];
    }
    return list;
  }

  function clock() {
    $('time').textContent = Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0');
  }

  function start() {
    clearInterval(timer);
    timer = null; first = null; lock = false; moves = 0; found = 0; seconds = 0;
    $('moves').textContent = '0';
    $('win').hidden = true;
    clock();
    const board = $('board');
    board.innerHTML = '';
    shuffle([...Object.keys(shapes), ...Object.keys(shapes)]).forEach(key => {
      const card = document.createElement('button');
      card.type = 'button';
      card.className = 'card';
      card.dataset.key = key;
      card.setAttribute('aria-label', 'Carte cachée');
      card.innerHTML = `<span class="face back"></span><span class="face front"><svg viewBox="0 0 24 24" aria-hidden="true">${shapes[key][1]}</svg></span>`;
      card.addEventListener('click', () => flip(card));
      board.appendChild(card);
    });
  }

  function flip(card) {
    if (lock || card === first || card.classList.contains('done')) return;
    if (!timer) timer = setInterval(() => { seconds++; clock(); }, 1000);
    card.classList.add('up');
    card.setAttribute('aria-label', shapes[card.dataset.key][0]);
    if (!first) { first = card; return; }
    moves++;
    $('moves').textContent = moves;
    if (first.dataset.key === card.dataset.key) {
      first.classList.add('done'); card.classList.add('done');
      first = null; found++;
      if (found === 8) finish();
      return;
    }
    lock = true;
    const a = first, b = card;
    setTimeout(() => {
      a.classList.remove('up'); b.classList.remove('up');
      a.setAttribute('aria-label', 'Carte cachée'); b.setAttribute('aria-label', 'Carte cachée');
      first = null; lock = false;
    }, 750);
  }

  function finish() {
    clearInterval(timer);
    const win = $('win');
    win.textContent = `Bravo ! Toutes les paires en ${moves} coups et ${$('time').textContent}.`;
    win.hidden = false;
    if (best === null || moves < best) {
      best = moves;
      $('best').textContent = 'Meilleur score : ' + best + ' coups';
    }
  }

  $('restart').addEventListener('click', start);
  start();
</script>
</body>
</html>

Jeu

Paires

La demande

« Un jeu de mémoire avec des formes colorées, un chronomètre et un compteur de coups. »

Ce qui fonctionne

  • Cartes mélangées à chaque partie.
  • Chronomètre et compteur de coups.
  • Jouable au clavier.
Budget du mois Exemple en français
Code Aperçu
<!doctype html>
<html lang="fr-CA">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Budget du mois</title>
<style>
  :root{--ink:#111827;--muted:#6b7280;--line:#e5e7eb;--soft:#f9fafb;--ok:#059669;--bad:#dc2626;--accent:#2563eb}
  *{box-sizing:border-box}
  body{margin:0;font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:#fff;padding:28px 5vw}
  h1{margin:0;font-size:26px;letter-spacing:-.02em}
  .sub{margin:2px 0 22px;color:var(--muted)}
  .layout{display:grid;grid-template-columns:1.25fr 1fr;gap:28px;align-items:start}
  label.income{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;background:var(--soft);border:1px solid var(--line);border-radius:14px;font-weight:600;margin-bottom:16px}
  input{font:inherit;color:inherit}
  .num{width:120px;text-align:right;border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-variant-numeric:tabular-nums;background:#fff}
  .num:focus,.name:focus{outline:2px solid var(--accent);outline-offset:1px}
  table{width:100%;border-collapse:collapse}
  th{text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;padding:0 0 8px}
  th:nth-child(2){text-align:right}
  td{padding:6px 0;border-top:1px solid var(--line)}
  td:nth-child(2){text-align:right}
  .name{width:100%;border:1px solid transparent;border-radius:8px;padding:7px 8px;background:none}
  .name:hover{border-color:var(--line)}
  .del{border:0;background:none;color:var(--muted);font-size:18px;cursor:pointer;width:32px;height:32px;border-radius:8px}
  .del:hover{background:#fef2f2;color:var(--bad)}
  .add{margin-top:10px;border:1px dashed #cbd5e1;background:none;border-radius:10px;padding:9px 14px;font:600 14px system-ui,sans-serif;color:var(--accent);cursor:pointer;width:100%}
  .summary{border:1px solid var(--line);border-radius:18px;padding:20px;display:grid;gap:16px;position:sticky;top:16px}
  .ring{width:150px;height:150px;border-radius:50%;margin:0 auto;display:grid;place-items:center;background:conic-gradient(var(--accent) calc(var(--p)*1%),#eef2ff 0)}
  .ring div{width:112px;height:112px;border-radius:50%;background:#fff;display:grid;place-items:center;text-align:center;line-height:1.2}
  .ring b{font-size:24px;font-variant-numeric:tabular-nums}
  .ring small{display:block;color:var(--muted);font-size:12px}
  .totals div{display:flex;justify-content:space-between;padding:6px 0;font-variant-numeric:tabular-nums}
  .left{font-weight:700;font-size:18px}
  .left.ok{color:var(--ok)}.left.bad{color:var(--bad)}
  .bars{display:grid;gap:8px}
  .bar{display:grid;grid-template-columns:110px 1fr 46px;gap:8px;align-items:center;font-size:13px}
  .bar span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .bar i{display:block;height:8px;border-radius:99px;background:var(--accent);min-width:2px}
  .bar em{font-style:normal;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
  .tip{margin:0;font-size:13px;color:var(--muted);background:var(--soft);border-radius:12px;padding:10px 12px}
  @media (max-width:720px){.layout{grid-template-columns:1fr}.summary{position:static}}
</style>
</head>
<body>
<h1>Budget du mois</h1>
<p class="sub">Modifiez les montants : tout se recalcule aussitôt.</p>

<div class="layout">
  <section>
    <label class="income">Revenu net du mois <input class="num" id="income" type="number" min="0" step="10" value="4800"></label>
    <table>
      <thead><tr><th>Dépense</th><th>Montant</th><th><span style="position:absolute;left:-9999px">Supprimer</span></th></tr></thead>
      <tbody id="rows"></tbody>
    </table>
    <button class="add" type="button" id="add">+ Ajouter une dépense</button>
  </section>

  <aside class="summary" aria-live="polite">
    <div class="ring" id="ring" style="--p:0"><div><span><b id="pct">0 %</b><small>du revenu dépensé</small></span></div></div>
    <div class="totals">
      <div><span>Dépenses</span><b id="spent">0 $</b></div>
      <div><span>Reste</span><b class="left" id="left">0 $</b></div>
    </div>
    <div class="bars" id="bars"></div>
    <p class="tip" id="tip"></p>
  </aside>
</div>

<script>
  const money = new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD', maximumFractionDigits: 0 });
  const rows = [
    ['Logement', 1350], ['Épicerie', 620], ['Transport', 180], ['Électricité', 85],
    ['Internet et cellulaire', 120], ['Loisirs', 200], ['Épargne', 500],
  ];
  const $ = id => document.getElementById(id);

  function render() {
    const tbody = $('rows');
    tbody.innerHTML = '';
    rows.forEach((row, i) => {
      const tr = document.createElement('tr');
      tr.innerHTML = `<td><input class="name" aria-label="Nom de la dépense"></td>
        <td><input class="num" type="number" min="0" step="5" aria-label="Montant"></td>
        <td><button class="del" type="button" aria-label="Supprimer">×</button></td>`;
      const [name, amount] = tr.querySelectorAll('input');
      name.value = row[0];
      amount.value = row[1];
      name.addEventListener('input', () => { row[0] = name.value; update(); });
      amount.addEventListener('input', () => { row[1] = Math.max(0, parseFloat(amount.value) || 0); update(); });
      tr.querySelector('.del').addEventListener('click', () => { rows.splice(i, 1); render(); });
      tbody.appendChild(tr);
    });
    update();
  }

  function update() {
    const income = Math.max(0, parseFloat($('income').value) || 0);
    const spent = rows.reduce((s, r) => s + r[1], 0);
    const left = income - spent;
    const pct = income ? Math.round(spent / income * 100) : 0;
    $('spent').textContent = money.format(spent);
    $('left').textContent = money.format(left);
    $('left').className = 'left ' + (left >= 0 ? 'ok' : 'bad');
    $('pct').textContent = pct + ' %';
    $('ring').style.setProperty('--p', Math.min(100, pct));
    const max = Math.max(...rows.map(r => r[1]), 1);
    $('bars').innerHTML = '';
    rows.slice().sort((a, b) => b[1] - a[1]).forEach(r => {
      const div = document.createElement('div');
      div.className = 'bar';
      div.innerHTML = '<span></span><i></i><em></em>';
      div.querySelector('span').textContent = r[0] || 'Sans nom';
      div.querySelector('i').style.width = (r[1] / max * 100) + '%';
      div.querySelector('em').textContent = income ? Math.round(r[1] / income * 100) + ' %' : '–';
      $('bars').appendChild(div);
    });
    const saving = rows.find(r => /pargne/i.test(r[0]));
    const savingPct = saving && income ? Math.round(saving[1] / income * 100) : 0;
    $('tip').textContent = left < 0
      ? 'Vos dépenses dépassent votre revenu de ' + money.format(-left) + '.'
      : 'Vous épargnez ' + savingPct + ' % de votre revenu. Il vous reste ' + money.format(left) + ' à répartir.';
  }

  $('income').addEventListener('input', update);
  $('add').addEventListener('click', () => {
    rows.push(['Nouvelle dépense', 0]);
    render();
    const inputs = document.querySelectorAll('.name');
    inputs[inputs.length - 1].select();
  });
  render();
</script>
</body>
</html>

Tableau

Budget du mois

La demande

« Un tableau de budget mensuel modifiable, avec le reste à dépenser et un graphique par catégorie. »

Ce qui fonctionne

  • Lignes modifiables, ajout et suppression.
  • Total, reste et part du revenu.
  • Graphique recalculé à chaque saisie.
Orbite Exemple en français
Code Aperçu
<!doctype html>
<html lang="fr-CA">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Orbite · L’agenda qui se range tout seul</title>
<style>
  :root{--bg:#0c0f0d;--ink:#f1f5ef;--muted:#9aa59c;--lime:#c6f432;--line:rgba(241,245,239,.12)}
  *{box-sizing:border-box}
  body{margin:0;font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}
  .nav{display:flex;align-items:center;justify-content:space-between;padding:20px 6vw}
  .logo{display:flex;align-items:center;gap:9px;font-weight:700;letter-spacing:-.02em;font-size:19px}
  .logo i{width:20px;height:20px;border-radius:50%;border:3px solid var(--lime);position:relative}
  .logo i::after{content:"";position:absolute;width:6px;height:6px;border-radius:50%;background:var(--lime);top:-6px;right:-5px}
  .tag{font-size:13px;color:var(--muted);border:1px solid var(--line);padding:5px 12px;border-radius:99px}
  .hero{display:grid;grid-template-columns:1.15fr .85fr;gap:5vw;align-items:center;padding:36px 6vw 56px}
  h1{font-size:clamp(36px,5.4vw,64px);line-height:1.02;letter-spacing:-.04em;margin:0 0 18px}
  h1 em{font-style:normal;color:var(--lime)}
  .lead{color:var(--muted);font-size:18px;max-width:40ch;margin:0 0 26px}
  .count{display:flex;gap:10px;margin-bottom:26px}
  .count div{min-width:70px;border:1px solid var(--line);border-radius:14px;padding:10px 12px;text-align:center}
  .count b{display:block;font-size:26px;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
  .count small{color:var(--muted);font-size:12px}
  .join{display:flex;gap:8px;max-width:440px;flex-wrap:wrap}
  .join input{flex:1;min-width:200px;background:rgba(241,245,239,.06);border:1px solid var(--line);border-radius:12px;padding:13px 14px;color:var(--ink);font:inherit}
  .join input:focus{outline:2px solid var(--lime);outline-offset:1px}
  .join button{border:0;background:var(--lime);color:#0c0f0d;font:700 15px system-ui,sans-serif;border-radius:12px;padding:13px 18px;cursor:pointer}
  #msg{min-height:24px;margin:10px 0 0;font-size:14px;color:var(--lime)}
  #msg.err{color:#ff8a7a}
  .phone{justify-self:center;width:250px;height:500px;border-radius:40px;border:8px solid #1f2420;background:#141815;padding:22px 16px;display:flex;flex-direction:column;gap:10px;box-shadow:0 40px 80px -30px rgba(198,244,50,.25)}
  .day{font-size:13px;color:var(--muted);display:flex;justify-content:space-between}
  .day b{color:var(--ink);font-size:20px}
  .ev{border-radius:14px;padding:10px 12px;font-size:13px;line-height:1.35}
  .ev small{display:block;opacity:.7}
  .ev.a{background:var(--lime);color:#0c0f0d}
  .ev.b{background:#232a24}
  .ev.c{background:#2b2f1c;border:1px dashed rgba(198,244,50,.5)}
  .features{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border-top:1px solid var(--line)}
  .features div{background:var(--bg);padding:28px 6vw 34px 28px}
  .features h2{font-size:17px;margin:0 0 6px}
  .features p{margin:0;color:var(--muted);font-size:14.5px}
  footer{padding:22px 6vw;color:var(--muted);font-size:13px;border-top:1px solid var(--line)}
  @media (max-width:720px){.hero{grid-template-columns:1fr}.phone{display:none}.features{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="nav"><span class="logo"><i></i>Orbite</span><span class="tag">Bêta privée</span></div>

<header class="hero">
  <div>
    <h1>Votre semaine, <em>rangée toute seule.</em></h1>
    <p class="lead">Orbite place vos tâches entre vos rendez-vous, au bon moment, et les déplace quand la journée change.</p>
    <div class="count" aria-label="Temps avant le lancement">
      <div><b id="d">0</b><small>jours</small></div>
      <div><b id="h">0</b><small>heures</small></div>
      <div><b id="m">0</b><small>minutes</small></div>
      <div><b id="s">0</b><small>secondes</small></div>
    </div>
    <div class="join">
      <input id="email" type="email" placeholder="[email protected]" aria-label="Adresse courriel">
      <button type="button" id="join">Rejoindre la liste</button>
    </div>
    <p id="msg" role="status"></p>
  </div>
  <div class="phone" aria-hidden="true">
    <div class="day"><span>Mardi</span><b>14</b></div>
    <div class="ev b">9 h 00 · Réunion d’équipe<small>Salle 2</small></div>
    <div class="ev a">10 h 00 · Préparer la présentation<small>Placée par Orbite · 90 min</small></div>
    <div class="ev b">12 h 00 · Dîner avec Léa</div>
    <div class="ev c">14 h 30 · Répondre aux courriels<small>Déplacée : la réunion a débordé</small></div>
    <div class="ev b">16 h 00 · Appel client</div>
  </div>
</header>

<section class="features">
  <div><h2>Planification automatique</h2><p>Indiquez la durée d’une tâche : Orbite lui trouve une place.</p></div>
  <div><h2>Journées qui s’adaptent</h2><p>Un imprévu ? Le reste de la journée se réorganise.</p></div>
  <div><h2>Vos agendas réunis</h2><p>Travail et personnel, dans une seule vue.</p></div>
</section>

<footer>Orbite est une application fictive, créée pour un exemple.</footer>

<script>
  const launch = new Date('2027-03-01T09:00:00-05:00');
  const $ = id => document.getElementById(id);

  function tick() {
    let left = Math.max(0, launch - new Date());
    const parts = [86400000, 3600000, 60000, 1000].map(unit => {
      const value = Math.floor(left / unit);
      left -= value * unit;
      return value;
    });
    ['d', 'h', 'm', 's'].forEach((id, i) => { $(id).textContent = parts[i]; });
  }

  function join() {
    const email = $('email').value.trim();
    const msg = $('msg');
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      msg.textContent = 'Entrez une adresse courriel valide.';
      msg.className = 'err';
      return;
    }
    msg.textContent = 'Merci ! Vous êtes sur la liste. (Exemple : aucune adresse n’est envoyée.)';
    msg.className = '';
    $('email').value = '';
  }

  $('join').addEventListener('click', join);
  $('email').addEventListener('keydown', e => { if (e.key === 'Enter') join(); });
  tick();
  setInterval(tick, 1000);
</script>
</body>
</html>

Page de lancement

Orbite

La demande

« Une page de lancement pour une app d’agenda, avec un compte à rebours et une liste d’attente. »

Ce qui fonctionne

  • Compte à rebours en direct.
  • Vérification de l’adresse courriel.
  • Mise en page sombre, adaptée au mobile.
Focus Exemple en français
Code Aperçu
<!doctype html>
<html lang="fr-CA">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Focus · Minuteur de concentration</title>
<style>
  :root{--ink:#2a1215;--muted:#8a6b6e;--red:#e5484d;--soft:#fff1f1;--green:#1f8a5b;--green-soft:#e9f7ef}
  *{box-sizing:border-box}
  body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--soft);transition:background .4s}
  body.pause{background:var(--green-soft)}
  main{text-align:center;padding:24px}
  .modes{display:inline-flex;gap:4px;background:#fff;border-radius:99px;padding:4px;margin-bottom:22px;box-shadow:0 1px 2px rgba(0,0,0,.05)}
  .modes button{border:0;background:none;border-radius:99px;padding:8px 16px;font:600 14px system-ui,sans-serif;color:var(--muted);cursor:pointer}
  .modes button[aria-pressed="true"]{background:var(--red);color:#fff}
  body.pause .modes button[aria-pressed="true"]{background:var(--green)}
  .dial{position:relative;width:240px;height:240px;margin:0 auto 22px}
  .dial svg{width:100%;height:100%;transform:rotate(-90deg)}
  .dial circle{fill:none;stroke-width:10}
  .track{stroke:#fff}
  .prog{stroke:var(--red);stroke-linecap:round;transition:stroke-dashoffset 1s linear}
  body.pause .prog{stroke:var(--green)}
  .time{position:absolute;inset:0;display:grid;place-content:center}
  .time b{font-size:54px;letter-spacing:-.03em;font-variant-numeric:tabular-nums;line-height:1}
  .time small{color:var(--muted);font-size:14px;margin-top:6px}
  .actions{display:flex;gap:10px;justify-content:center}
  .actions button{border:0;border-radius:14px;padding:13px 22px;font:700 15px system-ui,sans-serif;cursor:pointer}
  #go{background:var(--ink);color:#fff;min-width:130px}
  #reset{background:#fff;color:var(--ink)}
  .count{margin-top:22px;color:var(--muted);font-size:14px}
  .dots{display:flex;gap:6px;justify-content:center;margin-top:8px}
  .dots i{width:10px;height:10px;border-radius:50%;background:#fff;border:2px solid var(--red)}
  .dots i.on{background:var(--red)}
</style>
</head>
<body>
<main>
  <div class="modes" role="group" aria-label="Mode">
    <button type="button" data-mode="focus" aria-pressed="true">Concentration</button>
    <button type="button" data-mode="pause" aria-pressed="false">Pause</button>
  </div>
  <div class="dial">
    <svg viewBox="0 0 120 120" aria-hidden="true"><circle class="track" cx="60" cy="60" r="52"/><circle class="prog" id="prog" cx="60" cy="60" r="52"/></svg>
    <div class="time"><b id="clock" role="timer">25:00</b><small id="label">Séance de travail</small></div>
  </div>
  <div class="actions">
    <button type="button" id="go">Démarrer</button>
    <button type="button" id="reset">Réinitialiser</button>
  </div>
  <p class="count" id="count">0 séance terminée aujourd’hui</p>
  <div class="dots" id="dots" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</main>

<script>
  const modes = { focus: { min: 25, label: 'Séance de travail' }, pause: { min: 5, label: 'Petite pause' } };
  const ring = 2 * Math.PI * 52;
  let mode = 'focus', left = modes.focus.min * 60, timer = null, done = 0;
  const $ = id => document.getElementById(id);
  $('prog').style.strokeDasharray = ring;

  function draw() {
    const total = modes[mode].min * 60;
    $('clock').textContent = String(Math.floor(left / 60)).padStart(2, '0') + ':' + String(left % 60).padStart(2, '0');
    $('prog').style.strokeDashoffset = ring * (1 - left / total);
    $('label').textContent = modes[mode].label;
    document.body.classList.toggle('pause', mode === 'pause');
    document.title = $('clock').textContent + ' · Focus';
  }

  function setMode(next) {
    mode = next;
    stop();
    left = modes[mode].min * 60;
    document.querySelectorAll('[data-mode]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
    draw();
  }

  function stop() {
    clearInterval(timer);
    timer = null;
    $('go').textContent = 'Démarrer';
  }

  function beep() {
    try {
      const ctx = new AudioContext();
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.frequency.value = 880;
      gain.gain.setValueAtTime(0.2, ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8);
      osc.connect(gain).connect(ctx.destination);
      osc.start();
      osc.stop(ctx.currentTime + 0.8);
    } catch (e) { /* son indisponible */ }
  }

  $('go').addEventListener('click', () => {
    if (timer) { stop(); return; }
    $('go').textContent = 'Pause';
    timer = setInterval(() => {
      left--;
      if (left <= 0) {
        beep();
        if (mode === 'focus') {
          done++;
          $('count').textContent = done + (done > 1 ? ' séances terminées' : ' séance terminée') + ' aujourd’hui';
          document.querySelectorAll('.dots i').forEach((dot, i) => dot.classList.toggle('on', i < done % 4 || (done % 4 === 0 && done > 0)));
        }
        setMode(mode === 'focus' ? 'pause' : 'focus');
        return;
      }
      draw();
    }, 1000);
  });
  $('reset').addEventListener('click', () => setMode(mode));
  document.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
  draw();
</script>
</body>
</html>

Outil

Focus

La demande

« Un minuteur de concentration : 25 minutes de travail, 5 minutes de pause, avec un compteur de séances. »

Ce qui fonctionne

  • Anneau de progression animé.
  • Passage automatique à la pause.
  • Signal sonore en fin de séance.