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>