← Mike Higgins Home
Custom Code Experiment •

French: Imperfect Tense

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, viewport-fit=cover">
  <title>French: Imperfect Tense</title>
  <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
  <style>
    *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

    :root {
      --bg: #0b0b14;
      --surface: #13131f;
      --surface-2: #1a1a2e;
      --surface-3: #22223a;
      --border: rgba(255,255,255,0.07);
      --border-hover: rgba(255,255,255,0.13);
      --purple: #7c4dff;
      --purple-dim: rgba(124,77,255,0.18);
      --text-primary: #f0ecff;
      --text-secondary: #9090b8;
      --text-muted: #4a4a72;
      --accent-front: #b39ddb;
      --accent-back: #80cbc4;
    }

    html, body {
      background: var(--bg);
      font-family: 'Inter', sans-serif;
      min-height: 100%;
      color: var(--text-primary);
    }

    /* ── Header ── */
    header {
      position: fixed; top: 0; left: 0; right: 0; z-index: 100;
      height: 56px;
      background: rgba(11,11,20,0.85);
      backdrop-filter: blur(12px);
      border-bottom: 0.5px solid var(--border);
      display: flex; align-items: center; justify-content: space-between;
      padding: 0 2rem;
    }
    .header-flag { font-size: 1.3rem; }
    .header-title {
      font-family: 'Playfair Display', serif;
      font-size: 1rem; font-weight: 400;
      color: var(--text-primary); letter-spacing: 0.2px;
    }
    .header-title em { font-style: italic; color: var(--accent-front); }
    .header-count { font-size: 0.72rem; color: var(--text-muted); font-weight: 500; letter-spacing: 0.5px; }

    /* ── Layout ── */
    main {
      min-height: 100vh;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      padding: 80px 1.5rem 3rem;
      gap: 0;
    }

    /* ── Progress bar ── */
    .progress-track {
      width: min(34rem, 92vw);
      height: 2px;
      background: var(--surface-3);
      border-radius: 2px;
      margin-bottom: 2rem;
      overflow: hidden;
    }
    .progress-fill {
      height: 100%;
      background: var(--purple);
      border-radius: 2px;
      transition: width 0.5s cubic-bezier(0.4,0,0.2,1);
    }

    /* ── Flip scene ── */
    .scene {
      width: min(34rem, 92vw);
      height: 18rem;
      perspective: 1000px;
      cursor: pointer;
    }

    .flipper {
      width: 100%; height: 100%;
      position: relative;
      transform-style: preserve-3d;
      transition: transform 0.65s cubic-bezier(0.4, 0, 0.2, 1);
      will-change: transform;
    }
    .flipper.flipped { transform: rotateY(180deg); }

    .face {
      position: absolute; inset: 0;
      backface-visibility: hidden;
      -webkit-backface-visibility: hidden;
      border-radius: 20px;
      border: 0.5px solid var(--border);
      display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      padding: 2.5rem 3rem;
      transition: border-color 0.2s;
    }

    .scene:hover .face { border-color: var(--border-hover); }

    /* Front face */
    .face-front {
      background: var(--surface);
    }
    .face-front::before {
      content: '';
      position: absolute; inset: 0;
      border-radius: 20px;
      background: radial-gradient(ellipse at 30% 40%, rgba(124,77,255,0.06) 0%, transparent 65%);
      pointer-events: none;
    }

    .face-label {
      position: absolute; top: 1rem; left: 1.25rem;
      font-size: 0.65rem; font-weight: 600; letter-spacing: 1.5px;
      text-transform: uppercase; color: var(--text-muted);
    }

    .flip-hint {
      position: absolute; top: 0.9rem; right: 1.25rem;
      font-size: 0.65rem; font-weight: 500; letter-spacing: 0.5px;
      color: var(--text-muted);
      display: flex; align-items: center; gap: 4px;
    }
    .flip-hint svg { width: 11px; height: 11px; opacity: 0.5; }

    .french-text {
      font-family: 'Playfair Display', serif;
      font-size: clamp(1.4rem, 4vw, 2rem);
      font-weight: 600; color: var(--text-primary);
      text-align: center; line-height: 1.35;
      letter-spacing: -0.3px;
    }

    /* Back face */
    .face-back {
      background: var(--surface-2);
      transform: rotateY(180deg);
    }
    .face-back::before {
      content: '';
      position: absolute; inset: 0;
      border-radius: 20px;
      background: radial-gradient(ellipse at 70% 60%, rgba(128,203,196,0.05) 0%, transparent 65%);
      pointer-events: none;
    }

    .english-text {
      font-size: clamp(1.2rem, 3.5vw, 1.65rem);
      font-weight: 400; color: var(--text-primary);
      text-align: center; line-height: 1.4;
    }

    .note-pill {
      margin-top: 1.1rem;
      font-size: 0.76rem; color: var(--text-secondary);
      font-style: italic;
      background: var(--surface-3);
      border: 0.5px solid var(--border);
      border-radius: 100px;
      padding: 0.35rem 0.9rem;
      text-align: center;
      max-width: 90%;
    }

    /* ── Navigation ── */
    .nav {
      display: flex; align-items: center;
      gap: 1rem; margin-top: 2rem;
    }

    .nav-btn {
      display: flex; align-items: center; justify-content: center;
      width: 44px; height: 44px; border-radius: 50%;
      background: var(--surface);
      border: 0.5px solid var(--border);
      color: var(--text-secondary);
      cursor: pointer;
      transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.12s;
      user-select: none;
    }
    .nav-btn:hover { background: var(--surface-3); border-color: var(--border-hover); color: var(--text-primary); }
    .nav-btn:active { transform: scale(0.92); }
    .nav-btn:disabled { opacity: 0.25; cursor: default; pointer-events: none; }
    .nav-btn svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

    .nav-counter {
      font-size: 0.8rem; font-weight: 500;
      color: var(--text-muted); min-width: 3.5rem;
      text-align: center; letter-spacing: 0.5px;
    }

    /* ── Dots ── */
    .dots {
      display: flex; justify-content: center; gap: 6px;
      flex-wrap: wrap; max-width: min(34rem, 92vw);
      margin-top: 1.5rem;
    }
    .dot {
      width: 7px; height: 7px; border-radius: 50%;
      background: var(--surface-3);
      border: 0.5px solid transparent;
      cursor: pointer;
      transition: background 0.2s, transform 0.2s, border-color 0.2s;
    }
    .dot.active {
      background: var(--purple);
      transform: scale(1.25);
      border-color: rgba(124,77,255,0.4);
    }
    .dot:hover:not(.active) { background: var(--text-muted); }

    /* ── Keyboard hint ── */
    .hint {
      margin-top: 1.6rem;
      font-size: 0.7rem; color: var(--text-muted);
      text-align: center; letter-spacing: 0.4px;
    }
    kbd {
      display: inline-block;
      padding: 1px 5px;
      border: 0.5px solid var(--border-hover);
      border-radius: 4px;
      font-size: 0.68rem;
      color: var(--text-muted);
      font-family: inherit;
    }

    /* ── Slide transition (between cards) ── */
    @keyframes slideInRight {
      from { opacity: 0; transform: translateX(32px); }
      to   { opacity: 1; transform: translateX(0); }
    }
    @keyframes slideInLeft {
      from { opacity: 0; transform: translateX(-32px); }
      to   { opacity: 1; transform: translateX(0); }
    }
    .slide-right { animation: slideInRight 0.35s cubic-bezier(0.22,1,0.36,1); }
    .slide-left  { animation: slideInLeft  0.35s cubic-bezier(0.22,1,0.36,1); }
  </style>
</head>
<body>

<header>
  <span class="header-flag">🇫🇷</span>
  <span class="header-title">French · <em>L'imparfait</em></span>
  <span class="header-count" id="hcount">12 cards</span>
</header>

<main>
  <div class="progress-track">
    <div class="progress-fill" id="progress"></div>
  </div>

  <div class="scene" id="scene" role="button" aria-label="Flashcard — tap to flip" tabindex="0">
    <div class="flipper" id="flipper">

      <div class="face face-front">
        <span class="face-label">Français</span>
        <span class="flip-hint">
          tap to flip
          <svg viewBox="0 0 24 24"><path d="M1 4v6h6M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>
        </span>
        <div class="french-text" id="french-text"></div>
      </div>

      <div class="face face-back">
        <span class="face-label">English</span>
        <div class="english-text" id="english-text"></div>
        <div class="note-pill" id="note-text"></div>
      </div>

    </div>
  </div>

  <nav class="nav" aria-label="Card navigation">
    <button class="nav-btn" id="prev-btn" aria-label="Previous card">
      <svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
    </button>
    <span class="nav-counter" id="counter">1 / 12</span>
    <button class="nav-btn" id="next-btn" aria-label="Next card">
      <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
    </button>
  </nav>

  <div class="dots" id="dots" role="tablist" aria-label="Jump to card"></div>

  <p class="hint">
    <kbd>Space</kbd> flip &nbsp;·&nbsp; <kbd>←</kbd> <kbd>→</kbd> navigate
  </p>
</main>

<script>
  const CARDS = [
    { fr: "Je mangeais une pomme.", en: "I was eating an apple.", note: "Ongoing past action — used for was doing" },
    { fr: "Elle jouait au tennis chaque samedi.",    en: "She used to play tennis every Saturday.",        note: "chaque + time word = habitual imparfait trigger" },
    { fr: "Nous allions à la plage chaque été.",     en: "We used to go to the beach every summer.",       note: "aller → nous allons → stem: all-" },
    { fr: "Il prenait le train tous les matins.",    en: "He used to take the train every morning.",       note: "tous les matins (every morning) signals routine" },
    { fr: "Il faisait froid et le ciel était gris.", en: "It was cold and the sky was grey.",              note: "Imparfait sets the scene — background description" },
    { fr: "C'était une belle journée.",              en: "It was a beautiful day.",                        note: "être is the only irregular imparfait stem: ét-" },
    { fr: "Je lisais quand le téléphone a sonné.",   en: "I was reading when the phone rang.",             note: "lisais (imparfait) vs a sonné (passé composé)" },
    { fr: "Quand j'étais jeune, je parlais français.", en: "When I was young, I used to speak French.",   note: "parler → nous parlons → stem: parl-" },
    { fr: "Elle finissait ses devoirs avant le dîner.", en: "She used to finish her homework before dinner.", note: "finir → nous finissons → stem: finiss-" },
    { fr: "Je savais la réponse.",                   en: "I knew the answer.",                             note: "Stative verbs (savoir, croire, aimer…) always use imparfait" },
    { fr: "Elle voulait partir tôt.",                en: "She wanted to leave early.",                     note: "vouloir = state of wanting — not a completed event" },
    { fr: "D'habitude, il regardait la télé le soir.", en: "Usually, he watched TV in the evenings.",     note: "d'habitude (usually) is a strong imparfait trigger" },
  ];

  let idx = 0;
  let flipped = false;
  let direction = 1;

  const flipper   = document.getElementById('flipper');
  const scene     = document.getElementById('scene');
  const frText    = document.getElementById('french-text');
  const enText    = document.getElementById('english-text');
  const noteText  = document.getElementById('note-text');
  const counter   = document.getElementById('counter');
  const progress  = document.getElementById('progress');
  const prevBtn   = document.getElementById('prev-btn');
  const nextBtn   = document.getElementById('next-btn');
  const dotsEl    = document.getElementById('dots');

  function buildDots() {
    dotsEl.innerHTML = '';
    CARDS.forEach((_, i) => {
      const d = document.createElement('button');
      d.className = 'dot' + (i === 0 ? ' active' : '');
      d.setAttribute('role', 'tab');
      d.setAttribute('aria-label', 'Card ' + (i + 1));
      d.addEventListener('click', () => goTo(i, i > idx ? 1 : -1));
      dotsEl.appendChild(d);
    });
  }

  function updateDots() {
    dotsEl.querySelectorAll('.dot').forEach((d, i) => {
      d.classList.toggle('active', i === idx);
    });
  }

  function setCard(animClass) {
    const card = CARDS[idx];
    frText.textContent = card.fr;
    enText.textContent = card.en;
    noteText.textContent = card.note;
    counter.textContent = (idx + 1) + ' / ' + CARDS.length;
    progress.style.width = ((idx + 1) / CARDS.length * 100) + '%';
    prevBtn.disabled = idx === 0;
    nextBtn.disabled = idx === CARDS.length - 1;
    updateDots();

    scene.classList.remove('slide-right', 'slide-left');
    void scene.offsetWidth;
    if (animClass) scene.classList.add(animClass);
  }

  function goTo(newIdx, dir) {
    if (newIdx < 0 || newIdx >= CARDS.length) return;
    direction = dir;
    idx = newIdx;

    if (flipped) {
      flipper.classList.remove('flipped');
      flipped = false;
      setTimeout(() => setCard(dir > 0 ? 'slide-right' : 'slide-left'), 340);
    } else {
      setCard(dir > 0 ? 'slide-right' : 'slide-left');
    }
  }

  function flipCard() {
    flipped = !flipped;
    flipper.classList.toggle('flipped', flipped);
  }

  scene.addEventListener('click', flipCard);
  scene.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flipCard(); } });
  prevBtn.addEventListener('click', e => { e.stopPropagation(); goTo(idx - 1, -1); });
  nextBtn.addEventListener('click', e => { e.stopPropagation(); goTo(idx + 1,  1); });

  document.addEventListener('keydown', e => {
    if (e.key === 'ArrowRight') goTo(idx + 1,  1);
    if (e.key === 'ArrowLeft')  goTo(idx - 1, -1);
    if (e.key === ' ')          { e.preventDefault(); flipCard(); }
  });

  buildDots();
  setCard(null);
</script>
</body>
</html>