Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-65 into main AGit
9 changed files with 305 additions and 2 deletions
Showing only changes of commit 6e41ea1b2e - Show all commits

View file

@ -1 +1 @@
0.3.06-beta 0.3.07-beta

View file

@ -1310,3 +1310,54 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000;
.pen-current{width:40px; height:36px;} .pen-current{width:40px; height:36px;}
#penbar .pen-popover{position:fixed; left:50%; bottom:70px; transform:translateX(-50%);} #penbar .pen-popover{position:fixed; left:50%; bottom:70px; transform:translateX(-50%);}
} }
/* ============================================================
Nieuwe widgets: verjaardagstaart, getallenlijn en zinsbouwer
============================================================ */
/* Verjaardagstaart */
.bd{height:100%; min-height:0; display:grid; grid-template-columns:minmax(180px,220px) 1fr; background:var(--surface);}
.bd-controls{padding:12px; overflow:auto; display:flex; flex-direction:column; gap:8px; background:var(--surface-2); border-right:1px solid var(--line);}
.bd-controls input[type=text],.bd-controls input[type=number],.bd-controls select{
width:100%; height:36px; border:1px solid var(--line); border-radius:9px; padding:0 9px; background:var(--surface); color:var(--ink); font:inherit; font-size:13px;
}
.bd-controls label{display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:800; color:var(--muted);}
.bd-controls input[type=range]{width:96px;}.bd-color input{width:42px; height:30px; padding:1px; border:0; background:transparent; cursor:pointer;}
.bd-check{justify-content:flex-start !important;}.bd-party{margin-top:auto; min-height:38px;}
.bd-stage{min-width:0; position:relative; overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:54px 16px 34px; background:radial-gradient(circle at 50% 20%,#fff 0,var(--surface-2) 72%);}
.bd-title{position:absolute; top:16px; left:12px; right:12px; text-align:center; font-size:clamp(20px,4vw,34px); font-weight:900; color:var(--pink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.bd-cake{width:min(100%,390px); display:flex; flex-direction:column; align-items:center; filter:drop-shadow(0 10px 10px rgba(29,52,97,.18)); z-index:2;}
.bd-layer{height:72px; position:relative; background:var(--cake); border-radius:20px 20px 12px 12px; border-bottom:8px solid color-mix(in srgb,var(--cake),#8b4b2b 18%); box-shadow:inset 0 13px 0 var(--icing); flex:none;}
.bd-layer-1{width:94%;}.bd-layer-2{width:76%;}.bd-layer-3{width:58%;}
.bd-deco{position:absolute; inset:16px 8px auto; text-align:center; color:#fff; font-weight:900; letter-spacing:5px; white-space:nowrap; overflow:hidden; text-shadow:0 1px 2px rgba(0,0,0,.25);}
.bd-deco.sprinkles{color:#fff5a5;}.bd-deco.stars{color:#fff0a8;font-size:18px}.bd-deco.fruit{font-size:17px;letter-spacing:2px;}
.bd-candle-row{position:absolute; left:8%; right:8%; top:-31px; height:34px; display:flex; justify-content:center; align-items:flex-end; gap:3px;}
.bd-candle{display:block; width:5px; height:25px; border-radius:2px; background:repeating-linear-gradient(45deg,#fff 0 3px,var(--blue) 3px 6px); position:relative;}
.bd-candle::before{content:""; position:absolute; width:8px; height:12px; left:-1.5px; top:-12px; border-radius:70% 30% 60% 40%; background:linear-gradient(#fff7a0,var(--orange)); box-shadow:0 0 8px var(--orange);}
.bd-age-badge{position:absolute; right:8%; top:20%; z-index:4; min-width:58px; height:58px; padding:0 10px; border-radius:20px; display:grid; place-items:center; color:#fff; font-size:29px; font-weight:900; transform:rotate(7deg); box-shadow:0 5px 12px rgba(0,0,0,.2); border:3px solid #fff;}
.bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;}
/* Getallenlijn */
.nlw{height:100%; min-height:0; display:flex; flex-direction:column; gap:11px; padding:14px; background:var(--surface);}
.nlw-settings{display:flex; align-items:end; gap:8px; flex-wrap:wrap;}
.nlw-settings label{font-size:11px; font-weight:800; color:var(--muted); display:flex; flex-direction:column; gap:3px;}
.nlw-settings input,.nlw-settings select{width:72px; height:34px; border:1px solid var(--line); border-radius:9px; padding:0 8px; background:var(--surface); color:var(--ink); font:inherit;}
.nlw-new{margin-left:auto; min-height:34px;}.nlw-task{text-align:center; font-size:clamp(18px,3vw,27px); font-weight:900; color:var(--blue);}
.nlw-scroll{flex:1; min-height:115px; overflow-x:auto; overflow-y:hidden; border-radius:14px; background:var(--surface-2); padding:20px 18px 8px;}
.nlw-line{position:relative; min-width:max-content; height:88px; display:flex; align-items:center; padding:0 18px;}
.nlw-line::before{content:""; position:absolute; height:4px; left:18px; right:18px; top:38px; border-radius:4px; background:var(--ink);}
.nlw-tick{width:52px; height:76px; flex:none; border:0; background:transparent; color:var(--ink); position:relative; cursor:pointer; font:inherit; font-weight:800; padding:0;}
.nlw-tick i{position:absolute; width:3px; height:18px; background:var(--ink); top:31px; left:calc(50% - 1.5px);}
.nlw-tick span{position:absolute; top:53px; left:0; right:0; text-align:center; font-size:12px;}
.nlw-tick.selected::before{content:""; position:absolute; width:23px; height:23px; border-radius:50%; background:var(--pink); border:3px solid #fff; left:calc(50% - 14px); top:27px; z-index:2; box-shadow:0 2px 6px rgba(0,0,0,.25);}
.nlw-actions{display:flex; align-items:center; justify-content:center; gap:8px;}.nlw-value{min-width:76px; text-align:center; font-size:28px; font-weight:900; color:var(--pink);}
.nlw-feedback{min-height:24px; text-align:center; font-weight:800;}.nlw-feedback.ok{color:var(--green);}.nlw-feedback.bad{color:var(--red);}
/* Zinsbouwer */
.sb{height:100%; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; background:var(--surface);}
.sb-sentence{min-height:72px; border:2px dashed var(--line); border-radius:14px; padding:12px; display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:7px; font-size:clamp(17px,3vw,25px); font-weight:900;}
.sb-sentence.empty{color:var(--muted); font-size:14px; font-weight:700;}.sb-part{padding:7px 11px; border-radius:10px;}.sb-part.who{background:#dce9ff;color:#174c9b}.sb-part.does{background:#dcf7e7;color:#176b3a}.sb-part.what{background:#fff0cf;color:#855b00}
.sb-banks{flex:1; min-height:0; overflow:auto; display:grid; grid-template-columns:repeat(3,1fr); gap:8px;}.sb-bank{min-width:0; border-radius:12px; padding:9px; background:var(--surface-2);}.sb-bank h3{margin:0 0 7px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px;}
.sb-chips{display:flex; flex-wrap:wrap; gap:6px;}.sb-chip-wrap{display:inline-flex;align-items:stretch}.sb-chip{border:0; border-radius:999px; padding:7px 10px; font:inherit; font-size:12.5px; font-weight:800; cursor:pointer; text-align:left;}.sb-bank.who .sb-chip{background:#dce9ff;color:#174c9b}.sb-bank.does .sb-chip{background:#dcf7e7;color:#176b3a}.sb-bank.what .sb-chip{background:#fff0cf;color:#855b00}
.sb-chip-wrap .sb-chip{border-radius:999px 0 0 999px}.sb-chip-remove{border:0;border-radius:0 999px 999px 0;padding:0 7px;background:var(--surface-3);color:var(--muted);font-weight:900;cursor:pointer}.sb-chip-remove:hover{background:var(--red-soft);color:var(--red)}
.sb-add,.sb-actions{display:flex; align-items:center; gap:7px; flex-wrap:wrap;}.sb-add select,.sb-add input{height:36px; border:1px solid var(--line); border-radius:9px; padding:0 9px; background:var(--surface); color:var(--ink); font:inherit;}.sb-add input{flex:1; min-width:150px;}.sb-actions{justify-content:flex-end;}.sb-back{font-size:20px;}
body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2),var(--surface));}
@media(max-width:600px){.bd{grid-template-columns:1fr}.bd-controls{max-height:155px;border-right:0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:repeat(2,1fr)}.bd-party{margin-top:0}.sb-banks{grid-template-columns:1fr}.nlw-settings{gap:5px}}

View file

@ -239,6 +239,9 @@
<script src="js/widgets/light.js"></script> <script src="js/widgets/light.js"></script>
<script src="js/widgets/names.js"></script> <script src="js/widgets/names.js"></script>
<script src="js/widgets/mind.js"></script> <script src="js/widgets/mind.js"></script>
<script src="js/widgets/birthday.js"></script>
<script src="js/widgets/numberline.js"></script>
<script src="js/widgets/sentence.js"></script>
<script src="js/board.js"></script> <script src="js/board.js"></script>
<script src="js/pupil.js"></script> <script src="js/pupil.js"></script>
<script src="js/admin.js"></script> <script src="js/admin.js"></script>

View file

@ -25,17 +25,20 @@ const REGISTRY = [
{ id:"hak", cat:"taal", icon:"✂️", w:540, h:440, minW:420, minH:340, mount:mountHak }, { 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:"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:"anchor", cat:"taal", icon:"⚓", w:680, h:500, minW:440, minH:340, mount:mountAnchor },
{ id:"sentence", cat:"taal", icon:"🧩", w:620, h:520, minW:400, minH:360, mount:mountSentence },
{ id:"madd", cat:"rekenen", icon:"", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"add") }, { 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:"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") }, { id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"mul") },
{ id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") }, { id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") },
{ id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") }, { id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") },
{ id:"numberline", cat:"rekenen", icon:"↔️", w:660, h:430, minW:420, minH:320, mount:mountNumberline },
{ id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite },
{ id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind }, { id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind },
{ id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes }, { id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes },
{ id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer }, { id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer },
{ id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice }, { id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice },
{ id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames }, { id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames },
{ id:"birthday",cat:"tools", icon:"🎂", w:620, h:560, minW:400, minH:400, mount:mountBirthday },
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
]; ];
const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]]; const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]];

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.3.06-beta"; const VERSION = "0.3.07-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -90,6 +90,16 @@ const I18N = {
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor", hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor",
wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.", 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.", wg_anchor:"Ankerwoorden", wg_anchor_d:"Onbeperkt woordraster rond een thema: elk woord hoort bij de woorden eromheen.",
wg_birthday:"Verjaardagstaart", wg_birthday_d:"Maak een persoonlijke taart met naam, leeftijd, kleuren, lagen, versiering en kaarsjes.",
birthdayName:"naam van de jarige", birthdayAge:"Leeftijd", birthdayLayers:"Lagen", birthdayCakeColor:"Taart", birthdayIcing:"Glazuur", birthdayPlate:"Bord",
birthdayCandles:"Kaarsjes", birthdayCelebrate:"🎉 Vieren!", birthdayTitle:"Er is er één jarig!", birthdayFor:"Hoera voor {name}!",
birthdaySprinkles:"Spikkels", birthdayFruit:"Fruit", birthdayStars:"Sterren", birthdayNone:"Geen versiering",
wg_numberline:"Getallenlijn", wg_numberline_d:"Oefen getalbegrip met een instelbare, interactieve getallenlijn.",
numberlineFrom:"Van", numberlineTo:"Tot", numberlineStep:"Stap", numberlineNew:"Nieuwe opdracht", numberlineTask:"Zet de stip op {number}",
numberlineCheck:"Controleren", numberlineGood:"Goed gevonden! 🌟", numberlineTry:"Kijk nog eens naar de getallenlijn.",
wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.",
sentence_who:"Wie?", sentence_does:"Doet wat?", sentence_what:"Wat of waar?", sentenceHint:"Kies woorden om een zin te maken…",
sentenceAdd:"eigen woord of zinsdeel…", sentenceClear:"Wissen", sentenceSpeak:"🔊 Lees voor", sentenceRemove:"Verwijder woord",
anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven", anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven",
anchorHintReadonly:"Sleep de achtergrond om te schuiven", anchorHintReadonly:"Sleep de achtergrond om te schuiven",
anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)", anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)",
@ -230,6 +240,16 @@ const I18N = {
hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 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_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.", wg_anchor:"Anchor words", wg_anchor_d:"Unlimited word grid around a theme: every word relates to its neighbours.",
wg_birthday:"Birthday cake", wg_birthday_d:"Create a personal cake with a name, age, colours, layers, decorations and candles.",
birthdayName:"birthday person's name", birthdayAge:"Age", birthdayLayers:"Layers", birthdayCakeColor:"Cake", birthdayIcing:"Icing", birthdayPlate:"Plate",
birthdayCandles:"Candles", birthdayCelebrate:"🎉 Celebrate!", birthdayTitle:"Someone has a birthday!", birthdayFor:"Hooray for {name}!",
birthdaySprinkles:"Sprinkles", birthdayFruit:"Fruit", birthdayStars:"Stars", birthdayNone:"No decoration",
wg_numberline:"Number line", wg_numberline_d:"Practise number sense with an adjustable, interactive number line.",
numberlineFrom:"From", numberlineTo:"To", numberlineStep:"Step", numberlineNew:"New task", numberlineTask:"Move the marker to {number}",
numberlineCheck:"Check", numberlineGood:"Well done! 🌟", numberlineTry:"Take another look at the number line.",
wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.",
sentence_who:"Who?", sentence_does:"Does what?", sentence_what:"What or where?", sentenceHint:"Choose words to build a sentence…",
sentenceAdd:"your own word or phrase…", sentenceClear:"Clear", sentenceSpeak:"🔊 Read aloud", sentenceRemove:"Remove word",
anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan", anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan",
anchorHintReadonly:"Drag the background to pan", anchorHintReadonly:"Drag the background to pan",
anchorColor:"Colour: click to cycle (connect words with the same colour)", anchorColor:"Colour: click to cycle (connect words with the same colour)",

View file

@ -0,0 +1,74 @@
/* widget: volledig aanpasbare verjaardagstaart */
function mountBirthday(root, initState){
const S = Object.assign({
name:"", age:8, layers:2, cake:"#f2b880", icing:"#ff78a8", plate:"#57c7d4",
decoration:"sprinkles", candles:true,
}, initState || {});
root.innerHTML = `
<div class="bd">
<div class="bd-controls">
<input class="bd-name" type="text" maxlength="30">
<label><span class="bd-age-label"></span><input class="bd-age" type="number" min="1" max="120"></label>
<label><span class="bd-layers-label"></span><input class="bd-layers" type="range" min="1" max="3"></label>
<label class="bd-color"><span class="bd-cake-label"></span><input class="bd-cake-color" type="color"></label>
<label class="bd-color"><span class="bd-icing-label"></span><input class="bd-icing-color" type="color"></label>
<label class="bd-color"><span class="bd-plate-label"></span><input class="bd-plate-color" type="color"></label>
<select class="bd-decoration">
<option value="sprinkles"></option><option value="fruit"></option><option value="stars"></option><option value="none"></option>
</select>
<label class="bd-check"><input class="bd-candles" type="checkbox"><span class="bd-candles-label"></span></label>
<button class="tbtn bd-party" type="button"></button>
</div>
<div class="bd-stage">
<div class="bd-title"></div>
<div class="bd-cake" aria-live="polite"></div>
<div class="bd-age-badge"></div>
<div class="bd-plate"></div>
</div>
</div>`;
const q=s=>root.querySelector(s), name=q('.bd-name'), age=q('.bd-age'), layers=q('.bd-layers'),
cakeColor=q('.bd-cake-color'), icingColor=q('.bd-icing-color'), plateColor=q('.bd-plate-color'), decoration=q('.bd-decoration'),
candles=q('.bd-candles'), cake=q('.bd-cake'), title=q('.bd-title'), badge=q('.bd-age-badge');
name.value=S.name; age.value=S.age; layers.value=S.layers; cakeColor.value=S.cake;
icingColor.value=S.icing; plateColor.value=S.plate; decoration.value=S.decoration; candles.checked=S.candles;
function sync(){
S.name=name.value.trim(); S.age=Math.max(1,Math.min(120,+age.value||1)); S.layers=+layers.value;
S.cake=cakeColor.value; S.icing=icingColor.value; S.plate=plateColor.value; S.decoration=decoration.value; S.candles=candles.checked;
render();
}
function render(){
cake.innerHTML="";
for(let n=S.layers;n>=1;n--){
const layer=document.createElement('div'); layer.className='bd-layer bd-layer-'+n;
layer.style.setProperty('--cake',S.cake); layer.style.setProperty('--icing',S.icing);
if(S.decoration!=="none"){
const deco=document.createElement('div'); deco.className='bd-deco '+S.decoration;
deco.textContent=S.decoration==='fruit'?'🍓 🍒 🍓':S.decoration==='stars'?'★ ✦ ★ ✦ ★':'• • • • • •';
layer.appendChild(deco);
}
if(n===S.layers && S.candles){
const row=document.createElement('div'); row.className='bd-candle-row';
const count=Math.min(S.age,20);
for(let i=0;i<count;i++){ const c=document.createElement('i'); c.className='bd-candle'; row.appendChild(c); }
layer.appendChild(row);
}
cake.appendChild(layer);
}
title.textContent=S.name ? T('birthdayFor').replace('{name}',S.name) : T('birthdayTitle');
badge.textContent=String(S.age); badge.style.background=S.icing;
q('.bd-plate').style.background=S.plate;
}
name.addEventListener('keydown',e=>e.stopPropagation());
[name,age,layers,cakeColor,icingColor,plateColor,decoration,candles].forEach(el=>el.addEventListener('input',sync));
q('.bd-party').addEventListener('click',()=>{ confetti(q('.bd-stage')); sndWin(); });
function labels(){
name.placeholder=T('birthdayName'); q('.bd-age-label').textContent=T('birthdayAge');
q('.bd-layers-label').textContent=T('birthdayLayers'); q('.bd-cake-label').textContent=T('birthdayCakeColor');
q('.bd-icing-label').textContent=T('birthdayIcing'); q('.bd-plate-label').textContent=T('birthdayPlate'); q('.bd-candles-label').textContent=T('birthdayCandles');
q('.bd-party').textContent=T('birthdayCelebrate');
const keys=['birthdaySprinkles','birthdayFruit','birthdayStars','birthdayNone'];
[...decoration.options].forEach((o,i)=>o.textContent=T(keys[i])); render();
}
document.addEventListener('langchange',labels); labels();
return { getState:()=>({...S}) };
}

View file

@ -0,0 +1,63 @@
/* widget: interactieve getallenlijn */
function mountNumberline(root, initState){
const S=Object.assign({min:0,max:20,step:1,value:0,target:10},initState||{});
root.innerHTML=`
<div class="nlw">
<div class="nlw-settings">
<label><span class="nlw-from-label"></span><input class="nlw-min" type="number" min="-100" max="99"></label>
<label><span class="nlw-to-label"></span><input class="nlw-max" type="number" min="-99" max="200"></label>
<label><span class="nlw-step-label"></span><select class="nlw-step"><option>1</option><option>2</option><option>5</option><option>10</option></select></label>
<button class="tbtn ghost nlw-new" type="button"></button>
</div>
<div class="nlw-task"></div>
<div class="nlw-scroll"><div class="nlw-line"></div></div>
<div class="nlw-actions">
<button class="tbtn ghost nlw-minus" type="button"></button>
<output class="nlw-value"></output>
<button class="tbtn ghost nlw-plus" type="button"></button>
<button class="tbtn nlw-check" type="button"></button>
</div>
<div class="nlw-feedback" aria-live="polite"></div>
</div>`;
const q=s=>root.querySelector(s), minEl=q('.nlw-min'), maxEl=q('.nlw-max'), stepEl=q('.nlw-step'),
line=q('.nlw-line'), valueEl=q('.nlw-value'), task=q('.nlw-task'), feedback=q('.nlw-feedback');
minEl.value=S.min; maxEl.value=S.max; stepEl.value=S.step;
function normalize(){
let min=Math.max(-100,Math.min(99,+minEl.value||0)); let max=Math.max(-99,Math.min(200,+maxEl.value||20));
if(max<=min) max=min+1; const step=+stepEl.value||1;
if((max-min)/step>60) max=min+60*step;
S.min=min; S.max=max; S.step=step;
if(S.value<min||S.value>max||(S.value-min)%step!==0) S.value=min;
if(S.target<min||S.target>max||(S.target-min)%step!==0) S.target=min;
minEl.value=min; maxEl.value=max; render();
}
function render(){
line.innerHTML="";
for(let v=S.min;v<=S.max;v+=S.step){
const b=document.createElement('button'); b.type='button'; b.className='nlw-tick'; b.dataset.v=v;
const mark=document.createElement('i'), label=document.createElement('span'); label.textContent=v;
b.append(mark,label); b.classList.toggle('selected',v===S.value);
b.addEventListener('click',()=>{S.value=v; feedback.textContent=""; render();}); line.appendChild(b);
}
valueEl.textContent=S.value; task.textContent=T('numberlineTask').replace('{number}',S.target);
}
function newTarget(){
const count=Math.floor((S.max-S.min)/S.step); S.target=S.min+Math.floor(Math.random()*(count+1))*S.step;
feedback.textContent=""; render();
}
[minEl,maxEl,stepEl].forEach(el=>el.addEventListener('change',normalize));
q('.nlw-minus').addEventListener('click',()=>{S.value=Math.max(S.min,S.value-S.step);render();});
q('.nlw-plus').addEventListener('click',()=>{S.value=Math.min(S.max,S.value+S.step);render();});
q('.nlw-new').addEventListener('click',newTarget);
q('.nlw-check').addEventListener('click',()=>{
const ok=S.value===S.target; feedback.textContent=T(ok?'numberlineGood':'numberlineTry');
feedback.className='nlw-feedback '+(ok?'ok':'bad'); if(ok){sndGood();confetti(root);}else sndBad();
});
function labels(){
q('.nlw-from-label').textContent=T('numberlineFrom'); q('.nlw-to-label').textContent=T('numberlineTo');
q('.nlw-step-label').textContent=T('numberlineStep'); q('.nlw-new').textContent=T('numberlineNew');
q('.nlw-check').textContent=T('numberlineCheck'); render();
}
document.addEventListener('langchange',labels); labels(); normalize();
return {getState:()=>({...S})};
}

View file

@ -0,0 +1,57 @@
/* widget: zinsbouwer met aanpasbare woordbanken */
function mountSentence(root, initState){
const defaults=LANG==='nl' ? {
who:['De hond','Mijn vriend','De juf','Sara'], does:['lees','speelt','maakt','ziet'],
what:['een boek','op het plein','een mooie tekening','de rode bal'],
} : {
who:['The dog','My friend','The teacher','Sarah'], does:['read','plays','makes','sees'],
what:['a book','in the playground','a beautiful drawing','the red ball'],
};
const S={banks:JSON.parse(JSON.stringify(initState?.banks||defaults)),sentence:[...(initState?.sentence||[])]};
root.innerHTML=`
<div class="sb">
<div class="sb-sentence" aria-live="polite"></div>
<div class="sb-banks"></div>
<div class="sb-add">
<select class="sb-kind"><option value="who"></option><option value="does"></option><option value="what"></option></select>
<input class="sb-word" type="text" maxlength="40">
<button class="tbtn ghost sb-add-btn" type="button"></button>
</div>
<div class="sb-actions"><button class="tbtn ghost sb-back" type="button"></button><button class="tbtn ghost sb-clear" type="button"></button><button class="tbtn sb-speak" type="button"></button></div>
</div>`;
const q=s=>root.querySelector(s), sentenceEl=q('.sb-sentence'), banksEl=q('.sb-banks'), kind=q('.sb-kind'), word=q('.sb-word');
function sentenceText(){ return S.sentence.map(x=>x.word).join(' ').replace(/s+([,.!?])/g,'$1'); }
function renderSentence(){
sentenceEl.innerHTML="";
if(!S.sentence.length){ sentenceEl.textContent=T('sentenceHint'); sentenceEl.classList.add('empty'); return; }
sentenceEl.classList.remove('empty');
S.sentence.forEach(part=>{const span=document.createElement('span');span.className='sb-part '+part.kind;span.textContent=part.word;sentenceEl.appendChild(span);});
}
function renderBanks(){
banksEl.innerHTML="";
['who','does','what'].forEach(k=>{
const group=document.createElement('section'); group.className='sb-bank '+k;
const h=document.createElement('h3'); h.textContent=T('sentence_'+k); group.appendChild(h);
const chips=document.createElement('div'); chips.className='sb-chips';
S.banks[k].forEach((w,i)=>{
const wrap=document.createElement('span'); wrap.className='sb-chip-wrap';
const chip=document.createElement('button'); chip.type='button'; chip.className='sb-chip'; chip.textContent=w;
chip.addEventListener('click',()=>{S.sentence.push({kind:k,word:w});renderSentence();});
const remove=document.createElement('button'); remove.type='button'; remove.className='sb-chip-remove'; remove.textContent='×'; remove.title=T('sentenceRemove');
remove.addEventListener('click',()=>{S.banks[k].splice(i,1);renderBanks();}); wrap.append(chip,remove); chips.appendChild(wrap);
});
group.appendChild(chips); banksEl.appendChild(group);
});
}
function addWord(){const w=word.value.trim();if(!w)return;S.banks[kind.value].push(w);word.value='';renderBanks();}
q('.sb-add-btn').addEventListener('click',addWord); word.addEventListener('keydown',e=>{e.stopPropagation();if(e.key==='Enter')addWord();});
q('.sb-back').addEventListener('click',()=>{S.sentence.pop();renderSentence();});
q('.sb-clear').addEventListener('click',()=>{S.sentence=[];renderSentence();});
q('.sb-speak').addEventListener('click',()=>{const text=sentenceText();if(text&&'speechSynthesis'in window){speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(text);u.lang=LANG==='nl'?'nl-NL':'en-GB';speechSynthesis.speak(u);}});
function labels(){
[...kind.options].forEach(o=>o.textContent=T('sentence_'+o.value)); word.placeholder=T('sentenceAdd');
q('.sb-clear').textContent=T('sentenceClear'); q('.sb-speak').textContent=T('sentenceSpeak'); renderSentence();renderBanks();
}
document.addEventListener('langchange',labels);labels();
return {getState:()=>({banks:S.banks,sentence:S.sentence})};
}

32
test/widgets.test.js Normal file
View file

@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const ids=['birthday','numberline','sentence'];
test('nieuwe widgets zijn geladen vóór het widgetregister', async()=>{
const html=await readFile('public/index.html','utf8');
const board=html.indexOf('js/board.js');
for(const id of ids){
const pos=html.indexOf(`js/widgets/${id}.js`);
assert.ok(pos>0 && pos<board, `${id} moet vóór board.js laden`);
}
});
test('nieuwe widgets zijn geregistreerd en bewaren state', async()=>{
const registry=await readFile('public/js/board.js','utf8');
for(const id of ids){
assert.match(registry,new RegExp(`id:"${id}"`));
const source=await readFile(`public/js/widgets/${id}.js`,'utf8');
assert.match(source,/getState/);
assert.doesNotMatch(source,/innerHTML\s*=.*(?:name|word\.value)/);
}
});
test('nieuwe widgetnamen en beschrijvingen zijn tweetalig', async()=>{
const core=await readFile('public/js/core.js','utf8');
for(const id of ids){
assert.equal((core.match(new RegExp(`wg_${id}:`,'g'))||[]).length,2);
assert.equal((core.match(new RegExp(`wg_${id}_d:`,'g'))||[]).length,2);
}
});