← 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 →