Pre-implementation checklist for phonics-game sessions. Prints the coding rules most likely to cause bugs. Run this before writing any session code.
Read the session file, then confirm each rule below before writing code.
Session specs often contain code samples that violate CLAUDE.md rules. Before using any code sample from the spec, scan it for these patterns and replace them:
| ❌ Spec pattern | ✅ Fix |
|---|---|
style.display = 'none' / 'block' / 'flex' | Use a CSS class instead (.active, .open, .hidden) |
onclick="..." HTML attribute | Remove attr; bind with addEventListener in JS |
.onclick = () => ... | Use addEventListener — no stacking risk |
.sort(() => Math.random() - 0.5) | Fisher-Yates (see Shuffling section) |
color: #636e72 | color: var(--text-secondary) |
localStorage.getItem / .setItem |
SaveManager.load() / SaveManager.save() |
window.x = window.x || new X() | window.x = new X() in game.init() — always fresh |
new AudioContext() in constructor | Create lazily on first _getCtx() call; ctx.resume().then(() => schedule()) |
document.getElementById('tut-next-btn').onclick = ... | Same button, multiple steps → single listener that dispatches on this.step |
The ⚠️ Watch Out section in each session file lists spec-specific violations. Read it before writing a single line of code.
Every manager that uses setTimeout or setInterval must follow this exact pattern. Missing any part is a bug.
class SomeManager {
constructor() {
// Declare ALL timer IDs as null at construction.
this._mainTimer = null;
this._shakeTimer = null; // even short cosmetic timers
this.onComplete = null;
}
cancel() {
// cancel() clears EVERY timer and resets callbacks.
// Call this at the start of start()/open() AND from showLessonSelect().
clearTimeout(this._mainTimer); this._mainTimer = null;
clearTimeout(this._shakeTimer); this._shakeTimer = null;
this._close(); // if this manager controls an overlay
this.onComplete = null; // prevent stale callbacks
}
complete() {
const cb = this.onComplete; // ← save callback FIRST, before cancel() nulls it
this.cancel(); // cleanup: clears timers, overlay, and onComplete
// ... save state ...
if (cb) cb();
}
skip() {
this.complete(); // ← delegates — don't duplicate cancel logic here
}
start(data, onComplete) {
this.cancel(); // ← defensive reset — prevents stacked listeners on re-entry
this.onComplete = onComplete;
// ... initialize fresh state ...
}
}
Key rules:
setTimeout(...) call → store the return value in this._somethingTimerel.isConnected guard inside stored shake timers: if (el.isConnected) el.classList.remove(...)cancel() is the single source of truth for cleanup — never inline clearTimeout elsewhereshowLessonSelect() must call managerInstance.cancel() for every managerSequence timers (arpeggio notes, multi-step animations): use an array instead of a single ID:
constructor() { this._pendingTimers = []; }
cancel() { this._pendingTimers.forEach(clearTimeout); this._pendingTimers = []; }
// In play method:
const id = setTimeout(() => this._playTone(...), i * 20);
this._pendingTimers.push(id);
Every start() / open() / show() method that can be called more than once must reset state first:
// ❌ Missing — re-entry stacks a new focus-trap handler on top of the old one
start(lesson, progress, onComplete) {
this.lesson = lesson;
// ...
}
// ✅ Correct — cancel() resets everything; _open() adds exactly one listener
start(lesson, progress, onComplete) {
this.cancel(); // always call cancel() first
this.lesson = lesson;
// ...
}
This applies to any manager that opens an overlay — TutorialManager, and any future managers — wherever start() / open() / show() can be called more than once.
.active class (el.classList.add('active') / .remove('active')).open class.hidden { display: none } — NOT style.displayaddEventListener — never onclick= HTML attributes or .onclick = property assignment.this.step — do NOT rebind on each step.createElement + textContent for dynamic content.escHtml() on all variable values.container.innerHTML = '' to clear is fine — that's not interpolation.tutorialManager, sortManager, audioManager, narrativeManager) → initialize in game.init(), not lazily in startLesson().window.x = window.x || new X() — reuses stale state on second play.'phonics-progress' — always SaveManager.load() / SaveManager.save().localStorage.getItem/setItem from outside SaveManager.lessonId keys are strings: use String(id) when reading/writing data.lessons[id].lessonId + 1: coerce to number first: Number(lessonId) + 1.#foo) override class selectors (.bar) — never put display: in a base #id rule.@keyframes (not transition) for enter/exit animations on iOS Safari.var(--text-secondary) (#595143) — never #636e72 (fails WCAG AA on cream background).speechSynthesis.cancel() and speak().AudioContext lazily on first user gesture — never in a constructor. ctx.resume() is async — chain oscillator scheduling inside .then(), never immediately after calling it: ctx.resume().then(() => scheduleOscillator()).On open: focus the first focusable element inside the overlay/dialog.
On close: return focus to whichever element triggered the opening.
#board-grid .tile or #board-back-btn. Never leave focus in a void.Focus trap: Tab/Shift-Tab stays within the overlay; Escape closes/skips.
.hidden elements from focusable set.Matched/disabled tiles: tabindex="-1" + aria-disabled="true"; restored on refill.
Use aria-pressed only on buttons with a persistent binary state. Do NOT add it to pure action triggers.
| Button type | aria-pressed? | Example |
|---|---|---|
| Toggle / selected state | ✅ yes | selected tile, grade-filter tab, settings checkbox |
| Action trigger | ❌ no | "Hear word" chip, sort bucket, navigation button |
aria-pressed="false" on creation for toggle buttons; update to "true" when active.tabindex="-1" + aria-disabled="true" (not aria-pressed).<button> elements don't need aria-pressed unless they toggle state.aria-label in HTML is fixed — VoiceOver reads the attribute, not the textContent.textContent on an element that has (or needs) an aria-label, update the aria-label too:// ❌ VoiceOver says "Stars earned" regardless of actual star count
starsEl.textContent = '★★☆';
// ✅ VoiceOver says "2 of 3 stars earned"
starsEl.textContent = '★★☆';
starsEl.setAttribute('aria-label', '2 of 3 stars earned');
Lesson JSON fields beyond the required core (id, title, gradeLevel, patterns, wordPool) may be absent. Use optional chaining:
// ❌ Throws if lesson.patternLabels is undefined
const label = lesson.patternLabels[pattern];
// ✅ Safe
const label = lesson.patternLabels?.[pattern] || pattern;
Fields that may be absent: patternLabels, patternHint, tutorialWords, explorer.
Check what already exists before adding new infrastructure:
#settings-panel with _bindSettingsPanel() — do not add a second one.#pin-dialog with _openPinDialog() — do not use prompt().SaveManager.load().muteSpeech — do not create a separate localStorage key.SaveManager.load().muteSfx — same pattern.array.sort(() => Math.random() - 0.5) — biased. Use Fisher-Yates:
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
user-scalable=no to viewport — violates WCAG 1.4.4.