From 99e807595a9d9acae2e379cd2f6ff33e76f13a9c Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 08:02:20 +0200 Subject: [PATCH] feat: alle widgets mobielvriendelijk maken (v0.3.64-beta) --- VERSION | 2 +- public/css/teach.css | 157 ++++++++++++++++++++++++++++++++++++++++++- public/js/board.js | 40 +++++++++-- public/js/pupil.js | 4 +- test/widgets.test.js | 76 +++++++++++++++++++++ 5 files changed, 270 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index 591d9ab..c39603d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.63-beta +0.3.64-beta diff --git a/public/css/teach.css b/public/css/teach.css index 7a4d52e..fca1291 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -490,7 +490,7 @@ .wmin:hover{background:var(--surface-3);} .wz{font-size:10.5px; width:auto; min-width:30px; padding:0 5px;} .wz.on{background:var(--accent-soft); color:var(--accent-ink);} - .widget-body{flex:1; overflow:hidden; position:relative; display:flex; flex-direction:column; min-height:0;} + .widget-body{flex:1; overflow:hidden; position:relative; display:flex; flex-direction:column; min-height:0; container-type:inline-size; container-name:widget;} /* ---------- Modals ---------- */ .modalwrap{ position:fixed; inset:0; z-index:6000; background:rgba(15,22,38,.4); @@ -1591,3 +1591,158 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2 .clock-choices{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}.clock-choices.wood .clock-time-blocks{border:1px solid var(--line);background:var(--surface-2)}.clock-word-choice{min-height:48px;border:2px solid var(--wood-d)!important;background:linear-gradient(#fdf3dd,var(--wood))!important;color:var(--ink)!important;box-shadow:0 3px 0 var(--wood-d)}.clock-digital-choice{min-width:118px;min-height:58px;font-size:28px!important;color:var(--blue)!important}.clock-type-answer{width:min(100%,250px);display:flex;flex-direction:column;align-items:center;gap:9px}.clock-type-answer>p{margin:0;color:var(--muted);font-weight:800;text-align:center}.clock-type-display{min-width:180px;padding:9px 14px;border:3px dashed var(--line);border-radius:14px;background:var(--surface-2);font-size:34px;font-weight:950;letter-spacing:3px;color:var(--blue)}.clock-keypad{width:100%;display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.clock-keypad .tbtn{min-width:0;padding:0;font-size:20px}.clock-keypad .ok{background:var(--green);color:#fff} .clock-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.clock-feedback.ok{color:var(--green)}.clock-feedback.bad{color:var(--red)}.clock-actions{display:flex;justify-content:center;gap:8px;flex-wrap:wrap}.clock-actions .tbtn:not(.ghost){background:var(--green);color:#fff}.clock-drag-hint{color:var(--muted);font-weight:800;text-align:center} @media(max-width:480px){.clock-task-card{padding:12px}.clock-level-control>span{display:none}.clock-top{gap:5px}.clock{--clock-size:min(210px,68vw)}.clock-target{min-height:72px}.clock-block-tray.lg-tray{gap:7px;padding:9px}} + +/* ============================================================ + Widgetinhoud op telefoonformaat + De widget-body is de container: dit werkt zowel op het bord als + in de leerlingweergave en ook wanneer een widget zelf smal is. + ============================================================ */ +@media(max-width:600px){ + #pupilView{padding:8px;gap:12px} + #pupilView .pupil-card{width:100%!important;max-width:100%;min-width:0!important} + .widget:not(.bare){max-width:calc(100% - 8px)} + .widget-head{gap:4px;padding:6px 8px} + .widget-head .wicon{font-size:18px} + .wmin,.wclose{width:36px;height:36px;border-radius:10px} + .wz{min-width:36px;padding:0 4px} +} +@media(pointer:coarse){ + .lg-levels button,.lg-cards button,.lg-snd,.lg-mode,.fl-sel,.tseg button, + .nlw-settings input,.nlw-settings select,.sb-add input,.sb-add select, + .edu-controls input,.edu-controls select{min-height:var(--tap-sm)} + .mm-node .mm-add,.mm-node .mm-del,.mm-node .mm-cog{display:block} + .an-tools button{width:var(--tap-sm);height:var(--tap-sm)} +} + +@container widget (max-width:520px){ + /* gedeelde taal-, reken- en gereedschapskaarten */ + .lg,.mt,.dc,.np,.timer,.nlw,.sb{gap:8px;padding:8px} + .lg-top,.mt-top,.topts,.np-row,.nlw-settings{flex-wrap:wrap} + .lg-top>*,.mt-top>*{max-width:100%} + .lg-levels{max-width:100%;overflow-x:auto;scrollbar-width:thin} + .lg-levels button{padding:6px 9px} + .lg-progress,.mt-score{min-width:0;margin-left:0;flex:1;justify-content:flex-end;text-align:right} + .lg-card,.mt-card{gap:8px;padding:8px} + .lg-pic{font-size:clamp(42px,16cqi,60px)} + .lg-pic img{width:clamp(58px,20cqi,76px);height:clamp(58px,20cqi,76px)} + .lg-instr{padding:6px 8px;font-size:13px} + .lg-slots{width:100%;max-width:100%;justify-content:flex-start;gap:7px;overflow-x:auto;padding:3px 1px 8px} + .lg-tray{gap:7px;min-height:0;max-height:44%;overflow:auto;padding:8px} + .lg-kbd{gap:5px;padding:7px} + .lg-kbd button{width:36px;height:42px;font-size:18px} + .lg-ed-form{gap:7px;padding:9px} + .lg-ed-form input.ed-word,.ed-theme-name,.ed-theme-folder{width:min(100%,170px)} + .ed-pictorow{flex-wrap:wrap} + .fl-sel{min-width:0} + .fl-word{min-height:54px;font-size:clamp(30px,14cqi,48px);letter-spacing:2px;text-align:center} + .hk-word{min-height:54px} + .hk-whole{font-size:clamp(30px,13cqi,44px)} + .hk-l{font-size:clamp(27px,12cqi,40px)} + .hk-sep{font-size:22px;margin:0 1px} + .bl-balloons{font-size:clamp(24px,9cqi,32px);letter-spacing:2px} + .bl-word{min-height:45px;font-size:clamp(28px,12cqi,40px);letter-spacing:3px;text-align:center} + .mt-q{font-size:clamp(28px,12cqi,38px)} + .mt-a{min-width:90px;height:50px;font-size:28px} + .mt-pad{gap:6px} + .mt-pad button{min-height:44px;padding:8px 0} + + /* schrijfblad, media, mindmap, anker, dobbelsteen, namen en timer */ + .wp-bar{gap:4px;padding:5px;max-height:45%;overflow:auto} + .wp-group{gap:2px;padding:2px} + .wp-group button{width:30px;height:30px} + .wp-clear{width:34px;height:34px} + .media-bar{flex-wrap:wrap;padding:7px} + .media-bar input{flex-basis:190px} + .mm-top{max-width:calc(100% - 16px);gap:4px;padding:4px 6px} + .mm-hint{display:none} + .mm-seg button{padding:5px 8px;font-size:12px} + .mm-node{max-width:46%;padding:7px 10px} + .mm-pop{max-width:calc(100% - 8px)} + .an-hint{min-height:42px;padding:6px 92px 6px 8px} + .an-tools{top:4px;right:4px} + .an-cell{width:112px;height:52px;padding:2px 5px;font-size:13px} + .dc-row{gap:8px} + .die{width:clamp(64px,25cqi,84px);height:clamp(64px,25cqi,84px);padding:8px} + .die i{width:clamp(10px,4cqi,14px);height:clamp(10px,4cqi,14px)} + .np-result{min-height:62px;padding:7px;font-size:clamp(23px,9cqi,30px)} + .np-row{gap:7px} + .np-row .tbtn{flex:1} + .np-row label{margin-left:0} + .timer{gap:7px;padding:8px} + .timer .tpie{max-height:190px} + .timer .tdisp{font-size:clamp(36px,15cqi,46px)} + .timer .presets button{padding:7px 10px} + .timer .main button{padding:10px 16px} + .tseg button{padding:6px 9px} + + /* verjaardag, getallenlijn en zinsbouwer */ + .bd{grid-template-columns:1fr} + .bd-controls{max-height:165px;border-right:0;border-bottom:1px solid var(--line); + display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;padding:8px} + .bd-controls>*{min-width:0} + .bd-controls input[type=range]{width:min(96px,100%)} + .bd-party{margin-top:0} + .bd-stage{padding:48px 10px 25px} + .bd-layer{height:62px} + .bd-age-badge{min-width:48px;height:48px;font-size:24px} + .nlw-settings{align-items:end;gap:5px} + .nlw-settings label{flex:1;min-width:64px} + .nlw-settings input,.nlw-settings select{width:100%} + .nlw-new{margin-left:0} + .nlw-scroll{padding-inline:8px} + .nlw-actions{gap:5px;flex-wrap:wrap} + .nlw-value{min-width:58px;font-size:24px} + .sb-banks{grid-template-columns:1fr} + .sb-sentence{min-height:60px;padding:8px} + .sb-add{gap:5px} + .sb-add input{min-width:120px} + .sb-actions{gap:5px} + + /* educatieve widgets: dezelfde compacte opbouw als de oefenklok */ + .edu-stage{align-items:flex-start;justify-content:flex-start;padding:10px} + .edu-controls{gap:6px;padding:0 8px 8px} + .fraction-wall,.place-grid,.wordtypes,.schedule,.mood,.poll{align-self:center} + .place-grid b{padding:6px 2px;font-size:9px;overflow-wrap:anywhere} + .place-grid output{padding:12px 2px;font-size:clamp(22px,8cqi,36px)} + .wordtypes,.mood{width:100%;justify-content:center} + .schedule>div{gap:7px;padding:8px} + .schedule span{min-width:0;overflow-wrap:anywhere} + .mood .tbtn{font-size:30px;padding:9px} + .poll h3{margin:8px 0} + + .money-task-card,.clock-task-card{gap:9px;padding:10px} + .money-top,.clock-top{top:-10px;gap:5px} + .money-level-control>span,.clock-level-control>span{display:none} + .money-shop-task{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:7px} + .money-product,.money-paid{min-height:112px;padding:8px 6px;gap:4px} + .money-product-icon{font-size:46px} + .money-product-name{font-size:14px} + .money-product .money-task-amount{font-size:32px} + .money-product .money-task-words{font-size:16px} + .money-paid>output{font-size:26px} + .money-cash-tray{gap:6px;padding:9px} + .clock{--clock-size:min(210px,68cqi)} + .clock-target{min-height:72px} + .clock-read-layout{gap:10px} + .clock-read-layout>.clock-assignment{min-width:0;flex-basis:100%} + .clock-block-tray.lg-tray{gap:7px;padding:9px} +} + +@container widget (max-width:340px){ + .lg,.mt,.dc,.np,.timer,.nlw,.sb{padding:6px} + .lg-cards{gap:3px} + .lg-cards button,.lg-snd,.lg-mode{width:32px;height:34px} + .lg .slot:not(.clock-digit-slot):not(.money-digit-slot), + .lg .blok:not(.clock-digit-block):not(.money-digit-block){width:52px;height:82px} + .clock-answer-slots,.money-answer-slots{gap:4px} + .clock-digit-slot.slot.sm,.money-digit-slot.slot.sm{width:39px;height:60px} + .clock-digit-block.blok,.money-digit-block.blok{width:34px;height:52px;min-height:52px} + .clock-block-tray.lg-tray,.money-digit-tray.lg-tray{ + grid-template-columns:repeat(5,34px);gap:4px;padding:7px} + .clock-block-tray .clock-drag-block{width:34px;height:52px;min-height:52px} + .clock-type-display,.money-type-display{min-width:0;width:100%;font-size:30px} + .schedule>div{flex-wrap:wrap} + .schedule span{flex-basis:calc(100% - 70px)} + .sb-add input{flex-basis:100%} + .bd-controls{grid-template-columns:1fr;max-height:180px} +} diff --git a/public/js/board.js b/public/js/board.js index f7fa4cc..c7f43c0 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -88,11 +88,13 @@ function genWidgetId(){ return "w" + Date.now().toString(36) + Math.random().toS function makeWidget(def, saved){ const win = document.createElement("div"); win.className = "widget"; + win.dataset.widget = def.id; const size = clampWidgetSize(saved && saved.w ? saved.w : def.w, saved && saved.h ? saved.h : def.h, def); + const bounds = widgetSizeBounds(def); win.style.width = size.w+"px"; win.style.height = size.h+"px"; - if(def.minW) win.style.minWidth = def.minW+"px"; - if(def.minH) win.style.minHeight = def.minH+"px"; + win.style.minWidth = bounds.minW+"px"; + win.style.minHeight = bounds.minH+"px"; const cx = saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240; const cy = saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160; win.style.left = Math.max(0, Math.min(cx, board.clientWidth - size.w - 8)) + "px"; @@ -135,11 +137,10 @@ function makeWidget(def, saved){ const zf = VZ(); const sw = win.offsetWidth, sh = win.offsetHeight, sx = e.clientX, sy = e.clientY; const mv = ev=>{ - const maxW = board.clientWidth - win.offsetLeft - 8; - const maxH = board.clientHeight - win.offsetTop - 8; + const {minW,minH,maxW,maxH} = widgetSizeBounds(def, win.offsetLeft, win.offsetTop); let nw = sw + (ev.clientX - sx)/zf, nh = sh + (ev.clientY - sy)/zf; - nw = Math.max(def.minW || 240, Math.min(nw, maxW)); - nh = Math.max(def.minH || 180, Math.min(nh, maxH)); + nw = Math.max(minW, Math.min(nw, maxW)); + nh = Math.max(minH, Math.min(nh, maxH)); win.style.width = nw+"px"; win.style.height = nh+"px"; }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); }; @@ -175,6 +176,7 @@ function makeWidget(def, saved){ win.dataset.min = ""; win.style.display = "flex"; win.style.zIndex = ++zTop; + fitWidgetsToBoard(); } } minBtn.addEventListener("click", ()=>{ setMin(true); markBoardChange(); }); @@ -210,6 +212,7 @@ function makeWidget(def, saved){ win.style.width = win.dataset.pmw+"px"; win.style.height = win.dataset.pmh+"px"; maxBtn.textContent = "▢"; } + if(!m) fitWidgetsToBoard(); maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); } maxBtn.addEventListener("click", ()=>{ setMax(win.dataset.max!=="1"); markBoardChange(); }); @@ -265,6 +268,31 @@ function makeWidget(def, saved){ markBoardChange(); return inst; } + +/* Ook reeds geopende widgets blijven passen wanneer een telefoon draait, het + browservenster smaller wordt of een zijbalk de bordruimte verandert. */ +function fitWidgetsToBoard(){ + instances.forEach(inst=>{ + const {win,def} = inst; + const bounds = widgetSizeBounds(def); + win.style.minWidth = bounds.minW+"px"; + win.style.minHeight = bounds.minH+"px"; + if(win.dataset.min==="1") return; + if(win.dataset.max==="1"){ + win.style.left = "0px"; win.style.top = "0px"; + win.style.width = board.clientWidth+"px"; + win.style.height = board.clientHeight+"px"; + return; + } + const width = Math.max(bounds.minW, Math.min(win.offsetWidth, bounds.maxW)); + const height = Math.max(bounds.minH, Math.min(win.offsetHeight, bounds.maxH)); + win.style.width = width+"px"; win.style.height = height+"px"; + win.style.left = Math.max(0, Math.min(win.offsetLeft, board.clientWidth-width-8))+"px"; + win.style.top = Math.max(0, Math.min(win.offsetTop, board.clientHeight-height-8))+"px"; + }); +} +new ResizeObserver(fitWidgetsToBoard).observe(board); + function updateEmptyHint(){ document.body.classList.toggle("board-empty", !board.querySelector(".widget")); } diff --git a/public/js/pupil.js b/public/js/pupil.js index 4646970..39077dd 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -35,7 +35,9 @@ function renderPupilWidgets(widgets, mode){ usable.forEach(({ w, def }, idx)=>{ const card = document.createElement("div"); card.className = "widget pupil-card" + (watch ? " watch" : ""); - card.style.width = def.w + "px"; + card.dataset.widget = def.id; + card.style.width = `min(100%, ${def.w}px)`; + card.style.minWidth = "0"; card.style.minHeight = def.h + "px"; const body = document.createElement("div"); body.className = "widget-body"; diff --git a/test/widgets.test.js b/test/widgets.test.js index 560fef7..0f777fc 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -187,3 +187,79 @@ test('elk geldspel gebruikt geldige niveaugrenzen en realistische productcategor assert.ok(categories.includes(item.category)); } }); + +test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-bord',async()=>{ + const [board,pupil,css]=await Promise.all([ + readFile('public/js/board.js','utf8'), + readFile('public/js/pupil.js','utf8'), + readFile('public/css/teach.css','utf8'), + ]); + const registrySource=board.slice(board.indexOf('const REGISTRY'),board.indexOf('const WIDGET_CATS')); + const registered=[...registrySource.matchAll(/id:"([^"]+)"/g)].map(match=>match[1]).sort(); + const coverage={ + anchor:'.an-hint', + letters:'.lg-slots', + flits:'.fl-word', + hak:'.hk-word', + ball:'.bl-word', + sentence:'.sb-banks', + madd:'.mt-pad', + msub:'.mt-pad', + mmul:'.mt-pad', + mdiv:'.mt-pad', + mpct:'.mt-pad', + numberline:'.nlw-settings', + fractions:'.fraction-wall', + clock:'.clock-task-card', + placevalue:'.place-grid', + money:'.money-task-card', + wordtypes:'.wordtypes', + write:'.wp-bar', + mind:'.mm-top', + notes:'.notes{', + timer:'.timer', + dice:'.dc-row', + names:'.np-result', + birthday:'.bd{', + schedule:'.schedule>div', + mood:'.mood', + poll:'.poll', + media:'.media-bar', + }; + assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit'); + for(const [id,selector] of Object.entries(coverage)){ + assert.ok(css.includes(selector),id+' mist responsive/breedte-onafhankelijke opmaak'); + } + + assert.match(css,/\.widget-body\{[^}]*container-type:inline-size[^}]*container-name:widget/); + assert.match(css,/@container widget \(max-width:520px\)/); + const compact=css.slice(css.indexOf('Widgetinhoud op telefoonformaat')); + for(const selector of [ + '.lg,.mt,.dc,.np,.timer,.nlw,.sb', + '.wp-bar', + '.media-bar', + '.mm-top', + '.an-hint', + '.bd{grid-template-columns:1fr}', + '.sb-banks{grid-template-columns:1fr}', + '.edu-stage{align-items:flex-start', + '.money-task-card,.clock-task-card', + ]){ + assert.ok(compact.includes(selector),selector+' mist in de compacte containerregels'); + } + + const sizeCode=board.slice( + board.indexOf('function widgetSizeBounds'), + board.indexOf('/* stabiel widget-instantie-id'), + ); + const context={board:{clientWidth:320,clientHeight:480}}; + runInNewContext(sizeCode+';globalThis.mobileSize={bounds:widgetSizeBounds({minW:640,minH:540}),size:clampWidgetSize(760,640,{minW:640,minH:540})}',context); + assert.deepEqual(JSON.parse(JSON.stringify(context.mobileSize)),{ + bounds:{minW:312,minH:472,maxW:312,maxH:472}, + size:{w:312,h:472}, + }); + assert.ok(board.includes('new ResizeObserver(fitWidgetsToBoard).observe(board)')); + assert.ok(board.includes('win.dataset.widget = def.id')); + assert.match(pupil,/card\.style\.width = .min\(100%, \$\{def\.w\}px\)./); + assert.ok(pupil.includes('card.style.minWidth = "0"')); +});