[object Object]

← back to Free Roster Memory

Add light/dark theme toggle, Web Audio sound effects, and 8x8 difficulty

eb1e20fa03bf54a48a95901745127cfa20ae6192 · 2026-07-24 23:31:42 -0700 · Steve Abrams

Files touched

Diff

commit eb1e20fa03bf54a48a95901745127cfa20ae6192
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:31:42 2026 -0700

    Add light/dark theme toggle, Web Audio sound effects, and 8x8 difficulty
---
 index.html | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 96 insertions(+), 10 deletions(-)

diff --git a/index.html b/index.html
index 7cbb19a..0ceb407 100644
--- a/index.html
+++ b/index.html
@@ -9,15 +9,26 @@
     --bg1:#1e1b4b; --bg2:#312e81; --accent:#a78bfa; --accent2:#f472b6;
     --card-back:linear-gradient(135deg,#7c3aed,#db2777);
     --card-face:#ffffff; --matched:#34d399; --miss:#f87171;
+    --text:#f8fafc; --panel:rgba(255,255,255,.07); --panel-bd:rgba(255,255,255,.1);
+    --seg-bg:rgba(255,255,255,.08); --seg-bd:rgba(255,255,255,.12); --seg-txt:#e2e8f0;
+    --foot:rgba(255,255,255,.4);
+  }
+  body.light{
+    --bg1:#ede9fe; --bg2:#c7d2fe;
+    --card-face:#ffffff;
+    --text:#1e1b4b; --panel:rgba(30,27,75,.06); --panel-bd:rgba(30,27,75,.12);
+    --seg-bg:rgba(30,27,75,.06); --seg-bd:rgba(30,27,75,.14); --seg-txt:#312e81;
+    --foot:rgba(30,27,75,.5);
   }
   *{box-sizing:border-box;margin:0;padding:0}
   body{
     min-height:100vh;
     font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
     background:linear-gradient(160deg,var(--bg1),var(--bg2));
-    color:#f8fafc;
+    color:var(--text);
     display:flex;flex-direction:column;align-items:center;
     padding:20px;gap:16px;
+    transition:background .3s,color .3s;
   }
   h1{
     font-size:clamp(1.6rem,5vw,2.6rem);
@@ -30,15 +41,24 @@
     display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;
   }
   .seg{
-    display:inline-flex;background:rgba(255,255,255,.08);
+    display:inline-flex;background:var(--seg-bg);
     border-radius:999px;padding:4px;gap:4px;
-    border:1px solid rgba(255,255,255,.12);
+    border:1px solid var(--seg-bd);
   }
   .seg button{
-    border:none;background:transparent;color:#e2e8f0;
-    padding:8px 18px;border-radius:999px;cursor:pointer;
+    border:none;background:transparent;color:var(--seg-txt);
+    padding:8px 16px;border-radius:999px;cursor:pointer;
     font-size:.95rem;font-weight:600;transition:.2s;
   }
+  .icon-btn{
+    border:1px solid var(--seg-bd);background:var(--seg-bg);color:var(--seg-txt);
+    cursor:pointer;font-size:1.1rem;font-weight:600;line-height:1;
+    width:42px;height:42px;border-radius:50%;transition:.2s;
+    display:inline-flex;align-items:center;justify-content:center;
+  }
+  .icon-btn:hover{transform:translateY(-2px)}
+  .icon-btn:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
+  .icon-btn.off{opacity:.45}
   .seg button.active{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 4px 14px rgba(167,139,250,.4)}
   .seg button:focus-visible,.btn:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
   .btn{
@@ -51,8 +71,8 @@
   .btn:active{transform:translateY(0)}
   .stats{
     display:flex;gap:22px;font-size:1.05rem;font-weight:600;flex-wrap:wrap;justify-content:center;
-    background:rgba(255,255,255,.07);padding:12px 26px;border-radius:16px;
-    border:1px solid rgba(255,255,255,.1);
+    background:var(--panel);padding:12px 26px;border-radius:16px;
+    border:1px solid var(--panel-bd);
   }
   .stats span{color:var(--accent);font-variant-numeric:tabular-nums}
   .stats .best{color:#fcd34d}
@@ -116,7 +136,7 @@
   .win-card .newbest{color:#fcd34d;font-weight:700;margin-top:4px}
   .win-card .btn{margin-top:18px}
   .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
-  footer{font-size:.8rem;color:rgba(255,255,255,.4);margin-top:6px}
+  footer{font-size:.8rem;color:var(--foot);margin-top:6px}
 </style>
 </head>
 <body>
@@ -126,8 +146,11 @@
     <div class="seg" id="difficulty" role="group" aria-label="Difficulty">
       <button data-size="4" class="active" aria-pressed="true">4 × 4</button>
       <button data-size="6" aria-pressed="false">6 × 6</button>
+      <button data-size="8" aria-pressed="false">8 × 8</button>
     </div>
     <button class="btn" id="restart">↻ Restart</button>
+    <button class="icon-btn" id="soundToggle" title="Toggle sound" aria-pressed="true">🔊</button>
+    <button class="icon-btn" id="themeToggle" title="Toggle light / dark theme">🌙</button>
   </div>
 
   <div class="stats" aria-hidden="true">
@@ -159,7 +182,8 @@
 <script>
   const EMOJIS = ['🍕','🎸','🚀','🐱','🌈','⚽','🎮','🍩','🦊','🌵','🐙','🎈',
                   '🍔','🦄','🎧','🍦','🐳','🔥','🌸','🎲','🧩','🍭','🐢','🎁',
-                  '🐝','🌙','🍉','🎯','🦋','🍄','⭐','🐧'];
+                  '🐝','🌙','🍉','🎯','🦋','🍄','⭐','🐧','🍎','🐸','🎃','🌻',
+                  '🍇','🐬','🎹','🍓','🦖','🌴','🍒','🐨','🎨','🍿','🦉','🌊'];
 
   const board   = document.getElementById('board');
   const movesEl = document.getElementById('moves');
@@ -172,6 +196,35 @@
   let size = 4, first = null, lock = false;
   let moves = 0, matched = 0, totalPairs = 0;
   let timer = null, seconds = 0, started = false;
+  let soundOn = localStorage.getItem('freeRoster.sound') !== 'off';
+
+  /* ---- self-contained Web Audio sound engine (no files) ---- */
+  let audioCtx = null;
+  function tone(freq, start, dur, type='sine', gain=0.18){
+    if(!audioCtx) return;
+    const o = audioCtx.createOscillator(), g = audioCtx.createGain();
+    o.type = type; o.frequency.value = freq;
+    o.connect(g); g.connect(audioCtx.destination);
+    const t0 = audioCtx.currentTime + start;
+    g.gain.setValueAtTime(0, t0);
+    g.gain.linearRampToValueAtTime(gain, t0 + 0.01);
+    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
+    o.start(t0); o.stop(t0 + dur);
+  }
+  function ensureAudio(){
+    if(!soundOn) return;
+    if(!audioCtx){
+      const AC = window.AudioContext || window.webkitAudioContext;
+      if(AC) audioCtx = new AC();
+    }
+    if(audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
+  }
+  const sfx = {
+    flip(){ if(soundOn){ ensureAudio(); tone(520, 0, 0.09, 'triangle', 0.14); } },
+    match(){ if(soundOn){ ensureAudio(); tone(660,0,0.12,'sine',0.18); tone(880,0.08,0.16,'sine',0.16); } },
+    miss(){ if(soundOn){ ensureAudio(); tone(200,0,0.18,'sawtooth',0.12); } },
+    win(){ if(soundOn){ ensureAudio(); [523,659,784,1047].forEach((f,i)=>tone(f, i*0.13, 0.28, 'sine', 0.18)); } }
+  };
 
   const fmt = s => Math.floor(s/60) + ':' + String(s%60).padStart(2,'0');
   const bestKey = () => 'freeRoster.best.' + size;
@@ -237,6 +290,7 @@
     if(card.classList.contains('flipped') || card.classList.contains('matched')) return;
 
     startTimer();
+    sfx.flip();
     card.classList.add('flipped');
     card.setAttribute('aria-label', card.dataset.emoji);
 
@@ -255,13 +309,14 @@
       matched++;
       pairsEl.textContent = matched + ' / ' + totalPairs;
       say('Match! ' + matched + ' of ' + totalPairs + ' pairs.');
-      if(matched === totalPairs) win();
+      if(matched === totalPairs){ win(); } else { sfx.match(); }
     } else {
       lock = true;
       board.classList.add('locked');
       const a = first, b = card;
       first = null;
       a.classList.add('miss'); b.classList.add('miss');
+      sfx.miss();
       say('No match.');
       setTimeout(()=>{
         [a,b].forEach(c=>{
@@ -287,6 +342,7 @@
     document.getElementById('winTime').textContent = fmt(seconds);
     document.getElementById('newBest').hidden = !isBest;
     say('You won in ' + moves + ' moves and ' + fmt(seconds) + '.');
+    sfx.win();
     setTimeout(()=>{ overlay.classList.add('show'); document.getElementById('playAgain').focus(); }, 500);
   }
 
@@ -309,6 +365,36 @@
     if(e.key === 'Escape' && overlay.classList.contains('show')) newGame();
   });
 
+  /* ---- sound toggle ---- */
+  const soundBtn = document.getElementById('soundToggle');
+  function renderSound(){
+    soundBtn.textContent = soundOn ? '🔊' : '🔇';
+    soundBtn.classList.toggle('off', !soundOn);
+    soundBtn.setAttribute('aria-pressed', soundOn ? 'true' : 'false');
+  }
+  soundBtn.addEventListener('click', ()=>{
+    soundOn = !soundOn;
+    localStorage.setItem('freeRoster.sound', soundOn ? 'on' : 'off');
+    renderSound();
+    if(soundOn){ ensureAudio(); sfx.flip(); }
+  });
+  renderSound();
+
+  /* ---- theme toggle ---- */
+  const themeBtn = document.getElementById('themeToggle');
+  function applyTheme(theme){
+    const light = theme === 'light';
+    document.body.classList.toggle('light', light);
+    themeBtn.textContent = light ? '☀️' : '🌙';
+    themeBtn.setAttribute('aria-label', light ? 'Switch to dark theme' : 'Switch to light theme');
+  }
+  applyTheme(localStorage.getItem('freeRoster.theme') || 'dark');
+  themeBtn.addEventListener('click', ()=>{
+    const next = document.body.classList.contains('light') ? 'dark' : 'light';
+    localStorage.setItem('freeRoster.theme', next);
+    applyTheme(next);
+  });
+
   newGame();
 </script>
 </body>

← fbea953 Free-Roster emoji memory game: complete, accessible, best-sc  ·  back to Free Roster Memory  ·  add push-to-both.sh — one-command sync to games.aa + emoji.a 609bb33 →