teach/public/index.html
bes-r 516a048ce7
Some checks are pending
dev - build & deploy naar test / build-and-deploy (push) Waiting to run
Initiële opzet: Fastify + static, Docker, Postgres, Forgejo Actions
2026-07-07 22:41:05 +02:00

4712 lines
205 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>teach</title>
<style>
:root{
--bg:#eef3f8; --panel:#ffffff; --ink:#26344a; --muted:#7b8aa3;
--blue:#3b7dd8; --blue-d:#2b62b0; --red:#e0554d; --green:#3fae6a;
--yellow:#f7c948; --purple:#9b59b6; --orange:#f28c38;
--wood:#f6e7c9; --wood-d:#e8d2a4;
--radius:16px; --shadow:0 10px 30px rgba(30,50,90,.18);
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
*::-webkit-scrollbar{width:8px; height:8px;}
*::-webkit-scrollbar-thumb{background:#c3cfde; border-radius:4px;}
*::-webkit-scrollbar-track{background:transparent;}
html,body{height:100%; margin:0; overflow:hidden;}
body{
font-family:"Segoe UI Rounded","Arial Rounded MT Bold",ui-rounded,"Segoe UI",Verdana,sans-serif;
background:var(--bg); color:var(--ink); user-select:none;
}
input,textarea{user-select:text;}
/* ---------- Toolbar ---------- */
#toolbar{
position:fixed; top:0; left:0; right:0; height:58px; z-index:5000;
background:var(--panel); box-shadow:0 2px 10px rgba(30,50,90,.10);
display:flex; align-items:center; gap:10px; padding:0 16px;
overflow-x:auto; overflow-y:hidden; scrollbar-width:none;
}
#toolbar::-webkit-scrollbar{display:none;}
#zoomPct{min-width:58px; padding:9px 8px; font-size:13px; text-align:center;}
#zoomPct.on{background:var(--yellow); color:#6b5200;}
#btnZoomIn:disabled,#btnZoomOut:disabled{opacity:.35; cursor:default;}
.logo{display:flex; align-items:baseline; gap:1px; font-size:30px; font-weight:900; line-height:1;}
.logo span{display:inline-block; transform-origin:bottom center;}
.logo .l1{color:var(--red); transform:rotate(-6deg);}
.logo .l2{color:var(--orange); transform:rotate(4deg) translateY(-2px);}
.logo .l3{color:var(--green); transform:rotate(-3deg);}
.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);}
.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;}
@keyframes bounce{0%,100%{transform:translateY(0);} 40%{transform:translateY(-8px);}}
#toolbar .spacer{flex:1;}
.tbtn{
border:none; border-radius:12px; padding:9px 14px; font:inherit; font-weight:700;
background:var(--blue); color:#fff; cursor:pointer; font-size:14.5px;
}
.tbtn:hover{background:var(--blue-d);}
.tbtn.ghost{background:#e8eef6; color:var(--ink);}
.tbtn.ghost:hover{background:#dbe5f1;}
#btnSave{min-width:158px; text-align:center;}
#btnSave.ok{background:var(--green); color:#fff;}
#btnUser.on{background:var(--green); color:#fff;}
#langToggle{display:flex; background:#e8eef6; border-radius:12px; overflow:hidden;}
#langToggle button{
border:none; padding:9px 13px; font:inherit; font-weight:700; cursor:pointer;
background:transparent; color:var(--muted); font-size:13.5px;
}
#langToggle button.active{background:var(--blue); color:#fff;}
/* ---------- Board ---------- */
#board{position:absolute; inset:58px 0 0 0; overflow:hidden;}
#board.bg-dots{
background-image:radial-gradient(circle,#c9d6e5 1.4px,transparent 1.4px);
background-size:26px 26px;
}
/* board background line options */
#board.bg-blank{background-image:none;}
#board.bg-lines{background-image:repeating-linear-gradient(#0000 0 39px, #b9cbe3 39px 40px);}
#board.bg-write{background-image:repeating-linear-gradient(
#0000 0 19px,#cddcf0 19px 20px,#0000 20px 39px,#8aa8cf 39px 40.5px,#0000 40.5px 59px,#cddcf0 59px 60px,#0000 60px 79px);}
#board.bg-grid-s{background-image:
repeating-linear-gradient(#0000 0 24px,#cddcf0 24px 25px),
repeating-linear-gradient(90deg,#0000 0 24px,#cddcf0 24px 25px);}
#board.bg-grid-b{background-image:
repeating-linear-gradient(#0000 0 49px,#b9cbe3 49px 50px),
repeating-linear-gradient(90deg,#0000 0 49px,#b9cbe3 49px 50px);}
/* drawing layer on the board: sits ABOVE widgets like a transparency sheet,
so you can annotate over everything; clicks pass through when pen is off */
#boardInk{position:absolute; inset:0; pointer-events:none; z-index:4000;}
body.pen #boardInk{pointer-events:auto; cursor:crosshair;}
body.pen.pen-text #boardInk{cursor:text;}
body.pen.pen-hand #boardInk{pointer-events:none;} /* hand tool: interact with everything */
/* floating add-button (FAB) */
#fab{
position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px;
border-radius:50%; border:none; background:var(--blue); color:#fff; font-size:28px;
cursor:pointer; box-shadow:0 8px 24px rgba(59,125,216,.45); opacity:.8;
transition:all .2s; line-height:1;
}
#fab:hover{opacity:1; transform:scale(1.1);}
#fab.open{transform:rotate(45deg); opacity:1;}
body.board-empty #fab{opacity:1; animation:fabpulse 2s infinite;}
@keyframes fabpulse{
0%,100%{box-shadow:0 8px 24px rgba(59,125,216,.45);}
50%{box-shadow:0 8px 38px rgba(59,125,216,.9); transform:scale(1.07);}
}
#fabmenu{
position:fixed; right:24px; bottom:96px; z-index:5600; display:none;
flex-direction:column; gap:10px; align-items:flex-end;
}
/* board quick-switch: hover strip with live previews */
#fabBoardsBtn{
position:fixed; right:96px; bottom:31px; z-index:5590; width:46px; height:46px;
border-radius:50%; border:none; background:var(--purple); color:#fff; font-size:19px;
cursor:pointer; box-shadow:0 6px 18px rgba(155,89,182,.45); opacity:.85;
transition:all .2s; line-height:1;
}
#fabBoardsBtn:hover{opacity:1; transform:scale(1.1);}
#boardStrip{
position:fixed; right:16px; bottom:92px; z-index:5590; display:none; gap:10px;
padding:10px; border-radius:16px; background:rgba(255,255,255,.93);
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
box-shadow:0 10px 28px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7);
max-width:calc(100vw - 40px); overflow-x:auto; overflow-y:hidden;
}
#boardStrip.open{display:flex;}
.bs-card{
width:168px; flex:none; background:#fff; border-radius:12px;
border:2.5px solid #e3eaf3; cursor:pointer; overflow:hidden;
}
.bs-card:hover{border-color:var(--blue);}
.bs-card.active{border-color:var(--yellow);}
.bs-card canvas{display:block; width:168px; height:105px; background:#fff;}
.bs-name{
font-size:12px; font-weight:800; padding:5px 8px; display:flex;
justify-content:space-between; align-items:center; gap:6px;
}
.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;
}
.bs-x.arm{background:var(--red); color:#fff;}
.bs-new{
display:flex; align-items:center; justify-content:center; font-size:30px;
color:#fff; background:var(--green); border-color:var(--green);
width:90px; min-height:130px; font-weight:900;
}
/* folder management panel (save menu) */
#boardsPanel{
position:fixed; right:16px; top:64px; z-index:5600; width:300px; max-height:66vh;
overflow:auto; display:none; flex-direction:column; gap:8px; padding:12px;
border-radius:16px; background:rgba(255,255,255,.96); backdrop-filter:blur(12px);
box-shadow:0 10px 30px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7);
}
#boardsPanel.open{display:flex;}
.bp-folder{display:flex; align-items:center; gap:6px; font-weight:800; font-size:14px; margin-top:4px;}
.bp-folder .bpf-name{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.bp-folder button{
border:none; background:#e8eef6; border-radius:8px; padding:4px 8px; font:inherit;
font-weight:800; font-size:11.5px; cursor:pointer; color:var(--muted); flex:none;
}
.bp-folder button:hover{background:#dbe5f1; color:var(--ink);}
.bp-boards{display:flex; flex-wrap:wrap; gap:6px;}
.bp-board{
display:flex; align-items:center; gap:5px; border:2px solid #e3eaf3; background:#fff;
border-radius:10px; padding:5px 10px; font:inherit; font-weight:700; font-size:12.5px;
cursor:pointer; color:var(--muted);
}
.bp-board.active{border-color:var(--yellow); background:var(--yellow); color:#6b5200;}
.bp-board .bpb-x{border:none; background:transparent; color:var(--red); font-weight:900;
cursor:pointer; padding:0; font-size:11px; opacity:.8;}
.bp-board:hover .bpb-x{opacity:1;}
.bp-board input{border:none; outline:none; font:inherit; width:80px; background:transparent;}
.bp-new{display:flex; gap:6px; margin-top:6px;}
.bp-new input{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px; padding:7px 9px;
font:inherit; font-size:13px; outline:none;
}
.bp-hint{font-size:11px; color:var(--muted); font-weight:700;}
#fabmenu.open{display:flex;}
.fab-item{
display:flex; align-items:center; gap:10px; border:none;
background:rgba(255,255,255,.92); backdrop-filter:blur(10px);
border-radius:14px; padding:11px 16px; font:inherit; font-weight:800; font-size:14px;
color:var(--ink); cursor:pointer; box-shadow:0 6px 18px rgba(30,50,90,.22);
}
.fab-item:hover{background:#fff; transform:translateX(-4px);}
.fab-item.on{background:var(--yellow);}
.fab-item .fi{font-size:18px;}
/* pen palette (section inside #hud, always visible) */
#penbar{display:flex; gap:6px; align-items:center; flex-wrap:wrap;}
.pen-close{background:#e8eef6 !important; color:var(--ink) !important;}
.pen-close.on{background:var(--yellow) !important; color:#6b5200 !important;}
body:not(.pen) .pen-tools button.on{background:transparent; box-shadow:none;}
.pen-grip{cursor:grab; font-size:15px; color:var(--muted); padding:0 2px; touch-action:none;}
.pen-grip:active{cursor:grabbing;}
#penbar .wp-group{padding:2px;}
#penbar .wp-group button{width:28px; height:28px; font-size:14px;}
#penbar .wp-color i{width:15px; height:15px;}
#penbar .wp-bg i{width:17px; height:17px;}
#penbar .wp-clear{width:28px; height:28px; font-size:13px;}
/* images on the board */
.bimg{
position:absolute; z-index:0; border-radius:10px; touch-action:none; cursor:grab;
box-shadow:0 6px 18px rgba(30,50,90,.25);
}
.bimg:active{cursor:grabbing;}
.bimg img{width:100%; display:block; border-radius:10px; pointer-events:none;}
.bimg .dtool-x,.bimg .bimg-rs{opacity:0; transition:opacity .15s;}
.bimg:hover .dtool-x,.bimg:hover .bimg-rs{opacity:1;}
.bimg-rs{
position:absolute; right:-9px; bottom:-9px; width:20px; height:20px; border-radius:50%;
background:var(--blue); border:2.5px solid #fff; cursor:nwse-resize;
box-shadow:0 2px 6px rgba(0,0,0,.3);
}
body.pen .bimg{pointer-events:none;}
/* frameless (bare) widget for mindmap directly on the board */
.widget.bare{
background:transparent !important; border:none; box-shadow:none !important;
backdrop-filter:none; -webkit-backdrop-filter:none; resize:none; border-radius:0;
pointer-events:none; /* let clicks pass through to widgets/board below */
}
.widget.bare .widget-head,.widget.bare .mm-top,.widget.bare .mm-node,
.widget.bare .mm-pop{pointer-events:auto;}
.widget.bare .widget-head{
position:absolute; top:8px; right:8px; left:auto; width:auto; z-index:6;
background:rgba(255,255,255,.78); border:none; border-radius:12px; padding:5px 8px; gap:6px;
box-shadow:0 4px 12px rgba(30,50,90,.15);
}
.widget.bare .widget-head .wtitle{display:none;}
.widget.bare .widget-body,.widget.bare .mm-stage{background:transparent;}
/* mindmap node settings popover */
.mm-cog{
position:absolute; right:-11px; bottom:-11px; width:22px; height:22px; border-radius:50%;
border:none; background:var(--blue); color:#fff; font-size:11px; cursor:pointer;
line-height:1; display:none; padding:0;
}
.mm-node:hover .mm-cog{display:block;}
.mm-pop{
position:absolute; z-index:20; background:#fff; border-radius:12px; padding:9px 10px;
box-shadow:0 10px 26px rgba(30,50,90,.3); display:flex; flex-direction:column; gap:7px;
}
.mm-pop .mp-row{display:flex; gap:5px; align-items:center;}
.mm-pop .mp-lbl{font-size:11px; font-weight:800; color:var(--muted); width:16px; text-align:center;}
.mm-pop button{border:none; cursor:pointer; padding:0; background:transparent;}
.mm-pop .mp-sw{width:19px; height:19px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1.5px #cdd9e8;}
.mm-pop .mp-sw.on{box-shadow:0 0 0 2.5px var(--ink);}
.mm-pop .mp-fs{font-weight:900; color:var(--ink); width:24px; height:24px; border-radius:7px; background:#e8eef6;}
.mm-pop .mp-fs.on{background:var(--yellow);}
.mm-pop .mp-ls{width:34px; height:24px; border-radius:7px; background:#e8eef6; position:relative;}
.mm-pop .mp-ls.on{background:var(--yellow);}
.mm-pop .mp-ls i{display:block; border-top:2.5px solid var(--ink); width:24px; margin:0 auto;}
.mm-pop .mp-ls[data-ls=dash] i{border-top-style:dashed;}
.mm-pop .mp-ls[data-ls=dot] i{border-top-style:dotted;}
.mm-pop .mp-x{margin-left:auto; font-weight:900; color:var(--red); font-size:12px;}
/* physical tools: ruler, set square, magnifier */
.dtool{position:absolute; z-index:3; touch-action:none; user-select:none; cursor:grab; left:120px; top:120px;}
.dtool:active{cursor:grabbing;}
.dtool .dtool-x{
position:absolute; top:-10px; right:-10px; width:24px; height:24px; border-radius:50%;
border:none; background:#fbe3e1; color:var(--red); font-weight:900; cursor:pointer;
font-size:12px; line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.2); z-index:2;
}
.dtool .dtool-rot{
position:absolute; bottom:-10px; right:-10px; width:26px; height:26px; border-radius:50%;
border:none; background:var(--blue); color:#fff; cursor:grab; font-size:13px;
line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.25); z-index:2;
}
.dtool-ruler{width:600px; height:76px;}
.dtool-geo{width:340px; height:184px;}
.dtool-opts{position:absolute; top:-12px; left:8px; display:flex; gap:5px;}
.dtool-opts button{
width:24px; height:24px; border-radius:50%; border:none; background:#fff;
color:var(--ink); font-size:12px; font-weight:900; cursor:pointer; line-height:1;
box-shadow:0 2px 6px rgba(0,0,0,.25);
}
.dtool-opts button:hover{background:var(--yellow);}
.dtool-lens{z-index:4500;} /* the glass sits above the ink sheet */
.dtool-lens canvas{
border:5px solid #5a6c85; box-shadow:0 8px 22px rgba(0,0,0,.3);
background:#fff; display:block;
}
/* =========== Mindmap widget =========== */
.mm{flex:1; min-height:0; display:flex; flex-direction:column; position:relative;}
.mm-top{
position:absolute; top:8px; left:8px; z-index:7; display:flex; gap:8px; align-items:center;
padding:6px 8px; background:rgba(255,255,255,.85); backdrop-filter:blur(8px);
border-radius:12px; box-shadow:0 4px 12px rgba(30,50,90,.15); cursor:default;
}
.mm-grip{cursor:grab; color:var(--muted); font-size:14px; padding:0 2px; touch-action:none; user-select:none;}
.mm-grip:active{cursor:grabbing;}
.mm-hint{font-size:12px; font-weight:700; color:var(--muted); max-width:180px;}
.mm-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:#fff;}
.mm-stage svg{position:absolute; inset:0; width:100%; height:100%; pointer-events:none;}
.mm-node{
position:absolute; transform:translate(-50%,-50%); background:#fff;
border:2.5px solid var(--blue); padding:8px 16px; font-weight:800; font-size:15px;
cursor:grab; touch-action:none; min-width:64px; text-align:center;
box-shadow:0 3px 10px rgba(30,50,90,.15); border-radius:50px;
}
.mm-node:active{cursor:grabbing;}
.mm-node span{outline:none; cursor:text; display:inline-block; min-width:34px; user-select:text;}
.mm-node span:empty::before{content:"…"; color:#c2cddc;}
.mm-node.root{background:var(--yellow); border-color:#d9a400; font-size:17px; z-index:2;}
.mm.tree .mm-node{border-radius:10px;}
.mm.web .mm-node{border-radius:16px; border-style:dashed;}
.mm-node .mm-add,.mm-node .mm-del{
position:absolute; width:22px; height:22px; border-radius:50%; border:none;
font-size:13px; font-weight:900; cursor:pointer; line-height:1; display:none; padding:0;
}
.mm-node:hover .mm-add,.mm-node:hover .mm-del{display:block;}
.mm-add{right:-11px; top:50%; transform:translateY(-50%); background:var(--green); color:#fff;}
.mm-del{left:-9px; top:-9px; background:#fbe3e1; color:var(--red);}
/* ---------- Widget windows ---------- */
.widget{
position:absolute; background:rgba(255,255,255,.55); border-radius:var(--radius);
backdrop-filter:blur(14px) saturate(1.15); -webkit-backdrop-filter:blur(14px) saturate(1.15);
box-shadow:0 4px 14px rgba(30,50,90,.08); border:1px solid rgba(255,255,255,.6);
display:flex; flex-direction:column;
min-width:280px; min-height:200px; resize:both; overflow:hidden;
transition:background .2s, box-shadow .2s, border-color .2s;
}
.widget:hover,.widget:focus-within{
background:rgba(255,255,255,.92);
box-shadow:0 12px 30px rgba(30,50,90,.2);
border-color:rgba(255,255,255,.9);
}
.widget .wmin,.widget .wclose{opacity:.4; transition:opacity .15s;}
.widget:hover .wmin,.widget:hover .wclose,
.widget:focus-within .wmin,.widget:focus-within .wclose{opacity:1;}
/* unified floating bar: pen palette + dock, draggable, combined when both active */
#hud{
position:fixed; left:50%; bottom:16px; transform:translateX(-50%); z-index:5500;
display:none; gap:8px; align-items:center; flex-wrap:wrap; max-width:94vw;
padding:5px 8px; border-radius:14px;
background:rgba(255,255,255,.72); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
box-shadow:0 6px 18px rgba(30,50,90,.18); border:1px solid rgba(255,255,255,.7);
}
#hud{display:flex;}
.hud-div{display:none; width:1.5px; height:26px; background:#c3cfde; border-radius:2px; flex:none;}
#hud.has-dock .hud-div{display:block;}
#dock{display:none; gap:8px; align-items:center; flex-wrap:wrap;}
#hud.has-dock #dock{display:flex;}
/* collapse + orientation */
.hud-btn{
border:none; background:#e8eef6; border-radius:8px; width:26px; height:26px;
cursor:pointer; font-size:13px; line-height:1; padding:0; flex:none; font-weight:900; color:var(--ink);
}
.hud-btn:hover{background:#dbe5f1;}
#hud.min #penbar,#hud.min #dock,#hud.min .hud-div{display:none !important;}
#hud.vert{
flex-direction:column; align-items:center; flex-wrap:nowrap;
max-height:calc(100vh - 84px); overflow-y:auto; overflow-x:hidden;
max-width:none; width:92px;
}
#hud.vert #penbar{flex-direction:column; flex-wrap:nowrap; align-items:center;}
#hud.vert #penbar .wp-group{display:grid; grid-template-columns:repeat(2,30px); justify-items:center; align-items:center;}
#hud.vert .hud-div{width:26px; height:1.5px;}
#hud.vert #dock{flex-direction:column; flex-wrap:nowrap;}
.dock-item{
border:none; display:flex; align-items:center; gap:7px; background:#fff;
border-radius:10px; padding:6px 12px; font:inherit; font-weight:700; font-size:13px;
color:var(--ink); cursor:pointer; box-shadow:0 2px 6px rgba(30,50,90,.12);
}
.dock-item:hover{background:#eef3f9; transform:translateY(-2px);}
.dock-item .di{font-size:16px; line-height:1;}
.widget-head{
display:flex; align-items:center; gap:10px; padding:10px 14px;
background:rgba(255,255,255,.4); border-bottom:1px solid rgba(150,170,200,.28);
cursor:grab; flex:none;
}
.widget-head:active{cursor:grabbing;}
.widget-head .wicon{font-size:20px;}
.widget-head .wtitle{font-weight:800; font-size:15px; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.wclose{
border:none; background:#fbe3e1; color:var(--red); width:28px; height:28px;
border-radius:9px; font-weight:900; cursor:pointer; font-size:14px; line-height:1; flex:none;
}
.wclose:hover{background:var(--red); color:#fff;}
.wmin{
border:none; background:#e8eef6; color:var(--ink); width:28px; height:28px;
border-radius:9px; font-weight:900; cursor:pointer; font-size:15px; line-height:1; flex:none;
}
.wmin:hover{background:#dbe5f1;}
.wz{font-size:10.5px; width:auto; min-width:30px; padding:0 5px;}
.wz.on{background:var(--yellow); color:#6b5200;}
.widget-body{flex:1; overflow:hidden; position:relative; display:flex; flex-direction:column; min-height:0;}
/* ---------- Modals ---------- */
.modalwrap{
position:fixed; inset:0; z-index:6000; background:rgba(30,45,70,.45);
display:none; align-items:center; justify-content:center;
}
.modalwrap.open{display:flex;}
.modal{
background:var(--panel); border-radius:20px; padding:26px; width:min(680px,92vw);
max-height:88vh; overflow:auto; box-shadow:var(--shadow);
}
.modal h2{margin:0 0 18px; font-size:21px;}
.gallery{display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px;}
.gcard{
border:2px solid #e3eaf3; border-radius:16px; padding:18px 14px; text-align:center;
cursor:pointer; transition:all .15s; background:#fbfdff;
}
.gcard:hover{border-color:var(--blue); transform:translateY(-2px); box-shadow:0 6px 18px rgba(59,125,216,.18);}
.gcard .gi{font-size:38px;}
.gcard .gn{font-weight:800; margin-top:8px; font-size:15px;}
.gcard .gd{font-size:12.5px; color:var(--muted); margin-top:4px; line-height:1.35;}
/* login modal */
#loginModal{width:min(400px,92vw);}
#loginModal input{
width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px;
font:inherit; font-size:16px; outline:none; margin-bottom:10px;
}
#loginModal input:focus{border-color:var(--blue);}
.lrow{display:flex; gap:8px;}
.lrow .tbtn{flex:1;}
#loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;}
#loginMsg.ok{color:var(--green);}
.guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;}
#loggedBox{display:none;}
#loggedBox .hello{font-size:18px; font-weight:800; margin-bottom:14px;}
/* =========================================================
Letter game widget
==========================================================*/
.lg{display:flex; flex-direction:column; flex:1; min-height:0; padding:12px 14px; gap:10px;}
.lg-top{display:flex; gap:8px; align-items:center; flex-wrap:wrap;}
.lg-levels{display:flex; background:#e8eef6; border-radius:12px; overflow:hidden;}
.lg-levels button{
border:none; background:transparent; padding:8px 13px; font:inherit; font-weight:800;
font-size:13.5px; color:var(--muted); cursor:pointer;
}
.lg-levels button.active{background:var(--green); color:#fff;}
.lg-cards{display:flex; gap:5px; flex-wrap:wrap;}
.lg-cards button{
border:2px solid #e3eaf3; background:#fff; width:34px; height:34px; border-radius:10px;
font:inherit; font-weight:800; color:var(--muted); cursor:pointer; font-size:14px;
}
.lg-cards button.active{border-color:var(--yellow); background:var(--yellow); color:#6b5200;}
.lg-cards .lg-theme{
width:auto; padding:0 11px; max-width:120px; overflow:hidden;
text-overflow:ellipsis; white-space:nowrap;
}
.lg-num{
border:2px solid #e3eaf3; border-radius:10px; height:34px; font:inherit;
font-weight:800; font-size:13px; color:var(--muted); background:#fff;
padding:0 4px; outline:none; cursor:pointer;
}
.lg-snd,.lg-mode{
border:2px solid #e3eaf3; border-radius:10px; width:34px; height:34px;
background:#fff; cursor:pointer; font-size:15px; line-height:1; padding:0; flex:none;
}
.lg-snd.off{opacity:.55;}
/* small digit blocks/slots for the maths widgets */
.blok.sm{width:46px; height:74px; border-radius:10px; padding-top:4px;}
.blok.sm .bl{font-size:24px; height:30px; color:var(--purple);}
.blok.sm .dots,.slot.sm .dots{grid-template-columns:repeat(2,7px); grid-template-rows:repeat(3,7px); gap:2.5px;}
.blok.sm .dots i,.slot.sm .dots i{width:7px; height:7px;}
.blok.sm .blok-dots{margin-bottom:7px;}
.slot.sm{width:46px; height:74px; padding-top:4px;}
.slot.sm .ghost{font-size:22px; height:26px;}
.slot.sm .slot-dots{margin-bottom:6px;}
.slot.sm .dots i.off{width:6px; height:6px;}
.mt-slots{display:flex; gap:8px; justify-content:center;}
.mt-tray{gap:8px; padding:10px 8px; min-height:96px;}
.mt.blocks .mt-a,.mt.blocks .mt-pad{display:none;}
.mt:not(.blocks) .mt-slots,.mt:not(.blocks) .mt-tray{display:none;}
/* on-screen letter keyboard (letters widget, type mode) */
.lg-kbd{gap:6px; padding:10px;}
.lg-kbd button{
width:40px; height:46px; border-radius:10px; border:2px solid var(--wood-d);
background:linear-gradient(#fdf3dd,var(--wood)); font:inherit; font-weight:900;
font-size:20px; cursor:pointer; padding:0;
}
.lg-kbd button.v{color:var(--red);}
.lg-kbd button.c{color:var(--blue);}
.lg.typemode .lg-tray.blocks-tray{display:none;}
.lg:not(.typemode) .lg-kbd{display:none;}
.lg-ed-themerow{display:flex; gap:8px; align-items:center;}
.ed-theme-sel{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px;
font:inherit; font-weight:700; font-size:15px; outline:none; background:#fff; cursor:pointer;
}
.ed-theme-name{
border:2px solid var(--blue); border-radius:10px; padding:9px 10px;
font:inherit; font-size:15px; outline:none; width:180px;
}
.ed-theme-del{
border:none; background:#fbe3e1; color:var(--red); border-radius:10px;
width:40px; height:40px; cursor:pointer; font-size:15px; flex:none;
}
.ed-theme-del:hover{background:var(--red); color:#fff;}
.lg-progress{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:8px; white-space:nowrap;}
.lg-stars{color:#d9a400; font-size:14px; min-width:34px; text-align:right;}
.lg-instr{
background:#f1f7ff; border:1.5px dashed #bcd4f2;
border-radius:12px; padding:6px 14px; font-size:14.5px; font-weight:700; color:#37588a;
text-align:center; min-height:42px; display:flex; align-items:center; justify-content:center;
flex:none;
}
.lg-card{
flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:16px; background:linear-gradient(#ffffff,#f6fafe); border:2px solid #e3eaf3;
border-radius:18px; padding:12px; min-height:0; position:relative; overflow:hidden;
}
.lg-pic{font-size:64px; line-height:1; filter:drop-shadow(0 4px 6px rgba(0,0,0,.12));}
.lg-pic img{width:80px; height:80px; object-fit:contain; border-radius:12px; vertical-align:middle;}
.lg-slots{display:flex; gap:10px;}
.slot{
width:64px; height:98px; border-radius:12px; position:relative;
background:#e9eef5; border:2.5px dashed #b9c7d9;
display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
padding-top:6px; transition:all .12s;
}
.slot.droptarget{border-color:var(--green); background:#e3f6ea; transform:scale(1.06);}
.slot .ghost{font-size:30px; font-weight:800; color:#c2cddc; height:40px; display:flex; align-items:center;}
.slot.filled{border-style:solid; border-color:transparent; background:transparent; padding:0;}
.slot.filled .ghost,.slot.filled .slot-dots{display:none;}
.slot.shake{animation:shake .35s;}
@keyframes shake{
0%,100%{transform:translateX(0);} 20%{transform:translateX(-7px);}
40%{transform:translateX(7px);} 60%{transform:translateX(-5px);} 80%{transform:translateX(5px);}
}
.blok{
width:64px; height:98px; border-radius:12px; cursor:grab; touch-action:none;
display:flex; flex-direction:column; align-items:center; padding-top:6px;
background:linear-gradient(#fdf3dd,var(--wood)); border:2.5px solid var(--wood-d);
box-shadow:0 4px 0 var(--wood-d), 0 7px 14px rgba(60,40,0,.16);
position:relative; flex:none;
}
.blok:active{cursor:grabbing;}
.blok .bl{font-size:34px; font-weight:900; height:44px; display:flex; align-items:center;}
.blok.vowel .bl{color:var(--red);}
.blok.cons .bl{color:var(--blue);}
.blok .blok-dots{margin-top:auto; margin-bottom:10px;}
.blok.in-slot{cursor:pointer; animation:pop .3s;}
.blok.dragging{opacity:.25;}
@keyframes pop{0%{transform:scale(.6);} 60%{transform:scale(1.12);} 100%{transform:scale(1);}}
.slot .slot-dots{margin-top:auto; margin-bottom:9px;}
.dots{display:grid; grid-template-columns:repeat(2,9px); grid-template-rows:repeat(3,9px); gap:3px;}
.dots i{width:9px; height:9px; border-radius:50%; background:transparent;}
.blok .dots i.on{background:#b98a3c; box-shadow:inset 0 -1.5px 2px rgba(0,0,0,.35), 0 1px 0 rgba(255,255,255,.5);}
.blok .dots i.off{background:rgba(0,0,0,.05);}
.slot .dots i.on{background:#aebcce; box-shadow:inset 0 2px 3px rgba(30,50,80,.4);}
.slot .dots i.off{background:transparent; border:1px dotted #ccd7e4; width:7px; height:7px;}
.lg-tray{
display:flex; gap:12px; justify-content:center; flex-wrap:wrap;
background:#eef3f9; border-radius:16px; padding:14px 12px; min-height:130px; flex:none;
}
.lg-bottom{display:flex; align-items:center; justify-content:center; gap:12px; min-height:46px; flex:none;}
.lg-next{
border:none; border-radius:12px; padding:10px 22px; font:inherit; font-weight:800;
background:var(--green); color:#fff; cursor:pointer; font-size:15.5px; display:none;
}
.lg-next.show{display:inline-block; animation:pop .3s;}
.lg-done{
position:absolute; inset:0; border-radius:18px; display:none; align-items:center;
justify-content:center; flex-direction:column; gap:6px;
background:rgba(255,255,255,.88); backdrop-filter:blur(2px); z-index:5;
}
.lg-done.show{display:flex;}
.lg-done .dg{font-size:52px;}
.lg-done .dt{font-size:24px; font-weight:900; color:var(--green);}
.confetti{position:absolute; width:10px; height:10px; border-radius:3px; top:-12px; z-index:6; pointer-events:none; animation:fall linear forwards;}
@keyframes fall{to{transform:translateY(110vh) rotate(720deg);}}
#dragClone{
position:fixed; z-index:9000; pointer-events:none; transform:translate(-50%,-60%) scale(1.08);
filter:drop-shadow(0 12px 16px rgba(0,0,0,.28));
}
/* ---------- custom words editor ---------- */
.lg-editor{
position:absolute; inset:0; z-index:20; background:#fff; display:none;
flex-direction:column; gap:10px; padding:14px 16px; overflow:auto;
}
.lg-editor.open{display:flex;}
.lg-ed-head{display:flex; align-items:center; justify-content:space-between;}
.lg-ed-head b{font-size:17px;}
.lg-ed-form{
display:flex; gap:10px; align-items:center; flex-wrap:wrap;
background:#f1f7ff; border-radius:14px; padding:12px;
}
.lg-ed-form input[type=text]{
border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px; font:inherit;
font-size:16px; outline:none;
}
.lg-ed-form input.ed-word{width:170px; text-transform:lowercase;}
.ed-preview{width:52px; height:52px; display:flex; align-items:center; justify-content:center;
font-size:34px; background:#fff; border-radius:10px; border:2px dashed #cdd9e8; overflow:hidden; flex:none;}
.ed-preview img{width:100%; height:100%; object-fit:contain;}
.ed-add{border:none; border-radius:10px; padding:11px 20px; font:inherit; font-weight:800;
background:var(--green); color:#fff; cursor:pointer; font-size:14.5px;}
.ed-msg{width:100%; color:var(--red); font-weight:700; font-size:13.5px; min-height:16px;}
/* picture picker tabs */
.ed-tabs{display:flex; gap:6px;}
.ed-tabs button{
border:none; border-radius:10px 10px 0 0; padding:9px 16px; font:inherit; font-weight:800;
font-size:13.5px; background:#e8eef6; color:var(--muted); cursor:pointer;
}
.ed-tabs button.active{background:var(--yellow); color:#6b5200;}
.ed-pane{background:#fbfdff; border:2px solid #e3eaf3; border-radius:0 12px 12px 12px; padding:12px; min-height:120px;}
.ed-cats{display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px;}
.ed-cats button{
border:none; background:#e8eef6; border-radius:9px; padding:6px 11px; font:inherit;
font-weight:800; font-size:12.5px; color:var(--muted); cursor:pointer;
}
.ed-cats button.active{background:var(--blue); color:#fff;}
.ed-grid{display:flex; gap:4px; flex-wrap:wrap; max-height:170px; overflow:auto;}
.ed-grid button{
border:none; background:transparent; border-radius:9px; font-size:26px;
width:42px; height:42px; cursor:pointer; padding:0; line-height:1;
}
.ed-grid button:hover{background:#dbe8f8;}
.ed-grid button.sel{background:var(--yellow);}
.ed-pictorow{display:flex; gap:8px; margin-bottom:10px;}
.ed-pictorow input{flex:1; border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px; font:inherit; font-size:15px; outline:none;}
.ed-pictorow .tbtn{flex:none;}
.ed-pgrid{display:flex; gap:8px; flex-wrap:wrap; max-height:180px; overflow:auto;}
.ed-pgrid button{
border:2px solid #e3eaf3; background:#fff; border-radius:12px; width:74px; height:74px;
cursor:pointer; padding:5px;
}
.ed-pgrid button:hover{border-color:var(--blue);}
.ed-pgrid button.sel{border-color:var(--yellow); background:#fff8e1;}
.ed-pgrid img{width:100%; height:100%; object-fit:contain;}
.ed-credit{font-size:11.5px; color:var(--muted); margin-top:8px; line-height:1.4;}
.ed-credit a{color:var(--blue);}
.ed-status{font-size:13.5px; font-weight:700; color:var(--muted); padding:6px 0;}
.ed-upload{background:#e8eef6; border-radius:10px; padding:11px 16px; font-weight:700;
font-size:14px; cursor:pointer; display:inline-block;}
.ed-upload input{display:none;}
.ed-uphint{font-size:12.5px; color:var(--muted); margin-top:10px; line-height:1.45;}
.lg-ed-list{display:flex; flex-direction:column; gap:6px;}
.lg-ed-list .empty{color:var(--muted); font-weight:700; text-align:center; padding:18px 0;}
.ed-row{display:flex; align-items:center; gap:12px; background:#f7fafd; border-radius:12px; padding:7px 12px;}
.ed-row .ep{width:40px; height:40px; font-size:28px; display:flex; align-items:center; justify-content:center;}
.ed-row .ep img{width:100%; height:100%; object-fit:contain; border-radius:8px;}
.ed-row .ew{font-weight:800; font-size:17px; letter-spacing:2px; flex:1;}
.ed-row .edel{border:none; background:#fbe3e1; color:var(--red); border-radius:8px;
width:30px; height:30px; cursor:pointer; font-weight:900;}
.ed-row .edel:hover{background:var(--red); color:#fff;}
/* =========== Notes widget =========== */
.notes{flex:1; width:100%; min-height:0; border:none; resize:none; font:inherit; font-size:16px;
padding:14px; outline:none; background:#fffdf2; color:var(--ink);}
/* =========== Writing pad widget =========== */
.wp{flex:1; min-height:0; display:flex; flex-direction:column;}
.wp-bar{
display:flex; gap:10px; padding:8px 10px; flex-wrap:wrap; align-items:center;
flex:none; background:#f7fafd; border-bottom:1px solid #e3eaf3;
}
.wp-group{display:flex; gap:4px; align-items:center; background:#e8eef6; border-radius:10px; padding:3px;}
.wp-group button{
border:none; background:transparent; border-radius:8px; width:32px; height:32px;
cursor:pointer; font-size:16px; line-height:1; padding:0;
display:flex; align-items:center; justify-content:center;
}
.wp-group button.on{background:#fff; box-shadow:0 1px 4px rgba(30,50,90,.2);}
.wp-color{border-radius:50% !important; position:relative;}
.wp-color i{width:18px; height:18px; border-radius:50%; display:block;}
.wp-color.on{outline:2.5px solid var(--ink); outline-offset:-3px;}
.wp-size i{border-radius:50%; background:var(--ink); display:block;}
.wp-bg{font-size:0 !important;}
.wp-bg i{
width:20px; height:20px; display:block; border-radius:4px; border:1.5px solid #9db6d8;
background-color:#fff; background-size:100% 25%;
}
.wp-bg[data-bg=blank] i{background-image:none;}
.wp-bg[data-bg=dots] i{background-image:radial-gradient(circle,#9db6d8 1px,transparent 1px); background-size:5px 5px;}
.wp-bg[data-bg=lines] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px);}
.wp-bg[data-bg=write] i{background-image:repeating-linear-gradient(#0000 0 2px, #c3d5ec 2px 3px, #0000 3px 5px, #6d92c4 5px 6px);}
.wp-bg[data-bg=grid-s] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px),repeating-linear-gradient(90deg,#0000 0 4px, #9db6d8 4px 5px);}
.wp-bg[data-bg=grid-b] i{background-image:repeating-linear-gradient(#0000 0 8px, #9db6d8 8px 9px),repeating-linear-gradient(90deg,#0000 0 8px, #9db6d8 8px 9px);}
.wp-clear{border:none; background:#fbe3e1; color:var(--red); border-radius:10px;
width:34px; height:34px; cursor:pointer; font-size:15px; margin-left:auto;}
.wp-clear:hover{background:var(--red); color:#fff;}
.wp-stage{flex:1; min-height:0; position:relative; background:#fff;}
.wp-stage canvas{position:absolute; inset:0; touch-action:none; cursor:crosshair;}
.wp-stage.t-text canvas{cursor:text;}
.wp-text-input{
position:absolute; z-index:5; background:rgba(255,255,255,.85);
border:1.5px dashed var(--blue); border-radius:6px; padding:2px 6px;
font-family:inherit; font-weight:700; outline:none; min-width:130px;
}
/* paper backgrounds (CSS, so the eraser only removes ink) */
.wp-stage.bg-lines{background-image:repeating-linear-gradient(#0000 0 31px, #aec5e4 31px 32px);}
.wp-stage.bg-write{background-image:repeating-linear-gradient(
#0000 0 15px, #c3d5ec 15px 16px, #0000 16px 31px, #6d92c4 31px 32.5px, #0000 32.5px 47px, #c3d5ec 47px 48px, #0000 48px 63px);}
.wp-stage.bg-grid-s{background-image:
repeating-linear-gradient(#0000 0 19px, #c9d9ee 19px 20px),
repeating-linear-gradient(90deg,#0000 0 19px, #c9d9ee 19px 20px);}
.wp-stage.bg-grid-b{background-image:
repeating-linear-gradient(#0000 0 39px, #aec5e4 39px 40px),
repeating-linear-gradient(90deg,#0000 0 39px, #aec5e4 39px 40px);}
/* =========== Media widget =========== */
.media{flex:1; min-height:0; display:flex; flex-direction:column;}
.media-bar{
display:flex; gap:8px; padding:8px 10px; flex:none;
background:#f7fafd; border-bottom:1px solid #e3eaf3;
}
.media-bar input{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px;
padding:8px 10px; font:inherit; font-size:14px; outline:none;
}
.media-bar input:focus{border-color:var(--blue);}
.media-stage{
flex:1; min-height:0; background:#141a24; display:flex;
align-items:center; justify-content:center; overflow:hidden;
}
.media-stage iframe{width:100%; height:100%; border:none;}
.media-stage img,.media-stage video{max-width:100%; max-height:100%;}
.media-msg{color:#93a4bd; font-weight:700; font-size:14px; text-align:center; padding:20px; line-height:1.5;}
.media-yt{position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;}
.media-yt img{max-width:100%; max-height:100%;}
.media-play{
position:absolute; border:none; width:76px; height:54px; border-radius:14px;
background:rgba(224,60,50,.95); color:#fff; font-size:24px; cursor:pointer;
display:flex; align-items:center; justify-content:center;
box-shadow:0 6px 18px rgba(0,0,0,.4);
}
.media-play:hover{transform:scale(1.08);}
/* =========== Math widgets =========== */
.mt{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px 14px;}
.mt-top{display:flex; gap:8px; align-items:center; flex:none;}
.mt-score{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); white-space:nowrap;}
.mt-card{
flex:1; min-height:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:12px; background:linear-gradient(#fff,#f6fafe);
border:2px solid #e3eaf3; border-radius:18px; position:relative; padding:10px; overflow:hidden;
}
.mt-q{font-size:40px; font-weight:900; letter-spacing:1px; text-align:center;}
.mt-a{
min-width:110px; height:56px; border-radius:14px; border:2.5px dashed #b9c7d9;
background:#eef3f9; font-size:32px; font-weight:900; display:flex; align-items:center;
justify-content:center; color:var(--blue); padding:0 16px;
}
.mt-a.ok{border-style:solid; border-color:var(--green); background:#e3f6ea; color:var(--green);}
.mt-a.shake{animation:shake .35s;}
.mt-fb{min-height:24px; font-size:16px; font-weight:800; color:var(--green); text-align:center;}
.mt-fb.ans{color:var(--red);}
.mt-pad{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; flex:none;}
.mt-pad button{
border:none; border-radius:12px; background:#e8eef6; font:inherit; font-weight:900;
font-size:22px; padding:12px 0; cursor:pointer; color:var(--ink);
}
.mt-pad button:hover{background:#dbe5f1;}
.mt-pad .ok{background:var(--green); color:#fff;}
.mt-done{
position:absolute; inset:0; border-radius:18px; display:none; flex-direction:column;
align-items:center; justify-content:center; gap:8px; background:rgba(255,255,255,.9);
backdrop-filter:blur(2px); z-index:5;
}
.mt-done.show{display:flex;}
.mt-done .dg{font-size:46px;}
.mt-done .dt{font-size:22px; font-weight:900; color:var(--green);}
.mt-done .ds{font-size:16px; font-weight:800; color:var(--muted);}
.mt-again{
border:none; border-radius:12px; padding:10px 22px; background:var(--green);
color:#fff; font:inherit; font-weight:800; font-size:15px; cursor:pointer; margin-top:6px;
}
/* =========== high-contrast accessibility mode =========== */
body.hc{--bg:#fff; --ink:#000; --muted:#2b2b2b; --blue:#0047ab; --red:#c40000;
--green:#006b2d; --yellow:#ffd400; --wood:#ffe9b0; --wood-d:#8a6a00;}
body.hc #board.bg-dots{background-image:radial-gradient(circle,#7d8ea3 1.7px,transparent 1.7px);}
body.hc .widget{background:#fff; border:2.5px solid #000; box-shadow:0 4px 14px rgba(0,0,0,.35); backdrop-filter:none;}
body.hc .widget:hover,.body.hc .widget:focus-within{background:#fff;}
body.hc .widget .wmin,body.hc .widget .wclose{opacity:1;}
body.hc .widget-head{background:#f2f2f2; border-bottom:2px solid #000;}
body.hc .slot{border-color:#000; background:#fff;}
body.hc .slot .ghost{color:#666;}
body.hc .slot .dots i.on{background:#333;}
body.hc .blok .dots i.on{background:#6a4a00;}
body.hc .lg-instr{background:#fff; border-color:#000; color:#000;}
body.hc #hud,body.hc .dock-item,body.hc .fab-item,body.hc #penbar .wp-group{border:2px solid #000; background:#fff;}
body.hc #fab{background:#0047ab;}
body.hc :focus-visible{outline:3px solid #0047ab; outline-offset:2px;}
body.hc .mm-node{box-shadow:0 3px 10px rgba(0,0,0,.4); border-width:3px;}
#btnHC.on{background:var(--yellow); color:#000;}
/* high contrast inside every widget */
body.hc .lg-levels,body.hc .tseg{background:#fff; border:2px solid #000; border-radius:12px;}
body.hc .lg-levels button,body.hc .tseg button{color:#000;}
body.hc .lg-levels button.active{background:#006b2d; color:#fff;}
body.hc .lg-cards button,body.hc .lg-num,body.hc .fl-sel,body.hc .lg-snd,body.hc .lg-mode{
border-color:#000; color:#000; background:#fff;
}
body.hc .lg-cards button.active{background:#ffd400; border-color:#000; color:#000;}
body.hc .lg-tray{background:#fff; border:2px dashed #000;}
body.hc .lg-card,body.hc .mt-card{background:#fff; border-color:#000;}
body.hc .lg-progress,body.hc .mt-score,body.hc .mm-hint,body.hc .dc-sum{color:#000;}
body.hc .lg-stars{color:#7a5d00;}
body.hc .mt-a{border-color:#000; background:#fff;}
body.hc .mt-pad button{background:#fff; border:2px solid #000; color:#000;}
body.hc .mt-pad .ok{background:#006b2d; border-color:#006b2d; color:#fff;}
body.hc .mt-fb{color:#006b2d;}
body.hc .mt-fb.ans{color:#c40000;}
body.hc .lg-kbd button,body.hc .blok{border-color:#6a4a00;}
body.hc .fl-word.dim{color:#8a8a8a;}
body.hc .slot .dots i.off{border-color:#666;}
body.hc .np textarea,body.hc .media-bar input,body.hc .ed-theme-sel,
body.hc .lg-ed-form input[type=text],body.hc .ed-pictorow input{border-color:#000; color:#000;}
body.hc .np-result{background:#0047ab;}
body.hc .np-row label,body.hc .thint,body.hc .guestnote,body.hc .ed-credit,
body.hc .bp-hint,body.hc .gcard .gd{color:#222;}
body.hc .gcard{border-color:#000; background:#fff;}
body.hc .gcat-title{color:#000;}
body.hc .tbtn.ghost{background:#fff; border:2px solid #000; color:#000;}
body.hc .wmin,body.hc .wclose{border:1.5px solid #000; background:#fff;}
body.hc .wclose{color:#c40000;}
body.hc .wz.on,body.hc #zoomPct.on{background:#ffd400; color:#000;}
body.hc .ed-tabs button,body.hc .ed-cats button{color:#000; background:#fff; border:1.5px solid #000;}
body.hc .ed-tabs button.active,body.hc .ed-cats button.active{background:#ffd400;}
body.hc .ed-pane{border-color:#000; background:#fff;}
body.hc .timer .presets button,body.hc .timer .main button,body.hc .ticon,
body.hc .hud-btn,body.hc .mm-seg button{background:#fff; border:1.5px solid #000; color:#000;}
body.hc .timer .main .bstart{background:#006b2d; border-color:#006b2d; color:#fff;}
body.hc .bp-board{border-color:#000; color:#000;}
body.hc .bp-board.active{background:#ffd400;}
body.hc .dock-item{color:#000;}
body.hc .media-msg{color:#ddd;}
/* =========== Flash words widget =========== */
.fl-word{font-size:58px; font-weight:900; letter-spacing:3px; min-height:70px; display:flex; align-items:center;}
.fl-word.dim{color:#c2cddc;}
.fl-pic{font-size:46px; min-height:56px; display:flex; align-items:center;}
.fl-pic img{width:56px; height:56px; object-fit:contain;}
.fl-sel{
border:2px solid #e3eaf3; border-radius:10px; height:34px; font:inherit;
font-weight:800; font-size:13px; color:var(--muted); background:#fff;
padding:0 6px; outline:none; cursor:pointer;
}
/* =========== Chop & blend widget =========== */
.hk-word{min-height:70px; display:flex; align-items:center; gap:4px; flex-wrap:wrap; justify-content:center;}
.hk-whole{font-size:52px; font-weight:900; letter-spacing:3px;}
.hk-l{font-size:46px; font-weight:900; animation:pop .4s both;}
.hk-l.v{color:var(--red);}
.hk-l.c{color:var(--blue);}
.hk-sep{font-size:30px; color:var(--muted); font-weight:700; margin:0 3px;}
/* =========== Balloon word widget =========== */
.bl-balloons{font-size:34px; letter-spacing:4px; min-height:44px;}
.bl-word{font-size:44px; font-weight:900; letter-spacing:6px; min-height:56px; display:flex; align-items:center;}
.bl-msg{min-height:26px; font-size:17px; font-weight:800;}
.bl-msg.win{color:var(--green);}
.bl-msg.lose{color:var(--red);}
.bl-kbd button:disabled{cursor:default;}
/* =========== 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;}
.die{
width:92px; height:92px; background:#fff; border:3.5px solid var(--ink); border-radius:18px;
display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(3,1fr);
padding:10px; box-shadow:0 5px 0 rgba(0,0,0,.15);
}
.die.rolling{animation:shake .3s infinite;}
.die i{width:16px; height:16px; border-radius:50%; background:var(--ink); place-self:center; visibility:hidden;}
.die i.on{visibility:visible;}
.dc-sum{font-size:22px; font-weight:900; min-height:28px;}
/* =========== Traffic light widget =========== */
.tl{flex:1; min-height:0; display:flex; align-items:center; justify-content:center; padding:14px;}
.tl-house{background:#22272f; border-radius:26px; padding:20px 18px; display:flex; flex-direction:column; gap:16px; box-shadow:0 10px 26px rgba(0,0,0,.35);}
.tl-lamp{
width:100px; height:100px; border-radius:50%; border:none; cursor:pointer;
opacity:.22; transition:all .2s; padding:0;
}
.tl-lamp.red{background:#e33; color:#e33;}
.tl-lamp.orange{background:#f5a623; color:#f5a623;}
.tl-lamp.green{background:#2ecc71; color:#2ecc71;}
.tl-lamp.on{opacity:1; box-shadow:0 0 46px currentColor;}
.tl-mini{padding:12px 10px; gap:10px; border-radius:20px;}
.tl-mini .tl-lamp{width:62px; height:62px;}
/* =========== Name picker widget =========== */
.np{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px;}
.np textarea{
flex:1; min-height:0; border:2px solid #cdd9e8; border-radius:12px; padding:10px;
font:inherit; font-size:14.5px; resize:none; outline:none;
}
.np-result{
min-height:72px; display:flex; align-items:center; justify-content:center;
font-size:32px; font-weight:900; color:#fff; text-align:center; padding:8px 12px;
background:linear-gradient(135deg,var(--blue),var(--purple));
border-radius:18px; position:relative; overflow:hidden;
text-shadow:0 2px 6px rgba(0,0,0,.3); letter-spacing:1px;
}
.np-result.spin{animation:npspin .25s infinite; background:linear-gradient(135deg,var(--orange),var(--red));}
.np-result.winner{animation:pop .45s;}
@keyframes npspin{0%,100%{transform:scale(1) rotate(-1deg);} 50%{transform:scale(1.04) rotate(1deg);}}
.np-row{display:flex; gap:10px; align-items:center; flex:none;}
.np-row label{font-size:13px; font-weight:700; color:var(--muted); display:flex; gap:5px; align-items:center; margin-left:auto;}
/* gallery categories */
.gcat-title{font-size:13.5px; font-weight:800; color:var(--muted); margin:16px 0 8px; text-transform:uppercase; letter-spacing:.5px;}
.gcat-title:first-child{margin-top:0;}
/* =========== Timer widget =========== */
.timer{display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; gap:10px; padding:14px;}
.tstage{
flex:1; min-height:0; width:100%; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:8px;
}
.topts{display:flex; justify-content:space-between; align-items:center; width:100%; flex:none;}
.tseg{display:flex; background:#e8eef6; border-radius:10px; overflow:hidden;}
.tseg button{border:none; background:transparent; padding:7px 13px; font-size:15px; cursor:pointer; line-height:1;}
.tseg button.on{background:var(--blue); color:#fff;}
.mm-seg button{font:inherit; font-weight:800; font-size:13px;}
.ticon{border:none; background:#e8eef6; border-radius:10px; padding:7px 11px; font-size:15px; cursor:pointer; line-height:1;}
.ticon.on{background:var(--yellow);}
.timer .tpie{
flex:1 1 0; min-height:80px; aspect-ratio:1/1; width:auto; max-width:100%; max-height:220px;
display:none; cursor:grab; touch-action:none;
}
.timer .tpie:active{cursor:grabbing;}
.timer.m-pie .tpie,.timer.m-both .tpie{display:block;}
.timer .tdisp{font-size:46px; font-weight:900; font-variant-numeric:tabular-nums; flex:none;}
.timer.m-both .tdisp{font-size:26px;}
.timer.m-pie .tdisp{font-size:17px; color:var(--muted);}
.timer .tdisp.warn{color:var(--red); animation:blink 1s infinite;}
@keyframes blink{50%{opacity:.35;}}
.timer .trow{display:flex; gap:8px; flex-wrap:wrap; justify-content:center; flex:none;}
.thint{flex:none;}
.timer .presets button{
border:none; border-radius:999px; padding:6px 13px; background:#e8eef6; font:inherit;
font-weight:800; font-size:12.5px; color:var(--muted); cursor:pointer;
}
.timer .presets button:hover{background:#dbe5f1; color:var(--ink);}
.timer .main button{
border:none; border-radius:12px; padding:10px 26px; font:inherit; font-weight:800;
font-size:15.5px; cursor:pointer; background:#e8eef6; color:var(--ink);
}
.timer .main .bstart{background:var(--green); color:#fff; min-width:110px;}
.thint{font-size:11.5px; color:var(--muted); text-align:center; line-height:1.4;}
.timer.flash{animation:tflash .5s 4;}
@keyframes tflash{50%{background:#fdeaa0;}}
</style>
</head>
<body>
<div id="toolbar">
<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>
</div>
<button class="tbtn ghost" id="btnMute">🔊</button>
<button class="tbtn ghost" id="btnHC">👁</button>
<button class="tbtn ghost" id="btnZoomOut">A</button>
<button class="tbtn ghost" id="zoomPct">100%</button>
<button class="tbtn ghost" id="btnZoomIn">A</button>
<div class="spacer"></div>
<button class="tbtn ghost" id="btnFolders">📁</button>
<button class="tbtn ghost" id="btnSave">💾 <span data-i18n="saveBoard"></span></button>
<button class="tbtn ghost" id="btnUser">👤 <span id="userLbl"></span></button>
<div id="langToggle">
<button data-lang="nl">NL</button>
<button data-lang="en">EN</button>
</div>
<button class="tbtn ghost" id="btnFs" title="Fullscreen"></button>
</div>
<div id="board"></div>
<button id="fab"></button>
<button id="fabBoardsBtn">🗂</button>
<div id="boardStrip"></div>
<div id="boardsPanel"></div>
<div id="fabmenu">
<button class="fab-item" data-f="widgets"><span class="fi">🧩</span><span class="fl"></span></button>
<button class="fab-item" data-f="draw"><span class="fi">✏️</span><span class="fl"></span></button>
<button class="fab-item" data-f="mind"><span class="fi">🕸️</span><span class="fl"></span></button>
<button class="fab-item" data-f="img"><span class="fi">🖼️</span><span class="fl"></span></button>
</div>
<div id="hud">
<div class="pen-grip"></div>
<button class="hud-btn hud-orient"></button>
<button class="hud-btn hud-min"></button>
<div id="penbar">
<div class="wp-group pen-tools"></div>
<div class="wp-group pen-colors"></div>
<div class="wp-group pen-sizes"></div>
<div class="wp-group pen-bgs"></div>
<div class="wp-group pen-extras"></div>
<button class="wp-clear pen-clear">🗑</button>
<button class="wp-clear pen-close"></button>
</div>
<div class="hud-div"></div>
<div id="dock"></div>
</div>
<div class="modalwrap" id="galleryWrap">
<div class="modal">
<h2 data-i18n="chooseWidget"></h2>
<div id="gallery"></div>
</div>
</div>
<div class="modalwrap" id="loginWrap">
<div class="modal" id="loginModal">
<div id="loginBox">
<h2 data-i18n="login"></h2>
<input type="text" id="liName" maxlength="20" autocomplete="username">
<input type="password" id="liPass" maxlength="64" autocomplete="current-password">
<div id="loginMsg"></div>
<div class="lrow">
<button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button>
<button class="tbtn ghost" id="btnRegister" data-i18n="registerBtn"></button>
</div>
<div class="guestnote" data-i18n="guestNote"></div>
</div>
<div id="loggedBox">
<div class="hello" id="helloLbl"></div>
<div class="lrow">
<button class="tbtn ghost" id="btnLogout" data-i18n="logout"></button>
</div>
<div class="guestnote" data-i18n="storedNote"></div>
</div>
</div>
</div>
<script>
"use strict";
/* =========================================================
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", 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",
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.",
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", 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",
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.",
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; persistUser(); }
}
/* 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);
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:[]};
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);
});
/* =========================================================
Widget framework
==========================================================*/
const board = document.getElementById("board");
const dock = document.getElementById("dock");
let zTop = 10, widgetCount = 0;
const instances = new Set();
const REGISTRY = [
{ id:"letters", cat:"taal", icon:"🔤", w:760, h:640, minW:640, minH:540, mount:mountLetterGame },
{ 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:"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") },
{ 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:"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:"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:"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:"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"]];
function makeWidget(def, saved){
const win = document.createElement("div");
win.className = "widget";
win.style.width = (saved && saved.w ? saved.w : def.w)+"px";
win.style.height = (saved && saved.h ? saved.h : def.h)+"px";
if(def.minW) win.style.minWidth = def.minW+"px";
if(def.minH) win.style.minHeight = def.minH+"px";
win.style.left = (saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240) + "px";
win.style.top = (saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160) + "px";
win.style.zIndex = ++zTop;
widgetCount++;
if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; }
const head = document.createElement("div");
head.className = "widget-head";
head.innerHTML = `<span class="wicon">${def.icon}</span><span class="wtitle"></span>`;
const zBtn = document.createElement("button");
zBtn.className = "wmin wz"; zBtn.textContent = "A";
head.appendChild(zBtn);
const minBtn = document.createElement("button");
minBtn.className = "wmin"; minBtn.textContent = "";
head.appendChild(minBtn);
const maxBtn = document.createElement("button");
maxBtn.className = "wmin"; maxBtn.textContent = "▢";
head.appendChild(maxBtn);
const close = document.createElement("button");
close.className = "wclose"; close.textContent = "✕";
head.appendChild(close);
const body = document.createElement("div");
body.className = "widget-body";
win.appendChild(head); win.appendChild(body);
let pill = null;
function setMin(m){
if(m && !pill){
win.dataset.prevW = win.offsetWidth;
win.dataset.prevH = win.offsetHeight;
win.dataset.min = "1";
win.style.display = "none";
pill = document.createElement("button");
pill.className = "dock-item";
pill.innerHTML = `<span class="di">${def.icon}</span><span class="dl"></span>`;
pill.querySelector(".dl").textContent = T("wg_"+def.id);
pill.title = T("wMax");
pill.addEventListener("click", ()=>setMin(false));
dock.appendChild(pill);
}else if(!m && pill){
pill.remove(); pill = null;
win.dataset.min = "";
win.style.display = "flex";
win.style.zIndex = ++zTop;
}
}
minBtn.addEventListener("click", ()=>setMin(true));
/* per-widget content zoom for low-vision pupils */
let wz = (saved && saved.wz) || 1;
function applyWZ(){
body.style.zoom = wz;
win.dataset.wz = wz;
zBtn.textContent = wz===1 ? "A" : Math.round(wz*100)+"%";
zBtn.classList.toggle("on", wz!==1);
zBtn.title = T("wZoom");
}
zBtn.addEventListener("click", ()=>{
wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1;
applyWZ();
});
applyWZ();
function setMax(m){
if(m && win.dataset.max!=="1"){
win.dataset.pmx = win.offsetLeft; win.dataset.pmy = win.offsetTop;
win.dataset.pmw = win.offsetWidth; win.dataset.pmh = win.offsetHeight;
win.dataset.max = "1";
win.style.left = "0px"; win.style.top = "0px";
win.style.width = board.clientWidth+"px";
win.style.height = board.clientHeight+"px";
win.style.zIndex = ++zTop;
maxBtn.textContent = "❐";
setHudMin(true); /* keep the tools bar out of the way */
}else if(!m && win.dataset.max==="1"){
win.dataset.max = "";
win.style.left = win.dataset.pmx+"px"; win.style.top = win.dataset.pmy+"px";
win.style.width = win.dataset.pmw+"px"; win.style.height = win.dataset.pmh+"px";
maxBtn.textContent = "▢";
}
maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull");
}
maxBtn.addEventListener("click", ()=>setMax(win.dataset.max!=="1"));
head.addEventListener("dblclick", e=>{
if(e.target===close || e.target===minBtn || e.target===maxBtn) return;
setMin(true);
});
const setTitle = ()=>{
head.querySelector(".wtitle").textContent = T("wg_"+def.id);
minBtn.title = T("wMin");
maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull");
zBtn.title = T("wZoom");
if(pill){ pill.querySelector(".dl").textContent = T("wg_"+def.id); pill.title = T("wMax"); }
};
setTitle();
document.addEventListener("langchange", setTitle);
win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; });
head.addEventListener("pointerdown", e=>{
if(e.target===close) return;
e.preventDefault();
const zf = VZ();
const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop;
const move = ev=>{
win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px";
win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px";
};
const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",move); document.addEventListener("pointerup",up);
});
board.appendChild(win);
const api = def.mount(body, saved ? saved.state : null) || {};
if(saved && saved.min) setMin(true);
if(saved && saved.max) setMax(true);
const inst = { def, win, api };
instances.add(inst);
close.addEventListener("click", ()=>{
if(pill){ pill.remove(); pill = null; }
instances.delete(inst); win.remove(); updateEmptyHint();
});
updateEmptyHint();
return inst;
}
function updateEmptyHint(){
document.body.classList.toggle("board-empty", !board.querySelector(".widget"));
}
function serializeBoard(){
return {
ink: boardInkURL(),
bg: BOARD.bg,
widgets: [...instances].map(inst=>{
const w = inst.win;
const min = w.dataset.min === "1";
const max = w.dataset.max === "1";
return {
id: inst.def.id,
x: max ? +w.dataset.pmx : w.offsetLeft,
y: max ? +w.dataset.pmy : w.offsetTop,
w: min ? (+w.dataset.prevW || inst.def.w) : max ? +w.dataset.pmw : w.offsetWidth,
h: min ? (+w.dataset.prevH || inst.def.h) : max ? +w.dataset.pmh : w.offsetHeight,
min, max,
wz: +w.dataset.wz || 1,
bare: w.dataset.bare === "1",
state: inst.api.getState ? inst.api.getState() : null
};
}),
imgs: [...board.querySelectorAll(".bimg")].map(b=>({
src: b.querySelector("img").src,
x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth
}))
};
}
function restoreBoard(b){
[...instances].forEach(inst=>inst.win.remove());
instances.clear();
dock.innerHTML = "";
widgetCount = 0;
(b.widgets||[]).forEach(wd=>{
const def = REGISTRY.find(d=>d.id===wd.id);
if(def) makeWidget(def, wd);
});
setBoardInk(b.ink);
setBoardBg(b.bg || "dots");
board.querySelectorAll(".bimg").forEach(x=>x.remove());
(b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w));
updateEmptyHint();
}
/* ---------- board drawing layer (pen mode) ---------- */
const bInk = document.createElement("canvas");
bInk.id = "boardInk";
board.insertBefore(bInk, board.firstChild);
const bCtx = bInk.getContext("2d");
const bDpr = window.devicePixelRatio || 1;
function bResize(){
const w = board.clientWidth, h = board.clientHeight;
if(w<2 || h<2) return;
const keep = document.createElement("canvas");
keep.width = bInk.width; keep.height = bInk.height;
if(bInk.width) keep.getContext("2d").drawImage(bInk,0,0);
bInk.width = Math.round(w*bDpr); bInk.height = Math.round(h*bDpr);
if(keep.width) bCtx.drawImage(keep,0,0);
bCtx.lineCap = "round"; bCtx.lineJoin = "round";
}
new ResizeObserver(bResize).observe(board);
bResize();
const PEN = { tool:"pen", color:"#26344a", size:4, nib:4 };
const BOARD = { bg:"blank" };
const penbar = document.getElementById("penbar");
function setPen(on){
document.body.classList.toggle("pen", on);
document.body.classList.toggle("pen-text", on && PEN.tool==="text");
refreshPenbar();
}
function setBoardBg(bg){
BOARD.bg = bg;
board.className = (bg && bg!=="blank") ? "bg-"+bg : "";
refreshPenbar();
}
const PEN_TOOLS = [["hand","✋","toolHand"],["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]];
PEN_TOOLS.forEach(([id,icon])=>{
const b = document.createElement("button");
b.dataset.tool = id; b.textContent = icon;
b.addEventListener("click", ()=>{ PEN.tool = id; setPen(true); refreshPenbar(); });
penbar.querySelector(".pen-tools").appendChild(b);
});
["#26344a","#3b7dd8","#e0554d","#3fae6a","#9b59b6","#f7c948"].forEach(c=>{
const b = document.createElement("button");
b.className = "wp-color"; b.dataset.c = c;
const dot = document.createElement("i"); dot.style.background = c;
b.appendChild(dot);
b.addEventListener("click", ()=>{ PEN.color = c; if(PEN.tool==="erase") PEN.tool = "pen"; refreshPenbar(); });
penbar.querySelector(".pen-colors").appendChild(b);
});
[3,5,8].forEach(s=>{
const b = document.createElement("button");
b.className = "wp-size"; b.dataset.s = s;
const i = document.createElement("i");
i.style.width = i.style.height = (s+4)+"px";
b.appendChild(i);
b.addEventListener("click", ()=>{ PEN.size = s; refreshPenbar(); });
penbar.querySelector(".pen-sizes").appendChild(b);
});
const PEN_BGS = [["blank","bgBlank"],["dots","bgDots"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]];
PEN_BGS.forEach(([id])=>{
const b = document.createElement("button");
b.className = "wp-bg"; b.dataset.bg = id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>setBoardBg(id));
penbar.querySelector(".pen-bgs").appendChild(b);
});
const PEN_EXTRAS = [["ruler","📏","toolRuler"],["geo","📐","toolGeo"],["lens","🔍","toolLens"],["light","🚦","wg_light"]];
PEN_EXTRAS.forEach(([id,icon])=>{
const b = document.createElement("button");
b.dataset.extra = id; b.textContent = icon;
b.addEventListener("click", ()=>toggleDTool(id));
penbar.querySelector(".pen-extras").appendChild(b);
});
penbar.querySelector(".pen-clear").addEventListener("click", ()=>bCtx.clearRect(0,0,bInk.width,bInk.height));
/* the ✍-button toggles drawing mode on/off (palette itself stays visible) */
penbar.querySelector(".pen-close").addEventListener("click", ()=>setPen(!document.body.classList.contains("pen")));
/* orientation + collapse of the whole bar */
document.querySelector(".hud-orient").addEventListener("click", ()=>{
document.getElementById("hud").classList.toggle("vert");
});
document.querySelector(".hud-min").addEventListener("click", function(){
setHudMin(!document.getElementById("hud").classList.contains("min"));
});
/* unified draggable bar (pen palette + dock) */
const hud = document.getElementById("hud");
hud.querySelector(".pen-grip").addEventListener("pointerdown", e=>{
e.preventDefault();
const zf = VZ();
const r = hud.getBoundingClientRect();
hud.style.transform = "none";
hud.style.bottom = "auto";
hud.style.left = (r.left/zf)+"px";
hud.style.top = (r.top/zf)+"px";
const ox = (e.clientX - r.left)/zf, oy = (e.clientY - r.top)/zf;
const mv = ev=>{
hud.style.left = Math.max(4, Math.min(window.innerWidth/zf - r.width/zf - 4, ev.clientX/zf - ox))+"px";
hud.style.top = Math.max(4, Math.min(window.innerHeight/zf - 44, ev.clientY/zf - oy))+"px";
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
function setHudMin(m){
hud.classList.toggle("min", m);
const b = document.querySelector(".hud-min");
if(b) b.textContent = m ? "" : "";
}
function updateHud(){ hud.classList.toggle("has-dock", dock.children.length > 0); }
new MutationObserver(updateHud).observe(dock, {childList:true});
updateHud();
function refreshPenbar(){
penbar.querySelectorAll(".pen-tools button").forEach(b=>{
b.classList.toggle("on", b.dataset.tool===PEN.tool);
b.title = T(PEN_TOOLS.find(x=>x[0]===b.dataset.tool)[2]);
});
penbar.querySelectorAll(".wp-color").forEach(b=>
b.classList.toggle("on", b.dataset.c===PEN.color && PEN.tool!=="erase"));
penbar.querySelectorAll(".wp-size").forEach(b=>b.classList.toggle("on", +b.dataset.s===PEN.size));
penbar.querySelectorAll(".wp-bg").forEach(b=>{
b.classList.toggle("on", b.dataset.bg===BOARD.bg);
b.title = T(PEN_BGS.find(x=>x[0]===b.dataset.bg)[1]);
});
penbar.querySelectorAll(".pen-extras button").forEach(b=>{
b.classList.toggle("on", !!DTOOLS[b.dataset.extra]);
b.title = T(PEN_EXTRAS.find(x=>x[0]===b.dataset.extra)[2]);
});
penbar.querySelector(".pen-clear").title = T("clearAll");
const power = penbar.querySelector(".pen-close");
const penOn = document.body.classList.contains("pen");
power.textContent = "✍";
power.title = T("penMode");
power.classList.toggle("on", penOn);
document.querySelector(".hud-orient").title = T("hudOrient");
document.querySelector(".hud-min").title = T("hudMin");
document.body.classList.toggle("pen-text", penOn && PEN.tool==="text");
document.body.classList.toggle("pen-hand", PEN.tool==="hand");
}
document.addEventListener("langchange", refreshPenbar);
/* ---------- physical tools: ruler, set square, magnifier ---------- */
const DTOOLS = {};
function makeMovable(t){
let ang = 0;
t.addEventListener("pointerdown", e=>{
if(e.target.classList.contains("dtool-x") || e.target.closest(".dtool-opts")) return;
e.preventDefault(); e.stopPropagation();
if(e.target.classList.contains("dtool-rot")){
const r = t.getBoundingClientRect();
const cx = r.left+r.width/2, cy = r.top+r.height/2;
const start = Math.atan2(e.clientY-cy, e.clientX-cx)*180/Math.PI - ang;
const mv = ev=>{
ang = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start;
t.style.transform = `rotate(${ang}deg)`;
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
return;
}
const zf = VZ();
const sx = e.clientX/zf - t.offsetLeft, sy = e.clientY/zf - t.offsetTop;
const mv = ev=>{
t.style.left = (ev.clientX/zf-sx)+"px";
t.style.top = (ev.clientY/zf-sy)+"px";
if(t.lensDraw) t.lensDraw();
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
}
function toggleDTool(kind){
if(DTOOLS[kind]){
if(DTOOLS[kind]._iv) clearInterval(DTOOLS[kind]._iv);
DTOOLS[kind].remove(); delete DTOOLS[kind]; refreshPenbar(); return;
}
const t = document.createElement("div");
t.className = "dtool dtool-"+kind;
if(kind==="ruler"){
t._len = 600; t._hc = false;
const drawRuler = ()=>{
const L = t._len, hc = t._hc;
const ink = hc ? "#000" : "#5a4a20";
const bg = hc ? "rgba(255,214,0,.95)" : "rgba(255,243,205,.82)";
let ticks = "";
for(let i=5; i<L; i+=5){
const cm = i%50===0, half = i%25===0;
const len = cm ? 22 : half ? 14 : 8;
ticks += `<line x1="${i}" y1="2" x2="${i}" y2="${2+len}" stroke="${ink}" stroke-width="${cm ? (hc?2.5:1.6) : (hc?1.6:1)}"/>`;
if(cm) ticks += `<text x="${i}" y="${hc?46:42}" font-size="${hc?18:14}" font-weight="bold" fill="${ink}" text-anchor="middle">${i/50}</text>`;
}
t.style.width = L+"px";
t.querySelector(".dtool-svg").innerHTML = `<svg viewBox="0 0 ${L} 76" width="${L}" height="76">
<defs><linearGradient id="rg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgba(255,255,255,.65)"/><stop offset="1" stop-color="rgba(0,0,0,.06)"/>
</linearGradient></defs>
<rect x="0" y="0" width="${L}" height="76" rx="9" fill="${bg}" stroke="${ink}" stroke-width="${hc?3:1.5}"/>
<rect x="0" y="0" width="${L}" height="76" rx="9" fill="url(#rg)"/>
<line x1="0" y1="2" x2="${L}" y2="2" stroke="${ink}" stroke-width="${hc?3:2}"/>
<text x="${L/2}" y="66" font-size="12" fill="${ink}" text-anchor="middle" opacity=".65">teach · cm</text>
${ticks}</svg>`;
};
t.innerHTML = `<div class="dtool-svg"></div>
<div class="dtool-opts">
<button class="do-len" title="↔">↔</button>
<button class="do-hc" title="◐">◐</button>
</div>`;
t.querySelector(".do-len").addEventListener("click", ()=>{
t._len = t._len===600 ? 800 : t._len===800 ? 400 : 600; drawRuler();
});
t.querySelector(".do-hc").addEventListener("click", ()=>{ t._hc = !t._hc; drawRuler(); });
t.style.height = "76px";
drawRuler();
}else if(kind==="geo"){
t._sz = 340; t._hc = false;
const drawGeo = ()=>{
const W = t._sz, H = W/2 + 22, hc = t._hc;
const ink = hc ? "#000" : "#1e4f74";
const bg = hc ? "rgba(255,214,0,.9)" : "rgba(185,222,248,.6)";
const halo = `stroke="${hc?"#ffd400":"#ffffff"}" stroke-width="3.5" paint-order="stroke" stroke-linejoin="round"`;
const ax = W/2, ay = 16, by = H-18; /* apex + baseline */
const half = W/2 - 14;
let cmTicks = "";
for(let i=25; i<=half-16; i+=25){
const cm = i%50===0;
[ax-i, ax+i].forEach(x=>{
cmTicks += `<line x1="${x}" y1="${by}" x2="${x}" y2="${by-(cm?13:7)}" stroke="${ink}" stroke-width="${cm?(hc?2.2:1.4):1}"/>`;
});
if(cm){
[ax-i, ax+i].forEach(x=>{
cmTicks += `<text x="${x}" y="${by-18}" font-size="${hc?15:12}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">${i/50}</text>`;
});
}
}
/* degree arc with labels INSIDE the arc so they stay readable */
let degs = "";
const R = half*0.60;
for(let d=10; d<180; d+=10){
const rad = d*Math.PI/180;
const x1 = ax - Math.cos(rad)*(R-7), y1 = by - Math.sin(rad)*(R-7);
const x2 = ax - Math.cos(rad)*R, y2 = by - Math.sin(rad)*R;
degs += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${ink}" stroke-width="${d%30===0?(hc?2.2:1.5):1}"/>`;
if(d%30===0 && d>=30 && d<=150){
const xt = ax - Math.cos(rad)*(R-22), yt = by - Math.sin(rad)*(R-22);
degs += `<text x="${xt}" y="${yt+4}" font-size="${hc?14:11.5}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">${d}°</text>`;
}
}
t.style.width = W+"px"; t.style.height = H+"px";
t.querySelector(".dtool-svg").innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
<polygon points="14,${by} ${W-14},${by} ${ax},${ay}" fill="${bg}" stroke="${ink}" stroke-width="${hc?3:2}"/>
<path d="M ${ax-R} ${by} A ${R} ${R} 0 0 1 ${ax+R} ${by}" fill="none" stroke="${ink}" stroke-width="1.2"/>
<line x1="${ax}" y1="${by}" x2="${ax}" y2="${ay+18}" stroke="${ink}" stroke-width="1" stroke-dasharray="4 4"/>
<text x="${ax}" y="${by-6}" font-size="${hc?13:10}" font-weight="bold" fill="${ink}" ${halo} text-anchor="middle">0</text>
${cmTicks}${degs}</svg>`;
};
t.innerHTML = `<div class="dtool-svg"></div>
<div class="dtool-opts">
<button class="do-len">↔</button>
<button class="do-hc">◐</button>
</div>`;
t.querySelector(".do-len").addEventListener("click", ()=>{
t._sz = t._sz===340 ? 440 : t._sz===440 ? 280 : 340; drawGeo();
});
t.querySelector(".do-hc").addEventListener("click", ()=>{ t._hc = !t._hc; drawGeo(); });
drawGeo();
}else if(kind==="light"){ /* compact traffic light directly on the board */
let on = "green";
t.innerHTML = `<div class="tl-house tl-mini">
<button class="tl-lamp red" data-c="red"></button>
<button class="tl-lamp orange" data-c="orange"></button>
<button class="tl-lamp green" data-c="green"></button>
</div>`;
const apply = ()=>t.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on));
t.querySelectorAll(".tl-lamp").forEach(l=>{
l.addEventListener("pointerdown", e=>e.stopPropagation());
l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); });
});
apply();
t.style.left = (board.clientWidth-140)+"px"; t.style.top = "20px";
}else{ /* lens: magnifies background, pictures and ink (2x); resizable, round or square */
const Z = 2, LMIN = 120, LMAX = 340;
t._d = 184; t._sq = false;
const c = document.createElement("canvas");
t.appendChild(c);
t.lensDraw = ()=>{
const d = t._d, S = Z*2; /* canvas is 2x for sharpness */
const ctx2 = c.getContext("2d");
const cx = t.offsetLeft + d/2 + 2, cy = t.offsetTop + d/2 + 2; /* board css px */
const sw = d/Z;
const sx = cx - sw/2, sy = cy - sw/2;
ctx2.fillStyle = "#eef3f8";
ctx2.fillRect(0, 0, d*2, d*2);
if(BOARD.bg === "dots"){
ctx2.fillStyle = "#c9d6e5";
const g = 26;
for(let gx = Math.floor(sx/g)*g; gx < sx+sw; gx += g){
for(let gy = Math.floor(sy/g)*g; gy < sy+sw; gy += g){
ctx2.beginPath();
ctx2.arc((gx-sx)*S, (gy-sy)*S, 1.4*S, 0, 7);
ctx2.fill();
}
}
}
board.querySelectorAll(".bimg").forEach(b=>{
const im = b.querySelector("img");
if(!im.complete || !im.naturalWidth) return;
try{
ctx2.drawImage(im, (b.offsetLeft-sx)*S, (b.offsetTop-sy)*S, b.offsetWidth*S, b.offsetHeight*S);
}catch(e){}
});
try{
ctx2.drawImage(bInk, sx*bDpr, sy*bDpr, sw*bDpr, sw*bDpr, 0, 0, d*2, d*2);
}catch(e){}
};
const lensSetup = ()=>{
const d = t._d;
c.width = d*2; c.height = d*2;
c.style.width = d+"px"; c.style.height = d+"px";
c.style.borderRadius = t._sq ? "16px" : "50%";
t.style.width = (d+4)+"px"; t.style.height = (d+4)+"px";
t.lensDraw();
};
const opts = document.createElement("div");
opts.className = "dtool-opts";
[["","lensSmall",()=>{ t._d = Math.max(LMIN, t._d-40); lensSetup(); }],
["","lensBig", ()=>{ t._d = Math.min(LMAX, t._d+40); lensSetup(); }],
["▢","lensShape",()=>{ t._sq = !t._sq; lensSetup(); }]].forEach(([txt,key,fn])=>{
const b = document.createElement("button");
b.textContent = txt; b.title = T(key);
b.addEventListener("click", fn);
opts.appendChild(b);
});
t.appendChild(opts);
lensSetup();
t._iv = setInterval(()=>t.lensDraw(), 350);
}
const x = document.createElement("button");
x.className = "dtool-x"; x.textContent = "✕";
x.addEventListener("click", ()=>{ if(t._iv) clearInterval(t._iv); t.remove(); delete DTOOLS[kind]; refreshPenbar(); });
t.appendChild(x);
if(kind==="ruler" || kind==="geo"){
const rot = document.createElement("button");
rot.className = "dtool-rot"; rot.textContent = "↻";
t.appendChild(rot);
}
makeMovable(t);
board.appendChild(t);
DTOOLS[kind] = t;
if(t.lensDraw) t.lensDraw();
refreshPenbar();
}
/* board text tool */
function startBoardText(e){
if(board.querySelector(".wp-text-input")) return;
const zf = VZ();
const r = board.getBoundingClientRect();
const x = (e.clientX - r.left)/zf, y = (e.clientY - r.top)/zf;
const fontPx = PEN.size*6 + 12;
const inp = document.createElement("input");
inp.type = "text"; inp.className = "wp-text-input";
inp.style.left = Math.min(x, board.clientWidth-150)+"px";
inp.style.top = Math.min(y, board.clientHeight-40)+"px";
inp.style.color = PEN.color;
inp.style.fontSize = fontPx+"px";
inp.style.zIndex = 4700;
board.appendChild(inp);
setTimeout(()=>inp.focus(), 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v){
bCtx.globalCompositeOperation = "source-over";
bCtx.globalAlpha = 1;
bCtx.fillStyle = PEN.color;
bCtx.font = `700 ${fontPx*bDpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`;
bCtx.textBaseline = "top";
bCtx.fillText(v, x*bDpr, y*bDpr);
if(DTOOLS.lens) DTOOLS.lens.lensDraw();
}
inp.remove();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commit();
if(ev.key==="Escape"){ done = true; inp.remove(); }
});
inp.addEventListener("blur", commit);
}
let bDraw = false, bx = 0, by = 0;
const bPos = ev=>{
const r = bInk.getBoundingClientRect();
/* ratio-based: correct under any display zoom */
return [(ev.clientX-r.left)*(bInk.width/r.width), (ev.clientY-r.top)*(bInk.height/r.height)];
};
bInk.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
if(PEN.tool==="text"){ startBoardText(e); return; }
bInk.setPointerCapture(e.pointerId);
bDraw = true;
PEN.nib = PEN.size*0.8;
[bx,by] = bPos(e);
bStroke(bx+0.01, by+0.01, e.pressure, e.pointerType);
});
bInk.addEventListener("pointermove", e=>{
if(!bDraw) return;
(e.getCoalescedEvents ? e.getCoalescedEvents() : [e]).forEach(ev=>{
const [x,y] = bPos(ev); bStroke(x, y, ev.pressure, ev.pointerType);
});
});
const bStop = ()=>{
bDraw = false;
bCtx.globalAlpha = 1;
if(DTOOLS.lens) DTOOLS.lens.lensDraw();
};
bInk.addEventListener("pointerup", bStop);
bInk.addEventListener("pointercancel", bStop);
function bStroke(x,y,pressure,ptype){
bCtx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over";
bCtx.globalAlpha = PEN.tool==="mark" ? 0.32 : 1;
bCtx.strokeStyle = PEN.color;
let w = PEN.size;
if(PEN.tool==="erase") w = PEN.size*6;
else if(PEN.tool==="mark") w = PEN.size*3.5;
else if(PEN.tool==="vulp"){
let target;
if(ptype==="pen" && pressure > 0){
target = PEN.size*(0.45 + pressure*1.9);
}else{
const speed = Math.hypot(x-bx, y-by)/bDpr;
target = Math.max(PEN.size*0.5, Math.min(PEN.size*2.3, PEN.size*2.3 - speed*0.12));
}
PEN.nib += (target - PEN.nib)*0.3;
w = PEN.nib;
}
bCtx.lineWidth = w*bDpr;
bCtx.beginPath(); bCtx.moveTo(bx,by); bCtx.lineTo(x,y); bCtx.stroke();
bx = x; by = y;
}
function boardInkURL(){ try{ return bInk.toDataURL("image/png"); }catch(e){ return null; } }
function setBoardInk(url){
bCtx.clearRect(0,0,bInk.width,bInk.height);
if(!url) return;
const im = new Image();
im.onload = ()=>bCtx.drawImage(im,0,0);
im.src = url;
}
refreshPenbar();
/* gallery */
const galleryWrap = document.getElementById("galleryWrap");
const gallery = document.getElementById("gallery");
function renderGallery(){
gallery.innerHTML = "";
WIDGET_CATS.forEach(([cat,key])=>{
const defs = REGISTRY.filter(d=>d.cat===cat);
if(!defs.length) return;
const h = document.createElement("div");
h.className = "gcat-title";
h.textContent = T(key);
gallery.appendChild(h);
const grid = document.createElement("div");
grid.className = "gallery";
defs.forEach(def=>{
const c = document.createElement("div");
c.className = "gcard";
c.innerHTML = `<div class="gi">${def.icon}</div><div class="gn">${T("wg_"+def.id)}</div><div class="gd">${T("wg_"+def.id+"_d")}</div>`;
c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); });
grid.appendChild(c);
});
gallery.appendChild(grid);
});
}
galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); });
/* ---------- floating add-button (FAB) ---------- */
const fab = document.getElementById("fab");
const fabMenu = document.getElementById("fabmenu");
function refreshFab(){
fabMenu.querySelector('[data-f="widgets"] .fl').textContent = T("fabWidgets");
fabMenu.querySelector('[data-f="draw"] .fl').textContent = T("fabDraw");
fabMenu.querySelector('[data-f="mind"] .fl').textContent = T("fabMind");
fabMenu.querySelector('[data-f="img"] .fl').textContent = T("fabImg");
fabMenu.querySelector('[data-f="draw"]').classList.toggle("on", document.body.classList.contains("pen"));
}
fab.addEventListener("click", ()=>{
refreshFab();
fabMenu.classList.toggle("open");
const open = fabMenu.classList.contains("open");
fab.classList.toggle("open", open);
document.getElementById("fabArc").classList.toggle("hide", 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");
renderGallery(); galleryWrap.classList.add("open");
});
fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
setPen(!document.body.classList.contains("pen"));
});
fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
const def = REGISTRY.find(d=>d.id==="mind");
makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true });
});
/* image upload onto the board */
const imgInput = document.createElement("input");
imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none";
document.body.appendChild(imgInput);
fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
imgInput.click();
});
function loadImgFile(f, x, y){
const keepPng = /png|gif|svg/.test(f.type);
const img = new Image();
img.onload = ()=>{
const c = document.createElement("canvas");
const s = Math.min(1, 900/Math.max(img.width, img.height));
c.width = Math.round(img.width*s); c.height = Math.round(img.height*s);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
addBoardImage(keepPng ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.85), x, y, Math.min(360, c.width));
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(f);
}
imgInput.addEventListener("change", ()=>{
const f = imgInput.files[0]; if(!f) return;
loadImgFile(f, 80, 90);
imgInput.value = "";
});
/* drag & drop images from other websites or the computer onto the board */
board.addEventListener("dragover", e=>{ e.preventDefault(); });
board.addEventListener("drop", e=>{
e.preventDefault();
const zf = VZ();
const r = board.getBoundingClientRect();
const x = Math.max(4, (e.clientX - r.left)/zf - 100), y = Math.max(4, (e.clientY - r.top)/zf - 70);
const dt = e.dataTransfer;
if(dt.files && dt.files.length){
[...dt.files].forEach((f,i)=>{ if(f.type.startsWith("image/")) loadImgFile(f, x+i*24, y+i*24); });
return;
}
let url = null;
const html = dt.getData("text/html");
if(html){
const m = html.match(/<img[^>]+src\s*=\s*["']([^"']+)["']/i);
if(m) url = m[1];
}
if(!url){
const uri = (dt.getData("text/uri-list") || dt.getData("text/plain") || "").trim().split("\n")[0];
if(/^https?:\/\//i.test(uri) && (/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(uri) || uri.includes("image"))) url = uri;
else if(/^data:image\//.test(uri)) url = uri;
else if(/^https?:\/\//i.test(uri)) url = uri; /* try anyway; broken links can be removed */
}
if(url) addBoardImage(url, x, y, 320);
});
function addBoardImage(src, x, y, w){
const d = document.createElement("div");
d.className = "bimg";
d.style.left = x+"px"; d.style.top = y+"px"; d.style.width = w+"px";
const im = document.createElement("img");
im.src = src; im.draggable = false;
d.appendChild(im);
const xb = document.createElement("button");
xb.className = "dtool-x"; xb.textContent = "✕";
xb.addEventListener("click", ()=>d.remove());
d.appendChild(xb);
const rs = document.createElement("div");
rs.className = "bimg-rs";
rs.addEventListener("pointerdown", e=>{
e.preventDefault(); e.stopPropagation();
const zf = VZ();
const sw = d.offsetWidth, sx = e.clientX;
const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; };
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
d.appendChild(rs);
d.addEventListener("pointerdown", e=>{
if(e.target===xb || e.target===rs) return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
const zf = VZ();
const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop;
const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; };
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
board.appendChild(d);
return d;
}
document.addEventListener("langchange", refreshFab);
/* =========================================================
Multiple boards, organised in folders (quick nav next to +)
==========================================================*/
function freshBS(){
return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1",
boards:[{ name: LANG==="nl" ? "Bord 1" : "Board 1", data:null }] }],
f:0, b:0 };
}
let BS = freshBS();
function boardsFromData(data){
if(data && data.boards && data.boards.folders && data.boards.folders.length) return data.boards;
if(data && data.board){
const bs = freshBS();
bs.folders[0].boards[0].data = data.board; /* migrate the old single board */
return bs;
}
return freshBS();
}
function curSlot(){
if(BS.f >= BS.folders.length) BS.f = 0;
const fo = BS.folders[BS.f];
if(BS.b >= fo.boards.length) BS.b = 0;
return fo.boards[BS.b];
}
function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; }
function stashCurrent(){ curSlot().data = serializeBoard(); }
function loadCurrentSlot(){
restoreBoard(curSlot().data || emptyBoardData());
updateBoardsUI();
}
function switchTo(f, b){
stashCurrent();
BS.f = f; BS.b = b;
loadCurrentSlot();
}
const boardsPanel = document.getElementById("boardsPanel");
const btnFolders = document.getElementById("btnFolders");
btnFolders.addEventListener("click", ()=>{
renderBoardsPanel();
boardsPanel.classList.toggle("open");
});
document.addEventListener("pointerdown", e=>{
if(!boardsPanel.classList.contains("open")) return;
if(e.target.closest("#btnFolders") || e.target.closest("#boardsPanel")) return;
boardsPanel.classList.remove("open");
});
function updateBoardsUI(){
btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`;
fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`;
if(boardsPanel.classList.contains("open")) renderBoardsPanel();
if(boardStrip.classList.contains("open")) renderStrip();
}
function flatBoards(){
const out = [];
BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo})));
return out;
}
/* ---- quick switch: hover the 🗂 button → previews side by side ---- */
const fabBoardsBtn = document.getElementById("fabBoardsBtn");
const boardStrip = document.getElementById("boardStrip");
let stripTimer = null;
function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); }
function closeStrip(){ boardStrip.classList.remove("open"); }
fabBoardsBtn.addEventListener("click", ()=>{
boardStrip.classList.contains("open") ? closeStrip() : openStrip();
});
fabBoardsBtn.addEventListener("mouseenter", openStrip);
fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer));
boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); });
document.addEventListener("pointerdown", e=>{
if(!boardStrip.classList.contains("open")) return;
if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return;
closeStrip();
});
boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; });
/* mini preview of a board's contents */
function boardThumb(data){
const w = 168, h = 105;
const c = document.createElement("canvas");
c.width = w; c.height = h;
const g = c.getContext("2d");
g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
if(!data) return c;
const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1);
(data.imgs||[]).forEach(im=>{
g.fillStyle = "#d8e2ef";
g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh));
});
(data.widgets||[]).forEach(wd=>{
const x = wd.x*w/bw, y = wd.y*h/bh,
ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh);
g.fillStyle = "rgba(59,125,216,.14)";
g.fillRect(x, y, ww, wh);
g.strokeStyle = "rgba(59,125,216,.55)";
g.strokeRect(x, y, ww, wh);
const def = REGISTRY.find(d=>d.id===wd.id);
if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); }
});
if(data.ink){
const img = new Image();
img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} };
img.src = data.ink;
}
return c;
}
function renderStrip(){
boardStrip.innerHTML = "";
const items = flatBoards();
items.forEach(it=>{
const isCur = it.fi===BS.f && it.bi===BS.b;
const card = document.createElement("div");
card.className = "bs-card" + (isCur ? " active" : "");
card.title = `${it.fo.name} / ${it.bo.name}`;
card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data));
const row = document.createElement("div");
row.className = "bs-name";
const nm = document.createElement("span");
nm.textContent = it.bo.name;
row.appendChild(nm);
if(items.length > 1){
const x = document.createElement("button");
x.className = "bs-x"; x.textContent = "✕";
x.title = T("delBoard");
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);
return;
}
const fo = BS.folders[it.fi];
fo.boards.splice(it.bi, 1);
if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null });
if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); }
else if(it.fi===BS.f && it.bi < BS.b) BS.b--;
renderStrip();
});
row.appendChild(x);
}
card.appendChild(row);
card.addEventListener("click", ()=>{
if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); }
});
boardStrip.appendChild(card);
});
const add = document.createElement("div");
add.className = "bs-card bs-new";
add.textContent = "";
add.title = T("newBoard");
add.addEventListener("click", ()=>{
const fo = BS.folders[BS.f];
fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null });
switchTo(BS.f, fo.boards.length-1);
renderStrip();
});
boardStrip.appendChild(add);
}
function renderBoardsPanel(){
boardsPanel.innerHTML = "";
BS.folders.forEach((fo, fi)=>{
const fr = document.createElement("div");
fr.className = "bp-folder";
const nm = document.createElement("span");
nm.className = "bpf-name"; nm.textContent = "📁 " + fo.name;
fr.appendChild(nm);
const add = document.createElement("button");
add.textContent = T("newBoard");
add.addEventListener("click", ()=>{
fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null });
switchTo(fi, fo.boards.length-1);
renderBoardsPanel();
});
fr.appendChild(add);
if(BS.folders.length > 1){
const del = document.createElement("button");
del.textContent = "🗑";
del.addEventListener("click", ()=>{
if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); }
else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; }
renderBoardsPanel();
});
fr.appendChild(del);
}
boardsPanel.appendChild(fr);
const row = document.createElement("div");
row.className = "bp-boards";
fo.boards.forEach((bo, bi)=>{
const chip = document.createElement("div");
chip.className = "bp-board" + (fi===BS.f && bi===BS.b ? " active" : "");
const lbl = document.createElement("span");
lbl.textContent = bo.name;
chip.appendChild(lbl);
chip.addEventListener("click", e=>{
if(e.target.classList.contains("bpb-x") || e.target.tagName==="INPUT") return;
if(fi===BS.f && bi===BS.b) return;
switchTo(fi, bi);
renderBoardsPanel();
});
chip.addEventListener("dblclick", ()=>{
if(chip.querySelector("input")) return;
const inp = document.createElement("input");
inp.value = bo.name; inp.maxLength = 20;
chip.replaceChild(inp, lbl);
inp.focus(); inp.select();
let done = false;
const commit = ()=>{
if(done) return; done = true;
bo.name = inp.value.trim() || bo.name;
renderBoardsPanel(); updateBoardsUI();
};
inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); });
inp.addEventListener("blur", commit);
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
});
if(fo.boards.length > 1 || BS.folders.length > 1){
const x = document.createElement("button");
x.className = "bpb-x"; x.textContent = "✕";
x.addEventListener("click", ev=>{
ev.stopPropagation();
const wasCur = (fi===BS.f && bi===BS.b);
fo.boards.splice(bi,1);
if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null });
if(wasCur){ BS.b = 0; loadCurrentSlot(); }
else if(fi===BS.f && bi < BS.b) BS.b--;
renderBoardsPanel();
});
chip.appendChild(x);
}
row.appendChild(chip);
});
boardsPanel.appendChild(row);
});
const nf = document.createElement("div");
nf.className = "bp-new";
const inp = document.createElement("input");
inp.placeholder = T("folderPh"); inp.maxLength = 20;
const addFolder = ()=>{
const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`;
BS.folders.push({ name, boards:[{ name:T("boardName")+" 1", data:null }] });
inp.value = "";
renderBoardsPanel();
};
inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); });
const nb = document.createElement("button");
nb.className = "tbtn"; nb.textContent = T("newFolder");
nb.addEventListener("click", addFolder);
nf.appendChild(inp); nf.appendChild(nb);
boardsPanel.appendChild(nf);
const hint = document.createElement("div");
hint.className = "bp-hint"; hint.textContent = T("renameTip");
boardsPanel.appendChild(hint);
}
document.addEventListener("langchange", updateBoardsUI);
/* =========================================================
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)); };
/* =========================================================
Letter game data
==========================================================*/
const DOTCODE = {
a:[1], b:[1,2], c:[1,4], d:[1,4,5], e:[1,5], f:[1,2,4], g:[1,2,4,5], h:[1,2,5],
i:[2,4], j:[2,4,5], k:[1,3], l:[1,2,3], m:[1,3,4], n:[1,3,4,5], o:[1,3,5],
p:[1,2,3,4], q:[1,2,3,4,5], r:[1,2,3,5], s:[2,3,4], t:[2,3,4,5], u:[1,3,6],
v:[1,2,3,6], w:[2,4,5,6], x:[1,3,4,6], y:[1,3,4,5,6], z:[1,3,5,6],
/* digits use the braille number patterns (aj) */
"1":[1], "2":[1,2], "3":[1,4], "4":[1,4,5], "5":[1,5],
"6":[1,2,4], "7":[1,2,4,5], "8":[1,2,5], "9":[2,4], "0":[2,4,5]
};
const VOWELS = "aeiou";
function dotGrid(letter){
const code = DOTCODE[letter] || [];
const grid = document.createElement("div");
grid.className = "dots";
[1,4,2,5,3,6].forEach(p=>{
const d = document.createElement("i");
d.className = code.includes(p) ? "on" : "off";
grid.appendChild(d);
});
return grid;
}
/* built-in cards: 5 per language, 6 words each (max 5 letters) */
const WORDS = {
nl:[
[["aap","🐵"],["vis","🐟"],["zon","☀️"],["kat","🐈"],["bal","⚽"],["ei","🥚"]],
[["boom","🌳"],["huis","🏠"],["maan","🌙"],["boot","⛵"],["kip","🐔"],["muis","🐭"]],
[["boek","📕"],["ster","⭐"],["roos","🌹"],["vuur","🔥"],["fiets","🚲"],["taart","🎂"]],
[["jas","🧥"],["bril","👓"],["klok","⏰"],["peer","🍐"],["kaas","🧀"],["eend","🦆"]],
[["hand","✋"],["voet","🦶"],["neus","👃"],["oog","👁️"],["oor","👂"],["mond","👄"]]
],
en:[
[["cat","🐈"],["dog","🐕"],["sun","☀️"],["egg","🥚"],["ball","⚽"],["fish","🐟"]],
[["tree","🌳"],["moon","🌙"],["boat","⛵"],["star","⭐"],["book","📕"],["cake","🎂"]],
[["house","🏠"],["mouse","🐭"],["apple","🍎"],["train","🚂"],["fire","🔥"],["rose","🌹"]],
[["bear","🐻"],["duck","🦆"],["frog","🐸"],["bee","🐝"],["cow","🐄"],["pig","🐷"]],
[["hand","✋"],["foot","🦶"],["nose","👃"],["eye","👁️"],["ear","👂"],["milk","🥛"]]
]
};
const shuffle = a => { a=a.slice(); for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]];} return a; };
/* custom themes: folders of own words.
{nl:[{name:"Herfst", words:[[word,pic],...]}], en:[...]} */
let THEMES = {nl:[], en:[]};
function themesFromData(data){
if(!data) return {nl:[],en:[]};
if(data.themes) return data.themes;
/* migrate old flat word lists into one theme */
const t = {nl:[],en:[]};
if(data.words) ["nl","en"].forEach(l=>{
if(Array.isArray(data.words[l]) && data.words[l].length)
t[l] = [{name: l==="nl" ? "Mijn woorden" : "My words", words: data.words[l]}];
});
return t;
}
const isImg = p => typeof p==="string" && (p.startsWith("data:") || p.startsWith("http"));
function setPic(el, pic){
if(isImg(pic)){ el.innerHTML = ""; const im = document.createElement("img"); im.src = pic; im.alt=""; el.appendChild(im); }
else el.textContent = pic;
}
/* emoji palette (grouped) */
const splitEmoji = s => {
const out = [];
for(const ch of s){
if(out.length && (ch==="" || ch==="")) out[out.length-1]+=ch;
else if(out.length && out[out.length-1].endsWith("")) out[out.length-1]+=ch;
else out.push(ch);
}
return out;
};
const EMOJI_CATS = [
splitEmoji("🐵🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮🐷🐸🐔🐧🐦🦆🦉🐴🦄🐝🐛🦋🐌🐞🐠🐟🐬🐳🐢🐍🦎🐘🦒"),
splitEmoji("🍎🍐🍊🍋🍌🍉🍇🍓🍒🍑🥝🍅🥕🌽🥔🧀🥚🍞🥐🥞🍕🍟🍦🍪🎂🍫🍬🥛🧃🍯"),
splitEmoji("🌳🌲🌴🌵🌷🌹🌻🌸💐🍄⭐🌙☀️⛅🌈❄️🔥💧🌊🪨"),
splitEmoji("🚗🚕🚌🚑🚒🚜🚲🛴🏍️🚂✈️🚁⛵🚀🛶🚢"),
splitEmoji("⚽🏀🎈🎁🧸📕✏️🖍️✂️🎨🥁🎸👓⌚⏰💡🔑🧤🧢👟👗🧥🛏️🪑🚪🏠"),
splitEmoji("✋👂👃👁️👄🦶😀😢😴🤧👶👦👧👵👴")
];
function mountLetterGame(root, initState){
const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" };
if(initState){
S.level = initState.level||1;
S.card = initState.card??0;
S.count = initState.count||6;
S.snd = initState.snd !== false;
S.amode = initState.amode||"blocks";
}
const play = f=>{ if(S.snd) f(); };
root.innerHTML = `
<div class="lg">
<div class="lg-top">
<button class="lg-snd"></button>
<button class="lg-mode"></button>
<div class="lg-levels">
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
</div>
<div class="lg-cards"></div>
<select class="lg-num"></select>
<div class="lg-progress"><span class="lg-count"></span><span class="lg-stars"></span></div>
</div>
<div class="lg-instr"></div>
<div class="lg-card">
<div class="lg-pic"></div>
<div class="lg-slots"></div>
<div class="lg-done"><div class="dg">🌟</div><div class="dt"></div><div class="dp" style="color:var(--muted);font-weight:700;"></div></div>
</div>
<div class="lg-tray blocks-tray"></div>
<div class="lg-tray lg-kbd"></div>
<div class="lg-bottom"><button class="lg-next"></button></div>
<div class="lg-editor">
<div class="lg-ed-head"><b class="ed-title"></b><button class="wclose lg-ed-close">✕</button></div>
<div class="lg-ed-themerow">
<select class="ed-theme-sel"></select>
<button class="tbtn ed-theme-add"></button>
<input type="text" class="ed-theme-name" maxlength="16" style="display:none;">
<button class="ed-theme-del">🗑</button>
</div>
<div class="lg-ed-form">
<input type="text" class="ed-word" maxlength="5">
<div class="ed-preview">?</div>
<button class="ed-add"></button>
<div class="ed-msg"></div>
</div>
<div class="ed-tabs">
<button data-t="emoji"></button><button data-t="picto"></button><button data-t="upload"></button>
</div>
<div class="ed-pane">
<div class="ed-p-emoji">
<div class="ed-cats"></div>
<div class="ed-grid"></div>
</div>
<div class="ed-p-picto" style="display:none;">
<div class="ed-pictorow">
<input type="text" class="ed-psearch" maxlength="30">
<button class="tbtn ed-pgo"></button>
</div>
<div class="ed-status"></div>
<div class="ed-pgrid"></div>
<div class="ed-credit"></div>
</div>
<div class="ed-p-upload" style="display:none;">
<label class="ed-upload"><span class="ed-uplbl"></span><input type="file" accept="image/*"></label>
<div class="ed-uphint"></div>
</div>
</div>
<div class="lg-ed-list"></div>
</div>
</div>`;
const el = s => root.querySelector(s);
const slotsEl = el(".lg-slots"), trayEl = el(".blocks-tray"), picEl = el(".lg-pic"),
instrEl = el(".lg-instr"), nextBtn = el(".lg-next"), doneEl = el(".lg-done"),
countEl = el(".lg-count"), starsEl = el(".lg-stars"), cardsEl = el(".lg-cards");
/* card buttons (built dynamically: N cards + star + edit) */
const numSel = root.querySelector(".lg-num");
function buildNumSel(){
numSel.innerHTML = "";
[4,6,8,10,"all"].forEach(v=>{
const o = document.createElement("option");
o.value = v;
o.textContent = v==="all" ? T("all") : v;
numSel.appendChild(o);
});
numSel.value = S.count;
numSel.title = T("numWords");
}
numSel.addEventListener("change", ()=>{
S.count = numSel.value==="all" ? "all" : +numSel.value;
newRound(); render();
});
const sndBtn = root.querySelector(".lg-snd");
sndBtn.addEventListener("click", ()=>{
S.snd = !S.snd;
updateSndBtn();
});
function updateSndBtn(){
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
sndBtn.title = T("sndWidget");
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
modeBtn.title = T("amode");
root.querySelector(".lg").classList.toggle("typemode", S.amode==="type");
}
const modeBtn = root.querySelector(".lg-mode");
modeBtn.addEventListener("click", ()=>{
S.amode = S.amode==="blocks" ? "type" : "blocks";
updateSndBtn();
});
/* on-screen letter keyboard for type mode */
const kbdEl = root.querySelector(".lg-kbd");
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button");
b.textContent = l;
b.className = VOWELS.includes(l) ? "v" : "c";
b.addEventListener("click", ()=>typeLetter(l));
kbdEl.appendChild(b);
});
function typeLetter(l){
if(doneEl.classList.contains("show") || edEl.classList.contains("open")) return;
const slot = slotsEl.querySelector(".slot:not(.filled)");
if(!slot) return;
if(slot.dataset.letter === l){
const b = makeBlock(l);
b.classList.add("in-slot");
b.dataset.placedAt = Date.now();
slot.appendChild(b);
slot.classList.add("filled");
play(sndGood);
checkComplete();
}else{
slot.classList.add("shake");
setTimeout(()=>slot.classList.remove("shake"), 400);
play(sndBad);
}
}
function buildCardButtons(){
cardsEl.innerHTML = "";
WORDS[S.lang].forEach((_,i)=>{
const b = document.createElement("button");
b.textContent = i+1;
b.dataset.c = i;
b.addEventListener("click", ()=>{ S.card=i; newRound(); render(); });
cardsEl.appendChild(b);
});
THEMES[S.lang].forEach((th,i)=>{
if(!th.words.length) return;
const b = document.createElement("button");
b.className = "lg-theme";
b.textContent = th.name; b.title = th.name;
b.dataset.c = "t"+i;
b.addEventListener("click", ()=>{ S.card="t"+i; newRound(); render(); });
cardsEl.appendChild(b);
});
const edit = document.createElement("button");
edit.className = "lg-edit"; edit.textContent = "✎";
edit.addEventListener("click", openEditor);
cardsEl.appendChild(edit);
}
buildCardButtons();
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); });
});
nextBtn.addEventListener("click", ()=>{
S.wordIdx++;
if(S.wordIdx >= S.order.length){ showCardDone(); } else render();
});
document.addEventListener("langchange", ()=>{
S.lang=LANG; S.card=0; buildCardButtons(); buildNumSel(); newRound(); render();
});
document.addEventListener("wordschange", ()=>{
buildCardButtons(); newRound(); render();
if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); }
});
function fullList(){
if(typeof S.card==="string" && S.card.startsWith("t")){
const th = THEMES[S.lang][+S.card.slice(1)];
if(th && th.words.length) return th.words;
S.card = 0;
}
if(typeof S.card==="number" && S.card >= WORDS[S.lang].length) S.card = 0;
return WORDS[S.lang][S.card] || WORDS[S.lang][0];
}
function newRound(){
const list = fullList();
const n = S.count==="all" ? list.length : Math.min(+S.count, list.length);
S.order = shuffle(list.map((_,i)=>i)).slice(0,n);
S.wordIdx = 0; S.stars = 0;
}
function currentWord(){ return fullList()[S.order[S.wordIdx]]; }
function makeBlock(letter){
const b = document.createElement("div");
b.className = "blok " + (VOWELS.includes(letter) ? "vowel" : "cons");
b.dataset.letter = letter;
b.innerHTML = `<div class="bl">${letter}</div>`;
const dg = dotGrid(letter); dg.classList.add("blok-dots");
b.appendChild(dg);
attachDrag(b);
return b;
}
function makeSlot(letter, showGhost){
const s = document.createElement("div");
s.className = "slot";
s.dataset.letter = letter;
s.innerHTML = `<div class="ghost">${showGhost ? letter : ""}</div>`;
const dg = dotGrid(letter); dg.classList.add("slot-dots");
s.appendChild(dg);
s.addEventListener("click", ()=>{
const placed = s.querySelector(".blok");
/* ignore the synthetic click that follows a successful drop */
if(placed && !placed.classList.contains("fixed") &&
Date.now() - (+placed.dataset.placedAt || 0) > 400){
placed.classList.remove("in-slot");
trayEl.appendChild(placed);
s.classList.remove("filled");
}
});
return s;
}
function distractors(word, n){
const pool = "abcdefghijklmnoprstuvwz".split("").filter(c=>!word.includes(c));
return shuffle(pool).slice(0,n);
}
function render(){
if(!S.order.length || S.wordIdx >= S.order.length) newRound();
doneEl.classList.remove("show");
nextBtn.classList.remove("show");
nextBtn.textContent = T("next");
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.textContent = T("lvl"+b.dataset.l);
b.classList.toggle("active", +b.dataset.l===S.level);
});
cardsEl.querySelectorAll("button[data-c]").forEach(b=>{
b.classList.toggle("active", b.dataset.c===String(S.card));
});
cardsEl.querySelector(".lg-edit").title = T("manage");
numSel.title = T("numWords");
const allOpt = numSel.querySelector('option[value="all"]');
if(allOpt) allOpt.textContent = T("all");
const [word, pic] = currentWord();
setPic(picEl, pic);
const cardLbl = typeof S.card==="string"
? (THEMES[S.lang][+S.card.slice(1)]?.name || "★")
: S.card+1;
countEl.textContent = `${T("card")} ${cardLbl} · ${T("word")} ${S.wordIdx+1}/${S.order.length}`;
starsEl.textContent = S.stars ? S.stars+" ★" : "";
slotsEl.innerHTML = ""; trayEl.innerHTML = "";
const letters = word.split("");
if(S.level===1){
instrEl.innerHTML = T("instr1");
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,true)));
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
else if(S.level===2){
const types = ["begin","end","mid"];
const posType = types[S.wordIdx % 3];
let idx = posType==="begin" ? 0 : posType==="end" ? letters.length-1 : Math.floor(letters.length/2);
instrEl.innerHTML = T("instr2_"+posType);
letters.forEach((l,i)=>{
if(i===idx){ slotsEl.appendChild(makeSlot(l,false)); }
else{
const s = makeSlot(l,true);
s.classList.add("filled");
const b = makeBlock(l); b.classList.add("in-slot","fixed");
b.style.pointerEvents = "none";
s.appendChild(b);
slotsEl.appendChild(s);
}
});
shuffle([letters[idx]].concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
else{
instrEl.innerHTML = T("instr3");
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,false)));
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
}
function checkComplete(){
const open = slotsEl.querySelectorAll(".slot:not(.filled)").length;
if(open===0){
S.stars++;
starsEl.textContent = S.stars ? S.stars+" ★" : "";
play(sndWin); confetti(el(".lg-card"));
doneEl.querySelector(".dt").textContent = T("welldone");
doneEl.querySelector(".dp").textContent = currentWord()[0];
doneEl.classList.add("show");
nextBtn.classList.add("show");
}
}
function showCardDone(){
doneEl.querySelector(".dt").textContent = T("cardDone");
doneEl.querySelector(".dp").textContent = T("pickCard");
doneEl.classList.add("show");
nextBtn.classList.remove("show");
newRound();
}
/* ---------- custom words editor ---------- */
const edEl = el(".lg-editor"), edWord = el(".ed-word"),
edPrev = el(".ed-preview"), edMsg = el(".ed-msg"), edList = el(".lg-ed-list"),
edFile = el(".ed-p-upload input"), edPSearch = el(".ed-psearch"),
edPGrid = el(".ed-pgrid"), edStatus = el(".ed-status"),
edThemeSel = el(".ed-theme-sel"), edThemeAdd = el(".ed-theme-add"),
edThemeName = el(".ed-theme-name"), edThemeDel = el(".ed-theme-del");
let edPic = "", edTab = "emoji", edCat = 0, edTheme = 0;
/* ---- themes (folders of words) ---- */
function renderThemeSel(){
const list = THEMES[S.lang];
if(edTheme >= list.length) edTheme = 0;
edThemeSel.innerHTML = "";
if(!list.length){
const o = document.createElement("option");
o.textContent = T("noThemes"); o.disabled = true; o.selected = true;
edThemeSel.appendChild(o);
}else{
list.forEach((th,i)=>{
const o = document.createElement("option");
o.value = i; o.textContent = `${th.name} (${th.words.length})`;
edThemeSel.appendChild(o);
});
edThemeSel.value = edTheme;
}
edThemeAdd.title = T("newTheme");
edThemeDel.title = T("delTheme");
edThemeName.placeholder = T("themeNamePh");
}
edThemeSel.addEventListener("change", ()=>{ edTheme = +edThemeSel.value; renderEdList(); });
edThemeAdd.addEventListener("click", ()=>{
edThemeName.style.display = "block";
edThemeName.value = "";
edThemeName.focus();
});
let themeDone = false;
function commitTheme(){
if(themeDone) return; themeDone = true;
const nm = edThemeName.value.trim();
if(nm){
THEMES[S.lang].push({name:nm, words:[]});
edTheme = THEMES[S.lang].length-1;
persistWords();
renderThemeSel(); renderEdList();
}
edThemeName.style.display = "none";
setTimeout(()=>{ themeDone = false; }, 100);
}
edThemeName.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commitTheme();
if(ev.key==="Escape"){ themeDone=true; edThemeName.style.display="none"; setTimeout(()=>{themeDone=false;},100); }
});
edThemeName.addEventListener("blur", commitTheme);
edThemeDel.addEventListener("click", ()=>{
if(!THEMES[S.lang].length) return;
THEMES[S.lang].splice(edTheme,1);
edTheme = 0;
persistWords();
renderThemeSel(); renderEdList();
});
function selectPic(p, btn){
edPic = p; setPic(edPrev, p);
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
if(btn) btn.classList.add("sel");
}
/* tabs */
root.querySelectorAll(".ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>{ edTab = b.dataset.t; refreshTabs(); });
});
function refreshTabs(){
root.querySelectorAll(".ed-tabs button").forEach(b=>{
b.textContent = T("tab"+b.dataset.t[0].toUpperCase()+b.dataset.t.slice(1));
b.classList.toggle("active", b.dataset.t===edTab);
});
el(".ed-p-emoji").style.display = edTab==="emoji" ? "block" : "none";
el(".ed-p-picto").style.display = edTab==="picto" ? "block" : "none";
el(".ed-p-upload").style.display = edTab==="upload" ? "block" : "none";
if(edTab==="picto" && !edPSearch.value) edPSearch.value = edWord.value.trim();
}
/* emoji tab */
function renderEmojiCats(){
const cats = el(".ed-cats");
cats.innerHTML = "";
T("cats").forEach((name,i)=>{
const b = document.createElement("button");
b.textContent = name;
b.classList.toggle("active", i===edCat);
b.addEventListener("click", ()=>{ edCat=i; renderEmojiCats(); });
cats.appendChild(b);
});
const grid = el(".ed-grid");
grid.innerHTML = "";
EMOJI_CATS[edCat].forEach(em=>{
const b = document.createElement("button");
b.type="button"; b.textContent = em;
b.classList.toggle("sel", em===edPic);
b.addEventListener("click", ()=>selectPic(em, b));
grid.appendChild(b);
});
}
/* picto tab (ARASAAC) */
async function searchPictos(){
const term = edPSearch.value.trim();
if(!term) return;
edStatus.textContent = T("pictoLoad");
edPGrid.innerHTML = "";
try{
const resp = await fetch(`https://api.arasaac.org/api/pictograms/${S.lang}/search/${encodeURIComponent(term)}`);
if(!resp.ok) throw new Error("http "+resp.status);
const arr = await resp.json();
if(!arr.length){ edStatus.textContent = T("pictoNone"); return; }
edStatus.textContent = "";
arr.slice(0,12).forEach(p=>{
const url = `https://static.arasaac.org/pictograms/${p._id}/${p._id}_300.png`;
const b = document.createElement("button");
b.type="button";
const im = document.createElement("img");
im.src = url; im.loading = "lazy"; im.alt = term;
b.appendChild(im);
b.addEventListener("click", ()=>selectPic(url, b));
edPGrid.appendChild(b);
});
}catch(e){ edStatus.textContent = T("pictoErr"); }
}
el(".ed-pgo").addEventListener("click", searchPictos);
edPSearch.addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); });
/* typing a word auto-fills picto search */
edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); });
/* upload tab */
edFile.addEventListener("change", ()=>{
const f = edFile.files[0]; if(!f) return;
const img = new Image();
img.onload = ()=>{
const c = document.createElement("canvas");
const s = Math.min(1, 128/Math.max(img.width, img.height));
c.width = Math.round(img.width*s); c.height = Math.round(img.height*s);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
selectPic(c.toDataURL("image/png"));
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(f);
edFile.value = "";
});
el(".ed-add").addEventListener("click", ()=>{
const w = edWord.value.trim().toLowerCase();
if(!/^[a-z]{2,5}$/.test(w)){ edMsg.textContent = T("badWord"); return; }
if(!edPic){ edMsg.textContent = T("needPic"); return; }
edMsg.textContent = "";
if(!THEMES[S.lang].length){
THEMES[S.lang].push({name:T("myWords"), words:[]});
edTheme = 0;
renderThemeSel();
}
THEMES[S.lang][edTheme].words.push([w, edPic]);
persistWords();
renderThemeSel();
edWord.value = ""; edPSearch.value = ""; edPic = ""; edPrev.textContent = "?";
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
renderEdList();
play(sndGood);
});
el(".lg-ed-close").addEventListener("click", ()=>{
edEl.classList.remove("open");
buildCardButtons();
newRound();
render();
});
function openEditor(){
el(".ed-title").textContent = T("manage");
el(".ed-uplbl").textContent = T("upload");
el(".ed-uphint").textContent = T("upHint");
el(".ed-credit").innerHTML = T("pictoCredit");
el(".ed-pgo").textContent = T("pictoSearch");
edPSearch.placeholder = T("pictoPh");
edWord.placeholder = T("wordPh");
el(".ed-add").textContent = T("addWord");
el(".lg-ed-close").title = T("close");
edMsg.textContent = ""; edStatus.textContent = "";
refreshTabs();
renderEmojiCats();
renderThemeSel();
renderEdList();
edEl.classList.add("open");
}
function renderEdList(){
edList.innerHTML = "";
const th = THEMES[S.lang][edTheme];
const list = th ? th.words : [];
if(list.length===0){
edList.innerHTML = `<div class="empty">${T("noWords")}</div>`;
return;
}
list.forEach((entry, i)=>{
const row = document.createElement("div");
row.className = "ed-row";
const p = document.createElement("div"); p.className = "ep"; setPic(p, entry[1]);
const wv = document.createElement("div"); wv.className = "ew"; wv.textContent = entry[0];
const del = document.createElement("button"); del.className = "edel"; del.textContent = "✕";
del.addEventListener("click", ()=>{ list.splice(i,1); persistWords(); renderThemeSel(); renderEdList(); });
row.appendChild(p); row.appendChild(wv); row.appendChild(del);
edList.appendChild(row);
});
}
/* ---------- drag & drop ----------
Hit-testing is geometric (cursor vs slot rectangles) instead of
elementFromPoint, so overlays/transforms can never break drops. */
function attachDrag(block){
block.addEventListener("pointerdown", e=>{
if(block.classList.contains("in-slot")) return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
/* avoid implicit touch capture retargeting the follow-up click */
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
const clone = block.cloneNode(true);
clone.id = "dragClone";
document.body.appendChild(clone);
block.classList.add("dragging");
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
moveTo(e);
let hovered = null;
const hitSlot = ev=>{
const pad = 8;
let found = null;
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
const r = s.getBoundingClientRect();
if(ev.clientX >= r.left-pad && ev.clientX <= r.right+pad &&
ev.clientY >= r.top-pad && ev.clientY <= r.bottom+pad) found = s;
});
return found;
};
const move = ev=>{
moveTo(ev);
const slot = hitSlot(ev);
if(hovered && hovered!==slot) hovered.classList.remove("droptarget");
hovered = slot;
if(hovered) hovered.classList.add("droptarget");
};
const up = ev=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
block.classList.remove("dragging");
if(hovered) hovered.classList.remove("droptarget");
const target = hitSlot(ev);
if(target){
if(target.dataset.letter === block.dataset.letter){
target.classList.add("filled");
block.classList.add("in-slot");
block.dataset.placedAt = Date.now();
target.appendChild(block);
play(sndGood);
checkComplete();
}else{
target.classList.add("shake");
setTimeout(()=>target.classList.remove("shake"), 400);
play(sndBad);
}
}
};
const cancel = ()=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
block.classList.remove("dragging");
if(hovered) hovered.classList.remove("droptarget");
};
document.addEventListener("pointermove",move);
document.addEventListener("pointerup",up);
document.addEventListener("pointercancel",cancel);
});
}
buildNumSel();
updateSndBtn();
newRound();
render();
document.addEventListener("langchange", updateSndBtn);
return {
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }),
setState: st=>{ if(st){ S.level=st.level||1; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; buildNumSel(); updateSndBtn(); newRound(); render(); } }
};
}
function confetti(container){
const colors = ["#e0554d","#3b7dd8","#3fae6a","#f7c948","#9b59b6"];
const rect = container.getBoundingClientRect();
for(let i=0;i<28;i++){
const c = document.createElement("div");
c.className = "confetti";
c.style.left = Math.random()*rect.width + "px";
c.style.background = colors[i%colors.length];
c.style.animationDuration = (1.2+Math.random()*1.4)+"s";
c.style.animationDelay = (Math.random()*0.3)+"s";
container.appendChild(c);
setTimeout(()=>c.remove(), 3200);
}
}
/* =========================================================
Writing pad — pen on paper with lines or maths squares
==========================================================*/
function mountWrite(root, initState){
const COLORS = ["#26344a","#3b7dd8","#e0554d","#3fae6a","#f7c948"];
const SIZES = [3,6,10];
let color = COLORS[0], size = SIZES[1], tool = "pen", // pen | mark | text | erase
bg = (initState && initState.bg) || "write";
root.innerHTML = `
<div class="wp">
<div class="wp-bar">
<div class="wp-group wp-tools"></div>
<div class="wp-group wp-bgs"></div>
<div class="wp-group wp-colors"></div>
<div class="wp-group wp-sizes"></div>
<button class="wp-clear">🗑</button>
</div>
<div class="wp-stage"><canvas></canvas></div>
</div>`;
const stage = root.querySelector(".wp-stage"),
canvas = root.querySelector("canvas"),
ctx = canvas.getContext("2d"),
toolsEl = root.querySelector(".wp-tools"),
bgsEl = root.querySelector(".wp-bgs"),
colorsEl = root.querySelector(".wp-colors"),
sizesEl = root.querySelector(".wp-sizes"),
clearBtn = root.querySelector(".wp-clear");
const TOOLS = [["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]];
TOOLS.forEach(([id,icon,key])=>{
const b = document.createElement("button");
b.dataset.tool = id; b.textContent = icon;
b.addEventListener("click", ()=>{ tool = id; refresh(); });
toolsEl.appendChild(b);
});
/* background choices */
const BGS = [["blank","bgBlank"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]];
BGS.forEach(([id,key])=>{
const b = document.createElement("button");
b.className = "wp-bg"; b.dataset.bg = id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>{ bg = id; refresh(); });
bgsEl.appendChild(b);
});
COLORS.forEach(c=>{
const b = document.createElement("button");
b.className = "wp-color"; b.dataset.c = c;
const i = document.createElement("i"); i.style.background = c;
b.appendChild(i);
b.addEventListener("click", ()=>{ color = c; if(tool==="erase") tool = "pen"; refresh(); });
colorsEl.appendChild(b);
});
SIZES.forEach(s=>{
const b = document.createElement("button");
b.className = "wp-size"; b.dataset.s = s;
const i = document.createElement("i");
i.style.width = i.style.height = (s+4)+"px";
b.appendChild(i);
b.addEventListener("click", ()=>{ size = s; refresh(); });
sizesEl.appendChild(b);
});
clearBtn.addEventListener("click", ()=>{ ctx.clearRect(0,0,canvas.width,canvas.height); });
function refresh(){
stage.className = "wp-stage" + (bg==="blank" ? "" : " bg-"+bg) + (tool==="text" ? " t-text" : "");
toolsEl.querySelectorAll("button").forEach(b=>{
b.classList.toggle("on", b.dataset.tool===tool);
b.title = T(TOOLS.find(x=>x[0]===b.dataset.tool)[2]);
});
bgsEl.querySelectorAll("button").forEach(b=>{
b.classList.toggle("on", b.dataset.bg===bg);
b.title = T(BGS.find(x=>x[0]===b.dataset.bg)[1]);
});
colorsEl.querySelectorAll("button").forEach(b=>b.classList.toggle("on", b.dataset.c===color && tool!=="erase"));
sizesEl.querySelectorAll("button").forEach(b=>{ b.classList.toggle("on", +b.dataset.s===size); b.title = T("penSize"); });
clearBtn.title = T("clearAll");
}
document.addEventListener("langchange", refresh);
/* canvas sizing (preserves ink on resize) */
const dpr = window.devicePixelRatio || 1;
function resize(){
const w = stage.clientWidth, h = stage.clientHeight;
if(w<2 || h<2) return;
const keep = document.createElement("canvas");
keep.width = canvas.width; keep.height = canvas.height;
if(canvas.width) keep.getContext("2d").drawImage(canvas,0,0);
canvas.width = Math.round(w*dpr); canvas.height = Math.round(h*dpr);
canvas.style.width = w+"px"; canvas.style.height = h+"px";
if(keep.width) ctx.drawImage(keep,0,0);
ctx.lineCap = "round"; ctx.lineJoin = "round";
}
new ResizeObserver(resize).observe(stage);
resize();
/* restore saved ink */
if(initState && initState.ink){
const im = new Image();
im.onload = ()=>{ resize(); ctx.drawImage(im,0,0); };
im.src = initState.ink;
}
/* drawing */
let drawing = false, lx=0, ly=0, nibW = size;
const pos = ev=>{
const r = canvas.getBoundingClientRect();
/* ratio-based: correct under app zoom and widget zoom */
return [(ev.clientX-r.left)*(canvas.width/r.width), (ev.clientY-r.top)*(canvas.height/r.height)];
};
canvas.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
if(tool==="text"){ startText(e); return; }
canvas.setPointerCapture(e.pointerId);
drawing = true;
nibW = size*0.8;
[lx,ly] = pos(e);
strokeTo(lx+0.01, ly+0.01, e.pressure, e.pointerType);
});
canvas.addEventListener("pointermove", e=>{
if(!drawing) return;
const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
evs.forEach(ev=>{ const [x,y] = pos(ev); strokeTo(x, y, ev.pressure, ev.pointerType); });
});
const stopDraw = ()=>{ drawing = false; ctx.globalAlpha = 1; };
canvas.addEventListener("pointerup", stopDraw);
canvas.addEventListener("pointercancel", stopDraw);
function strokeTo(x,y,pressure,ptype){
ctx.globalCompositeOperation = tool==="erase" ? "destination-out" : "source-over";
ctx.globalAlpha = tool==="mark" ? 0.32 : 1;
ctx.strokeStyle = color;
let w = size;
if(tool==="erase") w = size*5;
else if(tool==="mark") w = size*3.5;
else if(tool==="vulp"){
/* nib: real pen pressure when available, otherwise slower = thicker */
let target;
if(ptype==="pen" && pressure > 0){
target = size*(0.45 + pressure*1.9);
}else{
const speed = Math.hypot(x-lx, y-ly)/dpr;
target = Math.max(size*0.5, Math.min(size*2.3, size*2.3 - speed*0.12));
}
nibW += (target - nibW)*0.3; /* smooth transitions between thin and thick */
w = nibW;
}
ctx.lineWidth = w*dpr;
ctx.beginPath();
ctx.moveTo(lx,ly);
ctx.lineTo(x,y);
ctx.stroke();
lx = x; ly = y;
}
/* text tool: click the sheet, type, Enter (or click away) to place */
function startText(e){
if(stage.querySelector(".wp-text-input")) return;
const r = stage.getBoundingClientRect();
const sc = r.width / stage.offsetWidth; /* effective zoom on this widget */
const x = (e.clientX - r.left)/sc, y = (e.clientY - r.top)/sc;
const fontPx = size*6 + 10;
const inp = document.createElement("input");
inp.type = "text"; inp.className = "wp-text-input";
inp.style.left = Math.min(x, r.width-140)+"px";
inp.style.top = Math.min(y, r.height-34)+"px";
inp.style.color = color;
inp.style.fontSize = fontPx+"px";
stage.appendChild(inp);
setTimeout(()=>inp.focus(), 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v){
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.fillStyle = color;
ctx.font = `700 ${fontPx*dpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`;
ctx.textBaseline = "top";
ctx.fillText(v, x*dpr, y*dpr);
}
inp.remove();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commit();
if(ev.key==="Escape"){ done = true; inp.remove(); }
});
inp.addEventListener("blur", commit);
}
refresh();
return {
getState: ()=>{
let ink = null;
try{ ink = canvas.toDataURL("image/png"); }catch(e){}
return { bg, ink };
}
};
}
/* =========================================================
Math practice engine — add / sub / mul / div / percentages
==========================================================*/
function mountMath(root, initState, mode){
const ROUND = 10;
const S = { level:1, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false };
if(initState){
S.level = initState.level||1;
S.snd = initState.snd!==false;
S.amode = initState.amode||"blocks";
}
const play = f=>{ if(S.snd) f(); };
const rnd = n => Math.floor(Math.random()*n);
root.innerHTML = `
<div class="mt blocks">
<div class="mt-top">
<button class="lg-snd"></button>
<button class="lg-mode"></button>
<div class="lg-levels">
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
</div>
<div class="mt-score"></div>
</div>
<div class="mt-card">
<div class="mt-q"></div>
<div class="mt-a"></div>
<div class="mt-slots"></div>
<div class="mt-fb"></div>
<div class="mt-done">
<div class="dg">🌟</div><div class="dt"></div><div class="ds"></div>
<button class="mt-again"></button>
</div>
</div>
<div class="lg-tray mt-tray"></div>
<div class="mt-pad"></div>
</div>`;
const el = s => root.querySelector(s);
const mtEl = el(".mt"), qEl = el(".mt-q"), aEl = el(".mt-a"), fbEl = el(".mt-fb"),
scoreEl = el(".mt-score"), doneEl = el(".mt-done"),
sndBtn = el(".lg-snd"), modeBtn = el(".lg-mode"),
padEl = el(".mt-pad"), slotsEl = el(".mt-slots"), trayEl = el(".mt-tray");
/* ---- digit blocks with dot patterns (like the letter blocks) ---- */
function makeDigitBlock(d){
const b = document.createElement("div");
b.className = "blok sm";
b.dataset.digit = d;
b.innerHTML = `<div class="bl">${d}</div>`;
const dg = dotGrid(d); dg.classList.add("blok-dots");
b.appendChild(dg);
attachDigitDrag(b);
return b;
}
"0123456789".split("").forEach(d=>trayEl.appendChild(makeDigitBlock(d)));
function makeDigitSlot(d){
const s = document.createElement("div");
s.className = "slot sm";
s.dataset.digit = d;
s.innerHTML = `<div class="ghost"></div>`;
const dg = dotGrid(d); dg.classList.add("slot-dots");
s.appendChild(dg);
s.addEventListener("click", ()=>{
const placed = s.querySelector(".blok");
if(placed && Date.now() - (+placed.dataset.placedAt||0) > 400){
placed.remove();
s.classList.remove("filled");
}
});
return s;
}
function attachDigitDrag(block){
block.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
if(S.busy || doneEl.classList.contains("show")) return;
e.preventDefault();
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
const clone = block.cloneNode(true);
clone.id = "dragClone";
document.body.appendChild(clone);
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
moveTo(e);
let hovered = null;
const hit = ev=>{
const pad = 8;
let found = null;
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
const r = s.getBoundingClientRect();
if(ev.clientX>=r.left-pad && ev.clientX<=r.right+pad &&
ev.clientY>=r.top-pad && ev.clientY<=r.bottom+pad) found = s;
});
return found;
};
const move = ev=>{
moveTo(ev);
const s = hit(ev);
if(hovered && hovered!==s) hovered.classList.remove("droptarget");
hovered = s;
if(hovered) hovered.classList.add("droptarget");
};
const up = ev=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
if(hovered) hovered.classList.remove("droptarget");
const target = hit(ev);
if(target){
if(target.dataset.digit === block.dataset.digit){
const copy = block.cloneNode(true);
copy.classList.add("in-slot");
copy.dataset.placedAt = Date.now();
target.appendChild(copy);
target.classList.add("filled");
play(sndGood);
if(!slotsEl.querySelector(".slot:not(.filled)")) blocksSolved();
}else{
target.classList.add("shake");
setTimeout(()=>target.classList.remove("shake"), 400);
play(sndBad);
}
}
};
const cancel = ()=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
if(hovered) hovered.classList.remove("droptarget");
};
document.addEventListener("pointermove",move);
document.addEventListener("pointerup",up);
document.addEventListener("pointercancel",cancel);
});
}
function blocksSolved(){
S.busy = true;
S.good++;
fbEl.textContent = T("mGood");
play(sndWin);
confetti(el(".mt-card"));
setTimeout(next, 1000);
}
function gen(){
const L = S.level;
let a, b, ans, txt;
if(mode==="add"){
const max = L===1 ? 10 : L===2 ? 20 : 100;
a = rnd(max+1); b = rnd(max-a+1); ans = a+b;
txt = `${a} + ${b} =`;
}else if(mode==="sub"){
const max = L===1 ? 10 : L===2 ? 20 : 100;
a = rnd(max+1); b = rnd(a+1); ans = a-b;
txt = `${a} ${b} =`;
}else if(mode==="mul"){
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b;
txt = `${a} × ${b} =`;
}else if(mode==="div"){
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10);
a = t*q; ans = q;
txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`;
}else{ /* pct */
const sets = L===1 ? [10,50,100] : L===2 ? [10,20,25,50,75,100] : [5,10,20,25,30,40,50,60,70,75,80,90];
const p = sets[rnd(sets.length)];
const step = {5:20,10:10,20:5,25:4,30:10,40:5,50:2,60:5,70:10,75:4,80:5,90:10,100:1}[p];
const base = step*(1+rnd(12));
ans = p*base/100;
txt = `${p}% ${T("mOf")} ${base} =`;
}
return { txt, ans };
}
function newQuestion(){
let q = gen();
if(S.q && q.txt===S.q.txt) q = gen(); /* avoid the same sum twice in a row */
S.q = q; S.tries = 0; S.input = ""; S.busy = false;
qEl.textContent = q.txt;
aEl.textContent = "";
aEl.classList.remove("ok");
fbEl.textContent = ""; fbEl.classList.remove("ans");
slotsEl.innerHTML = "";
if(S.amode==="blocks"){
String(q.ans).split("").forEach(d=>slotsEl.appendChild(makeDigitSlot(d)));
}
updateScore();
}
function updateScore(){
scoreEl.textContent = `${S.good} ${T("mGoed")} · ${Math.min(S.done+1,ROUND)}/${ROUND}`;
}
function endRound(){
doneEl.querySelector(".dt").textContent = T("mDoneT");
doneEl.querySelector(".ds").textContent = `${S.good}/${ROUND} ${T("mGoed")}`;
doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪";
doneEl.classList.add("show");
if(S.good>=8) play(sndWin);
}
function next(){
S.done++;
if(S.done >= ROUND){ endRound(); }
else newQuestion();
}
function check(){
if(S.busy || S.input==="") return;
if(+S.input === S.q.ans){
S.busy = true;
S.good++;
aEl.classList.add("ok");
fbEl.textContent = T("mGood");
play(sndGood);
confetti(el(".mt-card"));
setTimeout(next, 900);
}else{
S.tries++;
aEl.classList.add("shake");
setTimeout(()=>aEl.classList.remove("shake"), 400);
play(sndBad);
S.input = ""; aEl.textContent = "";
if(S.tries >= 3){
S.busy = true;
fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`;
fbEl.classList.add("ans");
setTimeout(next, 1800);
}
}
}
["7","8","9","⌫","4","5","6","0","1","2","3","✓"].forEach(k=>{
const b = document.createElement("button");
b.textContent = k;
if(k==="✓") b.className = "ok";
b.addEventListener("click", ()=>{
if(doneEl.classList.contains("show")) return;
if(k==="⌫"){ if(!S.busy){ S.input = S.input.slice(0,-1); aEl.textContent = S.input; } }
else if(k==="✓") check();
else if(!S.busy && S.input.length<4){ S.input += k; aEl.textContent = S.input; }
});
padEl.appendChild(b);
});
el(".mt-again").addEventListener("click", ()=>{
S.done = 0; S.good = 0;
doneEl.classList.remove("show");
newQuestion();
});
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.addEventListener("click", ()=>{
S.level = +b.dataset.l;
S.done = 0; S.good = 0;
doneEl.classList.remove("show");
labels(); newQuestion();
});
});
sndBtn.addEventListener("click", ()=>{
S.snd = !S.snd;
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
});
modeBtn.addEventListener("click", ()=>{
S.amode = S.amode==="blocks" ? "type" : "blocks";
labels(); newQuestion();
});
function labels(){
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.textContent = T("lvl"+b.dataset.l);
b.classList.toggle("active", +b.dataset.l===S.level);
});
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
sndBtn.title = T("sndWidget");
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
modeBtn.title = T("amode");
mtEl.classList.toggle("blocks", S.amode==="blocks");
el(".mt-again").textContent = T("mAgain");
updateScore();
}
document.addEventListener("langchange", ()=>{ labels(); newQuestion(); });
labels();
newQuestion();
return {
getState: ()=>({ level:S.level, snd:S.snd, amode:S.amode }),
setState: st=>{ if(st){ S.level=st.level||1; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; labels(); newQuestion(); } }
};
}
/* =========================================================
Media widget — photos and videos from external sites
==========================================================*/
function mountMedia(root, initState){
let cur = null;
root.innerHTML = `
<div class="media">
<div class="media-bar">
<input type="text" class="m-url" spellcheck="false">
<button class="tbtn m-go"></button>
<button class="tbtn ghost m-ext">↗</button>
</div>
<div class="media-stage"><div class="media-msg"></div></div>
</div>`;
const urlInp = root.querySelector(".m-url"),
goBtn = root.querySelector(".m-go"),
extBtn = root.querySelector(".m-ext"),
stage = root.querySelector(".media-stage");
function labels(){
urlInp.placeholder = T("mediaPh");
goBtn.textContent = T("mediaShow");
extBtn.title = T("mediaOpen");
const msg = stage.querySelector(".media-msg");
if(msg) msg.textContent = T(msg.dataset.err ? "mediaErr" : "mediaEmpty");
}
function showMsg(err){
stage.innerHTML = "";
const m = document.createElement("div");
m.className = "media-msg";
if(err) m.dataset.err = "1";
m.textContent = T(err ? "mediaErr" : "mediaEmpty");
stage.appendChild(m);
}
function show(raw){
let url = raw.trim();
if(!url){ showMsg(false); return; }
if(!/^https?:\/\//i.test(url)) url = "https://"+url;
let node = null;
const yt = url.match(/(?:youtube\.com\/(?:watch\?[^#]*v=|shorts\/|embed\/|live\/)|youtu\.be\/)([\w-]{6,})/);
const vi = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
if(yt){
/* show the thumbnail first (always works); play attempts the embed */
const id = yt[1];
stage.innerHTML = "";
const wrap = document.createElement("div");
wrap.className = "media-yt";
const im = document.createElement("img");
im.src = "https://i.ytimg.com/vi/"+id+"/hqdefault.jpg"; im.alt = "";
const pb = document.createElement("button");
pb.className = "media-play"; pb.textContent = "▶";
pb.addEventListener("click", ()=>{
const f = document.createElement("iframe");
f.src = "https://www.youtube.com/embed/"+id+"?rel=0&autoplay=1";
f.referrerPolicy = "strict-origin-when-cross-origin";
f.allow = "autoplay; encrypted-media; picture-in-picture";
f.allowFullscreen = true;
stage.innerHTML = "";
stage.appendChild(f);
});
wrap.appendChild(im); wrap.appendChild(pb);
stage.appendChild(wrap);
cur = url; urlInp.value = url;
return;
}
if(vi){
node = document.createElement("iframe");
node.src = "https://player.vimeo.com/video/"+vi[1];
node.allow = "autoplay; fullscreen; picture-in-picture";
node.allowFullscreen = true;
}else if(/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(url)){
node = document.createElement("img");
node.src = url; node.alt = "";
node.addEventListener("error", ()=>showMsg(true));
}else if(/\.(mp4|webm|ogg|mov)([?#]|$)/i.test(url)){
node = document.createElement("video");
node.src = url; node.controls = true;
node.addEventListener("error", ()=>showMsg(true));
}
if(node){
stage.innerHTML = "";
stage.appendChild(node);
cur = url;
urlInp.value = url;
}else{
showMsg(true);
}
}
goBtn.addEventListener("click", ()=>show(urlInp.value));
extBtn.addEventListener("click", ()=>{
let u = (cur || urlInp.value).trim();
if(!u) return;
if(!/^https?:\/\//i.test(u)) u = "https://"+u;
window.open(u, "_blank");
});
urlInp.addEventListener("keydown", e=>{
e.stopPropagation();
if(e.key==="Enter") show(urlInp.value);
});
document.addEventListener("langchange", labels);
if(initState && initState.url){ urlInp.value = initState.url; show(initState.url); }
else showMsg(false);
labels();
return { getState: ()=>({url: cur || ""}) };
}
/* =========================================================
Notes widget
==========================================================*/
function mountNotes(root, initState){
const ta = document.createElement("textarea");
ta.className = "notes";
ta.placeholder = T("notesPh");
if(initState && typeof initState.text==="string") ta.value = initState.text;
document.addEventListener("langchange", ()=>{ ta.placeholder = T("notesPh"); });
root.appendChild(ta);
return { getState: ()=>({text: ta.value}) };
}
/* =========================================================
Timer widget — digits, visual "kijkklok" disc, or both
==========================================================*/
function mountTimer(root, initState){
const DIAL = 3600; // the disc is a 60-minute dial, like a real kijkklok
let total = 300, left = 300, iv = null,
mode = "both", // dig | pie | both
snd = true;
if(initState){
total = initState.total || 300; left = total;
mode = initState.mode || "both";
snd = initState.snd !== false;
}
root.innerHTML = `
<div class="timer m-both">
<div class="topts">
<button class="bsnd ticon"></button>
<div class="tseg">
<button data-m="dig">🔢</button>
<button data-m="pie">🔴</button>
<button data-m="both">🔢🔴</button>
</div>
</div>
<div class="tstage">
<svg class="tpie" viewBox="0 0 150 150">
<circle cx="75" cy="75" r="70" fill="#eef3f9" stroke="#d5dfeb" stroke-width="2"/>
<g class="ticks"></g>
<path class="parc" fill="var(--red)"/>
<circle class="knob" r="7" fill="#fff" stroke="var(--red)" stroke-width="3"/>
<circle cx="75" cy="75" r="6" fill="#fff" stroke="#b9c7d9" stroke-width="2"/>
</svg>
<div class="tdisp">05:00</div>
</div>
<div class="trow presets"></div>
<div class="trow main">
<button class="bstart"></button>
<button class="breset"></button>
</div>
<div class="thint"></div>
</div>`;
const box = root.querySelector(".timer"),
svg = root.querySelector(".tpie"),
disp = root.querySelector(".tdisp"),
arc = root.querySelector(".parc"),
knob = root.querySelector(".knob"),
bstart = root.querySelector(".bstart"),
breset = root.querySelector(".breset"),
bsnd = root.querySelector(".bsnd"),
presets = root.querySelector(".presets"),
hint = root.querySelector(".thint");
const fmt = s => String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0");
/* minute tick marks (every 5 min) */
const ticks = root.querySelector(".ticks");
for(let i=0;i<12;i++){
const a = i*30*Math.PI/180;
const l = document.createElementNS("http://www.w3.org/2000/svg","line");
l.setAttribute("x1", 75+62*Math.sin(a)); l.setAttribute("y1", 75-62*Math.cos(a));
l.setAttribute("x2", 75+69*Math.sin(a)); l.setAttribute("y2", 75-69*Math.cos(a));
l.setAttribute("stroke", "#b9c7d9"); l.setAttribute("stroke-width", i%3===0?2.5:1.5);
ticks.appendChild(l);
}
function drawPie(){
const a = Math.min(360, left/DIAL*360);
const rad = a*Math.PI/180;
const kx = 75 + 63*Math.sin(rad), ky = 75 - 63*Math.cos(rad);
knob.setAttribute("cx", kx.toFixed(2)); knob.setAttribute("cy", ky.toFixed(2));
if(a<=0){ arc.setAttribute("d",""); return; }
if(a>=359.99){ arc.setAttribute("d","M75,5 A70,70 0 1 1 74.99,5 Z"); return; }
const x = 75 + 70*Math.sin(rad), y = 75 - 70*Math.cos(rad);
arc.setAttribute("d",`M75,75 L75,5 A70,70 0 ${a>180?1:0} 1 ${x.toFixed(2)},${y.toFixed(2)} Z`);
}
function draw(){
disp.textContent = fmt(left);
disp.classList.toggle("warn", left<=10 && left>0 && iv!==null);
drawPie();
}
function labels(){
bstart.textContent = (iv ? "⏸ "+T("pause") : "▶ "+T("start"));
breset.textContent = "↺ "+T("reset");
presets.querySelectorAll("button").forEach(b=>b.textContent = b.dataset.m+" "+T("min"));
root.querySelectorAll(".tseg button").forEach(b=>{
const key = b.dataset.m==="dig" ? "tmDigits" : b.dataset.m==="pie" ? "tmVisual" : "tmBoth";
b.title = T(key);
b.classList.toggle("on", b.dataset.m===mode);
});
bsnd.textContent = snd ? "🔔" : "🔕";
bsnd.title = T("tmSound");
bsnd.classList.toggle("on", snd);
box.className = "timer m-"+mode + (box.classList.contains("flash")?" flash":"");
hint.textContent = mode==="dig" ? "" : T("tmHint");
}
[1,5,10,15,30].forEach(m=>{
const b = document.createElement("button");
b.dataset.m = m;
b.addEventListener("click", ()=>{ stop(); total=left=m*60; draw(); labels(); });
presets.appendChild(b);
});
root.querySelectorAll(".tseg button").forEach(b=>{
b.addEventListener("click", ()=>{ mode = b.dataset.m; labels(); });
});
bsnd.addEventListener("click", ()=>{ snd=!snd; labels(); });
/* drag on the disc to set the time */
function angleFromEvent(ev){
const r = svg.getBoundingClientRect();
const px = (ev.clientX - r.left)*150/r.width - 75;
const py = (ev.clientY - r.top)*150/r.height - 75;
return (Math.atan2(px, -py)*180/Math.PI + 360) % 360;
}
svg.addEventListener("pointerdown", e=>{
e.preventDefault();
svg.setPointerCapture(e.pointerId);
const setFrom = ev=>{
const mins = Math.min(60, Math.max(1, Math.round(angleFromEvent(ev)/6)));
total = left = mins*60;
draw();
};
setFrom(e);
const move = ev=>setFrom(ev);
const up = ()=>{ svg.removeEventListener("pointermove",move); svg.removeEventListener("pointerup",up); };
svg.addEventListener("pointermove",move);
svg.addEventListener("pointerup",up);
});
function stop(){ if(iv){ clearInterval(iv); iv=null; } }
bstart.addEventListener("click", ()=>{
if(iv){ stop(); }
else if(left>0){
iv = setInterval(()=>{
left--;
if(left<=0){
left=0; stop();
if(snd) sndWin();
box.classList.add("flash");
setTimeout(()=>box.classList.remove("flash"), 2200);
labels();
}
draw();
},1000);
}
labels(); draw();
});
breset.addEventListener("click", ()=>{ stop(); left=total; draw(); labels(); });
document.addEventListener("langchange", labels);
labels(); draw();
return { getState: ()=>({total, mode, snd}) };
}
/* =========================================================
Flash words widget
==========================================================*/
function mountFlits(root, initState){
const S = { card:0, dur:2, idx:0, order:[] };
if(initState){ S.card = initState.card ?? 0; S.dur = initState.dur || 2; }
let flashTimer = null;
root.innerHTML = `
<div class="mt">
<div class="mt-top">
<select class="fl-sel fl-src"></select>
<select class="fl-sel fl-dur"></select>
<div class="mt-score fl-cnt"></div>
</div>
<div class="mt-card">
<div class="fl-word dim">• • •</div>
<div class="fl-pic"></div>
</div>
<div class="trow" style="display:flex;gap:10px;justify-content:center;flex:none;">
<button class="tbtn fl-flash"></button>
<button class="tbtn ghost fl-show"></button>
<button class="tbtn ghost fl-next"></button>
</div>
</div>`;
const el = s => root.querySelector(s);
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 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;
durSel.innerHTML = "";
[0.5,1,2,3,5].forEach(d=>{
const o = document.createElement("option");
o.value = d; o.textContent = d+" "+T("flSec");
durSel.appendChild(o);
});
durSel.value = S.dur;
}
function newRound(){
S.order = shuffle(list().map((_,i)=>i));
S.idx = 0;
}
function cur(){ return list()[S.order[S.idx]]; }
function hide(){
wordEl.textContent = "• • •";
wordEl.classList.add("dim");
picEl.textContent = "";
}
function flash(){
clearTimeout(flashTimer);
wordEl.textContent = cur()[0];
wordEl.classList.remove("dim");
picEl.textContent = "";
cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`;
flashTimer = setTimeout(hide, S.dur*1000);
}
function show(){
clearTimeout(flashTimer);
wordEl.textContent = cur()[0];
wordEl.classList.remove("dim");
setPic(picEl, cur()[1]);
}
el(".fl-flash").addEventListener("click", flash);
el(".fl-show").addEventListener("click", show);
el(".fl-next").addEventListener("click", ()=>{
S.idx++;
if(S.idx >= S.order.length) newRound();
flash();
});
srcSel.addEventListener("change", ()=>{
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
newRound(); hide();
cntEl.textContent = "";
});
durSel.addEventListener("change", ()=>{ S.dur = +durSel.value; });
function labels(){
el(".fl-flash").textContent = T("flFlash");
el(".fl-show").textContent = T("flShow");
el(".fl-next").textContent = T("flNext");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); });
labels(); newRound();
return { getState: ()=>({card:S.card, dur:S.dur}) };
}
/* =========================================================
Chop & blend widget — words in sounds, with read-aloud
==========================================================*/
function mountHak(root, initState){
const S = { card:0, idx:0, order:[], chopped:false };
if(initState) S.card = initState.card ?? 0;
const canSpeak = "speechSynthesis" in window;
root.innerHTML = `
<div class="mt">
<div class="mt-top">
<select class="fl-sel hk-src"></select>
<div class="mt-score hk-cnt"></div>
</div>
<div class="mt-card">
<div class="fl-pic hk-pic"></div>
<div class="hk-word"></div>
</div>
<div class="trow" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;flex:none;">
<button class="tbtn hk-say" ${canSpeak?"":'style="display:none"'}></button>
<button class="tbtn ghost hk-chop"></button>
<button class="tbtn ghost hk-glue"></button>
<button class="tbtn ghost hk-next">▸</button>
</div>
</div>`;
const el = s => root.querySelector(s);
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 cur(){ return list()[S.order[S.idx]]; }
function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; }
function display(){
const [w, pic] = cur();
wordEl.innerHTML = "";
if(S.chopped){
w.split("").forEach((l,i)=>{
if(i>0){
const sep = document.createElement("span");
sep.className = "hk-sep"; sep.textContent = "";
wordEl.appendChild(sep);
}
const s = document.createElement("span");
s.className = "hk-l " + (VOWELS.includes(l) ? "v" : "c");
s.textContent = l;
s.style.animationDelay = (i*0.08)+"s";
wordEl.appendChild(s);
});
}else{
const s = document.createElement("span");
s.className = "hk-whole";
s.textContent = w;
wordEl.appendChild(s);
}
setPic(picEl, pic);
cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`;
}
function speak(text, rate){
if(!canSpeak) return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = LANG==="nl" ? "nl-NL" : "en-GB";
u.rate = rate;
speechSynthesis.speak(u);
}
el(".hk-say").addEventListener("click", ()=>{
if(S.chopped) speak(cur()[0].split("").join(", "), 0.7);
else speak(cur()[0], 0.85);
});
el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); });
el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); });
el(".hk-next").addEventListener("click", ()=>{
S.idx++; if(S.idx >= S.order.length) newRound();
S.chopped = false; display();
});
const play = f=>f();
srcSel.addEventListener("change", ()=>{
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
newRound(); S.chopped = false; display();
});
function labels(){
el(".hk-say").textContent = T("hakSay");
el(".hk-chop").textContent = T("hakChop");
el(".hk-glue").textContent = T("hakGlue");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); S.chopped=false; display(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); });
labels(); newRound(); display();
return { getState: ()=>({card:S.card}) };
}
/* =========================================================
Balloon word widget — guess before the balloons pop
==========================================================*/
function mountBall(root, initState){
const S = { card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 };
if(initState){ S.card = initState.card ?? 0; S.snd = initState.snd !== false; }
const play = f=>{ if(S.snd) f(); };
root.innerHTML = `
<div class="mt">
<div class="mt-top">
<button class="lg-snd"></button>
<select class="fl-sel bl-src"></select>
<div class="mt-score bl-score"></div>
</div>
<div class="mt-card">
<div class="bl-balloons"></div>
<div class="bl-word"></div>
<div class="bl-msg"></div>
<button class="lg-next bl-next"></button>
</div>
<div class="lg-tray bl-kbd lg-kbd" style="display:flex;"></div>
</div>`;
const el = s => root.querySelector(s);
const srcSel = el(".bl-src"), balloonsEl = el(".bl-balloons"),
wordEl = el(".bl-word"), msgEl = el(".bl-msg"),
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;
}
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button");
b.textContent = l; b.dataset.l = l;
b.className = VOWELS.includes(l) ? "v" : "c";
b.addEventListener("click", ()=>guess(l, b));
kbdEl.appendChild(b);
});
function word(){ return list()[S.order[S.idx]][0]; }
function drawWord(reveal){
wordEl.textContent = word().split("").map(l=>(reveal || S.guessed.has(l)) ? l : "_").join(" ");
}
function drawBalloons(){
balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(6-S.lives);
}
function newWord(){
if(!S.order.length || S.idx >= S.order.length){
S.order = shuffle(list().map((_,i)=>i));
S.idx = 0;
}
S.lives = 6; S.guessed = new Set(); S.over = false;
msgEl.textContent = ""; msgEl.className = "bl-msg";
nextBtn.classList.remove("show");
kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; });
drawWord(false); drawBalloons();
scoreEl.textContent = S.stars ? S.stars+" ★" : "";
}
function guess(l, btn){
if(S.over || btn.disabled) return;
btn.disabled = true; btn.style.opacity = .3;
if(word().includes(l)){
S.guessed.add(l);
play(sndGood);
drawWord(false);
if(word().split("").every(x=>S.guessed.has(x))){
S.over = true; S.stars++;
msgEl.textContent = T("ballWin");
msgEl.classList.add("win");
play(sndWin); confetti(el(".mt-card"));
nextBtn.classList.add("show");
scoreEl.textContent = S.stars+" ★";
}
}else{
S.lives--;
play(sndBad);
drawBalloons();
if(S.lives <= 0){
S.over = true;
drawWord(true);
msgEl.textContent = `${T("ballLose")} ${word()}`;
msgEl.classList.add("lose");
nextBtn.classList.add("show");
}
}
}
nextBtn.addEventListener("click", ()=>{ S.idx++; newWord(); });
srcSel.addEventListener("change", ()=>{
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
S.order = []; newWord();
});
sndBtn.addEventListener("click", ()=>{
S.snd = !S.snd;
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
});
function labels(){
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
sndBtn.title = T("sndWidget");
nextBtn.textContent = T("next");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; S.order=[]; labels(); newWord(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); });
labels(); newWord();
return { getState: ()=>({card:S.card, snd:S.snd}) };
}
/* =========================================================
Dice widget
==========================================================*/
function mountDice(root, initState){
let count = (initState && initState.count) || 2, rolling = false;
const PIPS = {1:[4],2:[0,8],3:[0,4,8],4:[0,2,6,8],5:[0,2,4,6,8],6:[0,2,3,5,6,8]};
root.innerHTML = `
<div class="dc">
<div class="mt-top" style="width:100%;">
<div class="tseg dc-seg">
<button data-n="1">1</button><button data-n="2">2</button><button data-n="3">3</button>
</div>
<div class="mt-score dc-sumlbl"></div>
</div>
<div class="dc-row"></div>
<div class="dc-sum"></div>
<button class="tbtn dc-roll"></button>
</div>`;
const row = root.querySelector(".dc-row"),
sumEl = root.querySelector(".dc-sum"),
rollBtn = root.querySelector(".dc-roll");
function face(die, v){
die.querySelectorAll("i").forEach((p,i)=>p.classList.toggle("on", PIPS[v].includes(i)));
die.dataset.v = v;
}
function build(){
row.innerHTML = "";
for(let i=0;i<count;i++){
const d = document.createElement("div");
d.className = "die";
for(let j=0;j<9;j++) d.appendChild(document.createElement("i"));
face(d, 1+Math.floor(Math.random()*6));
row.appendChild(d);
}
sumEl.textContent = "";
}
function roll(){
if(rolling) return;
rolling = true;
sumEl.textContent = "";
const dice = [...row.querySelectorAll(".die")];
dice.forEach(d=>d.classList.add("rolling"));
let n = 0;
const iv = setInterval(()=>{
dice.forEach(d=>face(d, 1+Math.floor(Math.random()*6)));
tone(300+Math.random()*250, 0.04, 0, "square", 0.05);
if(++n >= 9){
clearInterval(iv);
dice.forEach(d=>d.classList.remove("rolling"));
const total = dice.reduce((s,d)=>s + +d.dataset.v, 0);
if(count>1) sumEl.textContent = `${T("diceSum")}: ${total}`;
rolling = false;
}
}, 90);
}
row.addEventListener("click", roll);
rollBtn.addEventListener("click", roll);
root.querySelectorAll(".dc-seg button").forEach(b=>{
b.addEventListener("click", ()=>{
count = +b.dataset.n;
root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", x===b));
build();
});
});
function labels(){
rollBtn.textContent = T("diceRoll");
root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", +x.dataset.n===count));
}
document.addEventListener("langchange", labels);
labels(); build();
return { getState: ()=>({count}) };
}
/* =========================================================
Traffic light widget
==========================================================*/
function mountLight(root, initState){
let on = (initState && initState.on) || "green";
root.innerHTML = `
<div class="tl">
<div class="tl-house">
<button class="tl-lamp red" data-c="red"></button>
<button class="tl-lamp orange" data-c="orange"></button>
<button class="tl-lamp green" data-c="green"></button>
</div>
</div>`;
function apply(){
root.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on));
}
root.querySelectorAll(".tl-lamp").forEach(l=>{
l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); });
});
apply();
return { getState: ()=>({on}) };
}
/* =========================================================
Name picker widget
==========================================================*/
function mountNames(root, initState){
let used = new Set(), spinning = false;
root.innerHTML = `
<div class="np">
<div class="np-result">🎯</div>
<textarea class="np-list" spellcheck="false"></textarea>
<div class="np-row">
<button class="tbtn np-pick"></button>
<label><input type="checkbox" class="np-nr"><span class="np-nrl"></span></label>
</div>
</div>`;
const ta = root.querySelector(".np-list"),
result = root.querySelector(".np-result"),
pickBtn = root.querySelector(".np-pick"),
nr = root.querySelector(".np-nr");
if(initState){
ta.value = initState.names || "";
nr.checked = !!initState.nr;
}
ta.addEventListener("input", ()=>used.clear());
ta.addEventListener("keydown", e=>e.stopPropagation());
function names(){ return ta.value.split("\n").map(s=>s.trim()).filter(Boolean); }
pickBtn.addEventListener("click", ()=>{
if(spinning) return;
const all = names();
if(!all.length){ ta.focus(); return; }
let pool = nr.checked ? all.filter(n=>!used.has(n)) : all;
if(!pool.length){
result.textContent = T("namesDone");
used.clear();
return;
}
spinning = true;
result.classList.remove("winner");
result.classList.add("spin");
const FUN = ["🌟","🎉","🦄","🚀","🐸","🎈","🍀","⚡"];
let n = 0;
const iv = setInterval(()=>{
result.textContent = all[Math.floor(Math.random()*all.length)];
tone(400+Math.random()*300, 0.03, 0, "square", 0.04);
if(++n >= 16){
clearInterval(iv);
const winner = pool[Math.floor(Math.random()*pool.length)];
const em = FUN[Math.floor(Math.random()*8)];
result.textContent = `${em} ${winner} ${em}`;
result.classList.remove("spin");
result.classList.add("winner");
confetti(result.parentElement);
if(nr.checked) used.add(winner);
sndWin();
spinning = false;
}
}, 80);
});
function labels(){
ta.placeholder = T("namesPh");
pickBtn.textContent = T("namesPick");
root.querySelector(".np-nrl").textContent = T("namesNoRep");
}
document.addEventListener("langchange", labels);
labels();
return { getState: ()=>({names:ta.value, nr:nr.checked}) };
}
/* =========================================================
Mindmap widget — spider, tree or word web
==========================================================*/
function mountMind(root, initState){
const S = { layout:"spider", nodes:[{id:1, x:0.5, y:0.45, t:"", parent:null}], nextId:2 };
if(initState){
S.layout = initState.layout || "spider";
if(Array.isArray(initState.nodes) && initState.nodes.length) S.nodes = initState.nodes;
S.nextId = initState.nextId || (Math.max(...S.nodes.map(n=>n.id))+1);
}
const PALETTE = ["#3b7dd8","#3fae6a","#e0554d","#9b59b6","#f28c38"];
root.innerHTML = `
<div class="mm spider">
<div class="tseg mm-seg">
<button data-m="spider"></button><button data-m="tree"></button><button data-m="web"></button>
</div>
<div class="mm-hint"></div>
<div class="mm-stage"><svg></svg></div>
</div>`;
const mmEl = root.querySelector(".mm");
/* toolbar row wrapper */
const topRow = document.createElement("div");
topRow.className = "mm-top";
mmEl.insertBefore(topRow, mmEl.firstChild);
const mmGrip = document.createElement("span");
mmGrip.className = "mm-grip"; mmGrip.textContent = "⠿";
topRow.appendChild(mmGrip);
topRow.appendChild(root.querySelector(".mm-seg"));
topRow.appendChild(root.querySelector(".mm-hint"));
mmGrip.addEventListener("pointerdown", e=>{
e.preventDefault(); e.stopPropagation();
const mr = mmEl.getBoundingClientRect();
const sc = mr.width / mmEl.offsetWidth; /* effective zoom on this widget */
const ox = e.clientX/sc - topRow.offsetLeft, oy = e.clientY/sc - topRow.offsetTop;
const mv = ev=>{
topRow.style.left = Math.max(0, Math.min(mmEl.offsetWidth - topRow.offsetWidth, ev.clientX/sc - ox))+"px";
topRow.style.top = Math.max(0, Math.min(mmEl.offsetHeight - topRow.offsetHeight, ev.clientY/sc - oy))+"px";
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
const stage = root.querySelector(".mm-stage"),
svg = stage.querySelector("svg");
function nodeById(id){ return S.nodes.find(n=>n.id===id); }
function depth(n){ let d=0, c=n; while(c && c.parent!=null){ c = nodeById(c.parent); d++; } return d; }
function drawLines(){
const w = stage.clientWidth, h = stage.clientHeight;
let paths = "";
S.nodes.forEach(n=>{
if(n.parent==null) return;
const p = nodeById(n.parent);
if(!p) return;
const x1 = p.x*w, y1 = p.y*h, x2 = n.x*w, y2 = n.y*h;
const col = n.lc || PALETTE[(depth(n)-1) % PALETTE.length];
const dash = n.ls==="dash" ? ' stroke-dasharray="11 8"' : n.ls==="dot" ? ' stroke-dasharray="2.5 7"' : "";
if(S.layout==="spider"){
const mx = (x1+x2)/2 + (y2-y1)*0.18, my = (y1+y2)/2 - (x2-x1)*0.18;
paths += `<path d="M${x1},${y1} Q${mx},${my} ${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
}else if(S.layout==="tree"){
const my = (y1+y2)/2;
paths += `<path d="M${x1},${y1} L${x1},${my} L${x2},${my} L${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
}else{
paths += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="3"${dash}/>`;
}
});
svg.innerHTML = paths;
}
function applyNodeStyle(n, d){
if(n.parent!=null) d.style.borderColor = n.c || PALETTE[(depth(n)-1) % PALETTE.length];
else if(n.c) d.style.borderColor = n.c;
d.style.fontSize = (n.fs || (n.parent==null ? 17 : 15))+"px";
}
function buildNode(n){
const d = document.createElement("div");
d.className = "mm-node" + (n.parent==null ? " root" : "");
applyNodeStyle(n, d);
d.dataset.id = n.id;
const span = document.createElement("span");
span.contentEditable = "true";
span.spellcheck = false;
span.textContent = n.t || (n.parent==null ? T("mmRoot") : "");
span.addEventListener("input", ()=>{ n.t = span.textContent; });
span.addEventListener("keydown", e=>e.stopPropagation());
span.addEventListener("pointerdown", e=>e.stopPropagation());
d.appendChild(span);
const add = document.createElement("button");
add.className = "mm-add"; add.textContent = "";
add.addEventListener("pointerdown", e=>e.stopPropagation());
add.addEventListener("click", ()=>{
const a = Math.random()*Math.PI*2;
S.nodes.push({
id: S.nextId++, parent: n.id,
x: Math.min(.95, Math.max(.05, n.x + Math.cos(a)*0.17)),
y: Math.min(.92, Math.max(.06, n.y + Math.sin(a)*0.2)),
t: ""
});
render();
});
d.appendChild(add);
const cog = document.createElement("button");
cog.className = "mm-cog"; cog.textContent = "🎨";
cog.addEventListener("pointerdown", e=>e.stopPropagation());
cog.addEventListener("click", ()=>openNodePop(n, d));
d.appendChild(cog);
if(n.parent!=null){
const del = document.createElement("button");
del.className = "mm-del"; del.textContent = "✕";
del.addEventListener("pointerdown", e=>e.stopPropagation());
del.addEventListener("click", ()=>{
const doomed = new Set([n.id]);
let grew = true;
while(grew){
grew = false;
S.nodes.forEach(m=>{
if(m.parent!=null && doomed.has(m.parent) && !doomed.has(m.id)){ doomed.add(m.id); grew = true; }
});
}
S.nodes = S.nodes.filter(m=>!doomed.has(m.id));
render();
});
d.appendChild(del);
}
/* drag */
d.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
const r = stage.getBoundingClientRect();
const mv = ev=>{
n.x = Math.min(.98, Math.max(.02, (ev.clientX-r.left)/r.width));
n.y = Math.min(.96, Math.max(.04, (ev.clientY-r.top)/r.height));
d.style.left = (n.x*100)+"%";
d.style.top = (n.y*100)+"%";
drawLines();
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv);
document.addEventListener("pointerup",up);
});
d.style.left = (n.x*100)+"%";
d.style.top = (n.y*100)+"%";
return d;
}
/* per-node settings popover: colour, font size, line colour & style */
function closeNodePop(){ stage.querySelectorAll(".mm-pop").forEach(x=>x.remove()); }
function openNodePop(n, nodeEl){
closeNodePop();
const pop = document.createElement("div");
pop.className = "mm-pop";
const w = stage.clientWidth, h = stage.clientHeight;
pop.style.left = Math.min(n.x*w + 30, w-190)+"px";
pop.style.top = Math.min(n.y*h + 24, h-150)+"px";
pop.addEventListener("pointerdown", e=>e.stopPropagation());
const restyle = ()=>{ applyNodeStyle(n, nodeEl); drawLines(); refreshPop(); };
const row = (lbl, title)=>{
const r = document.createElement("div");
r.className = "mp-row"; r.title = title;
const l = document.createElement("span");
l.className = "mp-lbl"; l.textContent = lbl;
r.appendChild(l);
pop.appendChild(r);
return r;
};
/* node colour */
const r1 = row("⬤", T("nodeColor"));
PALETTE.concat(["#26344a"]).forEach(c=>{
const b = document.createElement("button");
b.className = "mp-sw"; b.dataset.v = c; b.style.background = c;
b.addEventListener("click", ()=>{ n.c = (n.c===c ? null : c); restyle(); });
r1.appendChild(b);
});
const xBtn = document.createElement("button");
xBtn.className = "mp-x"; xBtn.textContent = "✕";
xBtn.addEventListener("click", closeNodePop);
r1.appendChild(xBtn);
/* font size */
const r2 = row("A", T("nodeFont"));
[["A",13],["A",16],["A",20]].forEach(([txt,fs],i)=>{
const b = document.createElement("button");
b.className = "mp-fs"; b.dataset.v = fs;
b.textContent = txt; b.style.fontSize = (11+i*3)+"px";
b.addEventListener("click", ()=>{ n.fs = fs; restyle(); });
r2.appendChild(b);
});
if(n.parent!=null){
/* line colour */
const r3 = row("", T("lineColor"));
PALETTE.concat(["#26344a"]).forEach(c=>{
const b = document.createElement("button");
b.className = "mp-sw"; b.dataset.v = "l"+c; b.style.background = c;
b.addEventListener("click", ()=>{ n.lc = (n.lc===c ? null : c); restyle(); });
r3.appendChild(b);
});
/* line style */
const r4 = row("┄", T("lineStyle"));
[["solid",""],["dash","dash"],["dot","dot"]].forEach(([id,val])=>{
const b = document.createElement("button");
b.className = "mp-ls"; b.dataset.ls = id; b.dataset.v = "s"+id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>{ n.ls = val || null; restyle(); });
r4.appendChild(b);
});
}
function refreshPop(){
pop.querySelectorAll(".mp-sw").forEach(b=>{
const v = b.dataset.v;
b.classList.toggle("on", v.startsWith("l") ? n.lc===v.slice(1) : n.c===v);
});
pop.querySelectorAll(".mp-fs").forEach(b=>
b.classList.toggle("on", +b.dataset.v === (n.fs || (n.parent==null?17:15))));
pop.querySelectorAll(".mp-ls").forEach(b=>
b.classList.toggle("on", (n.ls||"") === (b.dataset.ls==="solid" ? "" : b.dataset.ls)));
}
refreshPop();
stage.appendChild(pop);
}
document.addEventListener("pointerdown", e=>{
if(!stage.querySelector(".mm-pop")) return;
if(!e.target.closest(".mm-pop") && !e.target.closest(".mm-cog")) closeNodePop();
});
function render(){
mmEl.className = "mm "+S.layout;
root.querySelectorAll(".mm-seg button").forEach(b=>{
b.textContent = T(b.dataset.m==="spider" ? "mmSpider" : b.dataset.m==="tree" ? "mmTree" : "mmWeb");
b.classList.toggle("on", b.dataset.m===S.layout);
});
root.querySelector(".mm-hint").textContent = T("mmHint");
closeNodePop();
stage.querySelectorAll(".mm-node").forEach(x=>x.remove());
S.nodes.forEach(n=>stage.appendChild(buildNode(n)));
drawLines();
}
root.querySelectorAll(".mm-seg button").forEach(b=>{
b.addEventListener("click", ()=>{ S.layout = b.dataset.m; render(); });
});
new ResizeObserver(drawLines).observe(stage);
document.addEventListener("langchange", render);
render();
return {
getState: ()=>({ layout:S.layout, nodes:S.nodes, nextId:S.nextId }),
setState: st=>{ if(st && st.nodes){ S.layout=st.layout||"spider"; S.nodes=st.nodes; S.nextId=st.nextId||S.nextId; render(); } }
};
}
/* =========================================================
init
==========================================================*/
/* display size: scale the whole app up for pupils who need it (device setting) */
let ZOOM = 100;
const btnZoomIn = document.getElementById("btnZoomIn");
const btnZoomOut = document.getElementById("btnZoomOut");
const zoomPct = document.getElementById("zoomPct");
function setZoom(z){
ZOOM = Math.max(100, Math.min(200, Math.round(z/10)*10));
document.body.style.zoom = ZOOM/100;
zoomPct.textContent = ZOOM+"%";
zoomPct.classList.toggle("on", ZOOM!==100);
btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI");
btnZoomIn.disabled = ZOOM>=200;
btnZoomOut.disabled = ZOOM<=100;
try{ localStorage.setItem("teach.zoom", ZOOM); }catch(e){}
}
btnZoomIn.addEventListener("click", ()=>setZoom(ZOOM+10));
btnZoomOut.addEventListener("click", ()=>setZoom(ZOOM-10));
zoomPct.addEventListener("click", ()=>setZoom(100)); /* tap the % to reset */
document.addEventListener("langchange", ()=>{ btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI"); });
try{ const z = +localStorage.getItem("teach.zoom"); if(z>100) setZoom(z); else setZoom(100); }catch(e){ setZoom(100); }
/* high-contrast accessibility mode (device setting, persists) */
const btnHC = document.getElementById("btnHC");
function setHC(on){
document.body.classList.toggle("hc", on);
btnHC.classList.toggle("on", on);
btnHC.title = T("a11y");
try{ localStorage.setItem("teach.hc", on ? "1" : ""); }catch(e){}
}
btnHC.addEventListener("click", ()=>setHC(!document.body.classList.contains("hc")));
document.addEventListener("langchange", ()=>{ btnHC.title = T("a11y"); });
try{ if(localStorage.getItem("teach.hc")==="1") setHC(true); }catch(e){}
applyI18n();
updateEmptyHint();
/* restore session (same browser tab session only) */
(function(){
let name = null;
try{ name = sessionStorage.getItem("teach.session"); }catch(e){}
const users = loadUsers();
if(name && users[name]){
currentUser = { name, data: users[name].data };
THEMES = themesFromData(currentUser.data);
updateUserUI();
BS = boardsFromData(currentUser.data);
loadCurrentSlot();
}else{
updateBoardsUI();
}
})();
</script>
</body>
</html>