teach/public/js/core.js
bes-r d1d3265c6c
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
v0.1.02: opsplitsing in css/, js/core|data|board|app en js/widgets/* (geen functionele wijziging)
2026-07-09 21:56:49 +02:00

392 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* teach - kern: versie, i18n, accounts, opslag, geluid */
"use strict";
/* version — bump on every change (form 0.0.00) */
const VERSION = "0.1.02";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
/* dev branch deploys report a dev-… build via /api/version → mark as beta;
main/release builds show only the version number */
fetch("/api/version")
.then(r=>r.ok ? r.json() : null)
.then(v=>{
if(v && typeof v.version==="string" && v.version.startsWith("dev")){
tag.textContent = "v"+VERSION+" · beta";
tag.classList.add("beta");
}
})
.catch(()=>{});
})();
/* =========================================================
i18n
==========================================================*/
const I18N = {
nl:{
addWidget:"Widget", chooseWidget:"Kies een widget", saveBoard:"Bord opslaan",
emptyBoard:"Het bord is leeg. Klik op “Widget” om te beginnen.",
wg_letters:"Letterblokken", wg_letters_d:"Leer lezen en spellen met letterblokken 3 niveaus, zoals Ik leer lezen.",
wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.",
wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.",
wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.",
bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen",
bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes",
penSize:"Dikte", eraser:"Gum", clearAll:"Alles wissen",
toolPen:"Pen", toolFountain:"Vulpen (dun en dik, reageert op druk of snelheid)",
toolMark:"Markeerstift", toolText:"Tekst typen (klik op het blad)",
wMin:"Minimaliseren", wMax:"Herstellen",
lvl1:"Niveau 1", lvl2:"Niveau 2", lvl3:"Niveau 3",
instr1:"Leg de letterblokken op de goede plek. Kijk goed naar de letters!",
instr2_begin:"Zoek de <b>beginletter</b> van het woord.",
instr2_mid:"Zoek de <b>middelste letter</b> van het woord.",
instr2_end:"Zoek de <b>eindletter</b> van het woord.",
instr3:"Spel het woord bij het plaatje. De nopjes helpen je!",
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:" Toevoegen",
newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen",
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
catTaal:"Taal", catRekenen:"Rekenen", catTools:"Hulpmiddelen", catMedia:"Media",
wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.",
wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.",
wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.",
wg_mdiv:"Deelsommen", wg_mdiv_d:"Delen met de tafels van 1 t/m 12.",
wg_mpct:"Procenten", wg_mpct_d:"Procenten uitrekenen met mooie getallen.",
mGood:"Goed zo!", mAnsWas:"Het goede antwoord is", mAgain:"Nog een keer ▸",
mGoed:"goed", mOf:"van", mDoneT:"Klaar!",
amode:"Blokjes slepen of typen", penMode:"Tekenen op het bord",
fabWidgets:"Widget toevoegen", fabDraw:"Tekenen & schrijven", fabMind:"Mindmap op het bord",
fabImg:"Afbeelding op het bord",
nodeColor:"Kleur", nodeFont:"Lettergrootte", lineColor:"Lijnkleur", lineStyle:"Lijnstijl",
a11y:"Hoog contrast (voor slechtzienden)",
zoomUI:"Beeldgrootte: alles groter of kleiner tonen",
lensBig:"Loep groter", lensSmall:"Loep kleiner", lensShape:"Rond of vierkant",
toolHand:"Handje: verplaatsen en bedienen",
boardsNav:"Borden en mappen", newBoard:" Bord", newFolder:" Map",
folderPh:"naam nieuwe map…", boardName:"Bord", folderName:"Map",
delBoard:"Bord verwijderen (2× tikken)",
renameTip:"Dubbelklik op een bord om het te hernoemen",
hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal",
wFull:"Maximaliseren", wUnfull:"Terugzetten",
wZoom:"Inhoud groter of kleiner (voor slechtzienden)",
wg_flits:"Flitswoorden", wg_flits_d:"Een woord flitst kort in beeld lezen voordat het verdwijnt!",
flFlash:"⚡ Flits", flShow:"👁 Toon", flNext:"Volgende ▸", flSec:"sec",
wg_dice:"Dobbelstenen", wg_dice_d:"Gooi 1, 2 of 3 grote dobbelstenen.",
diceRoll:"🎲 Gooi!", diceSum:"Samen",
wg_light:"Stoplicht", wg_light_d:"Rood, oranje of groen bijvoorbeeld voor het werkrumoer.",
wg_names:"Namenkiezer", wg_names_d:"Kies willekeurig een naam uit de klas.",
namesPh:"Eén naam per regel…", namesPick:"🎯 Kies!", namesNoRep:"Zonder herhaling",
namesDone:"Iedereen is geweest! 🎉",
wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.",
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor",
wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.",
wg_anchor:"Ankerwoorden", wg_anchor_d:"Onbeperkt woordraster rond een thema: elk woord hoort bij de woorden eromheen.",
anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven",
anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)",
ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:",
bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas",
wg_mind:"Mindmap", wg_mind_d:"Woordweb, spin of boomdiagram om samen ideeën te ordenen.",
mmSpider:"Spin", mmTree:"Boom", mmWeb:"Woordweb", mmRoot:"Onderwerp",
mmHint:"Klik op tekst om te typen · sleep aan de rand",
wg_media:"Foto & video", wg_media_d:"Toon foto's en video's van internet, zoals YouTube of Vimeo.",
mediaPh:"Plak een link (YouTube, Vimeo, foto of video)…", mediaShow:"Tonen",
mediaEmpty:"Plak hierboven een link en klik op Tonen.",
mediaErr:"Deze link kan niet worden getoond. Probeer een YouTube-, Vimeo-, foto- of videolink, of open hem met ↗ in een nieuw tabblad.",
mediaOpen:"Open in nieuw tabblad",
wordPh:"woord (25 letters)", close:"Sluiten",
badWord:"Gebruik 2 t/m 5 letters (a-z).", needPic:"Kies eerst een plaatje.",
noWords:"Nog geen eigen woorden. Voeg er hierboven een toe!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload",
pictoSearch:"Zoeken", pictoPh:"zoekwoord…", pictoNone:"Niets gevonden. Probeer een ander woord.",
pictoLoad:"Zoeken…", pictoErr:"Kon picto's niet laden (internet nodig).",
pictoCredit:"Picto's: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (gratis download, dan via Upload toevoegen).",
upload:"📷 Kies een afbeelding…",
upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.",
cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"],
notesPh:"Schrijf hier…",
start:"Start", pause:"Pauze", reset:"Reset", min:"min",
tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit",
tmHint:"Sleep aan de rode schijf om de tijd in te stellen.",
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
namePh:"naam", passPh:"wachtwoord",
guestNote:"Zonder inloggen wordt niets bewaard: eigen woorden en het bord zijn weg als je de pagina sluit. Log in om alles op dit apparaat te bewaren.",
storedNote:"Je eigen woorden worden automatisch bewaard. Met “Bord opslaan” bewaar je ook de widgets op het bord.",
errLogin:"Onbekende naam of verkeerd wachtwoord.",
errExists:"Deze naam bestaat al.",
errName:"Naam: 220 letters of cijfers.",
errPass:"Wachtwoord: minimaal 4 tekens.",
errCrypto:"Inloggen werkt niet in deze browser.",
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen"
},
en:{
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
emptyBoard:"The board is empty. Click “Widget” to get started.",
wg_letters:"Letter blocks", wg_letters_d:"Learn to read and spell with letter blocks 3 levels, like Ik leer lezen.",
wg_notes:"Notes", wg_notes_d:"A sheet to write on.",
wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.",
wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.",
bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines",
bgGridS:"Small maths squares", bgGridB:"Big maths squares",
penSize:"Thickness", eraser:"Eraser", clearAll:"Clear all",
toolPen:"Pen", toolFountain:"Fountain pen (thin and thick, follows pressure or speed)",
toolMark:"Highlighter", toolText:"Type text (click the sheet)",
wMin:"Minimize", wMax:"Restore",
lvl1:"Level 1", lvl2:"Level 2", lvl3:"Level 3",
instr1:"Place the letter blocks in the right spot. Look closely at the letters!",
instr2_begin:"Find the <b>beginning letter</b> of the word.",
instr2_mid:"Find the <b>middle letter</b> of the word.",
instr2_end:"Find the <b>ending letter</b> of the word.",
instr3:"Spell the word for the picture. The little studs will help you!",
card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸",
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
myWords:"My words", manage:"Manage themes and words", addWord:" Add",
newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme",
numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
catTaal:"Language", catRekenen:"Maths", catTools:"Tools", catMedia:"Media",
wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.",
wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.",
wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 112.",
wg_mdiv:"Division", wg_mdiv_d:"Dividing with the tables 112.",
wg_mpct:"Percentages", wg_mpct_d:"Working out percentages with friendly numbers.",
mGood:"Well done!", mAnsWas:"The right answer is", mAgain:"Again ▸",
mGoed:"correct", mOf:"of", mDoneT:"Done!",
amode:"Drag blocks or type", penMode:"Draw on the board",
fabWidgets:"Add widget", fabDraw:"Draw & write", fabMind:"Mindmap on the board",
fabImg:"Picture on the board",
nodeColor:"Colour", nodeFont:"Font size", lineColor:"Line colour", lineStyle:"Line style",
a11y:"High contrast (for low vision)",
zoomUI:"Display size: show everything bigger or smaller",
lensBig:"Larger magnifier", lensSmall:"Smaller magnifier", lensShape:"Round or square",
toolHand:"Hand: move and interact",
boardsNav:"Boards and folders", newBoard:" Board", newFolder:" Folder",
folderPh:"name of new folder…", boardName:"Board", folderName:"Folder",
delBoard:"Delete board (tap twice)",
renameTip:"Double-click a board to rename it",
hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical",
wFull:"Maximize", wUnfull:"Restore",
wZoom:"Content bigger or smaller (low vision)",
wg_flits:"Flash words", wg_flits_d:"A word flashes briefly read it before it disappears!",
flFlash:"⚡ Flash", flShow:"👁 Show", flNext:"Next ▸", flSec:"sec",
wg_dice:"Dice", wg_dice_d:"Roll 1, 2 or 3 big dice.",
diceRoll:"🎲 Roll!", diceSum:"Total",
wg_light:"Traffic light", wg_light_d:"Red, orange or green e.g. for classroom noise.",
wg_names:"Name picker", wg_names_d:"Pick a random name from the class.",
namesPh:"One name per line…", namesPick:"🎯 Pick!", namesNoRep:"No repeats",
namesDone:"Everyone has had a turn! 🎉",
wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.",
hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud",
wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.",
wg_anchor:"Anchor words", wg_anchor_d:"Unlimited word grid around a theme: every word relates to its neighbours.",
anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan",
anchorColor:"Colour: click to cycle (connect words with the same colour)",
ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:",
bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier",
wg_mind:"Mind map", wg_mind_d:"Word web, spider or tree diagram to organise ideas together.",
mmSpider:"Spider", mmTree:"Tree", mmWeb:"Word web", mmRoot:"Topic",
mmHint:"Click text to type · drag by the edge",
wg_media:"Photo & video", wg_media_d:"Show photos and videos from the internet, such as YouTube or Vimeo.",
mediaPh:"Paste a link (YouTube, Vimeo, photo or video)…", mediaShow:"Show",
mediaEmpty:"Paste a link above and click Show.",
mediaErr:"This link cannot be shown. Try a YouTube, Vimeo, photo or video link, or open it with ↗ in a new tab.",
mediaOpen:"Open in new tab",
wordPh:"word (25 letters)", close:"Close",
badWord:"Use 2 to 5 letters (a-z).", needPic:"Pick a picture first.",
noWords:"No custom words yet. Add one above!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload",
pictoSearch:"Search", pictoPh:"search word…", pictoNone:"Nothing found. Try another word.",
pictoLoad:"Searching…", pictoErr:"Could not load pictos (internet required).",
pictoCredit:"Pictos: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Also nice: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (free download, then add via Upload).",
upload:"📷 Choose an image…",
upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.",
cats:["Animals","Food","Nature","Transport","Things","Body"],
notesPh:"Write here…",
start:"Start", pause:"Pause", reset:"Reset", min:"min",
tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off",
tmHint:"Drag the red disc to set the time.",
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
namePh:"name", passPh:"password",
guestNote:"Without logging in nothing is saved: custom words and the board are gone when you close the page. Log in to keep everything on this device.",
storedNote:"Your custom words are saved automatically. “Save board” also stores the widgets on the board.",
errLogin:"Unknown name or wrong password.",
errExists:"This name already exists.",
errName:"Name: 220 letters or digits.",
errPass:"Password: at least 4 characters.",
errCrypto:"Login does not work in this browser.",
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first"
}
};
let LANG = "nl";
const T = k => I18N[LANG][k];
/* effective CSS zoom of the app (display-size setting); pointer coordinates
are visual pixels, absolute positions are layout pixels → divide by VZ() */
const VZ = ()=>{ const z = parseFloat(document.body.style.zoom); return z>0 ? z : 1; };
function applyI18n(){
document.querySelectorAll("[data-i18n]").forEach(el=>{ el.textContent = T(el.dataset.i18n); });
document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG));
document.getElementById("liName").placeholder = T("namePh");
document.getElementById("liPass").placeholder = T("passPh");
document.getElementById("btnMute").title = T("muteAll");
updateUserUI();
document.dispatchEvent(new CustomEvent("langchange"));
}
document.querySelectorAll("#langToggle button").forEach(b=>{
b.addEventListener("click", ()=>{ LANG=b.dataset.lang; applyI18n(); });
});
document.getElementById("btnFs").addEventListener("click", ()=>{
if(document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen();
});
/* =========================================================
Accounts & storage
- Guest: everything in memory only, gone on close.
- Logged in: PBKDF2-SHA256 hashed password (Web Crypto),
per-user data (custom words + saved board) in localStorage.
Note: this protects the password itself; anyone with physical
access to this device/browser profile can still clear or read
stored data. For real multi-device security a server is needed.
==========================================================*/
const USERS_KEY = "teach.users";
let currentUser = null; // {name, data}
function loadUsers(){ try{ return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }catch(e){ return {}; } }
function saveUsers(u){ try{ localStorage.setItem(USERS_KEY, JSON.stringify(u)); }catch(e){} }
const bufToHex = b => [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,"0")).join("");
const hexToBuf = h => new Uint8Array(h.match(/.{2}/g).map(x=>parseInt(x,16)));
async function hashPw(pw, saltHex, iterations){
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{name:"PBKDF2", hash:"SHA-256", salt:hexToBuf(saltHex), iterations}, key, 256);
return bufToHex(bits);
}
const cryptoOK = !!(window.crypto && crypto.subtle && crypto.getRandomValues);
function persistUser(){
if(!currentUser) return;
const users = loadUsers();
if(users[currentUser.name]){ users[currentUser.name].data = currentUser.data; saveUsers(users); }
}
function persistWords(){
if(currentUser){
currentUser.data.themes = THEMES;
currentUser.data.generalExtra = GENERAL_EXTRA;
persistUser();
}
}
function generalExtraFromData(data){
return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]};
}
/* login UI */
const loginWrap = document.getElementById("loginWrap");
const loginMsg = document.getElementById("loginMsg");
function updateUserUI(){
document.getElementById("userLbl").textContent = currentUser ? currentUser.name : T("login");
document.getElementById("btnUser").classList.toggle("on", !!currentUser);
document.getElementById("loginBox").style.display = currentUser ? "none" : "block";
document.getElementById("loggedBox").style.display = currentUser ? "block" : "none";
if(currentUser) document.getElementById("helloLbl").textContent = `${T("hello")} ${currentUser.name}! 👋`;
}
document.getElementById("btnUser").addEventListener("click", ()=>{
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
loginWrap.classList.add("open");
});
loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); });
async function doLogin(register){
const name = document.getElementById("liName").value.trim();
const pass = document.getElementById("liPass").value;
loginMsg.classList.remove("ok");
if(!cryptoOK){ loginMsg.textContent = T("errCrypto"); return; }
if(!/^[a-zA-Z0-9_-]{2,20}$/.test(name)){ loginMsg.textContent = T("errName"); return; }
if(pass.length < 4){ loginMsg.textContent = T("errPass"); return; }
const users = loadUsers();
try{
if(register){
if(users[name]){ loginMsg.textContent = T("errExists"); return; }
const salt = bufToHex(crypto.getRandomValues(new Uint8Array(16)));
const iterations = 150000;
const hash = await hashPw(pass, salt, iterations);
users[name] = { salt, iterations, hash, data:{ themes:{nl:[],en:[]}, board:null } };
saveUsers(users);
}else{
const u = users[name];
if(!u){ loginMsg.textContent = T("errLogin"); return; }
const hash = await hashPw(pass, u.salt, u.iterations);
if(hash !== u.hash){ loginMsg.textContent = T("errLogin"); return; }
}
}catch(e){ loginMsg.textContent = T("errCrypto"); return; }
currentUser = { name, data: loadUsers()[name].data };
THEMES = themesFromData(currentUser.data);
GENERAL_EXTRA = generalExtraFromData(currentUser.data);
document.getElementById("liPass").value = "";
try{ sessionStorage.setItem("teach.session", name); }catch(e){}
updateUserUI();
loginWrap.classList.remove("open");
document.dispatchEvent(new CustomEvent("wordschange"));
BS = boardsFromData(currentUser.data);
loadCurrentSlot();
}
document.getElementById("btnLogin").addEventListener("click", ()=>doLogin(false));
document.getElementById("btnRegister").addEventListener("click", ()=>doLogin(true));
document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") doLogin(false); });
document.getElementById("btnLogout").addEventListener("click", ()=>{
persistUser();
currentUser = null;
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
try{ sessionStorage.removeItem("teach.session"); }catch(e){}
updateUserUI();
loginWrap.classList.remove("open");
document.dispatchEvent(new CustomEvent("wordschange"));
BS = freshBS();
loadCurrentSlot();
});
/* save board */
document.getElementById("btnSave").addEventListener("click", ()=>{
const btn = document.getElementById("btnSave");
const lbl = btn.querySelector("span");
if(!currentUser){
lbl.textContent = T("loginFirst");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200);
loginWrap.classList.add("open");
return;
}
stashCurrent();
currentUser.data.boards = BS;
delete currentUser.data.board;
persistUser();
lbl.textContent = T("boardSaved");
btn.classList.add("ok");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800);
});
/* =========================================================
Sounds
==========================================================*/
let AC=null, MUTED=false;
const btnMute = document.getElementById("btnMute");
btnMute.addEventListener("click", ()=>{
MUTED = !MUTED;
btnMute.textContent = MUTED ? "🔇" : "🔊";
btnMute.title = T("muteAll");
});
function tone(freq, dur=0.12, delay=0, type="sine", vol=0.18){
if(MUTED) return;
try{
AC = AC || new (window.AudioContext||window.webkitAudioContext)();
const o=AC.createOscillator(), g=AC.createGain();
o.type=type; o.frequency.value=freq;
g.gain.setValueAtTime(vol, AC.currentTime+delay);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+delay+dur);
o.connect(g); g.connect(AC.destination);
o.start(AC.currentTime+delay); o.stop(AC.currentTime+delay+dur+0.02);
}catch(e){}
}
const sndGood = ()=> tone(880,0.1);
const sndBad = ()=> tone(180,0.18,0,"square",0.10);
const sndWin = ()=>{ [523,659,784,1047].forEach((f,i)=>tone(f,0.16,i*0.12,"triangle",0.2)); };