Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
Showing only changes of commit 334b0f8e14 - Show all commits

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>teach</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233b7dd8'/%3E%3Ctext x='32' y='46' font-size='40' font-weight='900' font-family='Arial Rounded MT Bold,Arial,sans-serif' text-anchor='middle' fill='%23fff'%3Et%3C/text%3E%3Ccircle cx='47' cy='17' r='7' fill='%23f7c948'/%3E%3C/svg%3E">
<style>
:root{
--bg:#eef3f8; --panel:#ffffff; --ink:#26344a; --muted:#7b8aa3;
@ -41,6 +42,8 @@
.logo .l4{color:var(--blue); transform:rotate(5deg) translateY(-1px);}
.logo .l5{color:var(--purple); transform:rotate(-4deg);}
.logo .crayon{font-size:24px; margin-left:6px; transform:rotate(10deg);}
#verTag{font-size:10.5px; font-weight:700; color:var(--muted); align-self:flex-end; margin-left:4px; transform:none;}
#verTag.beta{color:#fff; background:var(--orange); padding:1px 7px; border-radius:8px;}
.logo:hover span:not(.crayon){animation:bounce .5s;}
.logo .l2{animation-delay:.05s;} .logo .l3{animation-delay:.1s;}
.logo .l4{animation-delay:.15s;} .logo .l5{animation-delay:.2s;}
@ -132,10 +135,11 @@
}
.bs-name span{overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.bs-x{
border:none; background:#fbe3e1; color:var(--red); border-radius:6px;
min-width:20px; height:20px; font-size:10px; font-weight:900; cursor:pointer; flex:none;
border:none; background:#fbe3e1; color:var(--red); border-radius:7px;
min-width:26px; height:24px; font-size:11px; font-weight:900; cursor:pointer; flex:none;
}
.bs-x.arm{background:var(--red); color:#fff;}
.bs-x:hover{background:var(--red); color:#fff;}
.bs-x.arm{background:var(--red); color:#fff; animation:pop .3s;}
.bs-new{
display:flex; align-items:center; justify-content:center; font-size:30px;
color:#fff; background:var(--green); border-color:var(--green);
@ -834,6 +838,26 @@
.bl-msg.win{color:var(--green);}
.bl-msg.lose{color:var(--red);}
.bl-kbd button:disabled{cursor:default;}
/* =========== Anchor words widget =========== */
.an{flex:1; min-height:0; display:flex; flex-direction:column;}
.an-hint{
flex:none; font-size:11.5px; font-weight:700; color:var(--muted);
padding:6px 10px; background:#f7fafd; border-bottom:1px solid #e3eaf3;
}
.an-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:#fff; cursor:grab; touch-action:none;}
.an-stage:active{cursor:grabbing;}
.an-layer{position:absolute; inset:0;}
.an-cell{
position:absolute; width:128px; height:56px; border-radius:12px; border:2px dashed #dbe4f0;
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:15px;
color:var(--ink); padding:2px 8px; overflow:hidden; text-align:center; cursor:pointer;
}
.an-cell:hover{border-color:#b6c6dc;}
.an-cell.filled{border-style:solid; background:#fff; box-shadow:0 3px 8px rgba(30,50,90,.12);}
.an-cell.root{border:2.5px solid #d9a400; background:#fff8dd;}
.an-cell.root.filled{background:var(--yellow); font-size:16px;}
.an-cell input{width:100%; border:none; outline:none; font:inherit; text-align:center; background:transparent;}
body.hc .an-cell{border-color:#555; color:#000;}
/* =========== Dice widget =========== */
.dc{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; align-items:center;}
.dc-row{flex:1; display:flex; gap:14px; align-items:center; justify-content:center; flex-wrap:wrap; cursor:pointer;}
@ -927,6 +951,7 @@
<div class="logo" title="teach">
<span class="l1">t</span><span class="l2">e</span><span class="l3">a</span><span class="l4">c</span><span class="l5">h</span>
<span class="crayon">🖍️</span>
<span id="verTag"></span>
</div>
<button class="tbtn ghost" id="btnMute">🔊</button>
<button class="tbtn ghost" id="btnHC">👁</button>
@ -1004,6 +1029,23 @@
<script>
"use strict";
/* version — bump on every change (form 0.0.00) */
const VERSION = "0.1.00";
(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
==========================================================*/
@ -1051,6 +1093,7 @@ const I18N = {
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",
@ -1066,6 +1109,8 @@ const I18N = {
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",
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.",
@ -1144,6 +1189,7 @@ const I18N = {
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",
@ -1159,6 +1205,8 @@ const I18N = {
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",
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.",
@ -1347,6 +1395,7 @@ const REGISTRY = [
{ id:"flits", cat:"taal", icon:"⚡", w:520, h:430, minW:400, minH:330, mount:mountFlits },
{ id:"hak", cat:"taal", icon:"✂️", w:540, h:440, minW:420, minH:340, mount:mountHak },
{ id:"ball", cat:"taal", icon:"🎈", w:620, h:560, minW:480, minH:440, mount:mountBall },
{ id:"anchor", cat:"taal", icon:"⚓", w:680, h:500, minW:440, minH:340, mount:mountAnchor },
{ id:"madd", cat:"rekenen", icon:"", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"add") },
{ id:"msub", cat:"rekenen", icon:"", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"sub") },
{ id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"mul") },
@ -2027,14 +2076,13 @@ fab.addEventListener("click", ()=>{
fabMenu.classList.toggle("open");
const open = fabMenu.classList.contains("open");
fab.classList.toggle("open", open);
document.getElementById("fabArc").classList.toggle("hide", open);
if(open) document.getElementById("boardStrip").classList.remove("open");
});
document.addEventListener("pointerdown", e=>{
if(!fabMenu.classList.contains("open")) return;
if(e.target.closest("#fab") || e.target.closest("#fabmenu")) return;
fabMenu.classList.remove("open");
fab.classList.remove("open");
document.getElementById("fabArc").classList.remove("hide");
});
fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
@ -2264,11 +2312,13 @@ function renderStrip(){
const x = document.createElement("button");
x.className = "bs-x"; x.textContent = "✕";
x.title = T("delBoard");
x.addEventListener("pointerdown", ev=>ev.stopPropagation());
x.addEventListener("click", ev=>{
ev.stopPropagation();
if(!x.dataset.arm){ /* first click arms, second click deletes */
x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "?";
setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="✕"; }, 2000);
clearTimeout(stripTimer); /* keep the strip open while confirming */
x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "✓?";
setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="✕"; }, 3000);
return;
}
const fo = BS.folders[it.fi];
@ -2485,6 +2535,38 @@ function setPic(el, pic){
else el.textContent = pic;
}
/* ---- shared word source (built-in cards + own themes) for all word widgets ---- */
function normWordSource(card){
if(typeof card==="string" && card.startsWith("t")){
const th = THEMES[LANG][+card.slice(1)];
if(th && th.words.length) return card;
return 0;
}
if(typeof card!=="number" || card >= WORDS[LANG].length || card < 0) return 0;
return card;
}
function wordSourceList(card){
card = normWordSource(card);
if(typeof card==="string") return THEMES[LANG][+card.slice(1)].words;
return WORDS[LANG][card];
}
function fillWordSourceSel(sel, card){
sel.innerHTML = "";
WORDS[LANG].forEach((_,i)=>{
const o = document.createElement("option");
o.value = i; o.textContent = `${T("card")} ${i+1}`;
sel.appendChild(o);
});
THEMES[LANG].forEach((th,i)=>{
if(!th.words.length) return;
const o = document.createElement("option");
o.value = "t"+i; o.textContent = "★ "+th.name;
sel.appendChild(o);
});
sel.value = String(normWordSource(card));
if(sel.selectedIndex < 0) sel.selectedIndex = 0;
}
/* emoji palette (grouped) */
const splitEmoji = s => {
const out = [];
@ -3915,29 +3997,9 @@ function mountFlits(root, initState){
const srcSel = el(".fl-src"), durSel = el(".fl-dur"),
wordEl = el(".fl-word"), picEl = el(".fl-pic"), cntEl = el(".fl-cnt");
function list(){
if(typeof S.card==="string" && S.card.startsWith("t")){
const th = THEMES[LANG][+S.card.slice(1)];
if(th && th.words.length) return th.words;
S.card = 0;
}
if(typeof S.card==="number" && S.card >= WORDS[LANG].length) S.card = 0;
return WORDS[LANG][S.card];
}
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
function buildSrc(){
srcSel.innerHTML = "";
WORDS[LANG].forEach((_,i)=>{
const o = document.createElement("option");
o.value = i; o.textContent = `${T("card")} ${i+1}`;
srcSel.appendChild(o);
});
THEMES[LANG].forEach((th,i)=>{
if(!th.words.length) return;
const o = document.createElement("option");
o.value = "t"+i; o.textContent = th.name;
srcSel.appendChild(o);
});
srcSel.value = S.card;
fillWordSourceSel(srcSel, S.card);
durSel.innerHTML = "";
[0.5,1,2,3,5].forEach(d=>{
const o = document.createElement("option");
@ -4024,30 +4086,8 @@ function mountHak(root, initState){
const srcSel = el(".hk-src"), wordEl = el(".hk-word"),
picEl = el(".hk-pic"), cntEl = el(".hk-cnt");
function list(){
if(typeof S.card==="string" && S.card.startsWith("t")){
const th = THEMES[LANG][+S.card.slice(1)];
if(th && th.words.length) return th.words;
S.card = 0;
}
if(typeof S.card==="number" && S.card >= WORDS[LANG].length) S.card = 0;
return WORDS[LANG][S.card];
}
function buildSrc(){
srcSel.innerHTML = "";
WORDS[LANG].forEach((_,i)=>{
const o = document.createElement("option");
o.value = i; o.textContent = `${T("card")} ${i+1}`;
srcSel.appendChild(o);
});
THEMES[LANG].forEach((th,i)=>{
if(!th.words.length) return;
const o = document.createElement("option");
o.value = "t"+i; o.textContent = th.name;
srcSel.appendChild(o);
});
srcSel.value = S.card;
}
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
function buildSrc(){ fillWordSourceSel(srcSel, S.card); }
function cur(){ return list()[S.order[S.idx]]; }
function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; }
function display(){
@ -4110,6 +4150,113 @@ function mountHak(root, initState){
return { getState: ()=>({card:S.card}) };
}
/* =========================================================
Anchor words — unlimited word grid around a theme
==========================================================*/
function mountAnchor(root, initState){
const CW = 128, CH = 56, G = 10;
const S = { cells:{}, pan:{x:0, y:0} };
if(initState){
S.cells = initState.cells || {};
S.pan = initState.pan || {x:0, y:0};
}
root.innerHTML = `
<div class="an">
<div class="an-hint"></div>
<div class="an-stage"><div class="an-layer"></div></div>
</div>`;
const stage = root.querySelector(".an-stage"),
layer = root.querySelector(".an-layer"),
hint = root.querySelector(".an-hint");
const ringColor = r => r===0 ? "#d9a400" : ["#3fae6a","#3b7dd8","#9b59b6","#f28c38"][r-1] || "#8fa1b8";
const key = (x,y)=>x+","+y;
function applyPan(){ layer.style.transform = `translate(${-S.pan.x}px,${-S.pan.y}px)`; }
function render(){
layer.innerHTML = "";
const w = stage.offsetWidth, h = stage.offsetHeight;
if(w < 2) return;
const cx = w/2, cy = h/2;
const x0 = Math.floor((S.pan.x - cx - CW)/(CW+G)), x1 = Math.ceil((S.pan.x + w - cx + CW)/(CW+G));
const y0 = Math.floor((S.pan.y - cy - CH)/(CH+G)), y1 = Math.ceil((S.pan.y + h - cy + CH)/(CH+G));
for(let gx=x0; gx<=x1; gx++){
for(let gy=y0; gy<=y1; gy++){
const c = document.createElement("div");
const word = S.cells[key(gx,gy)];
const ring = Math.max(Math.abs(gx), Math.abs(gy));
c.className = "an-cell" + (word ? " filled" : "") + (ring===0 ? " root" : "");
c.style.left = (cx + gx*(CW+G) - CW/2)+"px";
c.style.top = (cy + gy*(CH+G) - CH/2)+"px";
if(word){
c.textContent = word;
if(ring>0) c.style.borderColor = ringColor(ring);
}
c.dataset.gx = gx; c.dataset.gy = gy;
layer.appendChild(c);
}
}
applyPan();
hint.textContent = T("anchorHint");
}
/* pan by dragging, click (without moving) to type */
let pd = null;
stage.addEventListener("pointerdown", e=>{
if(e.target.tagName==="INPUT") return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
stage.setPointerCapture(e.pointerId);
const sc = stage.getBoundingClientRect().width / stage.offsetWidth;
pd = { x:e.clientX/sc, y:e.clientY/sc, px:S.pan.x, py:S.pan.y,
moved:false, target:e.target.closest(".an-cell"), sc };
});
stage.addEventListener("pointermove", e=>{
if(!pd) return;
const dx = e.clientX/pd.sc - pd.x, dy = e.clientY/pd.sc - pd.y;
if(Math.abs(dx) > 6 || Math.abs(dy) > 6) pd.moved = true;
if(pd.moved){ S.pan.x = pd.px - dx; S.pan.y = pd.py - dy; applyPan(); }
});
stage.addEventListener("pointerup", ()=>{
if(!pd) return;
const {moved, target} = pd;
pd = null;
if(moved){ render(); return; }
if(target) editCell(target);
});
stage.addEventListener("pointercancel", ()=>{
const moved = pd && pd.moved;
pd = null;
if(moved) render();
});
function editCell(cell){
if(cell.querySelector("input")) return;
const gx = +cell.dataset.gx, gy = +cell.dataset.gy, k = key(gx,gy);
const inp = document.createElement("input");
inp.maxLength = 24;
inp.value = S.cells[k] || "";
if(gx===0 && gy===0) inp.placeholder = T("anchorPh");
cell.textContent = "";
cell.appendChild(inp);
setTimeout(()=>{ inp.focus(); inp.select(); }, 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v) S.cells[k] = v; else delete S.cells[k];
render();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commit();
if(ev.key==="Escape"){ done = true; render(); }
});
inp.addEventListener("blur", commit);
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
}
new ResizeObserver(render).observe(stage);
document.addEventListener("langchange", render);
render();
return { getState: ()=>({ cells:S.cells, pan:S.pan }) };
}
/* =========================================================
Balloon word widget — guess before the balloons pop
==========================================================*/
@ -4139,30 +4286,8 @@ function mountBall(root, initState){
nextBtn = el(".bl-next"), kbdEl = el(".bl-kbd"),
scoreEl = el(".bl-score"), sndBtn = el(".lg-snd");
function list(){
if(typeof S.card==="string" && S.card.startsWith("t")){
const th = THEMES[LANG][+S.card.slice(1)];
if(th && th.words.length) return th.words;
S.card = 0;
}
if(typeof S.card==="number" && S.card >= WORDS[LANG].length) S.card = 0;
return WORDS[LANG][S.card];
}
function buildSrc(){
srcSel.innerHTML = "";
WORDS[LANG].forEach((_,i)=>{
const o = document.createElement("option");
o.value = i; o.textContent = `${T("card")} ${i+1}`;
srcSel.appendChild(o);
});
THEMES[LANG].forEach((th,i)=>{
if(!th.words.length) return;
const o = document.createElement("option");
o.value = "t"+i; o.textContent = th.name;
srcSel.appendChild(o);
});
srcSel.value = S.card;
}
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
function buildSrc(){ fillWordSourceSel(srcSel, S.card); }
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button");
b.textContent = l; b.dataset.l = l;