← Mike Higgins Home
Custom Code Experiment •

French: Oh La La!

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Ooh La La! French Flashcards</title>
  <link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Nunito:wght@400;700;800&display=swap" rel="stylesheet">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }

    html, body {
      width: 100%; height: 100%;
      font-family: 'Nunito', sans-serif;
      overflow: hidden;
    }

    .app-wrap {
      position: relative;
      width: 100%; height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      overflow: hidden;
    }

    canvas#bg {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      display: block;
    }

    .app {
      position: relative;
      z-index: 2;
      padding: 1.5rem 1rem 2rem;
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 1.1rem;
      width: 100%;
      max-width: 560px;
    }

    .title {
      font-family: 'Fredoka One', cursive;
      font-size: 2.2rem;
      color: #fff;
      letter-spacing: 1px;
      text-shadow: 2px 3px 0px rgba(40,20,80,0.35);
    }

    .progress-wrap {
      width: 100%;
      max-width: 480px;
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .progress-bar-bg {
      flex: 1;
      height: 10px;
      background: rgba(255,255,255,0.25);
      border-radius: 99px;
      overflow: hidden;
    }

    .progress-bar-fill {
      height: 100%;
      background: #fff;
      border-radius: 99px;
      transition: width 0.4s cubic-bezier(.4,2,.6,1);
    }

    .progress-text {
      font-size: 13px;
      font-weight: 800;
      color: rgba(255,255,255,0.9);
      min-width: 48px;
      text-align: right;
    }

    .scene {
      perspective: 900px;
      width: 100%;
      max-width: 480px;
      height: 250px;
      cursor: pointer;
    }

    .card-inner {
      width: 100%;
      height: 100%;
      position: relative;
      transform-style: preserve-3d;
      transition: transform 0.55s cubic-bezier(.4,1.6,.6,1);
    }

    .card-inner.flipped { transform: rotateY(180deg); }

    .card-face {
      position: absolute;
      inset: 0;
      backface-visibility: hidden;
      border-radius: 22px;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      padding: 1.25rem 1.5rem;
      text-align: center;
    }

    .card-front {
      background: rgba(83,74,183,0.82);
      border: 3px solid rgba(175,169,236,0.6);
      backdrop-filter: blur(6px);
    }

    .card-back {
      background: rgba(255,248,220,0.95);
      border: 3px solid #EF9F27;
      transform: rotateY(180deg);
    }

    .card-label {
      font-size: 11px;
      font-weight: 800;
      letter-spacing: 2px;
      text-transform: uppercase;
      margin-bottom: 8px;
      opacity: 0.7;
    }

    .card-front .card-label { color: #E8E6FF; }
    .card-back .card-label  { color: #BA7517; }

    .card-french {
      font-family: 'Fredoka One', cursive;
      font-size: 1.7rem;
      color: #fff;
      line-height: 1.2;
    }

    .card-english {
      font-size: 1.05rem;
      font-weight: 700;
      color: #633806;
      line-height: 1.4;
    }

    .card-literal {
      margin-top: 8px;
      font-size: 0.8rem;
      color: #BA7517;
      font-style: italic;
    }

    .card-example {
      margin-top: 10px;
      font-size: 0.75rem;
      color: #885010;
      background: #FFE9A0;
      border-radius: 10px;
      padding: 6px 10px;
      line-height: 1.4;
    }

    .tap-hint {
      font-size: 12px;
      color: rgba(255,255,255,0.75);
      font-weight: 700;
      letter-spacing: 0.5px;
    }

    .tap-hint-front {
      font-size: 11px;
      color: rgba(220,216,255,0.8);
      margin-top: 10px;
      font-weight: 700;
    }

    .nsfw-badge {
      background: #F09595;
      color: #501313;
      font-size: 10px;
      font-weight: 800;
      padding: 2px 8px;
      border-radius: 99px;
      margin-top: 6px;
      letter-spacing: 1px;
    }

    .controls {
      display: flex;
      gap: 12px;
      align-items: center;
    }

    .btn {
      font-family: 'Nunito', sans-serif;
      font-weight: 800;
      font-size: 1rem;
      border: 2px solid;
      border-radius: 14px;
      padding: 10px 22px;
      cursor: pointer;
      transition: transform 0.1s;
      letter-spacing: 0.3px;
    }

    .btn:active { transform: scale(0.95); }

    .btn-prev {
      background: rgba(255,255,255,0.18);
      border-color: rgba(255,255,255,0.5);
      color: #fff;
    }

    .btn-next {
      background: rgba(255,255,255,0.92);
      border-color: rgba(255,255,255,0.9);
      color: #3C3489;
    }

    .btn-shuffle {
      background: rgba(239,159,39,0.85);
      border-color: #EF9F27;
      color: #fff;
      font-size: 0.85rem;
      padding: 8px 14px;
    }

    .category-pill {
      font-size: 11px;
      font-weight: 800;
      padding: 3px 10px;
      border-radius: 99px;
      letter-spacing: 0.5px;
    }

    .cat-animal     { background: #9FE1CB; color: #04342C; }
    .cat-body       { background: #F5C4B3; color: #4A1B0C; }
    .cat-mood       { background: #CECBF6; color: #26215C; }
    .cat-expression { background: #C0DD97; color: #173404; }
    .cat-insult     { background: #F7C1C1; color: #501313; }
    .cat-thing      { background: #B5D4F4; color: #042C53; }

    .counter-dots {
      display: flex;
      gap: 5px;
      flex-wrap: wrap;
      justify-content: center;
      max-width: 420px;
    }

    .dot {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: rgba(255,255,255,0.25);
      transition: background 0.3s, transform 0.2s;
    }

    .dot.seen    { background: rgba(255,255,255,0.75); }
    .dot.current { background: #EF9F27; transform: scale(1.4); }
  </style>
</head>
<body>
<div class="app-wrap">
  <canvas id="bg"></canvas>
  <div class="app">
    <div class="title">🥐 Ooh La La! 🥐</div>

    <div class="progress-wrap">
      <div class="progress-bar-bg">
        <div class="progress-bar-fill" id="pbar" style="width:0%"></div>
      </div>
      <div class="progress-text" id="ptext">0 / 32</div>
    </div>

    <div class="counter-dots" id="dots"></div>

    <div class="scene" onclick="flipCard()">
      <div class="card-inner" id="cardInner">
        <div class="card-face card-front">
          <div class="card-label">français</div>
          <div class="card-french" id="cardFrench"></div>
          <div id="cardCat" style="margin-top:8px"></div>
          <div id="nsfwBadge"></div>
          <div class="tap-hint-front">tap to reveal ✨</div>
        </div>
        <div class="card-face card-back">
          <div class="card-label">english</div>
          <div class="card-english" id="cardEnglish"></div>
          <div class="card-literal" id="cardLiteral"></div>
          <div class="card-example" id="cardExample"></div>
        </div>
      </div>
    </div>

    <div class="tap-hint" id="tapHint">tap the card to flip!</div>

    <div class="controls">
      <button class="btn btn-prev" onclick="navigate(-1)">← Prev</button>
      <button class="btn btn-shuffle" onclick="shuffle()">🔀 Shuffle</button>
      <button class="btn btn-next" onclick="navigate(1)">Next →</button>
    </div>
  </div>
</div>

<script>
const raw = [
  { fr: "avoir des oursins dans les poches", en: "To be a cheapskate", lit: "To have sea urchins in your pocket", ex: "Il a des oursins dans les poches — il n'a jamais payé un verre!", cat: "expression", nsfw: false },
  { fr: "Saperlipopette !", en: "Whoopsie daisy! / My goodness!", lit: null, ex: "Saperlipopette ! J'ai oublié mon parapluie.", cat: "expression", nsfw: false },
  { fr: "un pigeon", en: "An easy mark / a sucker", lit: "A pigeon", ex: "Les escrocs l'ont pris pour un pigeon.", cat: "animal", nsfw: false },
  { fr: "râler", en: "To complain / to moan", lit: null, ex: "Il râle tout le temps !", cat: "mood", nsfw: false },
  { fr: "râleur / râleuse", en: "A complainer / grumbler", lit: null, ex: "Ne sois pas si râleur !", cat: "mood", nsfw: false },
  { fr: "grognon / grognonne", en: "Grumpy", lit: null, ex: "Tu es grognonne ce matin !", cat: "mood", nsfw: false },
  { fr: "ducon", en: "Total a**hole", lit: "Royal a**hole", ex: "Arrête, ducon !", cat: "insult", nsfw: true },
  { fr: "tomber dans les pommes", en: "To faint", lit: "To fall into the apples", ex: "Elle va tomber dans les pommes !", cat: "expression", nsfw: false },
  { fr: "machin", en: "Thingy / what's-his-name", lit: "Machine", ex: "C'est quoi ce machin dans les cheveux ?", cat: "thing", nsfw: false },
  { fr: "machin-truc", en: "Thingamajig / whatsherface", lit: "Thing-thing", ex: "Jean sort avec Machin-Truc ce soir.", cat: "thing", nsfw: false },
  { fr: "raplapla", en: "Exhausted / deflated", lit: null, ex: "Elle est raplapla après sa longue journée.", cat: "mood", nsfw: false },
  { fr: "avoir la banane", en: "To be all smiles", lit: "To have the banana", ex: "Il a la banane depuis sa promotion.", cat: "expression", nsfw: false },
  { fr: "Je m'en branle", en: "I don't give a flying f***", lit: "I jerk off with it", ex: "La réunion ? Je m'en branle !", cat: "insult", nsfw: true },
  { fr: "petit loup", en: "Little darling / sweetie", lit: "Little wolf", ex: "Il s'appelle comment, votre petit loup ?", cat: "animal", nsfw: false },
  { fr: "avoir la pêche", en: "To feel great / be in good mood", lit: "To have the peach", ex: "Après ses vacances, elle a la pêche.", cat: "expression", nsfw: false },
  { fr: "sentir le sapin", en: "One foot in the grave / seems risky", lit: "To smell of pine (coffin)", ex: "Aller dans cette grotte ? Ça sent le sapin.", cat: "expression", nsfw: false },
  { fr: "quand les poules auront des dents", en: "When pigs fly / never ever", lit: "When chickens have teeth", ex: "Il va ranger sa chambre ? Quand les poules auront des dents !", cat: "animal", nsfw: false },
  { fr: "Ce n'est pas tes oignons", en: "None of your business", lit: "Those aren't your onions", ex: "C'est pas tes oignons !", cat: "expression", nsfw: false },
  { fr: "Occupe-toi de tes oignons", en: "Mind your own business", lit: "Take care of your own onions", ex: "Occupe-toi de tes oignons !", cat: "expression", nsfw: false },
  { fr: "aux petits oignons", en: "With great care and attention", lit: "With little onions", ex: "Je vais vous offrir un traitement aux petits oignons.", cat: "expression", nsfw: false },
  { fr: "Chapeau !", en: "Congratulations! / Hats off!", lit: "Hat!", ex: "Tu as fini un marathon ? Chapeau !", cat: "expression", nsfw: false },
  { fr: "pisser dans un violon", en: "To do something completely pointless", lit: "To piss into a violin", ex: "Lui apprendre les bonnes manières ? Autant pisser dans un violon.", cat: "expression", nsfw: false },
  { fr: "une poule mouillée", en: "A wimp / a wuss", lit: "A wet chicken", ex: "N'aie pas peur — ne sois pas une poule mouillée !", cat: "animal", nsfw: false },
  { fr: "des tablettes de chocolat", en: "A six-pack (abs)", lit: "Chocolate bars", ex: "Elle l'aime pour ses tablettes de chocolat.", cat: "body", nsfw: false },
  { fr: "un ticket de métro", en: "A landing strip", lit: "A metro ticket", ex: "Ça fait référence au style de rasage !", cat: "body", nsfw: true },
  { fr: "chou", en: "Sweetheart / adorable / cute", lit: "Cabbage", ex: "Je t'aime, mon chou ! / Il est trop chou !", cat: "expression", nsfw: false },
  { fr: "un bidule", en: "A thingamajig / whatchamacallit", lit: null, ex: "C'est quoi ce bidule sur la table ?", cat: "thing", nsfw: false },
  { fr: "plouf !", en: "Splash!", lit: null, ex: "Il a sauté dans la piscine — plouf !", cat: "expression", nsfw: false },
  { fr: "un cumulus", en: "A cumulus cloud AND a hot water tank", lit: null, ex: "Mon propriétaire a installé un nouveau cumulus.", cat: "thing", nsfw: false },
  { fr: "farfelu(e)", en: "Eccentric / harebrained", lit: null, ex: "Il a des idées absolument farfelues !", cat: "mood", nsfw: false },
  { fr: "la barbe à papa", en: "Cotton candy / candy floss", lit: "Dad's beard", ex: "J'adore la barbe à papa à la fête foraine !", cat: "expression", nsfw: false },
  { fr: "nul / nulle", en: "Useless / sucks / a loser", lit: null, ex: "Je suis nul en maths. / Cette émission est nulle.", cat: "insult", nsfw: false },
];

const catLabels = {
  animal:     { label: "animal",     cls: "cat-animal" },
  body:       { label: "body",       cls: "cat-body" },
  mood:       { label: "mood",       cls: "cat-mood" },
  expression: { label: "expression", cls: "cat-expression" },
  insult:     { label: "insult",     cls: "cat-insult" },
  thing:      { label: "thing",      cls: "cat-thing" },
};

let cards = [...raw];
let current = 0;
let flipped = false;
let seen = new Set();

/* ── Audio ── */
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let actx;
function getCtx() { if (!actx) actx = new AudioCtx(); return actx; }

function playFlip() {
  try {
    const c = getCtx(), o = c.createOscillator(), g = c.createGain();
    o.connect(g); g.connect(c.destination); o.type = 'triangle';
    o.frequency.setValueAtTime(280, c.currentTime);
    o.frequency.exponentialRampToValueAtTime(560, c.currentTime + 0.09);
    g.gain.setValueAtTime(0.15, c.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + 0.15);
    o.start(c.currentTime); o.stop(c.currentTime + 0.18);
  } catch(e) {}
}

function playPop() {
  try {
    const c = getCtx(), o = c.createOscillator(), g = c.createGain();
    o.connect(g); g.connect(c.destination); o.type = 'sine';
    o.frequency.setValueAtTime(520, c.currentTime);
    o.frequency.exponentialRampToValueAtTime(320, c.currentTime + 0.12);
    g.gain.setValueAtTime(0.22, c.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + 0.18);
    o.start(c.currentTime); o.stop(c.currentTime + 0.2);
  } catch(e) {}
}

function playNext() {
  try {
    const c = getCtx();
    [0, 0.07].forEach((t, i) => {
      const o = c.createOscillator(), g = c.createGain();
      o.connect(g); g.connect(c.destination); o.type = 'sine';
      o.frequency.value = i === 0 ? 440 : 550;
      g.gain.setValueAtTime(0.12, c.currentTime + t);
      g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + t + 0.1);
      o.start(c.currentTime + t); o.stop(c.currentTime + t + 0.12);
    });
  } catch(e) {}
}

function playShuffle() {
  try {
    const c = getCtx();
    for (let i = 0; i < 5; i++) {
      const o = c.createOscillator(), g = c.createGain();
      o.connect(g); g.connect(c.destination); o.type = 'square';
      o.frequency.value = 200 + Math.random() * 300;
      const t = c.currentTime + i * 0.04;
      g.gain.setValueAtTime(0.06, t);
      g.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
      o.start(t); o.stop(t + 0.06);
    }
  } catch(e) {}
}

/* ── Deck ── */
function shuffleArr(a) {
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

function render() {
  const card = cards[current];
  flipped = false;
  document.getElementById('cardInner').classList.remove('flipped');
  document.getElementById('cardFrench').textContent = card.fr;
  const catInfo = catLabels[card.cat];
  document.getElementById('cardCat').innerHTML =
    `<span class="category-pill ${catInfo.cls}">${catInfo.label}</span>`;
  document.getElementById('nsfwBadge').innerHTML =
    card.nsfw ? '<span class="nsfw-badge">⚠ NSFW</span>' : '';
  document.getElementById('cardEnglish').textContent = card.en;
  document.getElementById('cardLiteral').textContent =
    card.lit ? `Literally: "${card.lit}"` : '';
  document.getElementById('cardExample').textContent =
    card.ex ? `e.g. ${card.ex}` : '';
  seen.add(current);
  const pct = Math.round((seen.size / cards.length) * 100);
  document.getElementById('pbar').style.width = pct + '%';
  document.getElementById('ptext').textContent = seen.size + ' / ' + cards.length;
  document.getElementById('tapHint').textContent = 'tap the card to flip!';
  renderDots();
}

function renderDots() {
  const c = document.getElementById('dots');
  c.innerHTML = '';
  cards.forEach((_, i) => {
    const d = document.createElement('div');
    d.className = 'dot' +
      (seen.has(i) ? ' seen' : '') +
      (i === current ? ' current' : '');
    c.appendChild(d);
  });
}

function flipCard() {
  flipped = !flipped;
  document.getElementById('cardInner').classList.toggle('flipped', flipped);
  if (flipped) {
    playFlip();
    document.getElementById('tapHint').textContent = 'got it? tap to flip back 🔄';
  } else {
    playPop();
    document.getElementById('tapHint').textContent = 'tap the card to flip!';
  }
}

function navigate(dir) {
  current = (current + dir + cards.length) % cards.length;
  playNext();
  render();
}

function shuffle() {
  playShuffle();
  seen.clear();
  cards = shuffleArr([...raw]);
  current = 0;
  render();
}

render();

/* ── WebGL Shader ── */
const canvas = document.getElementById('bg');
const gl = canvas.getContext('webgl');

const vertSrc = `
  attribute vec2 a_pos;
  void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;

const fragSrc = `
  precision mediump float;
  uniform float u_time;
  uniform vec2  u_res;
  uniform vec2  u_mouse;

  vec3 palette(float t) {
    vec3 a = vec3(0.35, 0.25, 0.65);
    vec3 b = vec3(0.25, 0.20, 0.40);
    vec3 c = vec3(0.55, 0.35, 0.80);
    vec3 d = vec3(0.10, 0.55, 0.75);
    return a + b * cos(6.28318 * (c * t + d));
  }

  float noise(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
  }

  float smoothnoise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    float a = noise(i);
    float b = noise(i + vec2(1.0, 0.0));
    float c = noise(i + vec2(0.0, 1.0));
    float d = noise(i + vec2(1.0, 1.0));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
  }

  float fbm(vec2 p) {
    float v = 0.0; float amp = 0.5;
    for (int i = 0; i < 5; i++) {
      v += amp * smoothnoise(p);
      p *= 2.1; amp *= 0.5;
    }
    return v;
  }

  void main() {
    vec2 uv = gl_FragCoord.xy / u_res;
    uv.y = 1.0 - uv.y;

    /* Mouse in 0..1 UV space, default centre */
    vec2 mouse = u_mouse / u_res;
    mouse.y = 1.0 - mouse.y;

    float t = u_time * 0.18;

    /* Warp origin pulled subtly toward mouse */
    vec2 offset = (mouse - 0.5) * 0.55;

    vec2 q = vec2(
      fbm(uv + offset + vec2(0.0, 0.0) + t * 0.4),
      fbm(uv + offset + vec2(1.7, 9.2) + t * 0.3)
    );
    vec2 r = vec2(
      fbm(uv + 4.0 * q + vec2(1.7, 9.2) + t * 0.25),
      fbm(uv + 4.0 * q + vec2(8.3, 2.8) + t * 0.2)
    );

    float f = fbm(uv + 4.0 * r);

    /* Proximity to mouse brightens / shifts the palette */
    float dist = length(uv - mouse);
    float spotlight = exp(-dist * dist * 3.5);

    float colorVal = f + 0.5 * (q.x + q.y) + 0.25 * length(r);
    colorVal += spotlight * 0.28;

    vec3 col = palette(colorVal * 0.85 + t * 0.12);

    /* Warm highlight at cursor position */
    vec3 warmTint = vec3(1.0, 0.72, 0.35);
    col = mix(col, warmTint, spotlight * 0.22);

    col = mix(col, vec3(0.15, 0.08, 0.38), 0.35);
    col = pow(col, vec3(0.88));

    gl_FragColor = vec4(col, 1.0);
  }
`;

function compileShader(gl, type, src) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  return s;
}

const prog = gl.createProgram();
gl.attachShader(prog, compileShader(gl, gl.VERTEX_SHADER, vertSrc));
gl.attachShader(prog, compileShader(gl, gl.FRAGMENT_SHADER, fragSrc));
gl.linkProgram(prog);
gl.useProgram(prog);

const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER,
  new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);

const aPos  = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

const uTime  = gl.getUniformLocation(prog, 'u_time');
const uRes   = gl.getUniformLocation(prog, 'u_res');
const uMouse = gl.getUniformLocation(prog, 'u_mouse');

/* Smoothly lerped mouse position */
let targetMX = window.innerWidth  * 0.5;
let targetMY = window.innerHeight * 0.5;
let smoothMX = targetMX;
let smoothMY = targetMY;
const LERP = 0.06;

window.addEventListener('mousemove', e => {
  targetMX = e.clientX;
  targetMY = e.clientY;
});

/* Touch support */
window.addEventListener('touchmove', e => {
  targetMX = e.touches[0].clientX;
  targetMY = e.touches[0].clientY;
}, { passive: true });

function resize() {
  canvas.width  = window.innerWidth;
  canvas.height = window.innerHeight;
  gl.viewport(0, 0, canvas.width, canvas.height);
}

resize();
window.addEventListener('resize', resize);

const startTime = performance.now();

function frame() {
  /* Lerp mouse toward target */
  smoothMX += (targetMX - smoothMX) * LERP;
  smoothMY += (targetMY - smoothMY) * LERP;

  const t = (performance.now() - startTime) / 1000;
  gl.uniform1f(uTime, t);
  gl.uniform2f(uRes, canvas.width, canvas.height);
  gl.uniform2f(uMouse, smoothMX, smoothMY);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  requestAnimationFrame(frame);
}

frame();
</script>
</body>
</html>