Ir al contenido
nella

Creaciones

Ejemplos que funcionan.

Cada ejemplo es una página real en HTML, CSS y JavaScript. Se ejecuta aquí, en un marco aislado, como una creación de Nella. Haga clic, escriba, juegue.

Estos ejemplos se prepararon para esta página con el fin de mostrar el tipo de resultado que se obtiene. Son ficticios y están redactados en francés.

Calculadora de propinas Ejemplo en francés
Código Vista previa
<!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>

Aplicación

Calculadora de propinas

La petición

«Una calculadora de propinas para Quebec: impuestos federal y provincial, porcentaje a elegir y reparto entre varias personas.»

Lo que funciona

  • Impuestos del 5 % y del 9,975 %, redondeados al céntimo.
  • Propina calculada antes o después de impuestos.
  • Reparto de la cuenta, redondeado al céntimo superior.
Mie & Levain Ejemplo en francés
Código Vista previa
<!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>

Sitio escaparate

Mie & Levain

La petición

«El sitio de mi panadería: nuestros panes, nuestros precios, el horario y las reservas para el día siguiente.»

Lo que funciona

  • Filtro de productos.
  • Reserva con total en dólares.
  • Indicador «Abierto ahora» según la hora real.
Paires Ejemplo en francés
Código Vista previa
<!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>

Juego

Paires

La petición

«Un juego de memoria con formas de colores, un cronómetro y un contador de movimientos.»

Lo que funciona

  • Cartas mezcladas en cada partida.
  • Cronómetro y contador de movimientos.
  • Se puede jugar con el teclado.
Budget du mois Ejemplo en francés
Código Vista previa
<!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>

Tabla

Budget du mois

La petición

«Una tabla de presupuesto mensual editable, con lo que queda por gastar y un gráfico por categoría.»

Lo que funciona

  • Filas editables, añadir y eliminar.
  • Total, saldo restante y parte del ingreso.
  • Gráfico recalculado con cada dato.
Orbite Ejemplo en francés
Código Vista previa
<!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>

Página de lanzamiento

Orbite

La petición

«Una página de lanzamiento para una app de agenda, con cuenta atrás y lista de espera.»

Lo que funciona

  • Cuenta atrás en directo.
  • Validación del correo electrónico.
  • Diseño oscuro, adaptado al móvil.
Focus Ejemplo en francés
Código Vista previa
<!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>

Herramienta

Focus

La petición

«Un temporizador de concentración: 25 minutos de trabajo, 5 minutos de pausa y un contador de sesiones.»

Lo que funciona

  • Anillo de progreso animado.
  • Paso automático a la pausa.
  • Señal sonora al final de cada sesión.